dhaga.docs
Roadmap & ideas

Build checklist

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

Exposes build status

This is the internal feature-by-feature build tracker. It reveals what's shipped, what's only code-complete, and where the known gaps are. Items are checked 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-line added 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 — /features is a tablist over the eight capabilities (constants/landing/capabilities.ts), each opening its own fixture-driven pane in FeatureStory/DeviceStage: six hand-built crops (CapturePane, CirclesPane, VoicePane, SearchPane, DraftPane, AlertsPane) plus the production sigma renderer for the graph and warm-path entries. Three ways in, because hover must never be the only one: pointing at a capability shows it (debounced 90ms), focus and arrow keys reach it from the keyboard, and a tap selects where there is no hover. It also auto-cycles every 5s so the page demonstrates itself — suspended while a pointer or focus is inside it, stopped for good on an explicit choice, never started under prefers-reduced-motion, and held while the section is off screen (IntersectionObserver), which 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. Below sm the tabs are a scrolling chip row that follows the rotation, so the selected chip is never parked off screen. For one day (2026-08-14) this was instead a single embedded AppWindow — the same mock the home page ships — with the eight capabilities demoted to copy cards beside it. That made /features a 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: /features requests none of those four in its first 6s (the home page requests nine such resources), and the graph fixture feature-graph.json is fetched only when a graph tab opens. /features stays 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, and ogl and @xyflow/react left the dependency graph.
  • npm workspaces (apps/*, packages/*) so packages/core is shareable
  • apps/web/.env.example documenting every env var
  • CI (typecheck + lint + tests + build on push)
  • Test suite (vitest, in-memory PGlite): heuristic parser, export formats, receipts cascade
  • Fix Dependabot alert (postcss <8.5.10 via next — npm override to ^8.5.16) (needed a version-keyed postcss override to actually dedupe next's nested pin — PR #55)
  • Deploy to Vercel (landing + app)

1. Shared core — packages/core

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

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

  • Contacts & sync journey clarity — /app/import is the canonical hub for file import and Google/Outlook account management; LinkedIn accepts its emailed ZIP directly with local, filtered extraction and persistent recovery states; Settings keeps one Capture signpost and redirects old #import bookmarks to the hub; focused parser, state, navigation, and journey tests cover the new paths.
  • Auth: real accounts (better-auth email/password), signed httpOnly session cookie — typecheck/lint/build/test all pass; manual browser click-through + push still pending
  • Sign in with a phone number + SMS code — genuinely unbuilt (recorded 2026-08-07). Better-auth's phoneNumber plugin is registered (lib/auth/config/plugins.ts) and its sendOTP is wired to Twilio (lib/sms/send.ts), so a code could be delivered — but nothing in the app ever calls signIn.phoneNumber: LoginForm offers email + password and a magic link, with passkey and social alongside. Verifying a number in Settings → Security therefore unlocks nothing, so that section is greyed out with a "Coming soon" reason gated at runtime on smsEnabled(), and the copy names whichever half is missing — PHONE_SIGN_IN_COMING_SOON when SMS is also unconfigured (the deploy's state: TWILIO_ACCOUNT_SID is unset), PHONE_SIGN_IN_UNBUILT_COMING_SOON when Twilio is set and only the sign-in path is absent. Ticking this needs the sign-in entry point, not a Twilio key
  • Every /app page + server action validates the session (guard helpers)
  • Responsive app navigation — rebuilt 2026-08-13 around one complete route model. Desktop (lg) gets a permanent 256px rail with the existing ThreadMark + dhaga wordmark, search, Capture, Workspace, Explore, import/settings and account utilities. Phone gets a safe-area-aware bottom bar (Today / People / Capture / Map / You) with 44px+ targets; You opens a bottom sheet containing every secondary route, resource, preference and account action. Tablet keeps the compact top bar and opens the same directory from a right sheet. One SearchPalette and one NavQuickAddDialog instance are repositioned rather than duplicated, so shortcuts/listeners cannot fire twice; main content and the toaster reserve the phone bar/safe-area footprint.
  • In-app feedback box (2026-08-03) — an icon button in the app nav beside the notification bell, so it is on every /app route (the responsive capture action already owns the phone bar's centre slot) and adds no extra fixed layer over the bottom navigation or bottom-right toaster at 375px. One free-text field and Send: no categories, tags, screenshots or triage states — reading a handful of reports is cheaper than making every user classify their own. POST /api/feedback (requireUserIdFromRequest → new feedback rate-limit bucket, 5 per 5 min → zod → withUserDb insert → email) writes the core feedback table (lib/db/ddl/core/feedback.ts, mirrored in db/schema/feedback.ts, added to EE's TENANT_TABLES), then notifies DHAGA_OWNER_EMAIL through the existing Resend layer best-effort: the tenant scope is closed before the send and a throw is swallowed and logged shape-only, so a Resend outage (or an unconfigured self-host) can never cost the user the words they wrote. The captured context is an allow-list, enforced three times over — a named column per field with no jsonb blob, a zod schema that strips unknown keys, and per-field patterns narrow enough that prose cannot ride in a "harmless" field: route, viewport, user agent, locale, timezone, build id, plus the RLS user_id. The route is the pattern, not the path (routePattern() substitutes Next's resolved params by value, so /app/people/<id> is stored as /app/people/[id] and a new dynamic segment is redacted the day it ships), and the server strips any query string or fragment because ?q= is exactly the forbidden payload. Nothing else is collected — no contact names or ids, note text, search terms, DOM snapshot, clipboard, referrer, IP or analytics id — and the whole attachment is spelled out under the textarea before the user sends, since silent collection is the thing the privacy stance forbids. Admin view at /app/admin/feedback: server-paged (listFeedbackPage, limit/offset + a separate count(*), sequential awaits on the one bypass-RLS admin connection) through the shared TanStack-backed DataTable in server mode — no new useReactTable, per docs/LIBRARIES.md §2, and FeedbackTable is an export of the already-listed AdminTables.tsx so the verify-without-ee removal list needs no additions. Tests: lib/__tests__/feedback-capture.test.ts (allow-list, route redaction, disclosure), feedback-submit.test.ts (row survives an email throw, forbidden fields never reach the row, 401, 429), packages/ee/src/admin/__tests__/feedback-page.test.ts (offset arithmetic, ordering, unfiltered total, row mapping, connection release). tsc/lint/vitest green; DDL replay verified twice against a throwaway PGlite. Pending: manual browser click-through
  • Empty states + error states; submit buttons have loading spinners
  • Loading skeletons on data-heavy screens (route loading.tsx files)
  • Branded public/app 404 and route loading states — cloth-shop illustration, recovery actions, theme-safe tokens, and reduced-motion-safe animation (2026-08-06; browser/screenshots pending)
  • General Tasks at /app/tasks (superseded 2026-08-13 — now the List view of /app/plan) — active/completed views, optional due date, optional person/company associations, and daily/weekly/monthly/yearly calendar recurrence without placeholder contacts (2026-08-06; browser/screenshots pending). Searchable + paginated 2026-08-06: the board is driven by @tanstack/react-table row models over a single pseudo-column — the card rows host an inline edit form a <table> cell cannot, so this follows the ActivityCard precedent rather than converting to DataTable. Search matches action text and the attached person/company names (debounced via the shared useDebouncedValue); the All/General/People/Companies and Active/Completed chips compose with it, and any change to search or filter resets to page 1 so a narrowed list cannot strand the user on an empty page. The footer was extracted from DataTable into a shared TablePagination (components/app/table/DataTable/Pagination.tsx) so the five existing DataTable call sites and the tasks board render identical controls from one copy. Predicates are pure and unit-tested (components/app/tasks/filters.test.ts). Still unbounded server-sidelistTasks() loads every open and done row per page view and done tasks are never pruned; client pagination fixes render cost, not payload. Keyset pagination in the repo layer is the follow-up Sort, a person filter, and a visible added-date (2026-08-12): every row now carries Added <date> (formatDate, never formatDueDatecreatedAt is a real timestamp in the reader's zone, while a due date is a semantic day pinned to UTC), a sort select offers Due date / Date added (newest) / Date added (oldest), and a person select narrows the board to one contact. Both run client-side over the already-loaded set through the SAME path as the existing chips — filter, then sort, then paginate — and reset to page 1 like every other control; listTasks()'s orderBy is untouched, and the due default reproduces it exactly (dated ascending, unscheduled last, oldest-created breaking a tie) so the board cannot silently re-order itself between the server render and hydration. Every sort tie-breaks on the id, because equal timestamps permuting between renders reads as rows shuffling under the cursor. The person options derive from the LOADED tasks via distinctOptions, hoisted out of the calendar's filter-follow-ups.ts into the shared lib/filters/follow-up-scope.ts so both boards build that dropdown one way; the control is hidden entirely when no task names a person, and it deliberately offers no "general" entry — the General scope chip already means that. Covered by components/app/tasks/filters.test.ts. No browser pass — the filter row at 375px is reasoned about, not screenshotted
  • Calendar-aware follow-ups — recurring rows advance on completion; note phrases resolve deterministically into calendar dates; ambiguous “next weekend” schedules Saturday and offers Sunday in Confirmations (2026-08-06; browser/live calendar verification pending)
  • Keep-in-touch scheduling — cadence-specific weekday/day/month selectors, persisted capacity-aware Auto assignment, user-timezone due dates, and a non-blocking People/day overload warning (2026-08-06; browser/screenshots pending)
  • Soothing midnight theme reused from landing tokens, with semantic colour cues shared by the real dashboard and its landing preview
  • Home lifecycle + daily briefing — rebuilt 2026-08-13 as three factual stages: first when peopleCount === 0, started after the first person but before every offered setup fact, and integrated only when person, WhatsApp/Telegram capture, calendar, preferred calling hours, reminder review, and map exploration are all complete. The messaging step is the one conditional row: it is dropped from the list (and from the counts, so the card reads "of 5" rather than a permanent "5 of 6") when the instance has no WhatsApp/Telegram provider configured, or the plan cannot link a chat and none is linked — a step nobody can ever tick would pin an otherwise finished account below integrated forever. First-day Home leads with add/import actions and a Dhaga-owned thread illustration; later stages lead with Moments for today, a contact-initials detail sheet, Reached out, and calendar-backed Find a time. The setup card persists calling/reminder/network choices in the existing settings table (no DDL); person/calendar completion remains derived from real data. User-facing setup/calling preferences are exported through an explicit allowlist; internal settings stay excluded. Settings caught up 2026-08-14: the same fact now hides the Messaging tab (components/app/settings/tab-visibility.ts, sharing lib/messaging/availability.ts with the checklist rather than re-deriving it). Both surfaces disagreeing is what a user hit — WhatsApp visible in Settings, absent from the checklist, with nothing saying why. A user who linked a chat while a channel was configured keeps the tab regardless, so a config change can never strand someone with a linked chat they cannot disconnect; and #messaging deep links are filtered by the same gate, since a hash selecting an unrendered tab hides every panel and reads as "nothing is selected". Where a channel is configured but one provider has no credentials, that row still reads Not configured — now a link to the self-hosting page's Set up a channel section, because naming a state without naming the missing piece or who owns it is a dead end for the self-hoster, who is usually two env vars away. The messaging tour steps are filtered at source, not left to driver.js's skipMissingElement: TOUR_PRESENCE_WAIT_FRAMES is paid up front waiting for every anchor to exist, so hiding the tab alone would have stalled the tour ~7s on every install with no bot.
  • Interactive Home network globe — production Three.js renderer over cached city summaries only: drag/pinch/wheel and keyboard rotation, click/Enter to /app/map, an Earth-fixed solar day/night terminator computed from the real current UTC — noon over India lights India, and because daylight is resolved against an object-space normal the terminator cannot drift under drag, keyboard or spin — city markers, and a Calling windows mode based on each city's DST-aware IANA zone, which now deepens the night side rather than switching the shadow on (Places used to render a flat, fully-lit Earth). Rendering Home never schedules geocoding and never sends contact data to a globe service. Two local 2048×1024 NASA-derived Earth textures (day and night) plus a 1024×512 combined topography/bathymetry elevation map total 285,707 bytes — the elevation map replaced a 2×1 neutral placeholder that contributed no terrain, and is built at asset-build time by averaging two separately-published NASA/GEBCO sources because neither carries both land and sea. The Earth spins west→east at one turn per two minutes, with a ±80° pitch clamp and a reset control that appears only after deliberate input; DPR/geometry/antialiasing reduce on narrow/≤4-core/≤4 GB devices, rendering pauses off-screen/hidden, reduced-motion stops the spin, and WebGL failure remains a keyboard/click route to the real map. The real map now exposes point, quick-chip, and all-city controls that deep-link a city at zoom 11. three is MIT, tz-lookup is CC0, and exact NASA/GEBCO/illustration provenance lives beside the assets; browser QA is tracked in the Testing guide.
    • Globe labels now show the busiest six cities, add local time beside Good window / Check first, and deep-link a clicked city into the selected city-level map view. The signed-in Home and synthetic landing preview share this implementation.
  • Suspense streaming on Home/profile/settings — Home now streams the lightweight OnboardingGate separately from one coherent DashboardSection; profile and settings retain their section-level boundaries. This lets the gate arrive first without presenting a partially assembled lifecycle screen.
  • Home inline summary: people plus available company/note/event/open-follow-up counts come from the existing one-query RLS-scoped getGraphStats() aggregate and render below the local-time greeting; the retired stat-card strip issues no second stats read
  • Searchable EntityCombobox + trigram GIN indexes replace bare entity text boxes
  • Saved page (/app/saved, "Saved" nav) — Starred + Watching tabs, each a server-mode paginated TanStack DataTable; a manual starred favourite flag (contacts column + partial index) with an optimistic StarButton on the contact header and in the People/Saved rows. The former Home Starred preview and CSS-columns bento were retired by the lifecycle rebuild; /app/saved remains the canonical surface. Pagination audit: People/Events/Admin/Import already paginate via the shared DataTable (server + client modes); interactive detail-page card lists left as-is by design. tsc/vitest/build green; visual pass pending
  • Last-touch ranking — lib/repo/last-touch.ts names the touch signals once (capture, a note, an event scan, an explicit "I reached out") and remains shared by recency, cadence, quiet-network, goal, and contact-detail reads. The former Home "Recent people" tile was retired by the lifecycle rebuild; current Home uses the same touch facts inside Moments for today instead of presenting a separate recency card. Built 2026-07-29 (web); mobile parity pending
  • Per-user /app appearance (theme + font) — built 2026-07-31. Seven contrast-tested colour themes (Dhaga, Monochrome, High contrast, Rose, Ocean, Forest, Violet) and eight font choices (Geist Pixel, System, Inter, Roboto, Open Sans, Lato, Montserrat, Poppins), persisted per user under the EXISTING settings key/value table (key ui_theme) — no migration, identical self-hosted and hosted. Every preset ships both a light and a dark palette, so the existing next-themes toggle is untouched: a preset picks the hues, the toggle still picks the mode (presets/types.ts documents why one-mode presets were rejected). Delivered as a <style> block emitted by the /app layout (components/app/AppThemeStyle.tsx + lib/theme/css.ts), so there is zero added client JS, no extra round trip and no flash of the default palette — a server-rendered choice is the only thing next-themes' pre-hydration script cannot know. The default preset emits nothing at all and the Google faces are declared preload: false, so a user who never opens the picker downloads no extra bytes. Scoped to /app/** on purpose — the marketing site, /blog and /docs are statically rendered SEO surfaces and per-reader colours would force dynamic rendering; they keep the brand. utils/constants/theme/__tests__/presets.test.ts holds every preset, in both modes, to WCAG AA for body/secondary/accent text and the 3:1 non-text minimum for control borders — which is why the picker is a curated list rather than a colour wheel. parseUiTheme degrades per field, so an unknown id from a rollback loses one choice, not both. Core: no packages/ee file and nothing to add to the core-only removal list in ci.yml. Browser-verified against the seeded Supabase account on a production build: preset + font apply, persist across a reload and revert on Reset, and all three doc screenshots (settings-appearance.png, app-theme-monochrome.png, app-theme-rose.png) are captured and committed — the capture entries live in scripts/capture-docs-screenshots.mjs behind the ONLY= allow-list so they can be regenerated after any UI change, and the run restores the account to the default preset in a finally. Verifying it surfaced one real defect, now fixed: components/ui/button.tsx hardcoded the primary button's amber cast glow, so a Monochrome or Rose button kept an amber halo — it now reads --shadow-accent/--shadow-accent-strong, derived from --brand-amber in globals.css. Verifying also turned up, and this branch fixes, a pre-existing crash unrelated to theming: /app Home and the confirmations inbox threw their error boundary on the seeded account because confirmationPayloadSchema reused the strict model-facing relationshipSchema to parse ALREADY-STORED rows, and a row written before the affiliation fields existed has them absent rather than null — which .nullable() rejects. The model contract stays strict (it must, for structured-output strict mode); the read path now goes through storedNoteExtractionSchema, which defaults those six fields to null. listPendingConfirmations also moved to safeParse-and-skip so one unreadable row can never take out the page again — zero rows actually needed skipping once the schema was fixed. Pending: mobile has no parity for the theme feature (the Expo app has its own token layer) and is deliberately out of scope. User guide: the Appearance section of apps/web/content/docs/guide/settings.mdx
  • Theme Studio — the Appearance panel rebuilt from the approved design (docs/design/theme-studio.html), 2026-08-14. The picker is no longer a grid of eight fixed themes: it is six source palettes (Dhaga, Racing, Gold & Blue, Rosewood, Terracotta, Earth — utils/constants/theme/palettes.ts) plus per-role override of the six things a colour can actually be — accent, background, cards & rail, text, muted text, borders (roles.ts) — mixable across palettes or taken from an OS colour picker. Stored under the SAME settings key (ui_theme) as a new colors object, so no migration; parseUiTheme keeps only well-formed hexes and degrades per field. Picking a palette writes six ROLE VALUES rather than a saved palette id, which is what makes "start from Rosewood, keep its ground, take Racing's accent" one click each. Everything the user sees is a NAME. nameFor() resolves a hex back to "Racing Red"/"Warm Greige", or renders Custom; the hex appears in exactly one place, the editable field inside the colour-picker popover — people do not think in hex and the row should not make them. Each role row carries a live WCAG ratio (lib/theme/contrast.ts, the one luminance implementation in the app) and a (?) popover listing what that colour paints, which also dims a miniature live /app preview down to the surfaces that role touches. The preview pins its own mode with .home-lifecycle-light/.home-lifecycle-dark — the class pair the landing mock already uses — and re-declares --brand-* on its own wrapper, so it needs no bespoke CSS and no hardcoded colour, and someone working in dark mode can still see the five light-only surface colours they are choosing. The eight THEME_PRESETS lost their grid (the palettes are the presets now) but are NOT removed: a stored preset still layers UNDER the colour overrides in buildUserThemeCss, so an existing user on Ocean keeps Ocean as the base layer, and Reset to default is what clears it. Browser verification found a real regression, now fixed. Surface overrides are light-only, and that had been implemented by leaving the property OUT of the dark rule — but the generated sheet declares light at :root:root:root (specificity 0,3,0) while palette-dark.css declares the stock dark values at .dark (0,1,0), so a user's LIGHT ground won in dark mode and the whole app stopped responding to the light/dark toggle. restateInDark (lib/theme/overrides/dark.ts) now writes the value dark should KEEP for every property light moves — the preset's own if it has one, else Dhaga's (STOCK_DARK_VALUES, pinned to the shipped stylesheet by a test). Same trap as the semantic.css alias layer and the .home-lifecycle-* scopes: a mode or a scope that restates only SOME of a group is half-themed, and half-themed reads as broken. Two existing tests had encoded the bug (expect(darkRule).not.toContain("--brand-panel:")) and were rewritten to assert the VALUE dark carries; lib/theme/__tests__/dark-restatement.test.ts is the new guard and was confirmed failing before the fix. Verified in a real browser at 375px and 1440px in both modes (e2e/theme-studio-visual.spec.ts, against local PGlite — never Supabase), which caught two more defects that measurement alone had missed: the palette cards' meta line overflowed its card (the Button primitive's whitespace-nowrap), and the preview clipped "View all" and both pills off its right edge at 375px (a grid item's min-width:auto). Known limits, inherited on purpose: surface roles apply to light mode only and dark follows only the accent; --brand-panel-2, --brand-line and --brand-well are driven by no role, so a hand-picked dark panel leaves the muted/secondary grounds on the preset's values; Gold & Blue and Earth supply a neutral light ground that is not in their own colour lists, so those rows legitimately read "Custom". Pending: no mobile parity (the Expo app has its own token layer); text-on-panel is not contrast-checked (only text, muted text and accent text against the background), so a dark card carrying dark text is possible and unwarned; the user-guide theme screenshots were regenerated 2026-08-19 against the Theme Studio — scripts/capture-docs-screenshots.mjs now drives the six palette BUTTONS rather than the dead radio grid. app-theme-monochrome.png and app-theme-rose.png keep their filenames but now show Racing and Rosewood, since neither Monochrome nor Rose exists any more; renaming them is a pending tidy-up. Hotfix, preview 500 (2026-08-14): adding colors INSIDE UiTheme took out the whole /app shell with Cannot read properties of undefined (reading 'accent'). getCachedAppConfig is a per-user cache with no TTL, so a warm entry written before this deploy came back as {preset, font}present, so the shell's uiTheme ?? DEFAULT_UI_THEME guard never fired — and isDefaultUiTheme short-circuits on a non-default preset before it reads colors, so undefined reached resolveOverrides, which indexes it by the first role id. The guard's own comment had anticipated this failure one level up but a nullish fallback can only catch the object going missing WHOLESALE, never a warm entry of the previous SHAPE. New coerceUiTheme(value: unknown) (utils/constants/theme/ui-theme.ts) rebuilds every field with the same per-field tolerance parseUiTheme already had — so a shape lagging one deploy keeps the choices it did carry instead of resetting to the brand — and the shell now coerces rather than falling back. Covered by two cases in lib/theme/__tests__/css.test.ts, the first of which asserts the raw stale object still throws, so it cannot pass vacuously.
  • Fix render-blocking font/animation on first load (BRD §7.6) — Geist Pixel is self-hosted via next/font/local; the 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): a cachePerUser/invalidatePerUser helper (lib/cache/per-user.ts) over unstable_cache — cache key + tag both include userId and the read runs inside withUserDb(userId), so an entry can only ever hold that user's data (missed invalidation = same-user staleness, never cross-tenant leak). The force-dynamic shell (getCachedAppConfig: isAdmin/searchWeights/sttEngine/storeCardPhotos) now costs zero Postgres round-trips per nav, and the node-type ontology (getCachedNodeTypes, home/entities) is cached too; both bust via revalidateTag(..,{expire:0}) in their settings/node-type mutations. Volatile feeds (due reach-outs, signals, suggestions) intentionally left live — though the Today suggestion list stopped being volatile on 2026-08-02: its score is now a pure function of (data, the user's local day), so it is newly a cachePerUserVersioned candidate keyed on that day plus a cheap data version — not done, recorded here as a future option rather than a claim. The heaviest hot read, /api/graph/full, is also cached now via cachePerUserVersioned/getCachedFullGraph — version-keyed on the cheap fetchGraphVersion() aggregate, so a graph change changes the key (no explicit invalidation, never stale) and the multi-table assembly runs once per version instead of per request. Store backend is Next's default incremental cache; Redis = a cacheHandler in next.config.ts, no app-code change (Vercel already has a durable shared Data Cache). Typecheck/lint/vitest pass; still to do: manual browser pass + push; extend to schedulePrefs/calendarConnected (home) with their own tags
  • Read scale at ~10k users — cache the remaining hot reads per user (contact/event lists version-keyed + JSON-safe; data-only parts of the home feed) with the cachePerUserVersioned pattern, behind a Redis cacheHandler for shared multi-instance self-hosting — graph payload already done; see SCALING.md §1 + roadmap
  • Read replicas — route graph/search/list reads to a Supabase replica, writes to primary (SCALING.md §3)
  • General rate-limiting on data/AI routes (per-user + per-IP) beyond AI-action metering (SCALING.md §5)
  • Library-first data gateways (docs/LIBRARIES.md): TanStack Query behind @/lib/data, TanStack Table + nuqs inside DataTable (adds client-mode sorting) — PRs #24/#25 (2026-07-20); lint/typecheck/vitest green, manual browser pass + merge pending
  • Virtualize with TanStack Virtual when any client list/table renders ~1k+ rows (docs/LIBRARIES.md §6) — no current surface qualifies; do not add speculatively

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

  • PGlite (embedded Postgres) + Drizzle; hosted Postgres = driver swap
  • Hosted Postgres support: DATABASE_URL → node-postgres (unlocks Vercel)
  • contacts table
  • companies table
  • events + event_contacts tables (M2)
  • notes table (kind: voice|text|photo|capture_source, body)
  • facts table (type, text, confidence, source_note_id, deleted_at)
  • edges table (src/dst typed, predicate, source_note_id)
  • embeddings table (pgvector, 384-dim, receipts via owner_type/owner_id)
  • follow_ups table
  • ai_actions metering table (day one requirement)
  • Deletion cascade: contact → notes → facts → edges → embeddings ("forget this person") — forgetContact cascaded the chain, but deleting a contact that had produced relationship suggestions was blocked by edge_suggestions RESTRICT FKs until PR #57 extended the cascade to them (repo/contacts/mutations/forget.ts)
  • Note deletion tombstones derived facts/edges (receipts invariant) — embeddings cleanup moved into deleteNote itself (2026-07-07, was only in the action layer, so any other caller skipped it); graph-receipts.test.ts now asserts embeddings are gone, not pushed
  • Tags carry receipts too (contact_tag_sources) — extraction used to union tags into the contacts.tags array with no provenance, so deleteNote/clearNoteDerivations could never revoke them. One row per (contact, tag, source note); source_note_id NULL = pinned by the user. A note's deletion drops only the tags nobody else vouches for; a second live note's tag and a hand-pinned tag both survive. Tags that predate the table are pinned by a re-runnable DDL backfill (TAG_SOURCES_BACKFILL_DDL, db/ddl/core/tag-sources.ts) rather than left with zero receipts — without it the first note to mention one wrote its only receipt, so deleting that note revoked a tag the user had all along (__tests__/tag-receipts/). Included in the full JSON export, repointed on contact merge, cleaned up by forgetContact; EE adds contact_tag_sources to TENANT_TABLES
  • groups + group_members + note_contacts, and a group_id on notes / facts / follow_ups (2026-08-23, lib/db/ddl/groups.ts, docs/MEETING_GROUPS.md §3) — a meeting group is a private set of the user's OWN contacts who meet together, the same shape as a tag: there is no invite table, no permissions column and no member who is not already this user's contact, because Dhaga has no inter-account collaboration and this adds none. group_members carries joined_at and left_at instead of deleting the row: a membership is a claim about a period of time, and deleting it would rewrite the group's history into "they were never here" while the shared notes written while they were here still exist. note_contacts is the snapshot of who was actually in the room for ONE note — seeded from a calendar event's matched attendees, editable by hand, and never derived live from group_members, which is what makes "adding somebody to a group never backfills old notes" a property of the schema rather than a rule a reader has to remember. Every contact_id is ON DELETE CASCADE for the same load-bearing reason calendar_event_contacts is: a plain REFERENCES is RESTRICT in this repo and forgetContact ends in a bare DELETE FROM contacts, so a RESTRICT reference would abort and roll the whole erasure back. notes.group_id and facts.group_id are RESTRICT and there is no hard delete — a group is archived (archived_at), because deleting one could only abort on its first shared note, orphan an owner-less note assertSingleOwner forbids, or cascade away what the user wrote about several people at once; the argument is restated in lib/repo/groups/index.ts so the next reader does not "fix" it. follow_ups.group_id is ON DELETE SET NULL and is not an owner — a follow-up already permits several nullable associations on top of a bare user_id. All three tables are in EE's TENANT_TABLES with RLS specs, and all three are in the full JSON export

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

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

5. Events / auto-grouping (M2)

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

6. Notes & entity extraction (M3, M4)

  • Text notes on a contact (add, list, delete)
  • Extraction: note → facts/relationships/follow-ups/tags (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 NULL source_note_id — hand-added facts, manual follow-ups, optimistic rows — never highlight and are not clickable at all; that rule is the pure isFromNote (components/app/contact/selected-note/selection.ts, covered by selection.test.ts). Highlight is a ring and a tint from the amber token plus aria-current, never colour alone. Same pass surfaced a follow-up's added date on the person page, which previously showed only a due date. (web only — mobile parity pending; tags deliberately left out, their provenance belongs with the planned tags page. Typechecked, lint-clean and unit-tested, but not yet browser-verified)
  • Edges carry a visible receipt too (2026-08-19) — edges.source_note_id had been stored since M4 and governed the deletion cascade, but nothing ever showed it: FullGraphEdge did not even carry the field, so an edge could not answer "why do you think these two know each other?" — the one thing that separates a derived graph from Dex/Orvo's drag-one-contact-onto-another. Every place an edge is inspectable now says where it came from, via one shared EdgeReceipt (components/app/relationships/EdgeReceipt.tsx) that mirrors FactItem's affordance: the relationship list on /app/people/[id], /app/companies/[id] and /app/entities/[id], the graph canvas's edge popover, and the node panel's in/out edge lists. Three honest states, never a fourth that bluffs. A note in THIS page's timeline → a button that highlights it in place, the same ?note= selection a fact uses. A note on the OTHER endpoint's page — an edge extracted from Rohan's note also reads on Priya's — → a link to /app/notes/{id}, a new resolver route that looks the owner up server-side (a note belongs to exactly one of contact/company/entity) and redirects to that page with ?note= already set, so no payload has to carry a path per edge. No note at all → the quiet label "Not from a note", no control: hand-drawn and import-derived edges legitimately have no receipt, and saying so is what makes the receipt on every other edge worth believing. Which of the first two applies is decided server-side, by left-joining notes in listNodeRelationships and comparing the note's owner with the viewed node — the join also proves the note is still live, so a tombstoned receipt degrades to "not from a note" rather than to a control that opens nothing. Synthesized edges inherit the right receipt: a works_at/affiliation edge carries its backing positions.source_note_id, attendance and tag spokes carry NULL. Payload cost is one nullable id per edge, and it is not free: +20 B for "sourceNoteId":null and +54 B for a real uuid, against a ~182 B edge object — 11% and 30%. On a realistic 5k-edge graph that is +0.10–0.27 MB; at the GRAPH_TIER3_EDGE_TRIPWIRE of 150k edges it is +3–8 MB, which makes the Tier-3 follow-up (payload slimming / viewport streaming) more urgent rather than less, and is the first thing to reconsider if that tripwire is ever approached. And GRAPH_PAYLOAD_IDB_KEY went to full.v2 because the ETag is a CONTENT version, not a shape version: a cached v1 payload would 304 forever and paint receipted edges as "not from a note". (web only — mobile parity pending; typechecked, lint-clean, existing graph/receipt tests green, but not yet browser-verified. The entity page has no SelectedNoteProvider, so an entity-owned receipt shows as a label there rather than a highlight.)
  • User can edit/delete a fact inline (M4 acceptance)
  • Manual add-fact / add-follow-up forms (no extraction path, NULL receipt — a row typed from scratch still carries no source_note_id, so no note deletion can tombstone it; the note-selection path below is the one exception, and it is deliberate)
  • Manual add-fact on a COMPANY too (2026-08-22)/app/companies/[id] can now be given a fact by hand, closing the cut the company detail page shipped with. The store stopped being contact-shaped rather than growing a second path: repo/manual-entries.addFact takes a FactOwner union ({contactId} | {companyId}) guarded at runtime by the same assertSingleOwner notes and embeddings use, addFactAction reads either owner off the form and hands BOTH keys on uncoalesced so a two-owner form is rejected by that gate rather than silently picking one, and upsertEmbedding files the vector under whichever subject it got — so a hand-typed company fact is semantically searchable exactly like a person's. Which owner it is changes three things and nothing else: whose existence is checked, which subject the vector is filed under, which detail route is revalidated. UI is the same FactListClient for both owners, not a fork. Behaviour matches the person path exactly: no source note, no extraction job, no LLM, ZERO credits — which is what makes the company page usable on an instance with no AI configured at all, previously impossible since extraction from a company note was the only way a company fact could exist. Covered by lib/__tests__/manual-entries.test.ts; an e2e spec was ADDED (e2e/facts.spec.ts, "add a fact to a company") but has never been executed, and the flow is not browser-verified
  • Select text in a note → add it as a fact, follow-up or relationship (2026-08-17) — the manual counterpart to extraction, for the claim the extractor missed or got wrong. Highlight a phrase inside any note body and a floating bar offers the three actions; the selected text is carried into the form verbatim — no model, no AI credit — and nothing is written until the user confirms. Fact → the text field, follow-up → the action field, relationship → seeds the target typeahead (a phrase is a lead, not an answer). These items DO carry source_note_id, unlike a hand-typed one: the note is genuinely where the words came from, so the item highlights with its note (?note=) and is tombstoned with it by the existing cascade (repo/notes/derivations) exactly like an extracted one. Threaded as an OPTIONAL argument through addFact, TaskInput/createTask and createRelationshipEdge, so every existing manual caller still writes NULL. Built on a reusable useTextSelection hook (lib/hooks/) — container-scoped (both ends of the selection must be inside the watched notes list), touch as a first-class path, and viewport-clamped so the bar flips below the selection rather than off the top of a phone; the note id is read off a data-note-body attribute so selecting the row's meta line offers nothing. The note body already sat inside the ?note= selection button, so that button now sits out the click that ends a drag (isTextDragSelect) — otherwise dragging inside the selected note deselected it mid-gesture. Wrapped once in NoteList, so it works on all four surfaces that render notes, including the two with no ?note= selection at all (Home's contact sheet, the entity page). Follow-ups go through createTaskAction, the only follow-up write that stamps user_id, which an entity note's contact-less follow-up needs for RLS. Known gap: "Add as fact" from a note selection is still CONTACT-only — but no longer for want of a write path, and the old reason recorded here was wrong from 2026-08-22 on. repo/manual-entries.addFact now takes a {contactId} | {companyId} owner and upsertEmbedding indexes under either, so a company fact writes and is searchable; FactListClient shows "Add fact" on the company page. What is still contact-shaped is THIS surface: FactFromSelection takes a contactId: string and posts it, so there is nothing to hand it for a company note, and a company note therefore offers the two actions that work. Widening it is a UI change, not a schema one. Entities remain a genuine gap — they have no facts table at all. Covered by lib/hooks/text-selection.test.ts, selected-note/selection.test.ts and lib/__tests__/note-selection-receipts.test.ts (which proves the deletion cascade takes the receipt-carrying items and leaves the hand-typed siblings alone). (web only — mobile parity pending; typechecked, lint-clean and unit-tested, but not yet browser-verified)
  • Deleting a note tombstones its derived facts/edges
  • Re-process a note: manual "re-run extraction" on an existing note (text/voice/photo) — after an edit or a missed fact — re-enqueues a note_extraction job for the same note; idempotent (worker's clearNoteDerivations replaces the note's prior facts/edges/follow-ups, never duplicates), AI budget still metered in the worker
  • Move a note to another person (2026-08-09) — inbound WhatsApp/Telegram/quick-add captures pick their subject with no confirm step (lib/messaging/process-session/apply/person.ts), so a note can land on the wrong contact. moveNoteToContact (lib/repo/notes/move.ts) re-points the note and everything keyed to its source_note_id — facts, follow-ups, positions, confirmations, edge suggestions and tag receipts (moveTagSourcesToContact) — plus extraction_jobs/card_images by note_id, the contact-typed edges endpoints only (a company or third-person endpoint is never touched, then self-edges and duplicates of this note's edges are dropped), and the denormalised embeddings.contact_id for the note's own vector and its facts'. One transaction, set-based SQL, no re-extraction — costs no AI credits and a fact the user edited or verified survives the move. contacts.title/company_id are recomputed on BOTH people (computePrimaryDenorm); capture_source receipts, entity notes, a missing target and a no-op onto the current owner are refused with a user-safe PreconditionError. Surfaced as a third control on the note row (MoveNoteButton, a dialog over the shared EntityCombobox) via moveNoteAction. Covered by lib/__tests__/move-note.test.ts. (web only — mobile parity pending; the dialog is typechecked and lint-clean but not yet browser-verified)
  • Import/scan/QR/WhatsApp receipt notes are permanent and excluded from the Notes count (2026-08-01) — capture_source notes (IMMUTABLE_NOTE_KINDS, utils/constants/extraction-jobs.ts) can no longer be deleted or reprocessed: deleteNoteAction/deleteEntityNoteAction (lib/actions/notes.ts) look the note up and no-op instead of deleting when its kind is immutable, and NoteList hides the delete button for them (the reprocess button was already gated by REPROCESSABLE_NOTE_KINDS, which dropped capture_source in the same change). They're also excluded from the dashboard's headline Notes count and activity sparkline (USER_AUTHORED_NOTE_KINDS, read in getGraphStats/getGraphActivity, lib/repo/stats.ts) — importing 751 contacts used to inflate the Notes tile to 760 (751 receipts + real notes); now the tile reflects notes a user actually wrote, and the receipts stand as a permanent, unlisted audit trail of how each contact was captured. Narrowed to actual machine receipts (2026-08-12) — the kind was also being written by capture paths whose text the user wrote, so a note saved with the browser extension, a message sent to the Telegram bot and a quick-add paste all came back with no Delete and no Reprocess (reported by a user against extension text capture). Those now write kind text with the channel in notes.source (api/capture/handlers/text.ts, api/telegram/route.ts, and lib/actions/contacts/create/create-contact.ts, which still writes a receipt when the save carries card photos), leaving capture_source to what no human typed: import receipts (lib/repo/import.tshasLinkedinImportSince still keys on the kind) and card-scan transcripts (api/capture/handlers/card.ts). They now count toward the Notes tile and are movable, which is correct — they are notes the user captured. HEAL_EXTENSION_CAPTURE_KIND in EXTEND_DDL frees rows the extension paths already wrote; Telegram and quick-add rows written before this change carry no marker that separates them from a genuine receipt and stay immutable. Covered by api/capture/__tests__/capture-note-kind.test.ts and lib/__tests__/heal-extension-capture-kind.test.ts
  • Voice notes on web (Dhaga Voice — Moonshine tiny, on-device WebGPU — live dictation → transcript → extraction)
  • Photo notes on web — a third capture mode in the same note composer, alongside typing and voice: attach photos of a whiteboard, poster, handwritten page, receipt or badge (transcribePhotoNote, lib/ai/photo-note.ts — vision call, structured output, metered + budget-checked like every other AI action) and the transcription becomes the note body, so it is searchable and runs the normal note pipeline (facts / follow-ups / embeddings) exactly as a typed note does. Photos are kept as the note's visual receipts under the same Store captured photos setting and are deleted with the note. Reuses the card-scan tray, cropper and downscaling. (web only — mobile parity pending, see M3)
  • A note about several people attributes what it extracts (2026-08-23, lib/repo/graph/attribution/, packages/core/src/schemas/extraction/) — applyExtraction(contactId, …) writes every fact to one contact, an assumption a shared note breaks. The rule the whole path enforces is that nothing lands on a person's profile unless the note was talking about that person. Facts, follow-ups and relationships gained an about (a name as written in the note), resolved against only that note's note_contacts set — a closed list of three to six people, never open-set resolution over the graph. Unambiguous → written to that contact with the ordinary source_note_id, and it renders on their page carrying a "from group note" link back to the shared note (GroupNoteLabel). Ambiguous → a confirmation, reusing the existing subject_resolution type with a second apply kind (attribute_extraction) that carries the single item as a one-item extraction, so the existing card, queue, resolver and dismissal all work unchanged and answering runs the plain applyExtraction — identical receipts and embeddings to a note captured on that person's own page. No about at all → a group-level fact (facts.group_id) or task (follow_ups.group_id), shown only on the group page. Three decisions worth keeping: about is .nullable().default(null) and never .optional(), because .nullable() keeps it in the JSON schema's required list (the strict-mode contract) while .default(null) makes it absent-tolerant on parse, so a confirmations.payload written before the field existed still reads — the failure that once took out the whole /app inbox; enforcement lives in code after parsing, with no superRefine and no required field, because #245 came from enforcing a rule in the prompt and schema instead of in code, and the prompt says outright that about never changes what is extracted (PR #212's verbatim-and-whole rule is untouched); and matching takes a whole name first then a single-token given name ("Sam" ⇒ "Sam Patel"), absorbing case, spacing, possessives and punctuation but never spelling and never a surname, with a tie or a miss counted as ambiguous. about is omitted from the company-note and unclear-note schemas, which have no room to choose from. The single-contact path is untouchedapplyExtraction never reads about; this is a superset, not a rewrite. Tests: lib/__tests__/shared-note-attribution/ (partition.test.ts asserts every item lands in exactly one of the three piles, never zero) and packages/core/src/schemas/extraction/about.test.ts
  • A shared note's extraction is queued AND driven (2026-08-23, docs/MEETING_GROUPS.md §4). Two gaps of the same shape, closed one after the other. First, nothing was queued: createExtractionJob took only a contact or company owner, so applyGroupNoteExtraction had no production caller and the whole attribution path was unreachable code. Giving extraction_jobs a group owner (guarded by assertSingleOwner), a group-note branch in the worker's dispatch and a createExtractionJob call on the shared-note create path fixed the queuing — and left the job sitting there, because nothing in this app runs an extraction job except a mounted ExtractionStatus, whose stream POSTs /api/jobs/extraction/run per active job. That chain was contact-scoped end to end, so a group-owned job was enqueued by a page that could neither show it nor fire it and was reaped as "Timed out — retry." fifteen minutes later. Closed by mirroring the person page's chain rather than inventing a second one: listRecentGroupExtractionJobs (keyed on group_id, never on members — one pill on several pages, each able to fire the worker, is not a smaller version of this bug), read by loadGroupPage into extractionJobs, rendered by GroupExtractionStatus on /app/groups/[id]mounting it is what runs the jobs — with GET /api/groups/[id]/extraction-status as the fallback a detached second tab reconciles against. ExtractionStatus moved to components/app/extraction/ and takes an ExtractionScope ({kind: "contact" | "company" | "group", id}company joined it when the company page gained its own status route and surface, PR #274) instead of a contactId, which is what makes the absence of a driver visible: an owner with no status route cannot be named in extractionStatusUrl and does not compile. The status route runs auth first and wraps its read in withUserDb(userId, …) — the group id comes from the client and may only narrow within the session's own data, and a bare getDb() on an x-api-key request has no cookie to resolve and falls through to the global unscoped connection. Reprocess for a shared note lives here too (reprocessSharedNoteAction, offered per note in GroupNoteList), and nowhere else: a member's page withholds it because it cannot drive a group job, and the action re-checks the client's groupId against the note's own before filing anything. Failure stays honest — a failed or blocked run never emits the done event that produces "extraction finished — N facts added", and an undriven job still reads "Timed out — retry." rather than as a quiet success. Idempotent because clearNoteDerivations clears by source_note_id and is owner-agnostic, so group-level rows are retired with the rest. Tests: lib/__tests__/group-extraction-driver/, which never call the worker by hand and never query extraction_jobs themselves — they save a note and drive the REAL loadGroupPage, since a helper reading the jobs table directly would pass straight through the bug it exists to catch
  • A relationship with no about at all is not written — the one relationship outcome still dropped. edges has no group endpoint type, minting one was out of scope, and fanning the link out to every attendee is precisely the rule the attribution path exists to enforce. The same holds for a shared note's tags: there is no group tag table, and tagging everyone in the room with a topic they never raised is the same violation. Both are counted and returned (unwritten), never dropped silently. Do not "fix" either by applying to all members
  • Note source colours (utils/constants/note-accents.ts, 2026-08-23) — a 3px coloured left spine on the note card plus a source chip tinted to match, so a timeline can be scanned by channel. Not a full background wash (five tinted cards fight "one accent, generous whitespace, no visual noise") and not chip-only (you could not scan by colour, which is the point). Every colour is an existing brand accent pair: group note → magic, WhatsApp → calm, Telegram → trust, extension → human, and web/mobile/unknown → no spine and no chip, because "written in Dhaga itself" is the default a colour would only add noise to. Spine and tint use the fill half, chip text uses the -ink half, which aliases its base in dark, so every class here is correct in both themes and must never grow a dark: variant. Amber is reserved for primary actions and is not a source colour. A group-owned note's group colour wins over its channel — which meeting it belongs to is the more salient fact, and the channel is still spelled out in words. Colour is never the only signal: the chip always carries its text label, and on a person's page it is NAMED with the group (NoteMeta, via groupNamesByIds, a read that filters on neither left_at nor archived_at — a note from a group the contact has since left is still on their page, so the moment the name matters most is the one a membership read cannot answer). source (channel) drives colour; kind (text/voice/photo) stays an icon — a voice note can arrive from Telegram. The map is exhaustive over NoteSource, so adding a channel without deciding its colour is a compile error. Search receipts were changed in the same pass: the keyword UNION now projects notes.source as note_source and it rides to the gateway as SearchIndexResult.matchSources, an optional array index-aligned with matches appended only by pushMatch so the two cannot drift; the unaccented rule became bg-line rather than bg-amber/60, since amber as the DEFAULT made "we know nothing about where this came from" the loudest row in the list. One limit, stated: the group accent never appears on a search receipt at all — the UNION projects notes.source and not notes.group_id, and every one of its branches scores a contact, so a group-owned note is not in the result set to be coloured (§8 has the detail). Mobile has nothing to port and the premise was checkedapps/mobile has no note list and no endpoint that returns notes, and every note it creates is stamped source: "mobile", which this table maps to no colour anyway
  • Background-job UX for extraction & enrichment (2026-07-30) — built, not browser-verified. Start copy now states that the work continues if you leave the page (verified true — the worker writes server-side regardless of who is watching), and a finished run shows a self-clearing confirmation with real counts ("Extraction finished — 4 facts and 1 follow-up added.", useExtractionStream/done-message.ts) plus a non-blocking toast in the tab that ran the worker. Fixes in the same change: a sticky "extracting facts…" notice that never cleared without a reload; three spinner paths that could never settle; and a bug where controller.enqueue throwing after the user navigated away marked an already-successful job FAILED. Covered by useExtractionStream/{settle,live-state}.test.ts; manual click-through pending (docs/TESTING.md §7z)
  • Voice notes on mobile (whisper.cpp / Apple Speech — mobile milestone) — built via expo-speech-recognition (wraps iOS SFSpeechRecognizer/Android SpeechRecognizer, forced on-device, no audio/transcript leaves the phone); tap mic → live interim transcript fills the same text-review box typed input uses (e64b336). Typecheck/lint pass; package has no published SDK-57 tag yet (installed cleanly, types check, but native linking in an EAS/dev-client build is unverified) — needs a real device build before this can be checked off, not pushed

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

  • STT is a pluggable engine gateway — the AsrEngine contract lives in @dhaga/core/src/voice/asr/types; an engineer adds a future model by implementing it and wiring a useDictation branch. Only Dhaga Voice (Moonshine) ships now — the browser SpeechRecognition engine, on-device Whisper, real-time Whisper, and the Settings engine-picker (VoiceInputSetting / the SttEngine preference) were all removed. The gateway remains the single extension point for a future engine.
  • "Dhaga Voice" (Moonshine tiny) engine, web — the only web STT engine; on-device WebGPU streaming. WebGPU is required and there is no fallback (no browser / CPU / WASM degrade), so voice notes do not work on iOS Safari or most mobile browsers — a real limitation of the product, not just of a deployment. No longer the whole story (2026-08-26): a second, server-side engine now takes precedence where the deployment has speech-to-text configured, and it runs in any browser — everything from here to the end of this entry describes what happens only when it does not. The on-device engine is gated behind a confirmed WebGPU adapter (resolveDictationState, components/app/contact/dictation-gate.ts): while the probe is still running the Voice button is not-ready and its start is inert, and once the probe comes back false the mic renders greyed out up front with a "Coming soon" reason (DICTATION_NO_WEBGPU_COMING_SOON through ComingSoonNotice, on the note composer, quick-add paste capture and the search palette alike) — it used to look live and explain itself in a warning toast only after a tap, which the beta product rule forbids. So a mistimed tap can never race the model loader into onnxruntime's "no available backend", and no "Downloading model…" progress ever shows on an unsupported device (loadTiny also throws fast when WebGPU is absent). @huggingface/transformers dynamic-import-only. COOP/COEP were scoped to /app/** for threaded WASM and removed 2026-08-25: they made Razorpay's checkout iframe unframable on every soft navigation into Settings, and WebGPU — which the engine hard-requires — needs no cross-origin isolation, so only the rare same-device WASM fallback loses threading. (typecheck + prod build pass; pending real-browser mic/WebGPU verification)
  • Dhaga Voice Phase-2 UX — the transcript streams live into the capture textarea as you speak (was: appeared only on Stop); the tap-to-fix word-chip review sits directly under the textarea, bounded to the last dictated segment (was: at the bottom of the modal); a silence gate stops the model transcribing dead air (fixes a garbled-transcription / hallucination bug); bounded memory supports long (~15-min) recordings; the microphone lifecycle was hardened — always tears down, rebuilds, resumes, and verifies (fixes a stale-mic-feeding-silence bug). (pending real-browser mic/WebGPU verification)
  • Phonetic teaching (double-metaphone) — shared pure-TS core (@dhaga/core/src/voice/teaching), deep-import-only, Hermes-safe (never the core barrel); web dictation applies taught spellings on finalize; teaching/vocabulary manager in Settings (Settings restructured into tabs — Account/Capture/Calendar/Suggestions/Import). (pending real testing)
  • Mobile phonetic teaching (parity) — shared core wired into expo-speech-recognition dictation (correct() on the transcript, interim + final) + a vocab screen; JSON-file VocabStore. (pending device build)
  • voice_vocab table (synthetic id PK, mirrors settings; EE TENANT_TABLES adds RLS) + /api/voice/vocab (GET/POST/DELETE) + "use server" actions + export coverage; core path unaffected (no apps/web@dhaga/ee import). LLM self-correction stays PARKED (§20 backlog — needs a GPU).
  • Search dictation append-vs-replace bug fixed (multi-segment finals accumulate).
  • Real-browser + device verification (mic capture, WebGPU streaming accuracy, teaching round-trip) and commit (currently uncommitted on main).

7. Knowledge graph v0 (M5)

  • Relationship edges written from extraction (works_at, knows, used_to_work_at, …)
  • Jobs & education written from extraction — an affiliation to a company (works_at, studied_at, interned_at, …) becomes a positions row carrying the note as its receipt, not a literal edge (the graph re-derives the affiliation edge from the row), and the contact's title/company denorm is recomputed. Strictly additive: an existing position for that employer — the user's or an earlier note's — is never rewritten or displaced, and a note's own rows are cleared on reprocess/delete by source_note_id alone. Built 2026-07-29 (web); mobile parity pending
  • Contact page: same-company + same-event + edge connections render
  • Graph browser page (React Flow: people/companies, typed edges, ring layout)
  • Directional graph edges + full affiliation history — past employers now render as their own solid "worked at" edges (every positions role ships from the graph payload, not just the denormalised current company) and education as "studied at"; edges are drawn directed, and hovering a node emphasises its outgoing edges (full amber, thickest, arrowhead — the subject side) over incoming ones (softer, thinner) so relationship direction reads at a glance. Built 2026-07-28 (web graph)
  • Edit an existing relationship — every relationship row on a person/entity page carries a pencil that reopens the same target picker + predicate picker + live direction preview, so a wrong endpoint, a mislabelled type or a backwards edge is corrected in one dialog instead of delete-and-re-add. The edit rewrites the existing edges row in place, so the edge keeps its id and its source_note_id receipt; the action revalidates the endpoints the edge LEFT as well as the ones it joins, and an edge deleted elsewhere reports "no longer exists" rather than a phantom save. Only the node whose page you're on is fixed. Same pass made the target typeahead's state legible (Searching… / Couldn't search + Retry / No matches, plus one automatic retry) after a silently-failed search read as "search is broken". Built 2026-08-06 (web); mobile parity pending
  • Tag ontology v0 (tags from extraction, filter chips on People)
  • Tags are first-class entities, with their own surface (2026-08-12) — a tag used to be a bare string in the contacts.tags jsonb array, so it could carry nothing but its own spelling, and there was no read path for contact_tag_sources at all. Now tags (id, name), contact_tags (composite PK, cascade both ways) and tag_aliases are the source of truth, contact_tag_sources gained a tag_id FK, and contacts.tags survives only as a derived cache with exactly ONE writer — refreshContactTagsCache (lib/repo/contacts/tags/cache.ts), the same denormalisation contract contacts.title/company_id already have over positions. Every previous jsonb writer (extraction, addTagToContacts, tagCluster, contact merge, dropTagFromContacts, pruneUnvouchedTags, moveTagSourcesToContact) now writes normalized and recomputes; a grep for a second writer of the column returns only cache.ts. The migration is EXPAND-ONLY — nothing dropped, contacts.tags and contact_tag_sources.tag both still exist, and the text column is written in lockstep with tag_id. Surface: /app/tags (server-paginated, one grouped aggregate for tag + people count, cross-page selection, bulk delete, merge) mirroring /app/companies; tag chips on a person link to /app/people?tags=… with an active-filter affordance and a clear; per-tag Also known as editor plus a global /app/tags/aliases. Merge folds the losing names in as aliases of the survivor, and findOrCreateTagIds resolves name → alias → create — that is the anti-treadmill point: without it the next extraction re-mints the tag the user just merged away and they merge the same pair forever (regression-tested through the real applyExtraction path, not a direct resolver call, so a writer that bypasses the resolver fails the test). Receipts survive a merge re-pointed rather than deleted, source_note_id intact, hand-pinned (NULL) rows included. The RLS wipe hazard and its guard: every new table is under RLS and the boot DDL connection sets no app.current_user_id, so the DDL-time backfill legitimately sees zero rows on a hosted install — leaving contacts.tags full and the normalized tables empty, where the first cache refresh would write [] and unrecoverably destroy that tenant's tags. ensureTenantTagsBackfilled(tx) re-runs the same shipped SQL constant inside the tenant's own scope, and the cache writer fails closed structurally: it demands a TagsBackfilled receipt whose class value is never exported, so a refresh that skips the backfill does not compile, with a runtime instanceof + same-transaction check behind it. The probe matches only "jsonb non-empty AND zero membership rows", so a user who genuinely removed their last tag is never mistaken for an un-backfilled tenant. EE adds tags/contact_tags/tag_aliases to TENANT_TABLES with RLS specs; all three are in the JSON export. Tests: lib/__tests__/tags-normalization/{backfill,merge,aliases,lifecycle}.test.ts — including the wipe regression proved to go red with the guard defeated, tenant-scoped backfill idempotency, and a cache-drift assertion run after every mutation. User guide: content/docs/guide/tags.mdx. (web only — mobile parity pending; typechecked, lint-clean and unit-tested, but not yet browser-verified)
  • Confirmations centre — unified "AI proposes, code disposes" inbox (/app/confirmations + navigation badge; the former Home tile was retired in the lifecycle rebuild). The nav row is hidden entirely when the inbox is empty (2026-08-14) — an empty inbox is not a destination, and the row was unconditional, so every user carried a permanent link to a page with nothing on it. countPendingConfirmations() was already resolved once per /app render and threaded to the nav for the badge, so the visibility rule (AppNav/link-state.ts, isNavLinkVisible) costs no extra query — deliberately not folded into getCachedAppConfig, whose per-user cache has no TTL and is busted only by settings mutations, which would leave a stale row and badge behind after the last confirmation was resolved generalizing edge-suggestions into typed confirmations (entity_link / subject_resolution "who is he?" / enrichment_match / supplement); ambiguous extraction defers to a confirmation, deterministic resolver applies on confirm; opt-in email digest on the daily cron A note that turns out to be about nobody (2026-08-10): a note_subject card whose note already carries reminders offers two more answers beside the person ones — Not about a person (the general tasks stay exactly as they are) and It’s about a company (they are re-pointed onto follow_ups.company_id). Neither writes a note, and the card says so in one line rather than letting the user assume otherwise: facts.contact_id/embeddings.contact_id are NOT NULL and a note hangs off a contact — never a company — so the honest outcome is that the reminders survive and get labelled while the note body is not persisted. Answering with a person re-points those pre-written tasks onto the contact with the note's receipt (attachTasksToContact), and dedupeNoteTasks then 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 as moveNoteToContact): 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: attachTasksToCompany writes no note and no source_note_id, so the note-based re-sync can never fire for it and the event kept its pre-answer title. The ids come from the pending payload, read inside the same mutation scope BEFORE the row stops being pending (lib/actions/confirmations/general-tasks.ts); the person answer needs nothing new, because attachTasksToContact gives the SAME rows the note's receipt and openFollowUpIdsForNote then hands those same ids to a write-out keyed on calendar_event_links.follow_up_id — an edit of the existing event, never a second one. Covered by lib/__tests__/confirmations-note-subject-calendar.test.ts. Both extra answers are hidden when the note carries no reminders: with nothing to keep, the only honest answers are a person or Dismiss. Covered by lib/__tests__/confirmations-note-subject-tasks.test.ts. The Links card can no longer dead-end (2026-08-12): its create affordance was gated on the user owning at least one node type — entities.type_id is NOT NULL and nothing seeds a type — so a user who had never opened the type manager saw a search box over an empty set and Dismiss, with no way to create the thing the question was asking about. With an empty ontology the type picker becomes a name field, prefilled from the extractor's own entityTypeHint, and one answer mints the node type and the entity together (components/app/confirmations/EntityLinkCard/NewObjectTargets.tsx). The same card also lets the user correct the object's KIND: a non-person mention is searched across entities and companies, and + New company resolves it through findOrCreateCompany — alias-aware, so a merged-away name links the surviving company instead of forking the duplicate createCompany would mint. The target union (EdgeSuggestionTarget) gained companyId/newCompany, ConfirmationResult's edge dstType is now the endpoint union rather than string, and both resolveTarget and the action's revalidation switch are exhaustive on it, so a future endpoint kind is a compile error rather than a silently stale page. Covered by lib/__tests__/entity-edge-suggestions/{company-target,no-node-types}.test.ts.

8. Natural-language search (M6)

  • Keyword + structured search (SQL ILIKE over contacts/notes/facts) — free path
  • Query understanding: LLM → structured filters + semantic residual (Ask AI stage 1)
  • Embeddings + pgvector similarity (bge-small via transformers.js — local, $0). Built and working wherever the embedder can run, but OFF on the hosted deployment (2026-08-07): the deploy env sets DHAGA_EMBEDDINGS=off because the local ONNX embedder is unsupported on Vercel serverless (same constraint §14's goal-curation entry records), so embeddingsEnabled() is false there and hosted search is keyword + trigram only. Self-host, Docker and local PGlite are unaffected. The search palette's Semantic similarity weight slider is greyed out with SEMANTIC_SEARCH_COMING_SOON in that state rather than pretending to weight an empty set — searchAction returns semanticEnabled: embeddingsEnabled() and WeightTuner wraps only that one row in ComingSoonNotice; it defaults to false until the first response, so the fail-closed direction is "greyed out", never "silently useless"
  • "Ask AI" answer over retrieved candidates with receipts (Sonnet, explicit click)
  • Acceptance: seeded test set — correct contact in top 3 (vitest, keyword path; semantic covered by the standalone E2E check)
  • Conversational questions rank their real answers (2026-08-10) — asking "who can I reach out to for investor relations" returned vendors and every contact named "… Research" while the contacts actually tagged investor never reached the model's top 10. Two scoring bugs, both fed by treating a question's grammar as search terms. (1) The fuzzy-name gate admits a row when any word scores word_similarity > 0.3 — "reach" vs "Research" is 0.33 — but the score then summed every word's raw similarity, including the ones that failed the gate, on a scale several times ts_rank's; weighted as identity that buried genuine matches at ~3:1. Only the margin above the threshold counts now, so a typo stays decisive when nothing else matches and negligible when something does. (2) queryWords kept English stopwords that to_tsquery discards, so they reached the two paths that don't consult the dictionary — %out% matched inside a "youtube.com" link and %for% inside another, each worth a flat trigram bonus bigger than any real rank. The shared tokenizer now drops Postgres' own english stoplist, so all three halves of keyword search agree on what a content word is. Verified against a real 768-contact graph: the three investor-tagged contacts move from unranked to positions 1–3, and the MIT, typo ("amchit") and exact-name cases are unchanged
  • Sources beyond the contact's own record (2026-08-10)positions (every job and education entry, not just the primary role mirrored onto contacts.title), entities, contact nickname, graph edges (a contact is credited when the node at the far end matches — the branch that makes "who do I know who knows an investor" answerable at all, with the other end's employer folded in since employment is a denormalised column rather than an edge), and notes written on an edge-linked entity (attributed through the edge and labelled with the entity, never as the person's own note). New GENERATED tsvectors on positions/entities plus an expression index for nickname — additive DDL only, no column rebuild. Two new ranking weights (positions, relationships) join the tuning panel
  • 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 about is owned by the group (facts.group_id), and upsertEmbedding asserts a single owner while embeddings carries only contact_id / company_id, so that fact has nowhere to hang a vector and none is written. It is fully readable on the group page and keeps its source_note_id receipt, but it will not come back from a semantic query until embeddings gains a group owner — a schema change plus an EE tenancy rule, deliberately not taken in v1. Do not work around it by attributing the vector to an arbitrary member: that puts the meeting's words on one person's record, which is the exact rule the attribution path (§6) exists to enforce. Keyword search does not reach them either, and this is wider than the spec states — every branch of the keyword UNION scores a CONTACT, so the notes branch is JOIN contacts c ON c.id = n.contact_id … WHERE n.contact_id IS NOT NULL and the facts branch joins the same way. A group-owned note has a NULL contact_id (its owner is group_id, enforced by assertSingleOwner), so a shared note's body and a group-level fact are absent from keyword search as well, not only from the semantic path. They are readable on the group page and reachable from a member's timeline, and nothing is lost — but a user who searches for words they wrote in a shared note will not find it. The shape of the fix is the one /app/plan's events and contact-less tasks already took: a new branch of the SAME UNION ALL (never a second query) carrying its own SearchKind and its own label, since a group is not an identity the palette can resolve. This also means the group accent can never appear on a search receipt — the honest version of the spec's note that "a shared note reads as its channel in search"
  • Rebuild SearchPalette's keyboard/list layer on cmdk if the palette next grows modes or item types (docs/LIBRARIES.md §8) — current custom modes (search vs metered Ask, dictation, weight tuner) don't map 1:1, so no forced swap

9. AI follow-up drafts (M7)

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

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

  • Every AI call logged to ai_actions (feature, model, tokens in/out) — metered per user-visible ACTION, not per call (2026-07-30): a card scan is one row whose tokens are the sum of its field-extraction and transcription calls, and enrichment folds its web search + extraction pass into one. withAiAction (lib/ai/metering/action-scope.ts) is an AsyncLocalStorage scope that nested AI calls join instead of double-billing; no schema change, and pre-existing rows still count as one action each
  • Free-tier cap enforced (10 credits/month since 2026-07-31, was 0 — PLAN_AI_CREDITS_PER_MONTH.free, which FREE_TIER_AI_CREDITS_PER_MONTH now derives from so the free tier has one number; an admin re-sizes it at /app/admin/ai-credits like any other plan, and DHAGA_AI_MONTHLY_CAP seeds it for an instance that has set nothing in the DB) with clear UI message. Free is a real taster rather than zero: 10 credits = 10 card scans, or 5 scans + 5 notes, or 5 Ask-Dhaga questions — deep research (20) never fits in a free month, and enrichment + pre-meeting briefs stay feature-gated to paid plans regardless of credits (PLAN_FEATURES). The cap is now denominated in AI credits, priced per action from real measured cost (BRD §8.3, packages/core/src/metering/credits.ts): 1 credit = a card scan, note, quick add or draft; 2 = Ask Dhaga or a brief; 20 = deep research; 0 = a nightly watchlist scan (throttled by the watch limit instead)
  • User-facing credits page (/app/settingsCredits, 2026-07-31) — the answer to "where did my credits go?", built as a Settings tab rather than its own route because it sits next to Billing in the IA and because the onboarding tour already has a settings leg to hang it on. Three cards: allowance (remaining of total, used-bar, reset date, and — only when something was added — a base / grant / promotion split, so a bigger-than-expected number is explainable rather than mysterious); breakdown by action kind for the current month, whose rows sum to the total by construction (the total is derived from the same per-feature aggregate, and ai-credits-overview.test.ts pins it against aiCreditsUsedThisMonth() — the figure the cap is enforced against); and recent activity, now keyset-paginated (2026-08-01) rather than capped: the first AI_ACTIVITY_LIMIT (20) rows render server-side, and a Load more button pages through the rest of the account's full history via getAiCreditActivityPageActionlistAiCreditActivityPage (lib/repo/ai-usage/activity.ts), a (created_at, id) keyset cursor rather than OFFSET so it stays fast as ai_actions grows without bound (it's append-only and never pruned). Zero-credit watchlist scans are listed and marked Free, never hidden — a month of nightly scanning that vanished from the ledger reads as a bug. Nothing user-facing renders a feature id or a token count (AI_ACTION_LABELS, utils/constants/ai-credits.ts); unlimited plans read "Unlimited" instead of dividing by a zero cap. One aggregate read on the request-scoped connection (lib/repo/ai-usage/overview.ts), no getDb() fan-out. Added to the driver.js walkthrough, and since the tour was rebuilt to start on Settings (§ onboarding walkthrough) it is its second step — a new user meets the price here rather than as a cap message later. Guide: AI credits
  • AI controls grey out before the click when credits are gone (2026-07-31) — a UX layer over assertAiBudget, never a replacement: aiGateReason (lib/ai/gate.ts, React-cache()-memoized so one request resolves it once instead of every server component fanning out its own getDb()) returns a reason or null, and AiGateNotice renders it beside the disabled control in the calm amber pill — never red, because running out of credits is a plan state, not an error. Greyed: Extract contact, card scan, Ask Dhaga (palette Ask tab + search-tab bridge), Brief me, Draft follow-up, Enrich from public web. Deliberately not greyed: manual capture and CRUD, hand-added facts/follow-ups, typed + photo notes (the note still saves; extraction degrades to the existing "…so facts weren't extracted" notice), note re-process, confirmation cards, extraction-job retry, keyword search, graph, import, export, on-device voice — greying those would break the free/no-AI product commitment. The predicate is zero credits left, not "can I afford this action": assertAiBudget refuses on used >= cap and never prices the action, so a price-aware gate would disable a button the server would happily run. !hasLLM()null, leaving the existing "Configure an LLM provider…" degraded messages to own the no-key case. The two controls the client-only app shell mounts (nav quick-add, Ask palette) have no server parent to take a prop, so they read /api/ai/gate lazily (useAiGate). Since 2026-08-14 that route serves a second field alongside the reason — llmConfigured (hasLLM() evaluated server-side, a configuration boolean that never carries key material) — used only to pick the search palette's default tab (Ask when AI is usable, Search otherwise, so ⌘K never opens onto a disabled box) and never to grey a control; aiGateReason itself is unchanged and stays credit-only. 8 tests in lib/__tests__/ai-gate/; manual walkthrough in TESTING.md §7r-ii. Guide: AI credits
  • Monthly inference-DOLLAR ceiling — the master cost gate (2026-08-03) — a second ceiling, independent of credits, because credits stopped bounding spend the moment three metered features were priced at 0 credits on purpose (signal_detection, person_classification, goal_matching; folding them in at 1 credit each was rejected as ~26× mispriced, so an uncredited nightly sweep costs real money and moves no counter). Dollars are computed, not estimated: utils/constants/model-pricing.ts holds the only copy of provider rates (MODEL_RATES_PER_MTOK, BATCH_PRICE_MULTIPLIER = 0.5) and lib/ai/cost/ turns a recorded ai_actions row into USD — an unknown model is priced at the dearest known rate and a dated snapshot matches by longest known prefix, since under-reporting cost is the dangerous direction. Prompt caching is deliberately not modelled (see the next item — there is no discount to apply). Batch-ness is recorded, not inferred: ai_actions gained batch boolean NOT NULL DEFAULT false (lib/db/ddl/core/meta.ts, self-healing ADD COLUMN IF NOT EXISTS) because goal_matching runs both a nightly Batch pass and a synchronous goal-resolve path, so inferring from the feature would halve a real bill; pre-existing rows read false, over-stating an old batch action by 2× (the safe direction) and ageing out within a month. Precedence (lib/ai/metering/dollar-cap.ts), mirroring the credit ladder rung for rung: per-user ai_monthly_dollar_cap_override (0 is a valid override) → the subscription's monthly revenue × multiplier (default 3.0 since 2026-08-19, raised from 2.0 — the arithmetic is below; revenue, not a list price, and every plan is recurring, so each has a real monthly figure) → the floor ($0.50) for any plan with no recurring revenue, which is the case that breaks a percentage model on day one since 0 × any multiplier = $0 would refuse a free user the ten credits they are owed → no ceiling when no plan is in play (a self-host with no billing) or enforcement is off. No promotion rung, deliberately: a promotion raises what a user may do, not the bill we absorb. The revenue basis changed on 2026-08-19 (§18 carries the prices): the rung reads the SUBSCRIPTION, not the plan. monthlyPlanRevenueUsd({ plan, cadence, currency }) (utils/constants/ai-budget/plan-revenue.ts) resolves tier × cadence × the currency the PROCESSOR charges — never the one a visitor toggled on /pricing — introductory offer prices 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 via STANDING_MONTHLY_REVENUE_USD (pricing/prices.ts), which is now only that fallback and no longer the general basis: there is no charged price to read, and refusing them a ceiling would be a $0 refusal. PLAN_MONTHLY_REVENUE_USD survives but is narrowed to the representative standing-USD figure the admin per-plan table renders (that screen lists plans, not subscribers, so it has no cadence or currency to resolve against) — it is not what any subscriber's gate computes. An INR subscription is expressed in the dollars the ceiling is denominated in through the pinned INR_PER_USD = 87 + inrToUsd() (pricing/currency.ts): a constant and deliberately not an FX feed, because it sizes a loose backstop where a few percent of drift moves a ceiling by cents and can never change whether a real user is refused, while a live rate would put a network call — and an outage mode — behind every metered AI action. It must never be used to charge anyone. The multiplier moved 2.0 → 3.0 the same day, and the reason matters more than the number (DEFAULT_AI_DOLLAR_CAP_MULTIPLIER, ai-budget/dollar-ceiling.ts): tying the ceiling to the price paid pulls the thinnest CHARGED row down to Pro introductory yearly, ₹167/mo ≈ $1.92. The measured heavy-user month (BRD §8.3) is $2.84 of inference plus ~$0.40 of uncredited watchlist ≈ $3.24 — at 2.0 that is a $3.84 ceiling with that user at ~84% of it, so real paying customers would have started being refused mid-month by a gate they had never met, with nothing on screen explaining why (the pre-click grey-out is credit-only — see the known gap below). At 3.0 the ceiling is $5.76 and the same user sits at 56%: a backstop again. Resulting ceilings on Razorpay, the only live processor — Pro standing monthly ₹499 → $17.21, Pro standing yearly ₹400/mo → $13.79, Pro introductory monthly ₹199 → $6.86, Pro introductory yearly ₹167/mo → $5.76; Power standing monthly ₹899 → $31.00, Power standing yearly ₹750/mo → $25.86 (Power's four rows became two on 2026-08-24, when its standing price was cut to ₹899 / ₹8,999 — exactly what its introductory rows already held — so the standing and introductory pairs collapsed; the superseded standing rows were ₹1,499 → $51.69 and ₹1,200/mo → $41.38); legacy founding_yearly (₹6,999/yr = ₹583/mo) → $20.11; free → the $0.50 floor; no plan in play at all → no ceiling. Two caveats, to be repeated every time this arithmetic is quoted. (1) The ~$0.40/month watchlist figure is BRD §8.3-flagged ESTIMATED, never measured, and it UNDER-reports by ~$0.01 per contact per cycle: search writes a 0-credit signal_detection row carrying the search turn's tokens, but the $10/1k per-search charge has no column on ai_actions. (2) The thinnest row in the whole table is not the ₹167 one — it is USD introductory yearly at $1.67/mo, a $5.01 ceiling, 65% utilised by that same heavy month, and that is only safe because no USD processor is live; re-run this arithmetic before the first USD charge. The danger did not disappear when the invariant flipped, it changed shape: it used to be "an offer price must never reach the ceiling", and it is now "the multiplier must clear the measured heavy month on the CHEAPEST thing we sell" — which has to be re-checked every time a cheaper offer is added or an uncredited feature grows. Instance state reuses ai_budget_settings (dollar_cap_enforcement on by default, dollar_cap_multiplier, dollar_cap_floorutils/constants/ai-budget/, a DIRECTORY since 2026-08-19: index.ts, allowances.ts, dollar-ceiling.ts, plan-revenue.ts. The import path @/utils/constants/ai-budget is unchanged because index.ts re-exports everything, so any doc still naming utils/constants/ai-budget.ts points at nothing); no new table and no new env var (there is no DHAGA_AI_MONTHLY_DOLLAR_CAP). Enforced inside the existing metering path (assertAiBudget + the hasMonthlyAiBudget pre-flight, lib/ai/metering/index.ts) so it covers every action including the three uncredited ones: credits are checked first, dollars second — the credit message is the one a user can act on (upgrade), the dollar gate is the operator's backstop — and the dollar check sits outside the hasUnlimitedAiCredits early return on purpose, since an unlimited-credit plan (one an admin has uncapped) is exactly the account nothing else bounds. Failure matches the credit cap: AiBudgetError with kind: "dollar_cap" (kinds are now "cap" | "dollar_cap" | "burst"), non-retryable at both sites that branch on kind. Known gap, stated rather than hidden: the pre-click grey-out (aiGateReason, lib/ai/gate.ts) is still credit-only, so an account stopped by the dollar ceiling learns at click time from the error rather than from a greyed control — acceptable because the dollar gate is an operator backstop sized to never touch a normal account, but it is not the same UX the credit cap gets. Tests: lib/__tests__/ai-cost.test.ts, ai-action-metering/dollar-cap.test.ts, ai-action-metering/dollar-gate.test.ts. Admin visibility in §19; rationale and the per-plan table in BRD §8.3
  • Prompt caching markers on stable system prompts — markers are in place (cachedSystem) but 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 via settings (getPendingSignalBatchId/setPendingSignalBatchId). Vercel Hobby's once-daily cron means a contact's signal now lands with a ~1-day lag instead of immediately — accepted tradeoff for a job that was already nightly and latency-insensitive. Typecheck/lint/tests pass (signal-detection-batch.test.ts); no live-API manual run

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

  • "Forget this person" — full cascade delete with confirmation
  • Forgetting a contact cannot delete a shared note that is also about other people (2026-08-23, documented rather than fixed — decided). note_contacts.contact_id is ON DELETE CASCADE, so the erasure takes their link to the note, their membership rows and everything the note attributed TO them; what it cannot take is the note itself, whose owner is the group and whose subject is several people at once. Their name can therefore survive in the note body. This is not new with groups and must not be overstated: it is already true of any note that mentions a third party ("met Priya, she introduced me to her co-founder"), and groups only make it more visible by putting one body on several timelines. Building redaction — rewriting a note the user wrote, on someone else's behalf, with no way to check the edit is right — was considered and rejected for v1. Stated on /privacy and in BRD §7.5 rather than silently relied on
  • Account deletion does not erase groups (2026-08-23) — group_members and note_contacts both cascade from contacts, which the erasure deletes, so those go; groups has no FK to anything the erasure names and is not in ACCOUNT_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's TENANT_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: after notes and facts (both RESTRICT to groups) and after contacts
  • Export: contacts CSV
  • Export: vCard — GET /api/export/vcard stays the full portability download (every contact, whatever its provenance) and gains an opt-in address-book seed scope, ?scope=authored[&provider=device|google|microsoft]: only contacts the user authored, minus anyone already linked on that provider (tombstoned links included, so a bulk seed cannot resurrect someone deleted on the phone). The predicate is the one offerUnlinkedCreates uses, shared via lib/repo/sync/authored.ts so the bulk path and the sync path cannot disagree about what may be written into an address book. An unrecognised scope/provider, or either on format=json, is a 400 rather than a silent fallback to everything. The card now also carries NICKNAME, BDAY and item-group X-ABDATE/X-ABLabel, emits one ADR per entry of the synced contacts.addresses array (labels ride X-ABLabel because resolveLabel maps only HOME/WORK/OTHER out of a TYPE token and would silently drop a custom one), and omits the TYPE parameter on an unlabeled email/phone instead of defaulting it to WORK — all round-trip correctness rather than completeness (see §20). ADR previously came from contacts.location, a free-text display column that is not a synced field, so exporting it as a structured address meant the first sync wrote a fabricated {label:"Work", street:<location>} entry into contacts.addresses; it is now left out of the vCard entirely (CSV and JSON still carry the column, so portability is unchanged). One item-group counter is shared across addresses and dates — an item1.ADR and an item1.X-ABDATE on one card would otherwise both resolve to whichever X-ABLabel came first, and an address would return labelled "Anniversary". Two round-trip gaps remain, both pre-existing, both the same delayed shape (run 1 looks fine, the base records the merged value, run 2 reads the shortfall as a deletion): a custom label on an email/phone/link is emitted as TYPE=NEWSLETTER, which resolveLabel discards, so the label can be cleared on the second sync — the value itself is never lost, and addresses and important dates are immune because they carry X-ABLabel; and import/vcard/tokenize.ts unescapes a value before map.ts splits it on ;, so an escaped \; inside a structured component lands in the wrong field (visible in a current fixture: ORG:Stripe\; Inc parses to company Stripe, department Inc with a leading space) — which now reaches ADR too, since it is the first five-component property we emit. Both were scoped, traced and deliberately left for a follow-up rather than folded into this change. The CSV export takes the same two parameters but is the lossier seed (no nickname, no important dates)
  • Export: full JSON dump (contacts+events+notes+facts+edges)
  • No contact PII / transcripts / extraction output in server logs
  • Enrichment & cloud AI strictly user-triggered (no background calls)
  • Operator access policy, stated and enforced (2026-08-12)/privacy now says plainly who can reach hosted data (whoever holds the production database credentials) instead of implying nobody can, and why end-to-end encryption is not on offer: a server that answers questions about your notes has to be able to read them, so E2EE and cloud AI are mutually exclusive and claiming both would be a lie. Two claims that were false for Dhaga Cloud were corrected rather than softened — "there is no central Dhaga server reading your graph" (true self-hosted, not hosted) and AI calls running "only using your own API key" (true self-hosted; hosted runs on ours, metered in credits). The commitment that replaces them is narrow enough to be checkable: nobody opens a graph except to fix a reported problem, and the admin panel has no screen that renders anyone's contacts, notes or facts.
  • Admin access log (2026-08-12) — every administrative screen opened and every administrative mutation writes one row to EE's admin_access_log (actor, action, target, timestamp), readable at /app/admin/audit, which logs itself. The write sits inside the two authorization chokepoints — requireAdminForPage() for pages, assertAdmin() for server actions — so an unlogged admin surface would have to be an unauthorized one; a logAdminAccess() call beside each guard would be one forgotten line away from a blind spot. It fails closed: a failed log write throws and the page or mutation never runs, because a control that fails open stops existing exactly when the database is misbehaving. The table stores identifiers only — names and emails are joined in at read time, so the audit trail never becomes a second copy of the user directory with weaker controls around it. No FK to user: cascade would make account deletion a way to erase the trail, restrict would make the trail a way to block a deletion the user is entitled to. Honest limit, stated on /privacy: append-only by construction (no code path updates or deletes a row), not by grant — a superuser can still rewrite it, so this is evidence of routine access, not proof against a determined operator. Consolidated the two hand-rolled requireAdmin() copies in lib/actions/admin/ onto assertAdmin() — they were the audit-less duplicates.
  • Terms accepted at signup, and recorded (2026-08-12) — a required checkbox on /signup that also holds the OAuth buttons closed, so consent can't be walked around with one click, plus a server-side record of what was accepted: user.terms_accepted_at / terms_version stamped in better-auth's user.create.before hook against TERMS_VERSION (utils/constants/legal.ts, the same constant /terms prints). Stamped there rather than in the form because the form is one of three signup paths — OAuth and magic link create accounts through better-auth directly — and because input: false on the additional fields means the value can never be posted by a client. Hosted-only: /terms is Dhaga Cloud's contract, and a self-hoster has no counterparty, sees no checkbox, and gets no row. Not backfilled — an account that predates the checkbox genuinely did not tick it.
  • Access asked before consent, not after (2026-08-24) — connecting a calendar or a contacts account now opens ONE dialog first (components/app/connect/) instead of connecting at the narrowest tier and leaving the useful one behind a second control found later. The old flow was a support problem rather than a preference: a calendar connected at free/busy showed no events, the separate "Upgrade to full access" link went unfound, and the user's conclusion was that the product was broken. Calendar's two answers were already reachable — ?upgrade=1 was and remains the entire opt-in mechanism in api/calendar/connect — so that half is UI only, with "see what's on my calendar" pre-selected and marked recommended, so the hurried answer is the working one. Contacts needed real plumbing. ContactSyncProvider.getAuthUrl gained write?: boolean, and one-way now asks for contacts.readonly / Contacts.Read instead of the write scope both providers previously requested unconditionally; that also closes a gap between the code and /privacy, which already described two-way as "a separate, wider grant you opt into on its own" while the write scope was in fact being requested for everyone — the page's claim is now true, which matters because Google verification reviews that wording against the scopes the code asks for. The answer rides the signed OAuth state (signState(provider, userId, intent) plus a new readState), never a callback query parameter: it decides whether Dhaga may write to an address book that syncs onward to every device the person owns, so a value the browser could edit would be a privilege escalation. The callback sets two_way_sync from that intent in BOTH directions and deliberately never derives it from the granted scope — Google's include_granted_scopes=true unions in permissions the account granted on an earlier connect, so a "just read them" answer could otherwise return write-capable and silently switch two-way back on. Two Graph bugs the read-only tier exposed, both already fixed this same way in the calendar provider: the contacts exchangeCode posted a fixed scope (would hand a two-way consent a read-only token) and refresh fell back to the write constant when the stored grant was unknown (would widen a one-way connection on its first refresh) — a new sync/microsoft-provider/scopes.ts derives both, mirroring the calendar's split. All six connect entry points go through the dialog, the reconnect prompt included, so no surface can link straight at a connect route and skip the question. Pending: no live OAuth round trip — there are still no Google/Microsoft credentials in the build environment, so the consent screens themselves remain unexercised and the read-only contacts scope has not been seen against a real account.

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

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

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

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

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

  • M1 card scan: edit-before-save, ≥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/data gateway contract with a TanStack Query adapter in Expo; FlashList v2 for all long lists (docs/LIBRARIES.md §§1, 6; mobile/web parity rule)

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

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

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

  • E2E-encrypted backup/sync

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

13. Browser extension (v1.1)

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/capture REST 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.app and https://www.dhaga.app are both declared host_permissions (both serve; neither redirects), so cloud users get no runtime access prompt. localhost:3000 stays 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 the x-api-key branch requireUserIdFromRequest already supported. Using the extension is not plan-gated even though minting a key is (multi_device_sync), because the cookie path needs no key. The key is stored in chrome.storage.local, never sync, so a credential is not replicated through the user's Google account
  • Screenshot capture (2026-08-10) — the panel captures the visible tab (chrome.tabs.captureVisibleTab, downscaled to 1600px JPEG), up to 6 views of one page, sent as images[] with imageKind: "page". Server side: a page_scan prompt + schema separate from the card scan (the card prompt is instructed to return empty fields for anything that is not a card, so screenshots through it came back blank), its own metered action priced at 2 credits, and /api/capture now checks the page branch BEFORE the card branch so contactId is honoured — previously images.length > 0 returned first and "screenshot this profile onto Sarah" silently created a duplicate Sarah. Screenshots are read and discarded; shouldStoreCardPhotos is 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 sidePanel permission and a service worker whose only job is setPanelBehavior
  • Runtime host access (2026-08-10)host_permissions is now EMPTY. an earlier build declared https://dhaga.app/* there while optional_host_permissions also matched it via https://*/*; Chrome resolves that overlap by withholding the grant, so the extension looked correctly installed while every request failed as a console-only CORS error with a silent UI. The panel now checks chrome.permissions.contains on open and shows a Grant access button. Also fixed: picker.ts had a try/finally with no catch, so a blocked contact search escaped as an unhandled rejection
  • Web Store packaging/listing (privacy policy page now live at /privacy) — further along than it looks: build/zip pipeline exists (apps/extension/build.mjs), a pre-built .zip and STORE_LISTING.md with listing copy are ready; only remaining blockers are a reviewer demo account + the $5 Chrome dev registration fee (both manual/external, not code). Until that clears, unpacked-only is the shipped reality and every surface that mentions the extension has to say so — the pricing FAQ does (utils/constants/landing/pricing/faq.ts), and no page offers an install link

14. Proactive intelligence (v1.2)

  • Job-change detection — simplified to web-search-only per product decision (2026-07-05), superseding the CSV-diff mechanism in BRD §6.7: shares the signal-detection sweep with the news watchlist below, kind: "job_change" when the search results show a different title/employer than what's on file. Typecheck/lint/build/tests pass; manual click-through needs a real ANTHROPIC_API_KEY, not done. Wired but unproven (2026-08-08). Firecrawl is no longer paid for, and the sweep now searches through Anthropic's own server-side web_search tool (packages/core/src/search/anthropic-client/) behind the same gateway; the default provider is anthropic wherever FIRECRAWL_API_KEY is unset, so hasSearch() (packages/core/src/search/index.ts) is true on the hosted instance, which already has ANTHROPIC_API_KEY. runSignalDetection() therefore no longer short-circuits on { skipped: "no_search" } — but no end-to-end run has been observed against a live key, so nothing is claimed about what it produces, and the box stays unticked for exactly that reason. The credit price is unverified too: signal_detection is still 0 credits, but the cost it was argued from is stale — see §8.3 of the BRD
  • Opt-in news watchlist: per-contact "Watch for job changes & news" toggle (contact page) → nightly cron (/api/jobs/detect-signals, apps/web/vercel.json) → provider-agnostic web search (packages/core/src/search, Anthropic's server-side web_search tool by default since 2026-08-08; Firecrawl still wins where FIRECRAWL_API_KEY is set) → Haiku classifies hits → signals table → signal-scored Home Moments + contact page, "Add as note" (receipted) or dismiss. Capped per plan (PRO_TIER_WATCHLIST_CAP/FREE_TIER_WATCHLIST_CAP) — Pro's cap dropped 25 → 5 on 2026-08-19 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] passes hasSearch() into WatchToggle as searchConfigured, and a false value wraps the control in ComingSoonNotice with SIGNAL_WATCH_COMING_SOON and swaps the "opt in to get alerted…" line for SIGNAL_WATCH_DISABLED_DESCRIPTION (utils/constants/coming-soon.ts). It is a runtime check, not a build flag, so the toggle un-greys itself the moment a key is set — no code change. That gate is now open (2026-08-08): with anthropic the default provider, ANTHROPIC_API_KEY alone makes hasSearch() true, and the hosted instance has it. What has not happened is a single observed end-to-end run — no live search, no live classification, no signal seen in the UI — so treat the feature as armed, not proven. Search is metered (lib/jobs/detect-signals/search-phase.ts: one 0-credit signal_detection row per search carrying the search turn's tokens, so the instance dollar ceiling sees them; Firecrawl/SearXNG report no cost and get no row), but the $10/1k per-search charge has no column on ai_actions and is under-reported
  • Keep-in-touch cadence reminders + Home Moments (ideas.md #2). Rewritten 2026-08-02 as one scored list (lib/repo/daily-suggestions/, weights in utils/constants/suggestions.ts). The retired Home "Today" tile used to walk three first-come-first-served buckets — daily check-ins, then cadence-due contacts in FNV-1a-hash order (so how overdue someone was never affected where they landed), then a degree-centrality filler — with three chips: Check-in / Due / Network. The surviving engine now runs one additive score and supplies Moments for today: five sources (cadence, dated open follow-ups, upcoming important dates, watchlist signals, graph degree) nominate candidates, six reason-bearing terms score them against each other (cadence 40, follow-up 30, important date 30, signal 25, quiet/decay 15, degree 10) plus two modifiers (starred 10, day-keyed rotation 5), and the top N are taken. The winning term names the row, so a chip can never claim a reason that didn't rank the person; the two modifiers can lift someone into view but are excluded from the copy outright ("you starred them" is not a thing to do today, and the reason string is emailed verbatim by the digest). Seven chips now: Check-in, Due, Follow-up, Occasion, Signal, Quiet, Network — an eighth, Goal, and an eighth scoring term landed the next day and were removed again on 2026-08-22, when goal left the suggestion engine for surfaces of its own; the engine is back to seven chips, six terms and five sources, with no SUGGESTION_WEIGHTS.goal (see the goal-driven curation entry below). Still zero AI — deterministic SQL + pure TypeScript, quantised to the user's local midnight so the list is identical on every render within a day: no LLM call, no withAiAction entry point, no credit price, no inference cost, and it therefore works unchanged on the free tier (CLAUDE.md Rule 5). Three behaviour changes worth naming: (1) "Due" now means real last touchlastTouchSql (GREATEST(created_at, last_reached_out_at, newest live note, newest event scan)) rather than only the "Reached out" button, so writing a note about someone or scanning them at an event resets their cadence clock; (2) source = 'mentioned' stubs ("Prashant's son" — referenced in a note, never captured) no longer reach the due feed, filtered once for every source in daily-suggestions/facts.ts; they were already excluded from the graph filler and the Going-quiet tile, the due feed was the one hole; (3) a busy calendar shrinks the list to a floor of one (MIN_SUGGESTIONS_ON_BUSY_DAY) instead of potentially silencing it — daily check-ins used to be added ahead of the meeting-load subtraction to escape it, and no longer need to, because the list is urgency-ordered and an urgent check-in is already row 0. Covered by lib/__tests__/daily-suggestions.test.ts. content/docs/guide/home.png now shows the current lifecycle composition with initials-only synthetic data. Pending: live click-through of the production Moments actions with populated data. User guide: apps/web/content/docs/guide/index.mdx
  • Goal-driven curation (2026-08-03; surfacing rebuilt 2026-08-22) — the engine and controls are built, and the 2026-08-14 lifecycle composition re-homed them in a Relationship goal panel below the daily briefing — superseded on 2026-08-22, when goal left the shared suggestion engine entirely for a route and a Home tile of its own (docs/GOAL_ENGINE.md; the rebuild is detailed at the end of this entry, and where it contradicts an older sentence here the rebuild is what shipped). Nothing is verified beyond typecheck/unit level. The user writes one objective in plain words ("reach out to VCs", "reconnect with people from the Delhi trip") through the Set a goal dialog on Home (which moved into goal's own Home tile on 2026-08-22), and Dhaga resolves it into a finite cohort out of the user's own graph that burns down as they reach out. Retrieval is deterministic and LLM-free (lib/repo/goals/recall/ calls hybridSearch(objective) — CLAUDE.md Rule 5: only the judgment of whether a person fits is a model call); a nightly Batch pass (lib/jobs/match-goal/) ranks the recalled pool with Haiku and writes goal_members rows carrying the frozen 0–100 fit, with anything under GOAL_MIN_FIT (40, added 2026-08-22) stored rejected rather than surfaced. GOAL_DAILY_SLICE (3) members surface each morning — on goal's own surfaces since 2026-08-22, not behind a Moments chip — rank-banded (GOAL_RANK_BAND, narrowed 25 → 5 on 2026-08-22 because a model that reliably answers 85 put every member in one band, so ordering degenerated to the day hash and the model's judgment contributed nothing) then day-key rotated so the cohort can't stall behind whoever the user least wants to message. Scoring: goal originally rode the shared scorer as an 8th additive term at weight 28 (SUGGESTION_WEIGHTS.goal, modulated by SUGGESTION_GOAL_BASE 0.7), reserving no slots — all of that was deleted on 2026-08-22: there is no SUGGESTION_WEIGHTS.goal, no SUGGESTION_GOAL_BASE and no goal candidate source (lib/repo/daily-suggestions/candidates/goal.ts is gone), so a goal member never appears in Today/Moments at all. "Done" is DERIVED, never stored (lib/repo/goals/cohort/): a member drops out once lastTouchSql moves past matched_at, so a note about them or an event scan burns them down exactly like the Reached out button, and markReachedOut — six callers, none goal-aware — needed no change. MAX_ACTIVE_GOALS = 1, enforced in the repo write rather than by a unique index: creating a second goal archives the oldest instead of erroring. GOAL_MATCH_RUN_CAP (150) is the per-run cost fuse; the nightly goal_matching pass is priced 0 credits (see §8.3). Saving a goal is free and calls no model (2026-08-03): matching is the nightly pass's job, the waiting copy says so (GOAL_NIGHTLY_LINE, corrected 2026-08-22 to say two nights rather than "tomorrow" — see the rebuild below), and a Request now button buys the same match immediately for 8 credits via lib/ai/goal-resolve/ — its own metered feature goal_match_now, confirmed behind a dialog that shows the price and that waiting is free, with the credit gate resolved before the click; it was priced at 3 until 2026-08-22, when a measured run (~1,059 in / ~20 out per candidate ⇒ ~$0.046/request at ~$0.006 per credit) repriced it to 8 and GOAL_SYNC_RESOLVE_CAP went 20 → 40 so one request can reach more of the graph. Credits (plus the dollar ceiling) are now the only fuse on that path; the old RATE_LIMITS.goal_resolve 3-a-day bucket was removed with the free-on-save behaviour that needed it. The surfacing half of this entry was rebuilt on 2026-08-22 (docs/GOAL_ENGINE.md), and the sentences above it about WHERE a goal appears no longer hold. The diagnosis was that the cohort was real and the user never saw it: goal nominations were merged into the shared daily-suggestions list where goal: 28 lost to every term that could realistically fire on the same person (cadence 40, followUp 30, importantDate 30) — though the weight was never the decisive problem (28 sat above signal 25, quiet 15 and degree 10); the caps were. Goal was capped at 3 candidates against every other source's 10, trimmed by meeting count, index-capped out of MomentsTimeline, and — on the rare render where a member survived — badged Cadence, because reason.ts names a row after its highest-scoring term (§2.4). So goal has left the suggestion engine entirely (§3.1): there is no Goal chip, no 8th scoring term and no SUGGESTION_WEIGHTS.goal. It has two surfaces of its own instead (§3.7). /app/goal — a real route, registered in APP_MORE_LINKS/APP_MORE_MENU_LINKS — renders four lists off ONE cohort read: Today (up to GOAL_DAILY_SLICE, never offered before), Already shown (offered earlier, still open, never buried), All matches (the whole cohort in one list, which was the user's explicit ask — "I should see all 14 at once") and Skipped, with the model's one-line reason printed on every person in every list. It can add a person by hand (the shared EntityCombobox) and reject one with Not relevant, which captures a reason — four one-tap answers plus free text — because a skip and its words are the only training signal the matcher gets (§3.5) and state = 'skipped' previously had no writer anywhere in the codebase (§2.5). Home's goal tile is a preview of that route: objective, counter, today's people with their reasons, and a link through. The bare 0/14 bar is gone — the counter reads "3 of 14 today · 3 already shown", since the user's first question about this feature was literally "what is this 14?". surfaced_at is stamped from a server action fired by a client effect on /app/goal only, never on read (a write during a React server render is forbidden) and never from Home (which router.refresh()es itself on the calendar pulse and would walk the cohort into "Already shown" with nobody watching). Waiting copy now matches the measured ~48h floor instead of promising "tomorrow" — GOAL_NIGHTLY_LINE says two nights, which is what a daily cron plus a Batch that submits one night and applies the next actually costs (§2.6a). The cohort can grow now, which it could not before. Three changes make that true: (1) goal recall stopped inheriting hybridSearch's 20-row presentation slice — GOAL_RECALL_LIMIT (150, = GOAL_RECALL_POOL) is passed explicitly, and that slice was the measured ceiling, not a theoretical one (~69 qualifying contacts existed in the observed account, ~20 were ever judged, and the cohort stalled at 14 forever); (2) a below-floor verdict is stored rejected and every already-judged contact id is excluded from the next recall, so each pass reaches genuinely NEW people instead of re-judging the same top 20 to the same answer; (3) the batch pointer gained a 36h staleness bound and is cleared on abandonment, so one wedged or expired batch can no longer freeze goal matching indefinitely (§2.9). Rewording a goal clears the model's rejected rows but deliberately keeps the user's skipped rows — the model may be wrong about a new objective, the user's "not relevant" is not ours to discard. GoalResolveSkip also stopped collapsing every refusal into one no_budget: it is now no_credits / spend_limit / rate_limited / partial, so a 5-second burst limit no longer reports "out of AI credits this month" (§2.6c). New/moved code worth knowing about: lib/repo/goals/recall/, lib/repo/goals/cohort/, lib/repo/goals/curation/, lib/actions/goals/, lib/ai/goal-resolve/ (new examples.ts, outcome.ts, report.ts), utils/constants/goal-surface.ts, components/app/goal/, app/app/goal/. GOAL_MATCH_RUN_CAP (150), MAX_ACTIVE_GOALS (1), GOAL_DAILY_SLICE (3), the derived-"done" behaviour and the 0-credit nightly goal_matching price are all unchanged by the rebuild. Pending / not verified — the honest state (Rule 12): verified at typecheck, lint and unit level only (apps/web 2521/2521, packages/core 289/289; home-connection-pressure.test.ts still proves goal_members is joined exactly once per Home render). /app/goal has never been loaded in a browser at any width, and there is no screenshot, and no production build has been run against it — so nothing here is proven past unit level. The nightly production path has never completed a cycle for the reporting account: the pass is wired into /api/jobs/daily (after signal detection, before the reach-out digest, so the digest emails the freshest cohort), but that route has never been driven end-to-end with a live key, and the Batch pass has never been exercised against the real API. The credit price is the one thing that is measured now — goal_match_now's 8 credits come from an observed run (see above), so it is no longer an estimate; the nightly goal_matching and watchlist figures in BRD §8.3 still are. content/docs/guide/home.png predates the 2026-08-22 rebuild and shows the old Relationship goal panel below the briefing rather than the goal tile above the moments list; it was not recaptured, because there has been no browser pass to recapture it from. Known limitation, by deployment: hybridSearch's semantic stage runs through a local ONNX embedder that is unsupported on Vercel serverless (lib/ai/embedder.ts), so hosted degrades to keyword + trigram — concretely worded objectives ("designers in Bangalore", "people to see on my Delhi trip") recall well because those words appear in notes and titles, abstract ones ("people who could open doors for me") recall poorly because nothing in the graph is worded that way. There was a second ceiling — hybridSearch returns at most 20 hits, so GOAL_RECALL_POOL (150) was an upper bound this path could never reach — fixed on 2026-08-22 by passing GOAL_RECALL_LIMIT explicitly (see the rebuild above); the deployment limitation above is unchanged. User guide: apps/web/content/docs/guide/index.mdx
  • 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 like detect-signals) classifies each row person / service / unknown into contacts.person_kind. The governing rule: a service row is never suggested, but is always findable. lib/repo/contacts/surfaceable.ts is the single definition of "a row Dhaga may nominate", applied only to surfaces the app chose (Today, graph fallback, cadence due list, going-quiet, recommendations, Recent people) and never to People, Saved, search, duplicates, merge, Wrapped, address-book sync or export — filtering there would be an irreversible invisible hide. Nothing is deleted, archived or hidden from a place the user navigated to on purpose. Three values not two: NULL means "never judged, batch it" and unknown means "the model looked and declined", so a row that says only a first name is left alone rather than coin-flipped; only service suppresses, and person_kind_confidence orders the review list but never decides suppression. Visible and correctable: an "N hidden from suggestions" link on /app/people (linking to ?kind=service, the rows themselves — not to an explanation of them), a provenance chip on the contact page ("Dhaga's guess" vs "You marked this"), one tap to overrule, and a People bulk action. A user ruling is a permanent lock (person_kind_by = 'user') the sweep never re-judges. A contact the user has already acted on is never nominated for classification at all (classify-people/due.ts): starred, watched for signals, has a cadence, has ever been marked reached out to, or has any live note — the model only judges rows nobody has touched, because the worst failure this design can have is labelling a real friend a service. Throttled by PERSON_CLASSIFICATION_RUN_CAP (1000 contacts/night), not by billing: person_classification is priced 0 credits (see §8.3). The due-predicate plus the cap is the backfill — deliberately no one-shot script to drift away from it. DDL in lib/db/ddl/core/extend.ts (four columns + one partial review index), covered by lib/__tests__/surfaceable-suppression.test.ts. Pending / not verified: no live Anthropic API run, and no browser click-through of the People link, the provenance chip or the bulk action. The pass is wired into /api/jobs/daily (ahead of the reach-out digest so it emails the freshest labels), but that route has never been driven end-to-end with a live key. content/docs/guide/people-bulk-actions.png is now stale too — it predates the Not a person / Is a person buttons. User guide: apps/web/content/docs/guide/index.mdx
  • Opt-in morning follow-up reminder email (daily nudge for open follow-ups + due reach-outs via /api/jobs/daily, dummy-account exclusion via isDummyAccount()). Correction (2026-07-30): this job, the reach-out digest and the confirmations digest read their opt-in setting on an unscoped connection, so under packages/ee RLS the read matched zero rows and all three could never send for a hosted user, whatever their toggles said. All three now use the hostedTenants() + withUserDb fan-out (the shape follow-up-reminders / linkedin-export-reminders already had), with per-tenant local-day idempotency via lib/jobs/last-run.ts. 32 new unit cases model RLS by returning rows only inside a tenant scope, so the old implementation fails them; no live hosted send verified. Superseded 2026-08-09: all three are sections of the one daily brief (lib/jobs/daily-brief/) rather than separate jobs — see the "One daily email per person" entry below. See docs/FOLLOW_UPS.md and docs/TESTING.md §7aa
  • Follow-up calendar (/app/calendar; superseded 2026-08-13 — now the Month view of /app/plan) — month + agenda view of every open follow-up (getCalendarFollowUps), built on FullCalendar v6 (@fullcalendar/react + daygrid + list + interaction) themed to the amber tokens, not a hand-rolled grid (see docs/LIBRARIES.md §12); follow-ups are all-day single-date items. Shipped on feat/followups-calendar
  • Drag-to-reschedule a follow-up on the calendar (FullCalendar interaction plugin, pointer + touch), persisted via the follow-up repo/action layer
  • Calendar readability + filtering (2026-08-06) — four reported problems, one change. (1) Unscheduled chips are now real <button>s opening the same details dialog the grid does, so the truncated action text is readable at all — it was previously reachable only via a native title tooltip, invisible on touch and unreachable by keyboard. They stay FullCalendar drag sources; a pointerdown/click slop threshold (TRAY_CHIP_CLICK_SLOP_PX) separates the two, and keyboard activation is detected on event.detail === 0 rather than on the absence of a press point — a drag released off a chip fires no click, so the stale press point would otherwise measure a keyboard event's 0,0 as a long drag and silently swallow it. (2) Done follow-ups render on the grid and list, struck through and faded (.fc-done), never overdue and never draggable; loadFollowUps reads listTasks() (open + done) directly instead of listAllOpenFollowUps(), and getNotificationSummary / getDueFollowUpRemindersForUser narrow back with a named isOpenFollowUp first, so the nav bell and reminder email are behaviourally unchanged — widening what the calendar loads must never widen those. (3) Search + person + company + scope + status filters trim the grid and the tray from ONE predicate (filter-follow-ups.ts); a filter that moved only one of the two would misreport what "3 results" means. Person/company options derive from the loaded follow-ups, so the control can never offer a name with nothing behind it. External calendar events and birthdays are deliberately unfiltered. (4) General/People/Companies uses the same scope vocabulary as /app/tasks, now hoisted with the free-text predicate into lib/filters/follow-up-scope.ts and consumed by both screens — the two must not be able to disagree about what "General" means. reconcileResolvedFollowUpEvent was replaced by pure list transforms (applyFollowUpOutcome/applyFollowUpDueDate): once events derive from a filtered list, an imperative event.remove() is undone by the next keystroke, and an unscheduled item has no grid event to remove. lib/repo/reminders/calendar.ts split into a directory (150-line rule); import paths unchanged. Pending: no browser pass — the drag-vs-click discriminator, the <button> chip binding as a Draggable item, and the .fc-done cascade are covered by unit tests and reasoning, not a real interaction; screenshots not recaptured
  • Tasks + Calendar + Follow-ups merged into /app/plan (2026-08-13) — built, nothing verified beyond typecheck/unit level; no browser pass. Three routes were three windows onto the same follow-ups (a due date is a list row and a calendar chip), so they are now one page with a Day / Week / Month / List switcher; /app/tasks, /app/calendar and /app/follow-ups permanentRedirect to it and the nav carries one "Plan" entry. List is Dhaga's own task board, not FullCalendar's listWeek — it hosts per-row actions a FullCalendar row cannot — and it is the DEFAULT view, so the page opens on the work you owe; Day/Week needed @fullcalendar/timegrid@6.1.21 (new dep) and the view is switched via getApi().changeView() rather than by remounting, so paging to a date survives a switch. One filter state and one follow-up list feed every view (components/app/calendar/use-plan-board.ts), so a row completed in the list is gone from the month grid without a reload. The one real collision was the status axis: the calendar's all|open|done vs the task board's active|completed; resolved as the superset all|active|completed defaulting to active, which let components/app/calendar/filter-follow-ups.ts be deleted rather than kept in sync with components/app/tasks/filters — the cross-screen parity test (lib/__tests__/follow-up-scope.test.ts) was re-pointed at guarding that the surviving board still delegates to lib/filters/follow-up-scope instead of re-inlining a predicate. CalendarFollowUp gained createdAt at the reminders mapper, which is what let the second row type (and therefore the second filter module) go. Imported events are now clickable — a read-only dialog naming the calendar, account and location, which is the only way to tell a work meeting from a personal one on a merged grid; they stay undraggable and unsaveable. Dhaga's own calendar events (local_calendar_events) join the grid as a fourth kind with create/edit/delete; the form posts ISO instants built in the BROWSER, and all-day ends are stored EXCLUSIVE (pinned by LocalEventDialog/instants.test.ts). Every follow-up row offers an inline DatePicker in both views, showing the free-text dueHint a note-extracted follow-up arrived with — those rows were previously invisible on a calendar and unactionable without opening an edit form. The two orphan blocks from /app/follow-ups (due check-ins, upcoming dates) now render from one component in two places: a collapsible side panel on /app/plan (a Sheet under lg) while Home consumes the same due/date facts through Moments rather than duplicate tiles; its upcoming-dates read is hoisted out of buildDailySuggestions and injected back in, so HOME_DB_ROUND_TRIP_BUDGET is unchanged. Known gaps: no browser/screenshot pass at 375px or on the timeGrid views; the #due anchor is unreachable on mobile because the panel is a closed Sheet there; the notification bell still links /app/calendar and /app/follow-ups and relies on the redirects
  • Follow-ups carry an OPTIONAL time of day (2026-08-13) — built, typecheck/unit level only; no browser pass. A due date was a calendar DAY and nothing else, so "call Priya at 3pm" could only be written as a day and landed on the connected calendar as an all-day banner. Time is now optional and absent is still the default and the common case: an untimed follow-up is byte-identical to what shipped — due_date pinned to UTC midnight, an all-day chip, an all-day calendar event. The flag is a real column, follow_ups.due_has_time boolean NOT NULL DEFAULT false (lib/db/ddl/core/extend.ts), not an "is it midnight?" inference: that inference loses the time of anyone who genuinely schedules 00:00, and misreads every legacy row in a non-UTC deployment. DEFAULT false makes the migration a no-op. The instant is built in the BROWSER and only validated on the server (components/app/tasks/due-instant.ts → the hidden dueAt field → lib/actions/task-input.ts), because the server does not know which zone "15:00" was typed in; it reuses LocalEventDialog/instants.ts, which Dhaga's own calendar events already do this way, rather than growing a second pattern. Three consequences fall out: recurrence advances through the new pure nextRecurrenceInstant (packages/core/src/dates/recurrence.ts), which preserves the offset from the occurrence's UTC midnight so "every Tuesday at 15:00" does not come back at midnight on the second occurrence; overdue is by instant when there is a time (a 15:00 item is not late at 09:00 that day) and by the user's day boundary when there is not; and a timed follow-up is written to Google/Outlook as a timed event, 30 minutes long (FOLLOW_UP_TIMED_DURATION_MS) — a follow-up is a prompt to act, not an hour of the user's day, and an hour-long block per follow-up makes a light day look fully booked. The time control sits beside the date in all four surfaces (TaskForm, FollowUpDuePicker, AddFollowUpForm, FollowUpEditForm) via one shared DueFields, is disabled until a day is picked, and is clearable back to no-time; no default time is ever invented. A drag onto a time slot makes a row timed, a drag back onto a month cell or the all-day row makes it a plain day again. Covered by new cases in packages/core/src/dates/recurrence.test.ts, lib/actions/task-input.test.ts, lib/__tests__/tasks-due-time.test.ts, lib/__tests__/calendar-follow-up-event.test.ts (including both provider bodies), lib/repo/reminders/calendar/overdue.test.ts and the calendar event-map tests. AI-extracted follow-ups now carry the time too (2026-08-20) — the extractor writes it, and note extraction stopped throwing it away. followUpSchema.due_time is a required-but-nullable 24-hour "HH:MM" wall-clock in the USER's zone (resolveDatePhrase is zone-free by construction and cannot read a clock, so only the model can hand the time over), and lib/repo/graph/follow-up-due.ts is the one place both note writers (apply-extraction, apply-company-extraction) turn day+time into a row — the same three-case table the message-capture path uses: day+time ⇒ instantFromZonedWallClock, due_has_time true; day only ⇒ UTC midnight, all-day, byte-identical to before; no day ⇒ no date, because a time alone would have to invent a day. An unparseable time degrades to all-day rather than guessing an hour, and "00:00" is a real time (=== null, never a truthiness check). The zone comes from the ONE prefs read lib/ai/note-extraction/prep.ts already makes for today and is threaded through as ExtractionPrep.timeZone; the supplement-confirmation caller passes none, so the writer reads it lazily and only for a note that actually states a time. Behaviour change on the main capture path: a note saying "meet at 3pm" now creates a TIMED task and a timed 30-minute calendar event where it used to create an all-day one. Covered by lib/__tests__/extraction-follow-up-time.test.ts. Pending: no browser pass at 375px or on the timeGrid views; mobile parity not done
  • In-app notification bell — nav bell with overdue + due-today follow-up counts and a capped preview (getNotificationSummary)
  • Notification feed (2026-07-30) — built, not browser-verified. The bell (moved to components/app/AppNav/NotificationBell/, header renamed "Reminders" → "Notifications") now carries three kinds in one list: derived follow-up reminders, derived upcoming important dates, and persisted notifications from a new notifications table (lib/db/schema/notifications.ts, DDL lib/db/ddl/core/notifications.ts, enrolled in EE TENANT_TABLES). A row is written when an extraction/enrichment job reaches a terminal state (job_done / job_failed / job_blocked) — the first thing that tells a user a background job finished after they navigated away. Dismissible + mark-read (status unread → read → dismissed; rows are never deleted, so a re-run cannot resurrect a dismissed one). Both FKs (contact_id, job_id) are ON DELETE CASCADE because titles embed contact names, so the privacy cascade must take them. Badge counts overdue + due-today follow-ups, unread notifications, and important dates only on the day — a birthday six days out is in the panel, not on the badge. Covered by __tests__/notifications.test.ts + __tests__/notification-feed/; click-through pending (docs/TESTING.md §7y)
  • Background-job alert email (2026-07-30) — built, not browser-verified. Opt-in (job_email_notifications_enabled, default off) email when an extraction/enrichment job fails or is blocked, capped at one per user per 15 minutes (JOB_EMAIL_COOLDOWN_MINUTES). Successful runs are deliberately not emailed — they ask nothing of the user and the notification row is already the record; emailing every success would get the channel muted and take the failure alerts with it. The in-app notification still fires for every job either way. The send happens strictly after the notification's withUserDb scope commits and releases (never across an open connection), and a send failure can neither fail the job nor lose the notification; a bounce does not write the cooldown stamp, so it cannot silence the next window. lib/email/job-notification.ts + lib/repo/notifications/job-email.ts; covered by __tests__/job-notification-email.test.ts
  • Birthdays & anniversaries as real reminders (2026-07-30) — built, not browser-verified. contacts.important_dates (JSONB, free-text values: YYYY-MM-DD, year-less MM-DD, or verbatim December 9 from the Google/vCard importers) now drives reminders that are derived, with no new table and no DDL — so editing a date or forgetting a contact needs no cleanup. Pure recurrence maths in packages/core/src/dates/ (Feb 29 clamps to 28 Feb in a non-leap year, local-midnight arithmetic, never toISOString()); reads via listUpcomingImportantDates(leadDays) / listImportantDateOccurrences(range) (lib/repo/reminders/important-dates.ts). Surfaces: read-only all-day entries on the calendar views of /app/plan (/app/calendar when this shipped) (not draggable, no Done/Reschedule — a birthday is not a task; edited on the contact), an Upcoming dates block in /app/plan's side panel, due occasions folded into Home Moments, and the nav bell. The contact form gained a real calendar picker with a year dropdown (1900 → current+10, components/ui/date-picker/, which also now opens on the selected date's month rather than today); verbatim and year-less stored values are preserved byte-for-byte and shown as placeholder text. Covered by packages/core/src/dates/important-dates.test.ts + upcoming-date.test.ts + calendar/__tests__/event-map.test.ts; click-through pending (docs/TESTING.md §7x). (web only — mobile parity pending)
  • Birthday/anniversary reminder email (2026-07-30) — built, not browser-verified. lib/jobs/daily-brief/sections/important-dates.ts (+ sections/important-date-state.ts, was lib/jobs/important-date-reminders/) + lib/email/important-date-reminder.ts, wired into /api/jobs/daily with the same per-tenant withUserDb fan-out. Gated on a new important_date_reminders_enabled setting (default off — imported address books arrive full of dates the user never reviewed) with a configurable important_date_lead_days (0–90, default 7). Anti-spam: at most two emails per occurrence — one when it enters the lead window, one on the day — keyed on [contactId, label, occurrenceDate, stage] tokens stored under the important_date_reminders_sent settings key (a JSON array, mirroring linkedin_export_reminders_sent; no table, past occurrences pruned). Covered by daily-brief/important-dates.test.ts; no live send. Superseded 2026-08-09: it is the "Important dates" section of the one daily brief now, not its own email; the toggle, the lead time, the two-per-occurrence rule and the important_date_reminders_sent key are all unchanged
  • Per-user IANA time zone (2026-07-30) — browser-verified, and it now prompts instead of waiting (2026-08-14). Set in Settings → Suggestions (components/app/settings/TimezoneSetting/), stored as SchedulePrefs.timezone inside the existing schedule_prefs blob, default "UTC" so existing users see no change until they choose; helpers in lib/time/zone.ts are Intl-only (no date-fns-tz, and it lives in apps/web not packages/core because Intl.supportedValuesOf is unreliable on Hermes — see docs/LIBRARIES.md). The setting no longer waits to be found: it was correctable only by opening Settings and noticing the passive Use detected zone link, and a stale zone is the worst kind of bug here — it never errors, it just puts every reminder, birthday and "due today" on the wrong morning. components/app/TimezoneMismatchBanner/ (TimezoneMismatchBanner.tsx, session.ts, index.ts, __tests__/) now raises a banner whenever the browser's IANA zone differs from the saved one, mounted in app/app/layout.tsx in normal document flow, immediately after LapsedPlanBanner and above {children} — so it reaches a user landing on any /app route, not only Settings. In-flow placement is also what keeps it off a collision course with the two onboarding surfaces: the /app crayon annotation overlay is fixed inset-0 z-50 behind an 0.88 scrim and driver.js paints its own overlay over Settings, and an in-flow banner can neither stack on, dim, nor steal a click from either — while an overlay is up it simply sits behind it and is there when it clears. It offers Switch to the detected zone, a Pick a different zone link to /app/settings#suggestions, and a dismiss X; accepting posts to the same setTimezoneAction (lib/actions/suggestions/schedule.ts) the settings card already uses, so there is no second write path. Dismissal is sessionStorage (dhaga:timezone-notice), not localStorage, and there is deliberately no "don't ask again" — dismissing silences it for that tab session only, so signing in again or reopening the tab prompts once more. Known and accepted trade-off: someone who wants a saved zone different from their browser's (managing a team in another zone, travelling briefly) is prompted once per session, indefinitely; the escape hatch is to save the zone they actually want. Zones are compared canonically (canonicaliseZone in the new lib/time/browser-zone.ts) so IANA alias pairs — Asia/Calcutta/Asia/Kolkata, Europe/Kiev/Europe/Kyiv — count as the same place; without it a user in India would be prompted every session forever, because this repo's Node ICU lists Asia/Calcutta while newer browser ICU says Asia/Kolkata. The browser-zone read (readBrowserZone, humaniseZone, subscribeNever, readNoZone) moved out of TimezoneSetting/zone-options.ts into that new module so both surfaces share one detector; zone-options.ts keeps only the combobox option shaping. app/app/layout.tsx now calls getSuggestionSettings() where it called getImportantDateLeadDays() — both are ONE settings round-trip, but the former also carries the saved zone, so the banner costs no extra DB read. Scope limit, 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 by timezone-zone.test.ts + timezone-settings.test.ts + TimezoneMismatchBanner/__tests__/session.test.ts + __tests__/TimezoneMismatchBanner.test.ts (15 cases, all passing; jsdom with createElement and real react-dom/client, since this repo has no @testing-library). What "browser-verified" covers: the picker and the new banner, rendered in Chromium under a Playwright timezoneId override at 375px and 1440px, light and dark — that pass is what caught the alias bug above, which every unit test had agreed with
  • Web Push / OS-level notifications — 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; was lib/jobs/follow-up-reminders.ts) emailing each opted-in tenant a "N follow-up(s) due" summary of overdue + due-today follow-ups (getDueFollowUpRemindersForUser); reuses the morning-reminder opt-in (morning_reminder_enabled), per-tenant fan-out via hostedTenants() + withUserDb, contact names HTML-escaped, no email when nothing is due or Resend is unconfigured; pure subject/HTML/send-guard covered by daily-brief/follow-ups.test.ts. Email sweep built + unit-tested this change; calendar/bell UI built by the sibling agents on the same branch. Lead window added 2026-07-30: the email set is now overdue, due today, or due within FOLLOW_UP_LEAD_DAYS (3), each row tagged Overdue / Due today / Due tomorrow / Due in N days — before this an item due in three days was never emailed until it was already late. The bell's predicate was deliberately left alone (its badge means "act now")
  • One daily email per person — the daily brief (2026-08-09) — built, not verified by a live send. A user was receiving three emails inside one minute, because the reach-out digest, the confirmations digest, the morning reminder, the due-follow-up sweep, the birthday/anniversary reminder and the LinkedIn nudge each decided independently that it was allowed to send. All six are now sections of one consolidated email (lib/jobs/daily-brief/: index.ts tenant fan-out, sweep.ts the one send decision, subject.ts, activation.ts, one file per section under sections/), in urgency order — follow-ups due → important dates → waiting for your review → people to reach out to → also waiting → your LinkedIn export — with the subject taken from the first section that has content. Every section still honours the opt-in it always had (morning_reminder_enabled for follow-ups and the ambient totals, important_date_reminders_enabled, confirmations_digest_enabled, daily_digest_enabled for reach-outs; the LinkedIn nudge still has no toggle — clicking "Get contacts from LinkedIn" is the opt-in). If every enabled section is empty, nothing is sent — no "you have nothing today" email, which is the disturbance this change exists to remove. daily_digest_enabled doubles as the daily check-in: with nobody due from the suggestion engine the reach-out section falls back to going-quiet contacts (listQuietContacts, capped at QUIET_CONTACTS_IN_BRIEF = 3), and when that is empty too the section — and possibly the whole email — is dropped. Activation nudge: an account with zero contacts and morning_reminder_enabled on gets a short "add your first contact + read the guide" email instead of the brief, at most ACTIVATION_NUDGE_MAX (3) sends ACTIVATION_NUDGE_INTERVAL_DAYS (7) apart, tracked in the activation_nudges_sent settings key, then silence forever; never in addition to a brief, and an established account having a quiet day still gets nothing. Opt-out footer: the brief and the nudge render through the new notificationEmailShell() (lib/email/send.ts), which always appends "If you'd rather not receive these, turn them off in Settings → Suggestions" pointing at /app/settings#suggestions via the shared emailLinkBase() (moved out of lib/auth/config/emails.ts so every mail links absolutely); transactional mail — welcome, verification, password reset, magic link, access requests, admin notices, feedback, the user-triggered event digest, background-job notifications — deliberately keeps the plain emailShell(), because no setting turns those off. Duplicate suppression is now one record, daily_brief_last_local_day, covering brief and nudge together; the five retired per-job rows are inert and left in place, so on the day this ships a user who already received the old emails that morning can also get one brief. /api/jobs/daily returns a single dailyBrief key ({ sent, activation, skipped }) where it returned six. Deleted: lib/jobs/{morning-reminder,daily-digest,confirmations-digest,follow-up-reminders,important-date-reminders,linkedin-export-reminders}; lib/email/morning-reminder.tslib/email/pending-summary.ts. Unchanged: the EMAIL_JOBS_HOURLY local-08:00 gate (it now gates the one brief), the hostedTenants() + withUserDb fan-out, the clean no-op without Resend, and a missing settings row still reading off. Covered by daily-brief/{index,activation,follow-ups,important-dates}.test.ts (headline: one email where there used to be three) and lib/__tests__/email-opt-out-footer.test.ts; manual cases in docs/TESTING.md §7aa
  • Automatic relationship-decay detection — built read-time (no nightly job needed): a Quiet-ranked reason in Home Moments surfaces contacts with no touch in ~8 months and no cadence set (repo/strength.ts); typecheck/lint/build/tests pass, manual click-through not done
  • Relationship-strength score from own-graph data (interaction recency/frequency, notes, events — no external data) — built: 0–100 recency×frequency score (scoreStrength), ranks the Going-quiet feed strongest-first; tests in strength.test.ts, manual click-through not done
  • Post-event digest email (user-triggered from the event page, template-based)
  • Pre-meeting brief on demand ("Brief me ✦" on the contact page, graph-only)
  • 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 adds calendar.readonly + calendar.app.created (the latter confines the app to calendars it created — a much narrower ask at verification); Microsoft goes Calendars.ReadCalendars.ReadWrite, and Calendars.Read is deliberately excluded from the read capability because every pre-M2 connection already holds it and would otherwise silently gain event reads. Two refresh-path regressions were fixed in the process: Microsoft re-sends scopes on refresh (would have narrowed an upgraded connection back to free/busy), and a reconnect whose token response omits scope would have wiped the upgrade. Write-out targets a secondary "Dhaga" calendar only, never the primary, and propagates on create (manual + AI-extracted), update, reschedule, complete and dismiss — complete/dismiss/date-cleared delete the event and drop its link row. It runs in after() in three separated phases (DB → network → DB) so a tenant connection is never held across an HTTP call. calendar_event_links maps follow-up → event because Graph refuses client-chosen event ids; it is in EE TENANT_TABLES. 21 new + 41 existing calendar/follow-up tests green, tsc + eslint clean. Pending: no live OAuth or API round-trip anywhere — no Google/Microsoft credentials in the build environment, so every HTTP path (event list, calendar find/create, event create/patch/delete, the 404/410 fallbacks) is exercised against mocked fetch only, and the consent/upgrade flow is entirely unexercised; no browser render or screenshot; the lib/repo/confirmations/apply.ts "supplement" path now propagates follow-ups to the calendar like every other path (it called applyExtraction and nothing scheduled the write-out); note that applyExtraction writes dueHint and leaves dueDate null, and an undated follow-up produces no event — so neither this path nor note-extraction places one until the user dates it; typedoc content/docs/api/** is stale for the calendar providers; mobile calendar parity is now built (see the M4 entry below), so the parity gap is closed in code though not on hardware; calendar-theme.css is 187 lines, over the 150-line rule (a side-effect-imported CSS theme cannot be split behind an index.ts); opened as PR #135. Scope note: Google's calendar-event scope is sensitive, not restricted — standard verification, no CASA security assessment, no annual third-party audit, no fee (restricted/CASA covers Gmail and Drive)
  • Demo Calendar fixture flipped opt-in (2026-08-13) — packages/core/src/calendar/demo-provider.ts now shows the synthetic provider only on DHAGA_CALENDAR_DEMO === "true" (was opt-out, !== "false"). Its busy blocks are fabricated but flow through the real free/busy path into Home's calendar-backed Find a time actions and the daily brief's suggestion count, so an opt-out default meant a production account could connect it and silently corrupt that advice. apps/web/.env.example and docs/SELF_HOSTING.md document the flip; the example file still ships DHAGA_CALENDAR_DEMO=true because it is a dev template
  • Imported events are clickable; per-event colour (2026-08-13) — built, not browser-verified. ExternalEventDialog.tsx opens read-only detail on an imported event: title, when, location if present, and calendar · account — the only way to tell a work meeting from a personal one on a merged grid. No deep link out to the provider, on purpose: the event carries no htmlLink and the provider URL never crosses the RSC boundary. Attendees are dropped at the repo and forbidden by the props type. Events stay editable: false, startEditable: false; isFollowUpEventProps is the positive gate so only follow-ups re-date on a drag. Colour resolution is two-stage and the precedence is deliberate: Google's per-event colorId (mapped through its fixed 1–11 palette) beats the calendar's colour, and the user's user_color override beats the provider's background_color — a meeting the user tinted in Google keeps that tint here. event-map/colors.ts discriminates on a leading # (provider hex) vs a palette token, and an unknown token resolves to the theme default rather than reaching the DOM as an invalid CSS colour — the one place the "never a raw hex in a component" rule does not apply, because the hex is user/provider DATA
  • Write-target calendar picker + writeAnyCalendar (2026-08-13) — built, consent flow unexercised. Follow-ups can be written into a calendar the USER picks instead of only the app-created "Dhaga" one. Third capability axis writeAnyCalendar on CalendarCapabilities, kept a SEPARATE token list from writeEvents rather than a widening: Google's calendar.events (SENSITIVE, pulls the app into verification) is required for it and is deliberately absent from GOOGLE_WRITE_SCOPE_TOKENS, so a connection that upgraded before this shipped derives writeEvents: true, writeAnyCalendar: false — keeps writing to "Dhaga", needs no reconnect, and unlocks the picker only by re-consenting through the same ?upgrade=1 link. Microsoft derives all three axes from Calendars.ReadWrite because Graph has no app-created-only scope — reporting it false would hide a control while changing nothing about what Dhaga could reach — but with no listCalendars there is nothing to pick, so behaviour is unchanged. connectionCapabilities gates writeAnyCalendar on writeEvents, never on the scope alone. ensureWriteCalendar throws rather than silently find-or-creating when a user-chosen calendar is unreachable: quietly writing someone's follow-ups into a fresh calendar is worse than an error. UI CalendarWriteTarget.tsx — three states, one sentence each, and the ungranted state is a disabled select inside a focusable tooltip trigger plus the same words visibly (hover and focus both miss on a phone), with a "Grant permission" link and no price, because this is a consent boundary and not an entitlement
  • Per-calendar records for a connected account (2026-08-13) — built, no live OAuth round-trip. A calendar_connections row is one per ACCOUNT; until now every read went to Google's primary, so anything on "Work" or a shared calendar was invisible. calendar_calendars (lib/db/schema/calendar-calendars.ts, DDL lib/db/ddl/calendar-calendars.ts) now holds one row per calendar, populated by the new optional CalendarProvider.listCalendars via lib/repo/calendar/enumerate.ts (three phases DB → network → DB, never throws, requires the read capability so a free/busy connection never spends quota on a guaranteed 403). Two columns are the USER's and the provider must never overwrite them: selected (show/hide) and user_color (a palette key overriding the provider hex) — upsertProviderCalendars updates only provider-owned fields, and calendars that vanish from the provider are left in place rather than dropped with the user's settings. Reads visit every selected calendar in one loop over the shared request-scoped connection; one calendar 403-ing no longer condemns the whole connection (needs_reconnect only when every target fails). Settings UI components/app/settings/CalendarConnectionsSetting/{CalendarList,CalendarRow,CalendarColorPicker}.tsx, optimistic toggles. Microsoft does not implement listCalendars, so an Outlook connection has no rows, stays primary-only, and gets no list button — stated in the UI rather than shown as a control that can only no-op
  • Dhaga's own calendar (2026-08-13) — built, no browser pass. local_calendar_events (lib/db/schema/calendar.ts, DDL lib/db/ddl/calendar.ts, enrolled in EE TENANT_TABLES) gives every account a working calendar with nothing connected and no OAuth — which is the honest self-hosting answer to "do I need a Google app to use the calendar?" (no). Repo lib/repo/calendar/local-events.ts (list is an OVERLAP query, starts_at < to AND ends_at > from, so a conference that began last week still draws on this week's grid); actions lib/actions/calendar-events.ts; dialog components/app/calendar/LocalEventDialog/ with title (required), all-day, start/end, optional location + notes, and an optional colour from the 8-token EVENT_COLORS palette (token persisted, never the hex). All-day ends are stored exclusive (pinned by LocalEventDialog/instants.test.ts) and the client posts ISO instants built in the browser. Not draggable — only follow-ups re-date on a drop; a local event is moved in its dialog. Deliberately not wired to free/busy: Home's calendar-backed Find a time actions and the daily brief still read a connected calendar's busy blocks only, so a local event does not make Dhaga think the user is booked; if that is ever wanted it is a new read, not a bug. Known gap: lib/actions/calendar-events.ts revalidates /app, /app/calendar and /app/tasks — the last two are now redirect stubs, so the real /app/plan path is never revalidated
  • Home actually renders "Due for a check-in" (2026-08-20) — the whole check-in half of Home was on disk and unreachable: HomeDashboard.tsx has zero importers and nothing else mounted the list, so a contact who was due a reach-out showed up in Moments and on /app/plan and nowhere on the Home rail that was supposed to carry it. New components/app/home/HomeRail.tsx composes SetupPanelNextDaysPanelDueCheckInsList as one mount point rendered at every width and imported by DashboardSection/index.tsx, so the two layout branches cannot drift into showing different tiles. HomeDashboard.tsx and the rest of that orphaned tile tree stay deliberately unrevived — a decision recorded in the internal build checklist and audited in docs/UNPLUMBED_FEATURES.md; this plumbs the check-ins half only, not the tree. Typecheck/lint/unit clean; no browser pass recorded
  • "Find a time" on a due check-in, on BOTH Home and /app/plan (2026-08-20) — a check-in row could say someone was due and then offer nothing to do about it. components/app/home/DueCheckInActions.tsx wraps the existing MomentActions so the row reuses the actions Moments already had rather than growing a second set, and ReachedOutButton.tsx was deleted (verified gone) because MomentActions already carries "Reached out" — two buttons doing one job is how they drift apart. /app/plan computes its open slots with zero extra reads, through a new shared lib/calendar/open-slots.ts that both surfaces call, so a time offered on Home and a time offered on the plan cannot disagree. Supporting pure helper components/app/home/slots-for-due.ts (+ slots-for-due.test.ts). Typecheck/lint/unit clean; no browser pass, and no run against a live connected calendar
  • FIXED — first login showed suggested people where the calendar should have been (2026-08-20) — on a brand-new connection the free/busy snapshot has not been written yet, and the pulse catch-up budget (3 polls × 2s = 6s) was shorter than a cold refresh takes: open a tenant scope → maybe refresh an OAuth token → listBusyreadExternalEvents → a second scope to store the result. The catch-up gave up before the snapshot landed and the correction was a full 60s away, so the first Home a new user ever saw was the fallback. The catch-up now BACKS OFF — catchUpDelayMs(attempt) returns 2/4/8/16s (lib/calendar/catch-up.ts), covering 30s in four polls instead of 6s in three, and it is a pure function so the schedule is unit-testable without a clock. Deliberately not "poll faster": every stale poll schedules another provider refresh, so widening the window must not multiply the doomed calls a permanently broken grant keeps paying for. The cost is stated rather than hidden — a refresh landing between 2s and 4s is now noticed at 6s instead of 4s, two seconds later in the band that already worked, in exchange for up to 54 seconds earlier in the band that did not. The first-login repro has not been re-run against a live connection, and there is no browser pass
  • FIXED — "your week is clear" for a calendar we never actually read (2026-08-20)getFreeBusy skips a calendar it cannot reach, so a TOTAL provider failure returned an empty array indistinguishable from a genuinely clear week, and refreshFreeBusySnapshot stored that emptiness as FRESH; Home then told the user their week was clear until the snapshot aged out. There is now readFreeBusy() returning { busy, attempted, failed } (lib/repo/calendar/free-busy.ts, exported from lib/repo/calendar/index.ts), and the refresh writes nothing when every connected calendar failed — a stale-but-true snapshot is better than a fresh lie. A PARTIAL failure still writes, because some real busy blocks beat none. Typecheck/lint/unit clean; no browser pass, and no run against a real provider outage
  • FIXED — "can't add an email to an existing contact" (2026-08-20) — Base UI's combobox echoes the chosen label back through onInputValueChange, and three panels cleared their selection UNCONDITIONALLY in that handler, so picking a contact instantly wiped the selection that had just been made and left the submit button permanently disabled. Affected: the calendar guest dialog's "Existing contact" panel (components/app/calendar/ExternalEventDialog/AddGuestDialog/ExistingContactPanel.tsx), "Add to event" (which also broke Create event), and the bulk "Change relationship" company picker. WarmPathPanel had already solved it locally, which is exactly why it kept recurring elsewhere — the echo comparison now lives once, in components/app/EntityCombobox/selection-echo.ts. Typecheck/lint/unit clean; no browser pass recorded

15. Graph power (v1.3)

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

16. Ecosystem (v1.4)

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

17. Teams (v2.0 — revenue engine)

  • 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 /features for 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 generated llms.txt discovery without restoring the home page's eager graph payload.

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

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

  • 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 RAZORPAY_OFFER_POWER_* id is configured, so nothing renders a Power discount) — Power ₹899/mo ($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 by INTRO_OFFER_ENDS_AT in packages/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 the intro_offer_availability key in a new hosted-only control-plane table billing_settings (text key PK, text value, updated_at, no RLS; DDL packages/ee/src/db/tables-ddl/billing-settings.ts, repo packages/ee/src/billing/repo/settings.ts) — the twin of apps/web's ai_budget_settings, deliberately not the tenant-scoped settings table, 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 /pricing page 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; IntroOfferClosedError is 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, mandate max_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 as RAZORPAY_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 column subscriptions.intro_offer_id (text) — cadence stays monthly / yearly, honest with what Razorpay holds, and intro_offer_id is 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 columns subscriptions.intro_price_held_at and intro_price_held_by (packages/ee/src/db/tables-ddl/subscriptions.ts, schema packages/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, is findIntroStepUpsBetween(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 on intro_offer_id being set rather than on an introductory cadence, and dates each row from introTermEndsAt() rather than from a change booked at the processor. How many introductory subscriptions have been sold is countIntroSubscriptionsSold() (packages/ee/src/billing/repo/intro.ts), and it is derived, not a stored counter: subscriptions UNION payments (never UNION ALL) de-duplicated by user_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 by packages/ee/src/billing/__tests__/intro-term.test.ts, intro-availability.test.ts and intro-notice-and-hold.test.ts (intro-window.test.ts went with the deleted date; intro-step-up.test.ts went with the deleted sweep). The admin surface exists (read off disk 2026-08-20): AvailabilityCard on /app/admin/subscriptions shows the toggle beside that derived sold count, IntroPriceHoldCard on /app/admin/users/[id] holds and releases one account's price, both driven by server actions in apps/web/src/lib/actions/admin/intro-offer.ts (setIntroOfferAvailabilityAction, holdIntroPriceAction, releaseIntroPriceAction; components under components/app/admin/intro-offer/, unit-tested in lib/actions/admin/__tests__/intro-offer.test.ts); manual click-through is pending like the rest of this section. The hosted module reaches apps/web through the billing gate (lib/hosted/gate/billing-types/, defaults.ts), whose defaults no-op — findIntroStepUpsBetween returns [] — 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/daily under the key introStepUpNotices. There is no api/cron route in this repo and never was; the daily jobs route is the schedule, the same one the plan-lapse notices ride. The index.ts sweep wrapper and the introStepUps job 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 quotes introTermEndsAt() — the deterministic anniversary — and there is no second job whose ordering it depends on. The notice window is INTRO_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 the settings k/v table as the step-up's own date, not a boolean (sent-record.ts; isSameStepUp compares 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. Template apps/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.ts went 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 under apps/web/src/components references a step-up" was reading a stale snapshot). apps/web/src/components/app/billing/IntroStepUpBanner.tsx, notice.ts the pure decision, resolve.ts the guarded read, dismissal.ts, actions.ts — mounted in apps/web/src/app/app/layout.tsx inside <main>, mutually exclusive with LapsedPlanBanner (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 now introTermEndsAt() off intro_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: the intro_offer_id check 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 in resolve.ts so the shell cannot forget it, and resolve.test.ts pins 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 by components/app/billing/notice.test.ts (13 cases) and resolve.test.ts (8) — again beside the source, not under __tests__/. Do not conflate it with the settings PlanCard's introTermLine (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-mandate max_amount registers 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 /pricing FAQ 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 cadences intro_monthly / intro_yearly are 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 cadence monthly / yearly (it is on the standing plan) and records the purchase as introductory in intro_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; /pricing keeps its INR/USD toggle, defaulted from x-vercel-ip-country via the existing preferredProcessor() signal and remembered per visitor, display only, not a second charging currency (the non-charging currency is labelled an approximate conversion, and the schema.org Offer always advertises INR). Power is LAUNCHED on the public surfaces (2026-08-23) — this line used to say its card was comingSoon: true with a waitlist CTA and that it stayed out of purchasable structured data, so its Razorpay Plans existed "ahead of the sell". That gap is closed: the card carries a buy CTA to /signup with no badge, and /pricing's schema.org offers now publish Power's standing and introductory prices, derived from BILLING_TIERS instead of a hardcoded Pro so a future tier cannot be sold on the page and forgotten in the markup. It was a copy and structured-data change only, and that is the point: there was never a server-side gate — parsePlanSelection already accepted power, BILLING_TIERS was already ["pro","power"], and the in-app plan pickers on /app/settings and /pending had been selling Power the whole time, so the only thing "coming soon" ever gated was the marketing copy. The comingSoon flag is deleted from PricingPlan (types/landing/index.ts) because its one reader — the queue-skip footnote in PricingPlanCard — now applies to every paid card; the unrelated comingSoon in the voice/dictation gate is a different field and untouched. The inference-dollar ceiling rule was REVERSED the same day, and this line used to assert the opposite (the gate is §10): the ceiling now follows what a customer is actually paying, 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 and PRO_FOUNDING_PRICE are deleted, and no surface sells it. Three things survive on purpose. (1) The founding_yearly BillingCadence string and its CADENCE_LABEL entry — a Razorpay Plan is immutable, so an existing subscriber's row carries that string forever and the plan status line renders CADENCE_LABEL[current.cadence]; deleting the union member would crash the settings page for exactly those customers. (2) The founding_seats table and its DDL (packages/ee/src/db/tables-ddl/billing.ts) — no destructive migration, it is the only sale record for a live founding subscriber, and it stays in the account-deletion table list. (3) Its reverse-lookup-only entry in RAZORPAY_PLAN_ENV (packages/ee/src/billing/catalog/plan-env/tables.ts) — isSellableCadence refuses it, but without the entry a renewal webhook for that immutable plan id would resolve no tier, grant nothing, and silently drop a paying customer to free. LEGACY_FOUNDING_PRO_YEARLY_INR = 6999 is likewise kept in ai-budget/plan-revenue.ts rather than falling back to standing yearly because founding is the more expensive plan (₹583/mo against standing yearly's ₹400/mo) — a silent fallback would quietly shrink the ceiling of the customers who paid earliest and most. BRD §11 Q6 keeps the founding-price reasoning as history: a retired offer's reasoning is why the next one is shaped the way it is. Discharged since this line was last written: all four introductory Razorpay Plans now exist live (INR, verified by read-back) and RAZORPAY_PLAN_PRO_INTRO_MONTHLY / _PRO_INTRO_YEARLY / _POWER_INTRO_MONTHLY / _POWER_INTRO_YEARLY are set on Vercel Production and Previewkept as RESOLVE-ONLY ids since 2026-08-23, beside the _LEGACY ones, 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 _LEGACY reverse-lookup set. Pro's two standing plans are unchanged. Razorpay Plans cannot be edited or deleted — a price change is always a new Plan object and a repointed env var, never a dashboard edit — so a repricing is only finished once every configured plan id has been read back and its amount compared against the price table, which is what was done. The four superseded ids are preserved in _LEGACY environment variables (RAZORPAY_LEGACY_PLAN_ENV, packages/ee/src/billing/catalog/plan-env/tables.ts) for renewal reverse-lookup only — the same hazard founding_yearly is kept for: without the entry a renewal webhook carrying an immutable old plan id would resolve no tier, grant nothing, and silently drop a paying customer to free. Also still unchecked: 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, /refunds and /contact now exist alongside /privacy, all four share components/legal/LegalPage, are in MARKETING_SITEMAP_ROUTES, and are linked from a new Legal column in the landing footer (before this, /privacy was in the sitemap and linked from nowhere, and the other three did not exist). Unchecked deliberately: the entity name, address, phone and support email are TODO placeholders in utils/constants/legal.ts, the copy has not been reviewed by a lawyer, and /refunds promises a 7-day window that nothing enforces in code — a refund is issued by hand in the processor dashboard and still does not revoke access (see docs/FOLLOW_UPS.md). Razorpay live-mode onboarding checks exactly these pages

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

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

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

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

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

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

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

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

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 + /signupflagged 2026-07-07: this is the same code/checkbox as §2's "Auth: real accounts", not a distinct multi-tenant implementation (DHAGA_PASSWORD doesn't exist anywhere in code, only in this checklist); consider deleting one of the two entries once confirmed there's no separate hosted-mode acceptance bar
  • Signup terms gate stops disabling the way in (2026-08-13) — built, not browser-verified. On hosted mode (DHAGA_HOSTED_MODE === "true") /signup shows a terms checkbox; the social buttons and the submit button used to be disabled until it was ticked, which is the bug — a dead button explains nothing, and "Google" looking broken is indistinguishable from OAuth being down. They now stay enabled and in the tab order, and the click is what triggers the explanation: SocialButtons.signInWith returns on blocked before authClient.signIn.social, and useSignupForm.handleSubmit returns on termsBlocked before any network call, so no OAuth redirect and no signUp.email ever starts without consent. nudgeToTerms() scrolls the checkbox into view, focuses it, and renders TERMS_GATE_NUDGE in a role="status" region referenced by both buttons' aria-describedby, with a two-pulse amber highlight (static ring under prefers-reduced-motion). Self-hosts see no checkbox and no gate. The gate is a UI guard onlybeforeUserCreate stamps terms_accepted_at/terms_version on every hosted-mode user regardless of what the client sent, so the form's job is making sure the terms were seen, and the hook's job is writing down that they were; there is no server-side rejection of an unticked signup. Stale comment left behind: TermsAcceptance.tsx still says "The submit button is disabled until this is ticked"
  • Per-user personal access tokens (better-auth apiKey plugin) replacing DHAGA_API_TOKEN, settings UI to create/revoke — flagged 2026-07-07: duplicate of §12's "Per-user API keys", same apiKey plugin/settings UI, not SaaS-specific
  • Multi-tenant data isolation via Postgres Row-Level Security, entirely in packages/ee — zero query-logic changes in apps/web/src/lib/repo/*
  • EE boundary: packages/ee is Dhaga Cloud only — a self-hosted enterprise deployment runs fully without it (see Self-hosting)
  • Early access: public request form → admin approve/reject → gated signup (DHAGA_HOSTED_MODE only; inert and 404s otherwise)
  • Admin panel (/app/admin): dashboard, access requests, users, subscriptions — 404s for non-admins
  • First-admin bootstrap via DHAGA_ADMIN_EMAILS (see SELF_HOSTING.md)
  • Stripe billing: Checkout (Pro/Power, monthly or yearly), billing portal, webhook (4 event types), AI-cap bypass for paid users. Admin-comping a user to Free (setSubscriptionForUser, packages/ee/src/admin/subscription-admin/set-subscription.ts) cancels a real Stripe subscription first — outside the advisory-lock transaction, before deleting the row — so a downgrade can't leave Stripe still billing a subscription our DB has forgotten about; the admin-granted:<userId> comp sentinel (null stripeSubscriptionId) skips the cancel call
  • Plan changes for existing subscribers — upgrade, downgrade and cadence switch on both processors (packages/ee/src/billing/plan-change/), never a first-purchase checkout. Checkout is refused outright for an account that already has a live subscription (assertNoExistingSubscription, billing/checkout.ts) — a second charging one would bill the same card twice and neither processor deduplicates for us. Tier dominates cadence when both move (classifyPlanChange): power/yearly → pro/monthly is a downgrade, pro/yearly → power/monthly an upgrade. The two processors then diverge completely, and changePlan returns a discriminated union rather than a void because of it. STRIPE modifies the subscription in place — upgrades immediately with proration_behavior: "create_prorations", downgrades booked for the renewal boundary (Subscription Schedule, two phases, end_behavior: "release") because an immediate one makes Stripe credit the unused difference, a liability against revenue already recognised. RAZORPAY CANNOT MOVE A MANDATE TO ANOTHER PLAN AT ALL — confirmed 2026-08-20 by testing on human-authorised subscriptions on both Indian rails, for a tier change and a cadence change alike, with no timing option permitted. That defect is FIXED, by a redesign rather than a retry (2026-08-21, plan-change/switch/, razorpay/change-checkout.ts, razorpay/book-cancel.ts, razorpay/confirm/pending.ts, razorpay/webhook/pending.ts): a change mints a SECOND Razorpay subscription starting at the current one's live renewal date → the customer authorises its mandate in checkout → only that authorisation books the old subscription to end at the boundary → the new one becomes the account's subscription of record on its first settled charge. Measured facts the design rests on: a future start date is accepted and works on UPI AutoPay, the mandate registers at the new plan's amount, and no invoice is raised until that start date, but authorising takes a small refundable registration debit (₹5 observed, 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().plan reports it and a new comped field says until when, and lib/ai/metering/dollar-cap.ts sizes the inference ceiling off the new cadence so a just-upgraded customer isn't held to the cheaper plan they are leaving. No undo after approval: revertScheduledChange cancels an unapproved pending subscription but refuses once approved (the old mandate is already ending and cannot be resumed), and cancelPlan cancels both subscriptions, pending one first, so a customer told "your plan ends on the 3rd" is not debited for the new plan on the 3rd. An account now holding two subscription objects forced two more rules: every webhook routes on the entity id and skips one belonging to neither (switch/route.ts), because the subscription row is keyed on user alone and would otherwise be repointed at a stranger's; and a failed first charge is an ordinary lapse (switch/fallback.ts), because by then the old mandate is gone and only an entitlement outcome is still available. STATUS, HONESTLY: built and unit-tested; NOBODY HAS RUN IT AGAINST A LIVE RAZORPAY SUBSCRIPTION END TO END. No real authorisation, hand-over, discard, cancel-with-a-change-in-flight or first-charge failure has ever happened on a live mandate — the two-subscription overlap in particular exists only in tests. The pure decision layer is unit-tested (plan-change-decision.test.ts, plan-change-guards.test.tsfounding-plan-change.test.ts went with the retired founding offer (§18)) and reconcilePlanState plus both describe*Plan helpers are tested against STUBBED processor SDKs (reconcile-status-repair.test.ts), but no test is a live round-trip; the Stripe deferred-downgrade schedule has likewise never touched the live Stripe API. Unchecked on the evidence, not just on this section's click-through bar. 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 refused PATCH had exactly two callers — that sweep and the old in-place plan change — and both are gone, so it is no longer made anywhere
  • Cancel at the renewal boundary on both processors, behind a confirmation dialog (Razorpay customers previously had no cancel at all). Stripe is reversible from the same screen — Keep my plan (resumeStripePlan); Razorpay has no resume API, so the dialog says so and restarting there means a new subscription. A booked change is separately reversible with Undo scheduled change (revertScheduledChange) — but on Razorpay only until the customer approves the new mandate, after which it is refused rather than leaving them with nothing after the boundary, and the control is hidden. Cancelling with a change in flight cancels both subscriptions and the dialog says so. Not exercised against either live processor
  • Scheduled changes are visible and reversible before they land — the subscriptions row denormalises cadence, scheduled_plan, scheduled_cadence, scheduled_change_at and synced_at (all nullable, all added idempotently), so settings can render "Power until 3 Mar, then Pro" and offer the undo without a processor round-trip. getPlanSummary is consequently DB-only, and that is a contractentitlement-no-processor-call.test.ts mocks both describe*Plan helpers and getStripe() to throw and asserts five read paths never reach them. The one deliberate live read is reconcilePlanState, called best-effort by the settings page only. It also write-backs the status, which is what stops the plan-lapse notice being a dead end: a dropped terminal webhook leaves the row stored active, so effectiveStatus correctly withdraws the entitlement and emails "purchase to continue" while activeSubscriptionRef — which reads the column raw, deliberately, because it is the double-billing guard — still calls it live and assertNoExistingSubscription refuses the checkout we just asked for. Opening billing settings asks the processor and repairs the row; both processors resolve their status through the SAME map their webhook uses (STRIPE_STATUS_TO_STORED, RAZORPAY_STATUS_TO_STORED), never a second one. Three exclusions, unit-tested in reconcile-status-repair.test.ts: a processor outage writes nothing (an outage must not be able to downgrade anyone), an unrecognised status is omitted rather than blanked, and an admin_granted row is never restatused — a comp over a real subscription keeps that subscription's id, so it does reach the call, and taking the processor's canceled there would revoke a grant no processor knows about. No background job reconciles drift: between webhooks the stored target can lag (Stripe's customer.subscription.updated carries only a schedule id, so it leaves scheduled_* alone), and a half-written schedule renders no pending line rather than a wrong one
  • "Your plan has ended" notice email (2026-08-10) — built, not verified by a live send. Lapse is authoritative at READ time (packages/ee/src/billing/entitlement.ts: isLapsed / effectiveStatus, 72h grace) and nothing flips a column, which is right for entitlement and silent for the user — a comp expires, a card stops renewing, and the paid features simply stop being there with no message. This is that message. lib/jobs/plan-lapse/ (index.ts the sweep, sweep.ts the one row's send-then-mark) + lib/email/plan-lapsed.ts, wired into /api/jobs/daily under a planLapseNotices key. ONE gate call for the whole instance, deliberately not the hostedTenants() + withUserDb fan-out the daily brief needs: subscriptions is an un-RLS'd control-plane table with one row per user, so a per-tenant loop would be N checkouts from the small tenant pool to answer what one indexed scan answers (packages/ee/src/billing/repo/lapse.ts, exposed on the billing gate with core-only no-op defaults — the sweep returns [], the mark no-ops, so a self-host without packages/ee runs the job and finds nobody). Idempotency is the new subscriptions.lapse_notified_for column, NOT the per-local-day record in lib/jobs/last-run.ts — a digest asks "emailed today?", but a lapse must be announced exactly once and never again, and the column re-arms itself when someone resubscribes (a later current_period_end moves past the stored value), so there is no cleanup job and no DAILY_EMAIL_JOB_KEYS entry. Commit only after delivery: markLapseNotified runs strictly after sendEmail resolves ok, so a Resend outage costs a retry tomorrow rather than a permanently swallowed notice; a null currentPeriodEnd marks with now, which permanently silences a boundary-less cancelled row. Two statuses are never notifiedpast_due (Stripe/Razorpay run their own dunning; our mail would contradict a processor mail still asking for a card) and incomplete (a checkout whose first charge never settled — the plan never started, so it cannot have ended). The email is transactional: plain emailShell, no opt-out footer, because no Settings toggle silences a billing state change (pinned by lib/__tests__/email-opt-out-footer.test.ts). It names the actual tier, says the account is now on Free and that nothing was deleted, quotes the Free allowance from PLAN_AI_CREDITS_PER_MONTH (never "credits reset at renewal" — the credit window is the UTC calendar month), and always carries the buy CTA to /app/settings#billing; a comped/referral grant gets an admin-contact line as well as the button, never instead of it. Batch fuse PLAN_LAPSE_NOTICE_BATCH_LIMIT (200), oldest-lapse-first. Covered by lib/jobs/plan-lapse/index.test.ts and packages/ee/src/billing/__tests__/lapse-sweep-query.test.ts (the exclusions asserted on compiled SQL, no driver). Pending: no live send, no browser click-through, and the endpoint has never been driven end-to-end against a real processor
  • In-app plan visibility + "your plan has ended" notice (2026-08-10) — built, no visual check performed. The email above was the only place a lapse was ever explained, and an email is the one channel a user may never open. Three surfaces, one signal. The signal, lib/entitlements/lapse.ts: summary.plan is the STORED tier (what they HAD) while summary.status is the EFFECTIVE one (whether it still grants anything), so a stored pro/power that is no longer active is exactly "was paid, now free" — and it is the only pairing that separates that user from one who was never paid, since currentPlan() collapses both to free. Nothing in the UI re-derives lapse from a date: effectiveStatus decides it once, server-side, and getPlanSummary now also carries the two row facts the copy needs (periodEndsAt, adminGranted) — current.renewsAt could not serve, because current is null for exactly the row that needs a date most, an expired admin comp with no processor subscription to describe. (1) The profile dropdown names the plan on every screen (ProfileMenu, first row, links to /app/settings#billing); self_hosted renders NOTHING rather than a meaningless "Free" badge, since billing isn't running there and the owner already has everything. Resolved server-side in the /app layout through the request-cached getCachedPlanSummary that currentPlan itself now reads, so the shell's gated controls re-read it free rather than issuing a second subscription lookup. (2) A dismissible banner (components/app/LapsedPlanBanner) at the top of every /app page: names the tier, says the account is on Free, quotes the Free allowance, and always carries the buy CTA — a comped account gets the admin-contact line as well as the button, same rule as the email. Styled as the calm amber PlanGateNotice pill, not text-destructive: a plan boundary is a state, not a failure. Dismissal persists per user in the settings k/v table (lapsed_plan_notice_dismissed) as the LAPSE'S OWN TOKEN, not a boolean — which is what makes it re-arm: buying again and lapsing again moves the boundary, changes the token, and the notice returns without anything having to reset it. The token is re-derived server-side in dismissLapsedPlanNoticeAction, never posted by the browser, or a client could silence itself permanently. (3) The billing settings card is no longer self-contradictory: a lapsed row still stores pro with a current_period_end, so the card used to read "Pro · canceled · renews 3 Jan" — badge, status word and a renewal date already in the past, three fragments each saying something different. The badge now shows what the account is entitled to NOW (Free), one sentence names the tier that ended and frames the date as when it ENDED, and cancel/resume is hidden (nothing left to cancel). One deliberate divergence from the email: past_due/unpaid DO get the in-app notice, worded "is paused — we couldn't take payment", where the email skips them to avoid contradicting a processor's dunning mail; in-app there is no competing message and the user is watching the features not work. incomplete is excluded on both, for the same reason — the plan never started, so it cannot have ended. Covered by lib/entitlements/lapse.test.ts (12 cases: never-paid, self-host, still-active, never-started, lapsed, payment-failed, expired comp, dismissal, and the two re-arm paths). Known gap, NOT introduced here: for a lapse the processor never confirmed (a dropped customer.subscription.deleted), the stored status is still active, so activeSubscriptionRef returns a ref — PlanPicker renders "Switch to…" change actions and createCheckoutUrl would refuse a fresh checkout. Both paths are wrong for that user and the fix belongs in the EE checkout/change rules, not the UI. Pending: no rendered screenshot at 375px, light or dark
  • Payments ledger — a payments table (packages/ee/src/billing/payments/) recording every confirmed charge, refund, partial refund, dispute and failure from either processor: minor-unit integers, the processor's own occurred_at, and a UNIQUE processor_payment_id the upsert dedupes on, with a setWhere guard so a redelivered capture can never un-refund a row. Written by the Stripe (charge.succeeded / charge.failed) and Razorpay webhooks plus the browser-confirm path; back-filled once from the old subscriptions.razorpay_payment_id scalar behind a marker row. It exists so a refund or chargeback resolves back to an account without depending on Razorpay copying subscription notes onto charge payments — which is not documented behaviour and remains only a fallback. This is also what makes approval honest: access is granted only by a payment the processor has confirmed (never at checkout intent, never by the browser redirect), 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 is active or past_due floors at their own plan, refused server-side and re-checked inside the advisory-lock transaction, because otherwise our row and the processor would disagree and the card would keep being charged for access we just revoked. A comp is recorded on the row, not inferred afterwards from the admin-granted:<userId> sentinel: comping an existing row PRESERVES its processor ids and sets status='active', so a plan comped to unblock a stuck pro/incomplete checkout used to read back as a paying customer and lock the admin out of their own grant permanently. subscriptions.admin_granted records that an admin put the plan there and admin_granted_over_plan/admin_granted_over_status record what it was written over — the underlay is the floor, and the processor can't be asked for it because the plan path is deliberately DB-only. Every writer that records a processor's view clears the flag on a paying status (compColumnsForProcessorStatus, applied in upsertSubscription, updateSubscriptionStatusByStripeId and patchSubscriptionForUser), so a settled 3DS charge re-locks the row; a non-paying status leaves the comp alone. Setting free revokes the COMP, not necessarily the subscription: a comp over an incomplete checkout restores that checkout (ids kept, nothing cancelled, row not deleted) so the pending payment can still settle, while a pure comp is deleted and an abandoned processor subscription is still cancelled before deletion. admin_granted backfilled once from the old sentinel behind a marker row; the underlay columns are deliberately not backfilled (null = nothing paid underneath = the behaviour those rows already had). The admin plan selector disables exactly the options the server would refuse and states why — UI convenience, not the enforcement point. Unit-tested (plan-change-guards.test.ts, comp-cleared-by-processor.test.ts, admin/subscription-admin/__tests__/); manual click-through pending like the rest of this section
  • Billing UI cleanly absent (not broken) when STRIPE_SECRET_KEY is unset, even in hosted mode
  • Admin AI cost & credit controls (/app/admin/ai-credits, titled "AI cost & credits" since 2026-08-03) — built 2026-07-30, enforcement enabled by default 2026-07-31. Four credit levers, in the order an operator reasons about them: a master switch for plan-cap enforcement (AI_PLAN_CAP_ENFORCEMENT_DEFAULT = true — ships ON, so every user is held to the monthly allowance for their plan and the pricing page's numbers — 300 credits/month on Pro/Annual, 10 on Free — are the behaviour, not a promise. The switch stays, but turning it off is now an escape hatch for a migration or an incident, not a resting state: with it off the allowances are stored and ignored, paid plans fall back to the raw billing entitlement (hasUnlimitedAi) and everyone else falls to the instance default. Promotions and grants keep working either way); a runtime-editable allowance per plan (free/pro/power, defaulting to PLAN_AI_CREDITS_PER_MONTH, and the Free allowance doubles as the instance-wide default, so editing it re-sizes both the free tier and every user no plan governs); an instance-wide promotional month that applies regardless of the switch and self-expires (its window is compared at read time, so no cron and no admin cleanup); and an additive grant ledger for make-goods — the same card pinned to one user on /app/admin/users/[id] (which also shows "+N granted"), and, unscoped on the instance-wide page, a "grant to everyone" card (no free-text user-id field — always broadcasts) plus a searchable, paginated ledger at /app/admin/ai-credits/grants (search matches the recipient's name/email, or the literal word "everyone" for broadcast grants). Precedence, pinned by budget-precedence.test.ts + promotion-and-grants.test.ts + env-seed.test.ts: per-user override → promotion (applies whether or not the switch is on) → plan allowance (only when enforced — it is, by default — and only when a paid plan is in play) → the instance default (instanceDefaultCap(), lib/ai/metering/cap/instance-default.ts: the admin-set Free allowance, else the DHAGA_AI_MONTHLY_CAP seed, else FREE_TIER_AI_CREDITS_PER_MONTH), with active grants added on top (lib/ai/metering/cap/index.ts). DHAGA_AI_MONTHLY_CAP is a seed, not an override: nothing is copied into the DB at boot, env is simply read last, so the moment an admin sets a number — a per-user override, a promotion, a plan allowance, or the Free allowance — the stored number wins and the env var stops mattering, and the admin screen can name where the live number came from. Free users resolve through the instance default rather than the plan ladder, deliberately, so the env var means the same thing on a self-host as on an instance with billing. A grant never edits ai_actions — that table is the only record of what cloud AI actually cost, so "End now" ends a grant without deleting its row and recorded usage always stays intact. Storage is two new instance-wide core tables (ai_budget_settings, ai_credit_grantslib/db/ddl/ai-budget.ts), deliberately not in the tenant-scoped settings table or in EE's TENANT_TABLES: ai_credit_grants gets a bespoke RLS policy in packages/ee (user_id IS NULL OR user_id = <tenant>) because the generic policy would hide instance-wide grants from everyone, and ai_budget_settings gets none (operator config). Grant writes live in packages/ee/src/admin/ai-grants/ (the paginated, name/email-joined ledger read is list-page.ts); a core-only self-host keeps both tables, gets no admin UI to write them, and resolves the cap from the DHAGA_AI_MONTHLY_CAP seed / the free-tier constant (now 10, not 0) exactly as before. tsc/lint/vitest clean; manual click-through pending, same bar as the rest of this section
  • Admin cost visibility for the dollar ceiling (2026-08-03, same page, above the credit levers — the gate itself is §10) — four cards: Cost this month (total / credited / uncredited dollars from real recorded tokens, plus measured $/credit and all-in $/credit shown next to the ~$0.006 blended ceiling the credit table assumed — drift between the two is the signal to re-derive the credit prices — plus a per-feature table of the uncredited spend, which is the only place the 0-credit sweeps are visible at all); Top 10 accounts by AI cost (cost, credits, the dollar ceiling in force, which rung set it, and utilisation %, so "is 300% the right multiplier?" is answered from data — typical Pro inference is ~$1.35 against the $5.74/mo a ₹499 standing Pro subscription earns); a dollar-ceiling enforcement toggle; and a multiplier + floor form with a live per-plan ceiling table (ceilingForPlanRevenue is shared with the gate so the table cannot drift from what is enforced). Server actions in lib/actions/admin/ai-dollar-cap.ts, components in components/app/admin/ai-budget/cost/. tsc/lint/vitest clean; manual click-through pending, same bar as the rest of this section

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, maxDuration 60). Library, not hand-rolled JSON-RPC: mcp-handler v2 + @modelcontextprotocol/server v2 serve the 2026-07-28 MCP spec natively with a stateless fallback for 2025-era clients — MCP is a negotiated protocol (the client discovers the tool list and schemas at connect time), so hand-rolling it would have meant owning transport, session and capability negotiation for no gain (docs/LIBRARIES.md §16). Two credentials, both accepted, because the two client families can't share one (lib/mcp/auth.ts): an OAuth 2.1 bearer — this instance is its own authorization server via better-auth's mcp() plugin (lib/auth/config/plugins.ts), whose endpoints ride the existing auth catch-all (/api/auth/mcp/{authorize,token,register}, the last being Dynamic Client Registration) with RFC 8414 + RFC 9728 discovery served from /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource, which is what lets claude.ai / ChatGPT add Dhaga as a one-click connector behind a normal login + consent screen; and the x-api-key personal access token that already exists (Settings → API keys — the same key the mobile app and /api/follow-ups take) for local/stdio clients and self-hosters, e.g. claude mcp add --transport http dhaga https://<host>/api/mcp --header "x-api-key: <token>". Unauthenticated requests get 401 + the WWW-Authenticate challenge pointing at the protected-resource document; a bearer that fails to resolve is a hard 401 rather than a silent fall-through to the PAT branch, so an expired token can't be masked as a missing one. The OAuth issuer is derived from BETTER_AUTH_URL — set it wrong and clients reject the tokens this instance issued. 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 in lib/mcp/schemas.ts, envelope in result.ts): reads are dhaga_search (hybrid keyword + semantic over contacts and note text, returning the matched snippets as receipts, capped at 20), dhaga_list_contacts (name/company/tag/starred filters, paginated, pageSize ≤ 100), dhaga_get_contact (details, job history, facts each carrying its sourceNoteId receipt, ≤ 25 recent notes, open follow-ups), dhaga_list_follow_ups (whole network, soonest-due first), dhaga_find_warm_path (≤ 3 intro paths), dhaga_list_upcoming_dates (default 30 days, max 365, resolved in the user's timezone); writes are dhaga_add_note, dhaga_create_contact (only name required), dhaga_create_follow_up (YYYY-MM-DD due date optional) and dhaga_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_up is a status change, not a deletion, which is why it is the one "closing" verb that survived. There is deliberately no AI/"ask" tool, and that is the cost story: the connecting client is already an LLM, so it gets raw retrieval with receipts and reasons itself — every read costs zero AI credits. The one charge is a note: dhaga_add_note (and dhaga_create_contact when given a note) queues the normal background note_extraction job at 1 credit, exactly like writing a note in the app, and mirrors addNoteAction by checking hasMonthlyAiBudget first — out of credits still saves the note and skips only extraction, and says so in the tool result rather than letting the model assume it worked. Every handler resolves the user from the verified token and runs its reads inside a single withUserDb scope, never a fan-out (the tenant pool caps at 3 — the repeat failure mode in this codebase). The route also enforces a per-user burst guard (enforceRateLimit(userId, "mcp"), 60 req/min — new mcp bucket in utils/constants/ratelimit.ts) after auth so the bucket keys to a real user, answering 429 with retry-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 the mcp plugin reuses (oauth_application, oauth_access_token, oauth_consent) are appended to the existing auto-applied DDL (lib/db/ddl/oidc.ts, concatenated after AUTH_DDL because all three FK to "user"(id)), so the ddl_history hash gate replays them on next boot — no manual migration step. They are global auth tables, not tenant tables, and are correctly absent from EE's TENANT_TABLES. EE boundary: pure core. Nothing under lib/mcp/, /api/mcp or /.well-known/* imports from packages/ee, so nothing is added to the verify-without-ee removal list in .github/workflows/ci.yml — that list is unchanged — and a self-hosted enterprise deployment gets the byte-identical endpoint (see Self-hosting). Tests: 17 vitest cases across three files. lib/mcp/auth.test.ts pins both credential paths and the rule that a presented-but-invalid bearer token is a hard 401 rather than a silent fall-through to x-api-key — the shape that would otherwise keep a client working after the user revoked its OAuth grant. lib/mcp/tools/surface.test.ts snapshots the exact ten-tool surface, fails if any delete/forget/merge/bulk/export/admin verb ever reaches it, and asserts the read/write readOnlyHint split that clients gate auto-approval on. lib/mcp/tools/scoping.test.ts pins that each call takes its tenant from its own token (including no bleed between back-to-back calls for different users), that a tool whose token carried no user throws rather than running on whatever scope is ambient, the empty-result copy, and the out-of-credits branch saving the note while skipping only extraction. Verified against a running build (next start): both discovery documents serve correctly — protected-resource naming this instance as its own authorization server, and RFC 8414 metadata advertising /api/auth/mcp/{authorize,token,register} with PKCE S256 — and an unauthenticated POST /api/mcp answers 401 carrying the WWW-Authenticate challenge that names the protected-resource URL, which is the whole discovery loop a connector walks. Pending: no client has been connected end-to-end past discovery — no OAuth authorization round trip, no consent screen, no DCR registration, no claude mcp add run against a real client (better-auth requires a verified email before a session, which blocked minting a PAT over HTTP in the smoke environment); the oidc DDL has not been applied against a real Postgres (the concatenation is verified by reading, and next build does not run DDL); and the 2026-07-28 spec deprecates Dynamic Client Registration in favour of Client ID Metadata Documents (CIMD) — better-auth 1.6 implements DCR but does not advertise CIMD, so CIMD support lands here when better-auth ships it; DCR works with the clients shipping today. Payment-gated since 2026-08-07 (hosted only): /api/mcp is part of multi_device_sync, enforced by mcpPlanGateResponse (lib/mcp/auth.ts) on the resolved user, which is the only point both credentials pass through — gating token minting alone left the OAuth connector route (claude.ai, ChatGPT) completely open, since it negotiates its own bearer token and never touches a PAT. It could not live inside verifyMcpToken: withMcpAuth rewrites everything the verifier returns-as-undefined or throws into the same 401 invalid_token, which tells a client to re-authenticate — a loop that can never succeed when the credential was fine. So it sits one step later, in the route's post-auth wrapper, and answers 403 {"error": "plan_required", "error_description": …}. Rate limiting runs first, so a client cannot turn its own refusals into plan-lookup load. Self-hosts resolve to self_hosted and are unaffected. Pinned by lib/mcp/auth-plan-gate.test.ts (both credential branches refused, both let through when paid, self-host exempt, and the code is not invalid_token). 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/duplicates clusters likely duplicates by shared email, shared phone, or the same whole name, in a collapsible section per reason (strongest first, first one open), paged 10 clusters at a time and 6 members per cluster. Name matching was surname-based until 2026-08-22 and that was a bug: it reused computeNameClusters (lib/suggestions/name-clusters.ts), which drops the given name on purpose because it is the community-tag suggester for import ("all these people are Jains"). Read as a duplicate detector it told the user that all forty of their contacts surnamed "Singh" were probably one person. Duplicate detection now owns fullNameKey (lib/suggestions/full-name-key.ts) — every token normalized and sorted so "Singh, Amit" matches "Amit Singh", honorifics stripped, a single-token name refused outright — and name-clusters.ts is left to the tag suggester it was written for. A pair matching on two signals is emitted once, under the strongest. Each group is a tick list, not an all-or-nothing suggestion: exact email/phone clusters arrive fully selected, a name cluster arrives with nothing selected (two people really can share a name), and Merge selected (n) is disabled below 2 — five look-alikes where only three are the same person merge as three. The Merge flow (also on the People bulk bar) picks a surviving contact, resolves conflicting single-value fields (name/nickname/location), unions every multi-value field (emails, phones, links, addresses, important dates, custom fields, tags, positions, notes, facts, relationship edges) onto the survivor, then hard-deletes the rest — one atomic transaction, no undo (same model as "forget this person"). Shared merge types + computeScalarConflicts live in @dhaga/core; no schema/DDL change. Mobile parity pending
  • AI-suggested connections — 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 full MapPayload from GET /api/map and draws a clustered MapLibre point layer: selecting a city point frames it at zoom 11 without pulling back from a closer zoom, and opens a Sheet of the contacts at that place, each linking to /app/people/[id]. The initial fit remains capped at zoom 9 and pins remain city-grain — a closer user-controlled camera never becomes an address claim. Locations resolve through the shared geocode_cache; because geocoding is deferred and capped at 1 req/sec by provider ToS, a first-ever load can legitimately return no places and the client polls conditionally (ETag) until pendingCount reaches zero, pausing while hidden and stopping at a ceiling. Coverage stays honest: mapped, still being placed, couldn't be placed, and no location are separate counts. Basemap is OpenFreeMap, not mapcn's CARTO default (licensing — see Libraries §15), with visible OSM attribution. The same-origin worker keeps its COEP: credentialless header even though /app is no longer cross-origin isolated (removed 2026-08-25) — inert now, and the guard against a silent re-break if isolation ever returns. City focus is shared with the synthetic landing preview; browser/preview verification is tracked in the Testing guide.
  • 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.tsxcomponents/contact-sync/, lib/sync/); the web side is the conflict inbox at /app/sync/conflicts. A three-way merge against a per-link base snapshot (packages/core/src/sync/merge/, mergeSyncedContact) derives ownership per field, because no platform (iOS, Android, Google People, MS Graph) exposes per-field modification times — last-write-wins would clobber "added a phone number" with "renamed the contact". Scalars (name/nickname/title/company) go to whichever side moved off the base; multi-values (emails/phones/links/addresses/importantDates) union by entry key, and a removal is honoured only when the other side left that entry untouched. Conflicts are reported, never silently resolved, and a contested field is never pushed outward; on a genuine both-edited scalar the phone's value wins in Dhaga and the losing Dhaga value is persisted to contact_links.conflicts, then reviewed and restored at /app/sync/conflicts. An entry is carried forward until that value is actually recovered — clearing it because a later sync sees no divergence would destroy the record one run after making it, since by then both sides agree. Restoring deliberately does not rewrite the base snapshot, so Dhaga becomes the side that moved and the next sync pushes the restored value back out. Privacy boundary: only the nine vCard-shaped fields round-trip — notes, AI-derived facts, graph edges and signal state are never written to an address book (it syncs onward to laptops/cars/shared devices), and source === "mentioned" stubs are never pushed out. Link state lives in contact_links (lib/db/ddl/sync.ts + schema/sync.ts; EE TENANT_TABLES adds RLS — external ids collide freely across users); a contact missing from the phone only sets state='unlinked', never deletes a person, note or fact. Server: POST /api/sync/contacts (+ /ack), session-or-x-api-key auth, import rate-limit bucket, whole batch on one scoped connection (db-scope.guard.test.ts); chunked at SYNC_MAX_CONTACTS 1000 with observedExternalIds (cap SYNC_MAX_OBSERVED_IDS 25000) on the last chunk only. iOS writes into a cardDAV/exchange container so iOS itself relays the change to iCloud/Google — the only route to iPhone Contacts (Apple ships no server API) and no Google write scope needed. Android limitation (definitive): Container is iOS-only in expo-contacts and the native insert sets no ACCOUNT_TYPE, so contacts Dhaga creates stay device-local — edits to existing contacts still ride the owning account; stated verbatim to the user (ANDROID_ACCOUNT_NOTICE). Unit + PGlite tests green (merge, chunking, field mapping, containers, deletion/identity/company/push-outward); mobile tests wired into CI. Pending: no runtime/device testing at all — the expo-contacts I/O path (device-target.ts, engine/index.ts, the components) has no test that executes it, and it needs a native build, not OTA; the one-connection guard is a source-text assertion, not a real pooled-Postgres run; an emptied address book now DOES sweep: contacts.min(1) is dropped and a new observedEmpty flag carries the positive claim "enumeration ran and found nothing", honoured only when the batch really is empty — full: true with zero contacts and observedExternalIds: [] are both what a FAILED enumeration looks like, so neither may authorise it on its own (sweep-authorisation.test.ts unchanged and still green); google/microsoft are now implemented server-side (below). Two CodeQL high-severity findings from the first CI run are fixed: the multi-value link key stripped trailing slashes with /\/+$/, which backtracks polynomially on a long run of slashes (the same defect fixed earlier in geocoding/nominatim-client, but reachable from a sync payload here because link values are user-controlled), now a linear endsWith/slice loop; and the name+company dedup key tested for content with replace("|", ""), which strips only the FIRST separator — normalizeForMatch keeps punctuation, so a contact named literally "|" was indexed under a content-free key and every other such contact then matched it, merging two unrelated people (now a split-based check, covered by identity.test.ts). Shipped in PR #135 (merged 2026-07-30). The review queue is now screenshotted (/docs/guide/sync-conflicts.png, seeded by scripts/seed-sync-conflicts.mjs — the page renders only links with a non-empty conflicts array, which a real run reaches only when both sides moved the same field). Still unphotographed: the Settings scope-upgrade button and a Dhaga-calendar event both need a live Google/Microsoft OAuth round-trip, and the mobile sync screen needs a device — none available in this environment. User guide: Syncing with your phone
  • Server-side Google / Outlook contact sync — built 2026-07-31. Closes the Android gap: expo-contacts inserts a RawContact with no ACCOUNT_TYPE, so a contact Dhaga creates on Android never leaves the handset. Connecting the account directly writes through the provider's own API instead and Android syncs it down — the reverse of the iOS relay. Reuses the existing engine rather than duplicating it: lib/repo/contact-sync/run plays the role the mobile client plays, calling the same reconcileContacts + acknowledgeWrites, with the provider bound behind a plain ContactSyncTarget so the merge cannot tell a People API account from an iPhone. Providers are a gateway like LLM/search/messaging: ContactSyncProvider (packages/core/src/sync/provider-types.ts) owns the OAuth flow and mints a token-bound target; google-provider/ (People API) and microsoft-provider/ (Graph) ship built-in, seeded lazily like the calendar registry. Capability is derived from the granted scope, never a column. Grants live in a separate contact_connections table, not calendar_connections: contacts and calendar are independent consents, and sharing a row would let a reconnect on one narrow the other's grant; tokens are AES-256-GCM, and the table is in EE TENANT_TABLES (it holds tokens, so unscoped reads would be account takeover, not just a leak). Phase separation is load-bearing — DB → network → DB → network → DB, so a tenant connection is never held across an HTTP call (the pool-exhaustion failure this codebase has shipped repeatedly). Sync now in Contacts & sync (/app/import#accounts), and since 2026-08-15 a background schedule as well: lib/jobs/contact-sync (runContactSyncSweep) rides the daily aggregator /api/jobs/daily, with a standalone /api/jobs/contact-sync (same CRON_SECRET bearer, fails closed) for a Vercel-Pro or system cron that wants it sub-daily — deliberately not a second vercel.json entry, 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 calls runContactSync(userId, "scheduled"), and the trigger changes exactly one thing — a narrower connection set (run/eligible.ts: auto_sync_enabled on, and not synced within CONTACT_AUTO_SYNC_MIN_INTERVAL_MS = 6h). Every other gate is shared, so a scheduled run still only pulls unless two_way_sync is separately on. auto_sync_enabled is an opt-out of the SCHEDULE, never an off switch for the connection: the manual path keeps the broader predicate because pressing the button is itself the asking, and both halves are mutation-verified (__tests__/contact-connections-auto-sync.test.ts, lib/jobs/contact-sync/index.test.ts). Rate limiting is last_synced_at — no new table, and deliberately not lib/jobs/last-run.ts, which answers a once-per-local-day question this isn't; the endpoint's period is therefore not the rate. Self-host runs ONE global pass (no tenant gate → withUserDb doesn't scope, so a per-user loop would re-sync every connection once per account); hosted sweeps tenants sequentially via lib/jobs/tenant-sweep with per-tenant error isolation, and the sweep is deliberately NOT wrapped in a tenant scope because runContactSync opens its own between its network phases. Two-way had to become opt-in because the OAuth grant cannot carry that consent: both providers request a write scope at connect time (asking later would cost a second consent screen), so holding one says only what Dhaga may do — contact_connections.two_way_sync is what says the user asked. Both must hold. The gate lives in applyWrites at the point of writing, not only in the UI, and ReconcileOptions.pushOutward stops a write even being derived. The subtle half is the base snapshot: declining to push while still recording the merged value as the base would make the next run read the untouched remote as a fresh remote edit and adopt it — silently reverting the user's own Dhaga edit, two runs later, with nothing reporting a problem. nextBaseSnapshot() therefore keeps the REMOTE's value for fields it did not send, which is lossless and what lets a later switch-on push the whole accumulated divergence. A read-only grant now pulls instead of erroring; turning two-way on without the scope reports SYNC_NEEDS_WRITE_SCOPE once and prompts reconnect rather than failing per record on a provider 403. Both gates mutation-verified (run/two-way.test.ts, __tests__/sync/pull-only.test.ts). Microsoft is deliberately narrower: Graph gives one url slot, one birthday and three fixed address slots, so links/importantDates/addresses are declared unsupported and excluded from the merge via a new ReconcileOptions.unsupportedFields. That is not cosmetic — mapping them anyway would read back short, and on the second run (base snapshot now recording them as synced) the merge would honour the shortfall as a deletion and destroy the user's data; the guard is mutation-verified in unsupported-fields.test.ts. OAuth state is shared with the calendar signer but namespaced contacts: so a calendar-issued state cannot be replayed against the contacts callback. Reuses GOOGLE_CLIENT_ID/SECRET and MICROSOFT_CLIENT_ID/SECRET — no new env vars, but the OAuth app needs the People API / Contacts.ReadWrite enabled and /api/contact-sync/callback/* registered. Google's contacts scope is sensitive, not restricted (standard verification, no CASA, no fee). 33 core + 30 web sync tests green, tsc clean. Pending: no live OAuth or API round trip anywhere — no Google/Microsoft credentials in this environment, so every HTTP path (list/create/patch, pagination, the 401/412 rejections) is unexercised even by a mock, and the consent flow entirely so; no browser render or screenshot of the Settings card; the background sweep has never run against a live provider or a real cron either — its tests stub at the connections + merge seam, so what is proven is which connections a scheduled run selects and that a failing tenant is isolated, not that a scheduled run completes end to end; incremental sync now ships: Google syncToken and Graph /me/contacts/delta, persisted as an opaque contact_connections.sync_cursor. The sweep is the hazard here — an incremental page is NOT the complete address book, so sending it with full: true would unlink nearly every contact. Guarded three ways: mode is a discriminant ("full" | "incremental") that must be spelled at the site that actually enumerated everything, a named authorisesSweep(page) predicate replaces the old hardcoded full: true, and the cursor is withheld when any write failed (an incremental run never re-observes an unchanged contact, so "will retry" would otherwise be a lie). Both providers' deletion tombstones are dropped from incremental pages — Google metadata.deleted / Graph @removed are bare ids with no fields, and passing one through would hand the merge an all-empty contact which it would write back as the user clearing their own name and phones. 410/resyncRequired drops the cursor and re-runs full. Mutation-verified twice; 429/408/5xx and Google's 403 rateLimitExceeded now retry with bounded exponential backoff in one shared core/src/sync/http.ts (Retry-After beats the exponential guess, 3 attempts max, a stated delay over 30s fails fast rather than hanging a serverless invocation, and the error label drops the query string because an incremental URL embeds the syncToken); mobile has no UI for this (it is a web/server surface by nature). Fail-loud pass (2026-08-15) — three compounding silences closed. (1) A revoked grant was never flagged. usableAccessToken sets needs_reconnect only when a token REFRESH fails, and a refresh is only attempted near expiry — so a user who revoked Dhaga in their Google/Microsoft account settings while the stored access token was still inside its ~1h window got 401s that nothing acted on: the run caught a bare Error into result.error, the row stayed status = 'connected', and the settings card went on showing a dead account as healthy. A new isAuthFailure predicate at the packages/core/src/sync/http seam narrows the thrown SyncHttpError, and run/failure.ts reuses the SAME markNeedsReconnect marker rather than inventing a second state (http.ts became a directory, http/{index,classify}.ts, under the 150-line rule). 401 and only 401: Google answers an exhausted quota (RESOURCE_EXHAUSTED) and a disabled People API (SERVICE_DISABLED) with 403 too, and isRateLimited403 has already consumed the body that told them apart by the time the error exists — flagging needs_reconnect on a rate limit would send a user to the consent screen for nothing, which is a worse failure than the silence. (2) “Sync now” reported success when every connection failed. runContactSync never throws (it catches per connection so one dead account cannot stop the others), the action returned only the summed remaining, and the card only toasts on a throw — so the spinner stopped and nothing was said. runContactSyncAction now returns ContactSyncRunOutcome (remaining + failed + needsReconnect) and the card toasts SYNC_NEEDS_RECONNECT. Counts and a flag cross the boundary, never the per-connection error strings: those can carry a provider payload, and a provider payload can quote the user's address book. (3) Nothing told the user a connection broke. The background sweep now writes a contact_sync_disconnected notification naming the account (repo/notifications/contact-sync-notify.ts, modelled on job-notify.ts — its own short withUserDb scope opened after the run's have closed, every failure swallowed so a notification can never cost a tenant their sync; the bell row links to /app/import#accounts). Once per breakage, and it needed no new column: the sweep only ever selects status = 'connected' rows, so a connection flagged in one run is not a candidate on the next tick and cannot re-notify — the notification is pinned to the CONNECTED → NEEDS_RECONNECT transition for free, and reconnecting re-arms it. usableAccessToken/markNeedsReconnect had zero coverage before this; run/failure.test.ts (401 flips it, 403/429/network do not), __tests__/contact-sync-action.test.ts and two new sweep cases now cover all four. Still pending: no live provider has ever returned a real 401 here — every case is a constructed SyncHttpError, and the notification has never been seen rendered in the bell. User guide: the "Google and Outlook, without a phone" section of Syncing with your phone
  • Mobile calendar parity (M4) + contact-sync product rules — built 2026-07-31. Mobile calendar: expo-calendar on the DEVICE calendar rather than OAuth, mirroring the address-book relay (the OS carries changes to iCloud/Google, so no tokens and no second consent screen). Agenda screen lists real device events beside Dhaga follow-ups; write-out reconciles follow-ups into a dedicated "Dhaga" device calendar and removes events on done/dismissed/undated/deleted. Reuses followUpToCalendarEvent + DHAGA_CALENDAR_NAME from @dhaga/core so phone and web cannot drift. Three traps handled: findDhagaCalendar requires title match AND allowsModifications AND isPrimary !== true (a name match is not permission to write someone's main calendar); iOS/EventKit treats an all-day END as inclusive and Android/CalendarContract as exclusive, so unhandled every follow-up renders as a two-day event on iOS; and a followUpId → deviceEventId link file is the phone's stand-in for calendar_event_links, without which every run duplicates every follow-up. iOS files the new calendar under the default calendar's source so it reaches other devices; Android gets a local account and says so, because CalendarContract offers no way to add a calendar a Google sync adapter will pick up — the same honesty as ANDROID_ACCOUNT_NOTICE. Needed a new GET /api/follow-ups (packages/core/src/api/follow-ups.ts + route): the web calendar renders server-side so nothing published follow-ups over HTTP, and /api/export/json was rejected as the carrier because it embeds every scanned card as base64. It serves OPEN follow-ups only so the payload stays bounded, which means completion is signalled by ABSENCE — clients reconcile against the whole set. Also declared the undeclared vitest and expo-file-system in apps/mobile/package.json (both were resolving via root hoisting only). Product rules (user-stated): deleting on the phone never deletes in Dhaga ✅ already true; adding on the phone creates in Dhaga ✅ already true; deleting in Dhaga never deletes on the phone ✅ true (no target has a delete method) but it silently undid itselfcontact_links.contact_id is ON DELETE CASCADE, so the link died with the contact and the next sync re-imported the person. Fixed with a contact_sync_tombstones table written as the FIRST statement of cascadeForget (after the delete there is nothing left to read external ids from), consulted only on the create path after the dedup ladder misses. Tombstones store no identity — provider, external id, timestamp only, because keeping the name would make "forget this person" a lie — and never expire; they clear when the pair re-links. mergeContacts deliberately does not tombstone (a merged-away duplicate should be re-adopted by the survivor). Fourth rule, creating in Dhaga creates on the phone: offered, never assumed — every address book defaults OFF, the phone's own included (decided 2026-07-31; it shipped ON for one change and was reversed). Writing into an address book is the user's call to make, so the choice is surfaced rather than made for them: the onboarding tour points from the legacy Contact accounts card to Contacts & sync (SETTINGS_TOUR_STEPS, third of the settings leg's seven since that leg became the tour's opening, overlay left blocking) while the hub names the switch and says it is off — the same convention every email preference already follows. offerUnlinkedCreates also skips source === "import", so turning it on cannot replay every CSV a user ever imported. On a first link only, a real value on one side against a blank on the other is treated as silence rather than disagreement (core/src/sync/merge/blank.ts): the value is taken with no conflict, while two different real values still conflict and still keep Dhaga's copy. That is a volume property, not a nicety — a 700-contact seed whose methods are mostly unlabeled produced 1400 conflict rows before it and 0 after (conflicts are counted per contested field per contact). Mobile, web-sync and core suites are green and the tombstone and sweep guards are mutation-verified; raw test counts are deliberately not quoted here, because the ones this entry used to carry were stale two changes later and a number that rots reads as a fact. Pending: no device or simulator anywhere — every expo-calendar call (device.ts, write.ts) and the link-store file I/O is untested, so a first run should confirm the iOS all-day adjustment renders as one day, createCalendar succeeds under a real iCloud source, and Android's local-account insert is accepted; no live Google/Microsoft credentials, so Graph's /me/contacts/delta (a DIFFERENT endpoint from the one that shipped) has never been exercised against a tenant and has no fallback if one rejects it; the first sync after a user turns the switch on offers their whole back-catalogue of authored contacts, not only the ones created afterwards — there is no "created after I enabled this" marker and inventing one was judged speculative. That is now a caveat rather than an open question: the product call was made on 2026-07-31 (default OFF everywhere, discoverability via the onboarding tour), and the guide states the back-catalogue consequence at the point where someone would enable it. Two further consequences are answered as of 2026-07-31 (same change as the §11 vCard entry). (a) The SYNC_MAX_CREATES 500/run ceiling is no longer silent: SyncPushResponse.remaining reports what a run held back and both platforms render it ("437 contacts still to add — sync again to continue"). It counts only contacts a later run could actually add — eligibility is evaluated before the cap rather than by breaking mid-loop, so the number cannot be padded with stubs, imports or already-linked people and then never drain. Web showed nothing at all before this: the server action returned void and dropped the run's results. (b) A large back-catalogue no longer has to go 500 at a time — ?scope=authored on the vCard export gives a seed file the user imports into the address book in one bulk operation, and the first sync matches and links those contacts (email → phone → name+company, repo/sync/dedup.ts) instead of re-creating them one write at a time. Documented in the "Copying a big back-catalogue across" section of the user guide; packages/core/src/sync/types.ts is 194 lines and deliberately NOT split into a directory because apps/mobile deep-imports that exact path and Metro's directory-index resolution cannot be proven here; calendar-theme.css (187 lines) was split and proven byte-identical through the real PostCSS pipeline but is held back — another session is editing that file, and a modify/delete conflict there would silently drop their work; eslint-config-expo is missing entirely so npx expo lint cannot run at all (pre-existing); typedoc content/docs/api/** remains stale and wants its own PR
  • Voice dictation self-correction ("schedule at 3, no make it 4") — semantic LLM edits + a deterministic number/time pass; deferred pending a dedicated GPU host for the correction model. The in-browser correction LLM is CPU-bound (~48 s/edit) on consumer GPUs, too slow to be "then and there". Prototyped in the browser-voice R&D (llm-experiments repo, feat/voice-browser-jarvis); real-time in-browser STT (Moonshine, WebGPU) is proven there and is the intended whisper-base replacement that ports independently — only the correction layer is parked. (BRD §5.4)

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

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

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

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

  • Two-sided referral (hosted/EE) — a free month of Pro for both sides. /r/<code> sets an httpOnly cookie; a valid code admits the referee past the invite allowlist; the reward fires on email-verify. Stripe-safe: a live-Stripe advocate gets a 100%-off-1-month coupon (STRIPE_REFERRAL_COUPON_ID), free users get an additive comp Pro month (null-preserving). 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.

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

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

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

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

On this page