dhaga.docs
Product & Vision

Business Requirements

The full BRD — problem, competitive landscape, MVP vs full-product scope, technical architecture, the deployment strategy, and the cost model.

Planning document (v0.1 draft)

This is the working business-requirements doc, published as-is. It contains forward-looking pricing, unit economics, and competitor analysis — planning brackets, not commitments.

(Product renamed from working title "NetworkPro" to Dhaga — धागा, "thread" — July 2026. Mentions of NetworkPro below are historical.)

Product: Dhaga — Intelligence in Your Network Version: 0.1 (Draft) Date: 2 July 2026 Owner: Anchit Shrivastava


1. Executive Summary

NetworkPro is an AI-native personal CRM that turns fleeting professional encounters into a living, searchable knowledge graph. Where legacy products (the incumbent card scanner, CamCard) stop at "scan a card → save a contact," NetworkPro treats the scan as the ingestion point of a compounding intelligence system: contacts are auto-grouped by context (event, time, place), enriched with public data, connected through voice-note-derived relationships, and made queryable in natural language.

One-line pitch: Your professional memory, augmented.

Business model: Subscription SaaS. Revenue comes from a hosted cloud tier (sync, enrichment, team graph) sold as recurring Pro and Power subscriptions; a self-hosted deployment is available to enterprise customers on request, provisioned as part of an enterprise agreement.


2. Problem Statement

  1. Capture is easy; memory is not. Existing card scanners digitize contacts but lose all context: where you met, what you discussed, why it mattered. Six months later the contact is a dead row in an address book.
  2. Notes don't compound. Even diligent networkers who take notes can't query across them ("who did I meet in logistics who mentioned an AI budget?").
  3. Networks decay silently. Job changes, funding events, and relationship staleness go unnoticed — precisely the moments when outreach is most valuable.
  4. Incumbents are stagnant. The incumbent card scanner is functionally identical to its 2015 self; it validated willingness-to-pay (AUD $99.99 one-time) without ever adding intelligence. The category is ripe for an AI-native replacement.

3. Target Users

PersonaDescriptionPrimary jobs-to-be-done
Conference-heavy sales/BDAttends 6–20 events/year, meets 30–100 people per eventCapture fast, follow up same-day, recall context before next meeting
Founders & fundraisersNetworks across investors, partners, hiresWarm-path finding, relationship maintenance, investor tracking
Consultants / freelancersBusiness depends on referral networkLong-tail recall, staleness alerts, sector-based search
Teams (v2)Sales/partnership teams sharing relationship intelligence"Who at our company knows someone at X?"

4. Competitive Landscape (researched July 2026)

The market splits into five camps.

Corrected 2026-08-19 after a primary-source audit of six competitors (docs/COMPETITIVE_ANALYSIS.md). This section previously read "Nobody occupies the intersection… event-native capture + private knowledge graph + AI intelligence + data ownership." The knowledge-graph half of that is false. Dex ships an interactive relationship graph, Mesh ships network maps on five platforms plus 3D on visionOS, Orvo ships a Network Map, folk ships scored relationship intelligence, and Covve shipped and patented warm-intro graph matching in 2015 before abandoning it. Only Monica lacks any visualisation.

The defensible claim is narrower: their edges are drawn by hand; ours are derived from notes with a stored source-note receipt that governs deletion. Do not restate the original claim anywhere.

4.1 Camp A — Card scanners & digital business cards

ProductPricingStrengthsWeaknesses vs NetworkPro
The incumbent card scannerAUD $99.99 one-timeBest-in-class OCR (25 languages), Salesforce export, proven one-time-purchase modelFrozen product; zero intelligence, no context, no notes, no search
A digital business-card appFree / paid tiersTop-rated digital card on G2 (8,800+ reviews); simple shareable profile; card + badge scanningTheir graph is outbound (share my card), not inbound (remember who I met); no notes/knowledge layer
An enterprise digital-card appFree / team tiersPolished; enterprise-grade (SOC 2, SSO/SAML/SCIM); card + badge scanner, email signaturesSame — digital-identity tool, not memory tool
An NFC badge-scanner / lead-capture toolFree / paid + NFC hardwareNFC tap-to-share; universal badge scanner with ~90% AI enrichment success; strong at eventsLead-capture for exhibitors, priced/designed for sales teams at booths, not attendees building a personal network

Takeaway: this camp has nailed capture UX (badge scanning is now table stakes — we must match it earlier than planned) but treats the contact as the end product. None build a queryable memory on top.

4.2 Camp B — Personal CRMs

ProductPricingStrengthsWeaknesses vs NetworkPro
An auto-enrichment inbox CRMFree ≤1,000 contacts; Pro ~$10/moClosest philosophical competitor: auto-ingests email/calendar/LinkedIn/Twitter, web-based enrichment, reconnect nudgesDesktop/inbox-centric — no card/badge/voice capture at events; enrichment-feed model, not a user-built knowledge graph; closed source; US-cloud privacy posture
A sync-based personal CRM~$12/mo (free tier very limited)LinkedIn + email sync, reminders, cross-platformManual/import-centric capture; no event context; no NL search over notes
A business-card-scanner contact app~$9.99/moMobile-first, auto-enriches phone contacts, staying-in-touch nudges; strong security postureEnrichment of the address book, not capture of new encounters; no graph, no voice notes
A lightweight team CRMFrom $18/user/moLinkedIn Chrome extension (1-click capture), AI icebreakers, pipelinesTeam sales CRM in personal clothing; per-seat pricing; no mobile event capture

Named, detailed head-to-head comparisons live in the public comparison pages (see the blog: /blog/guides/dhaga-vs-dex, dhaga-vs-folk, dhaga-vs-mesh, dhaga-vs-covve, dhaga-vs-orvo, dhaga-vs-monica, dhaga-vs-twenty, dhaga-vs-yourpond, dhaga-vs-openvc, dhaga-vs-louisa, and the roundups).

The evidence behind them — sitemap crawls, pricing, app-store data, patents, SEC filings — is in docs/research/competitors/, and the investor-facing synthesis is docs/COMPETITIVE_ANALYSIS.md. Read that before making any competitive claim.

Takeaway: subscription fatigue is real in this camp ($10–18/mo for what users perceive as a contacts app), and every one of them is weak at in-person event capture — our wedge.

4.3 Camp C — Team relationship intelligence (the high end)

ProductPricingNotes
A VC/PE relationship-intelligence platform$2,000–2,700/user/year (published)VC/PE standard; email-mining based "who knows whom"; validates that relationship graphs command serious money
A private-markets relationship-intelligence tool~$100–300/user/monthSame category, private markets focus

Takeaway: these prove the team graph (our v2.0) is worth $1.8K–3.6K/user/year to relationship-driven firms. A bottoms-up, capture-first product that grows into a lightweight team graph at 1/10th the price is a classic disruption path.

4.4 Camp D — Capture extensions (sales tooling)

One-click LinkedIn→CRM enrichment extensions — Chrome extensions with enrichment (20+ data points/contact) — are all sales-prospecting tools feeding team CRMs. This validates the browser-extension capture pattern we're adopting, but none feed a personal, private graph — and their scrape-heavy enrichment posture is exactly the privacy stance we differentiate against.

4.5 Camp E — Open source

ProductNotes
The established open-source personal-relationship managerThe OSS personal-relationship manager (personal life focus: birthdays, family). No mobile app, no email/calendar/LinkedIn sync, fully manual entry, no AI. Popular repo, but a journal — not a networking tool
A well-designed open-source sales CRMWell-designed OSS sales CRM (inspired by modern SaaS CRMs). Team pipelines, not personal networks; no capture layer

Takeaway: this camp is journals and team pipelines — there is no AI-native, mobile-first, professional network tool in it. That the established relationship manager stays popular despite those limitations shows how much users will trade away for software where they keep control of the data.

4.6 Positioning statement

For professionals who build their careers on in-person and online networking, NetworkPro is the only tool that captures a contact from anywhere — card, badge, QR, LinkedIn page, pasted email — in one action, and turns every note into a private, searchable knowledge graph with proactive intelligence. Unlike digital-card apps it remembers who they are to you; unlike personal CRMs it captures at the moment of meeting; unlike enterprise relationship platforms it is affordable, personal, and private by design.

Strategic implications adopted into scope:

  1. Badge scanning moves up to v1.1 (the digital-card and badge-scanner apps made it table stakes).
  2. Browser extension is a first-class capture surface (validated by adoption of the one-click LinkedIn-capture pattern) — promoted into v1.1.
  3. Subscription only. A one-time tier was considered as a counter to Camp B's subscription fatigue and rejected: an unbounded AI allowance sold for a single payment has no defensible unit economics (see §8.3).
  4. Privacy and data ownership are the marketing spearhead against the auto-enrichment / sync CRMs and the sales-tooling camp.

5. Product Scope: MVP vs Full Product

5.0 Platform scope

SurfacePurposePhase
Mobile app — iOS + Android (single React Native codebase)Primary capture (camera, mic) + full experienceMVP — both OSes ship together; RN makes the delta small, and Android matters in APAC/EU conference markets
Web appQuick-add & desk workflows: paste an email signature, a LinkedIn URL, or an article link → extract/attach to a contact; full graph browsing and search on a big screenv1.1
Browser extension (Chrome/Edge first, Firefox later)One-click "Add to my network" on any LinkedIn profile, news article, or company page; article-to-contact linking ("save this article to Sarah")v1.1
Apple Watch / widgetsGlanceable pre-meeting briefsv1.3+

The web app and extension share one TypeScript core (parsing, API client) — the extension is effectively the web quick-add panel in a popup. Both write through the same ingestion API the mobile app uses, so every capture surface feeds the same graph.

5.1 MVP (target: 3–4 months to TestFlight/Play beta)

The MVP must prove one loop end-to-end:

Scan → auto-group by event → voice note → entity extraction → natural-language search → AI follow-up draft

#FeatureDescriptionAcceptance criteria
M1Card/badge scanCamera capture → on-device OCR → structured contact (name, title, company, email, phone) with edit-before-save≥90% field accuracy on clean Latin-script cards; under 5s scan-to-review
M2Auto event groupingScans within a time+location cluster grouped as an "Event"; user names it once ("Web Summit 2026")Contacts scanned same day/venue auto-attach to the active event
M3Voice + text notesAttach a voice note per contact; on-device transcriptionTranscript attached in under 10s for a 60s note
M4Entity extractionLLM extracts entities/facts from notes: role, intent, personal facts, relationships ("used to work at X", "knows Y")Structured facts visible on contact; user can correct/delete
M5Knowledge graph (v0)Contacts, companies, events, facts stored as nodes/edges; browsable per contact"Same company" and "same event" connections render on contact page
M6Natural-language search"Who did I meet at GITEX in fintech?" → ranked contactsHybrid vector + structured search returns correct contact in top 3 for seeded test set
M7AI follow-up draftOne-tap personalized follow-up email/LinkedIn message using notes + contextDraft references at least one note-derived fact; user edits & copies/shares
M8Local-first storage + exportAll data on device (SQLite); CSV/vCard export; optional encrypted cloud backupApp fully functional offline; export round-trips

Explicitly out of MVP: team features, enrichment from external sources, change-detection alerts, Android badge/QR formats beyond vCard QR, CRM integrations, Apple Watch.

5.2 Full Product (12–18 month horizon)

PhaseFeature clusterContents
v1.1 — Capture everywhereNew surfaces + enrichmentWeb app quick-add (paste email/article/LinkedIn URL → extract → link to contact); browser extension (one-click add from LinkedIn/articles, "save this article to Sarah"); LinkedIn Connections ZIP import (the user's own LinkedIn data export, filtered locally to Connections.csv, with the CSV accepted as a fallback — ToS-safe bulk import, see §6.7); vCard (.vcf) / device-contacts import (user's own exported .vcf from iPhone/iCloud, Android, or Google Contacts — one file, parsed in-browser, nothing scraped, see §6.7); one-click Google / Outlook-Hotmail contacts connectors (OAuth, user connects their own account — no Apple/iCloud API exists, see §6.7); mobile on-device contacts import (expo-contacts, permission-gated); badge scanning (table stakes per competitor analysis); user-triggered public-web enrichment; email-forwarding ingestion
v1.2 — Proactive intelligenceAlerts & digestsCalendar-aware keep-in-touch cadence reminders (optional weekday/day/month, capacity-aware Auto assignment, dismissed only on "reached out"), recurring follow-ups and general tasks with optional person/company links, deterministic note-date scheduling with weekend confirmation, job-change detection (LinkedIn-export re-import diff + watchlist hits, see §6.7), opt-in news watchlist, relationship-decay alerts, post-event digest email, birthday/anniversary reminders, pre-meeting briefs via calendar integration. Surface consolidated 2026-08-13: the separate Tasks, Calendar and Follow-ups pages are one /app/plan board (Day / Week / Month / List over one filtered set of follow-ups; the old routes redirect), and Dhaga now ships its own calendar — a first-party event store every account has with nothing connected and no OAuth — alongside the opt-in connected-calendar tier. Connected accounts are enumerated per calendar (show/hide + colour, Google only; Graph has no equivalent), their events open a read-only detail panel, and follow-ups can be written to a user-chosen calendar behind one further sensitive Google grant
v1.3 — Graph powerDeep graphWarm-path finding ("who can intro me to Airbus?"), second-degree suggestions, sector/tag ontology, timeline view of the relationship, watch/widgets
v1.4 — EcosystemIntegrationsSalesforce/HubSpot/Notion sync, Zapier/webhooks, LinkedIn QR formats, WhatsApp share-to-capture, email/calendar interaction sync (Gmail/Outlook OAuth, opt-in — the one ToS-clean ambient-capture channel, see §6.7), personal MCP server (built 2026-08-02 — any MCP client reads and additively writes the user's own graph at /api/mcp, see §6.8)
v2.0 — TeamsShared graphOrg workspace, contact-level sharing controls, "who knows whom" across the team, SSO; this is the primary revenue engine

Engineering constraint on v2.0 — clear the patent before scoping, not before shipping. A granted US patent, US10628798B2 (filed 2017, granted 2020-04-21, in force to ~2036), covers private cross-user contact sharing with referrer-based introduction resolution. Dhaga's warm-path finder today traverses only the user's own graph and does not implicate it. Any cross-user or team intro matching — the "who knows whom" line above — must be reviewed by patent counsel before the feature is scoped: the review may constrain the mechanism we design, so it is a design input, not a launch checkbox.

Added 2026-08-22, and deliberately outside the phased table above — neither is capture nor intelligence, and neither waits on a phase: /app/me (the user's own profile and company, plus a library of reusable document templates and email drafts merged deterministically from those details) and share links (one contact or company published at an expiring, unauthenticated URL, with disclosure opt-in per link). Both are core, both make no AI calls at all, and neither touches the v2.0 cross-user sharing question above: a share link is a read-only page handed to someone with no account, not a shared graph. Mechanics in §6.9–6.10.

5.3 MVP vs Full Product — at a glance

DimensionMVPFull Product
CaptureCard scan (single or multi-image), vCard QR, voice notes (mobile)+ badges, web quick-add (paste email/article/URL), browser extension one-click add, LinkedIn export ZIP import, vCard import, Google Contacts CSV import, Google/Outlook OAuth connectors, mobile device import, email forwarding, LinkedIn QR, call-log prompts
IntelligenceExtraction + NL search + follow-up drafts+ enrichment, change detection, decay alerts, pre-meeting briefs, warm paths
GraphPer-user, on-device, basic edgesRich ontology, article-to-contact links, team-shared graph, cross-user dedup
PlatformiOS + Android (one RN codebase)+ web app + browser extension + watch/widgets
SyncOptional encrypted backupFull multi-device sync (mobile ↔ web ↔ extension), team workspaces
MonetizationFree betaFree tier + Pro (monthly or annual) + Power + Teams (per-seat)

5.4 Considered features backlog (2026-07 review)

A July 2026 competitive review surfaced feature gaps. The genuinely-additive ones (deduped against existing scope) are captured below as backlog — considered, not committed, and (except where a Notes cell says otherwise) unbuilt. They inherit Dhaga's constraints: own-graph-first, no scraping, privacy by default (§6.7). Tracked as unchecked items in checklist.md §20.

CandidateNotes
Duplicate-contact detection & merge (entity resolution) across import sourcesBuilt 2026-07-27 (web). /app/people/duplicates clusters likely duplicates (shared email / phone / similar name), each with a per-field Merge that folds all multi-value data onto a chosen survivor in one atomic transaction; the same batch shipped companies management + company merge/dedup (/app/companies, /app/companies/duplicates). No schema change. Distinct from the team-graph cross-user dedup in §5.3 — this is per-user resolution across LinkedIn/vCard/Google/manual sources. See checklist §4/§20. Mobile parity pending
AI-suggested connections — surface likely graph edges from shared company/school/city/eventBacklog. Own-graph inference only; suggested, never auto-linked (mirrors the confirm-inbox pattern)
Relationship analytics dashboard — most-connected, longest-known, city clusters, network growthBacklog. Read-only stats over the user's own graph; no external data
Profile-completeness scoring + enrichment nudgesBacklog. Nudge to fill gaps; enrichment stays user-triggered per §7.5
Map view of contacts' locations ("who's nearby when I travel")Built 2026-07-29 (web); production worker isolation fixed 2026-08-06. /app/map clusters city-grain locations the user already holds, with no live tracking. Its same-origin MapLibre worker carries a COEP: credentialless header, which is what let Chrome execute it while the app shell was cross-origin isolated; the shell no longer is (isolation removed 2026-08-25 — it blocked Razorpay checkout and bought nothing), so the header is now inert insurance. Mobile parity pending
Personal-life logging modules (optional) — gift tracking, journal/diary + mood, activity log, debt tracking, petsBacklog. Optional modules to reach broader personal-relationship breadth; off by default so the professional-networking core stays uncluttered
Mail-merge / bulk personalized outreach + public API + Zapier appBacklog. Extends the existing outbound webhooks (checklist §16) and the Zapier/webhooks line already in §5.2 v1.4; bulk outreach is the new delta
Two-way native phone address-book syncBacklog. Extends the current one-way expo-contacts import (§6.7) to write curated contacts back to the phone
Voice dictation self-correction — spoken self-edits ("schedule at 3, no make it 4") folded into the transcript, both a semantic LLM pass and a deterministic number/time passBacklog — deferred pending a dedicated GPU host for the correction model. The real-time in-browser STT (Moonshine, WebGPU) is proven and is the intended whisper-base replacement — it ports independently and now. But the in-browser correction LLM is CPU-bound (~48 s/edit) on consumer GPUs where WebGPU falls back to CPU — too slow to be the "then and there" correction the product needs. Ships when a dedicated 24 GB-class GPU can host the correction model server-side (aligns with the §7.2 Phase-2 server tier). The STT-side layers (phonetic teaching, deterministic cleanup) are not part of this deferral — they ship with the STT

Promoted out of this table:

  • Personal MCP server was listed here as backlog ("read-only, user-scoped"). It shipped on 2026-08-02, and wider than that: read and additive write. It is now a product feature with its own mechanics section — see §5.2 v1.4 and §6.8.

Already covered — not re-added:

  • Relationship-strength scoring is already scoped: §6.7 "Relationship strength" row + checklist §14.
  • LinkedIn import & job-change detection / reach-out nudges are already scoped: LinkedIn export ZIP + QR import (§6.7, checklist §4/§16), job-change detection + news watchlist + keep-in-touch cadence (§5.2 v1.2, checklist §14). Continuous automatic LinkedIn network sync is not on the roadmap — it requires scraping/session-piggybacking, a §6.7 hard line.
  • WhatsApp capture is already tracked (§5.2 v1.4, checklist §16).

Considered and declined (different product / against policy):

  • iMessage / SMS capture — declined. SMS/call-log ingestion is a §6.7 hard line (blocked by iOS, disallowed by Play Store policy). WhatsApp share-to-capture (§5.2 v1.4) is the ToS-clean equivalent.
  • External fundraising suite — a curated external investor database, cold-outreach sequences, pitch-deck hosting/analytics, and an AI deck reviewer. Declined: that is a fundraising-discovery product, not a personal CRM. Dhaga records the investors you actually meet; it does not sell an external investor list or run outbound campaigns.
  • Enterprise org graph — a firm-owned relationship graph mined at bank/PE scale. Declined: Dhaga's team story is individual-first (§5.2 v2.0 / checklist §17 "who knows whom" across a small team), not an enterprise deal-intelligence platform whose graph the employer owns.

6. How It Will Be Achieved — Feature-by-Feature Mechanics

6.1 Capture (M1)

  • OCR is free and on-device. iOS: Apple Vision framework (VNRecognizeTextRequest) — excellent accuracy, zero cost, zero latency, zero privacy exposure. Android: Google ML Kit Text Recognition (also free, on-device).
  • OCR yields raw text lines + bounding boxes. A small LLM call (or on-device model) converts raw OCR text → structured contact JSON (name/title/company/email/phone/address), handling layout ambiguity that regex can't ("is this line a company or a title?").
  • Fallback for degraded/multilingual cards: server-side pass with a vision-capable model (send the image, get structured JSON directly). This is the premium path, used only when on-device confidence is low.
  • One card, one or many photos. A single capture can bundle multiple images of the same card (front + back) or a multi-page leaflet — via multi-shot camera, desktop live webcam, or multi-file upload on web and mobile; the server vision pass merges them into one contact and keeps every image as a receipt. (Extracting several contacts from one leaflet is not yet in scope — a multi-image capture always yields a single contact.)

6.2 Auto-grouping (M2)

Dhaga groups people two different ways, and they answer different questions. An encounter event is scoped to a time and a place — who did I meet at that conference? A meeting group is scoped to a set of people who keep meeting — who is in the Series A sync? Neither subsumes the other: a group meets in many places over months, and an event is one occasion whose attendees may never share a room again. They are separate tables and separate surfaces, and neither is ever derived from the other.

Encounter events — time + place clustering. Pure client-side logic — no AI needed for v0:

  • Each scan records timestamp + coarse geohash (with user permission).
  • Scans within a rolling window (same geohash-6, gaps under 4h) cluster into a Event.
  • First scan in a new cluster prompts once: "Name this event?" (pre-filled from calendar if an all-day event matches).
  • Later (v1.2): batch LLM pass suggests merging/splitting events and infers "these 3 people were probably in the same conversation" from sub-minute scan proximity.

Meeting groups — people-groups derived from the calendar (built 2026-08). A group is a private lens over the user's own contacts, the same shape as a tag. There is no inter-account collaboration in Dhaga and this adds none: no invites, no permissions, no sharing with another account, and no member who is not already a contact of this one user.

  • Groups hold shared notes — one note about a meeting with several people, stored once, owned by the group, and appearing on the group's page and on the page of every person who was actually in the room. A contact's timeline is their own notes UNION the notes they were linked to, in one query.
  • Who was in the room is a snapshot, not a derivation. Membership and note links are separate records, so adding somebody to a group in July never makes March's note appear on their page — they were not there, and their timeline must not assert a conversation that did not happen. Removing them hides nothing that is already on their page, for the mirror-image reason.
  • Suggestions are deterministic — no LLM anywhere in the feature. Each meeting in the calendar window the app has already loaded reduces to its set of matched contacts; a set seen twice is a recurring candidate, once is a one-off, near-identical sets are folded together so one weekly sync does not shatter into three one-offs, and the proposed name is mechanical (the meeting's title, or title + date, or the members' names). Nothing is written until the user confirms, and a dismissed set never returns.
  • Extraction from a shared note is attributed, never fanned out. Nothing lands on a person's profile unless the note was talking about that person; an ambiguous mention becomes a confirmation rather than a guess, and a fact the note attributes to nobody is owned by the group and shown only there.
  • A group is archived, never deleted — it owns notes about several people at once, and there is no deletion of one that is not either an abort, an orphan, or the silent erasure of what the user wrote.
  • Known limit, stated because a roadmap built on it would be fiction: attendance is read from a rolling calendar window (one month back, two forward) with no backfill, so suggestions and a group's meeting history see only that window. Long-run cadence intelligence ("you haven't met this group in six weeks") is not answerable today and must not be promised; a durable attendance table is the future fix.

6.3 Notes → Knowledge Graph (M3–M5)

  • Transcription: two engines on web, picked at runtime. Dhaga Voice (Moonshine tiny) streams the transcript live in the browser — on-device, free, private, but WebGPU-required with no fallback. Where the deployment has server-side speech-to-text configured, that engine takes precedence instead: the clip is uploaded and transcribed by the provider, which costs an AI credit, has no live partial transcript, and works in any browser including iOS Safari. Mobile: whisper.cpp (or Apple's on-device speech APIs on iOS 17+), unchanged and still on-device.
  • Extraction: one structured-output LLM call per note. Schema (enforced via the API's output_config.format JSON schema, so output is guaranteed parseable):
{
  "facts": [{"type": "role|intent|personal|preference", "text": "...", "confidence": 0.9}],
  "relationships": [{"subject": "contact", "predicate": "works_at|used_to_work_at|knows|reports_to|invests_in|competitor_of", "object": "Acme Corp", "object_type": "company|person"}],
  "follow_ups": [{"action": "...", "due_hint": "when their fiscal year starts"}],
  "tags": ["fintech", "decision-maker"]
}
  • Graph storage: nodes (person, company, event, tag) and edges (typed, timestamped, source-linked to the originating note) in plain relational tables. A property graph in SQLite/Postgres is entirely sufficient at this scale — a dedicated graph DB (Neo4j) is deliberate over-engineering for under 100k nodes per user. Every fact keeps a pointer to its source note for auditability ("why does the app think Sarah is leaving Stripe?").

6.4 Natural-language search (M6)

Hybrid retrieval, three stages:

  1. Query understanding: small LLM call converts the query into structured filters (event=GITEX, sector≈fintech) + a semantic residual.
  2. Candidate retrieval: structured filters via SQL + semantic match via vector embeddings over notes/facts (sqlite-vec on device; pgvector in cloud). Embeddings from an open model (e.g. bge-small / nomic-embed-text) — runnable on-device or on a $5 VPS.
  3. Rerank + answer: LLM reranks top-20 candidates and composes the answer with citations to the underlying notes.

Stage 1 and 3 are skippable for simple queries (keyword fallback), keeping most searches free and instant.

6.5 Follow-up drafts (M7)

Single LLM call: contact + event context + extracted facts + user's writing-style sample → draft. Prompt-cached system prompt makes marginal cost negligible (see §9).

6.6 Full-product intelligence (v1.1+)

  • Enrichment: user-triggered web search/fetch for the contact's public footprint (company news, funding, role verification) → summarized into graph facts. Runs through the LLM's server-side web search tooling or a search API; always attributed, always deletable. A company can be enriched from its own page too, with its own prompts. Company findings land unverified with a source_note_id receipt and the same one-tap confirm a person's do — and behind the same entitlement gate and the same single 20-credit action (§8.3), so researching a company is not a cheaper back door into the same web searches.
  • Change detection: nightly Batch API job (50% cost discount, latency-insensitive) re-checks key contacts' public signals; diffs become alerts ("Marcus is now VP at …").
  • Pre-meeting briefs: calendar webhook → assemble contact dossier from graph → one LLM call → push notification 30 min before the meeting.
  • Warm paths: pure graph traversal (BFS over works_at/used_to_work_at/knows edges) — no AI cost.

6.7 Source legality — the enrichment-feed auto-sync we can and can't do (researched 2026-07)

Auto-enrichment CRMs claim continuous auto-recording from LinkedIn and Twitter. That runs on user-session piggybacking or scraping: LinkedIn's API is partner-gated, closed to CRM/enrichment tools (the Connections API died in 2015; Proxycurl was shut down by LinkedIn legal in 2025), and X's API has no free read tier (pay-per-use $0.005/post read, Enterprise ~$42K/mo) — uneconomical at our price point. Our channels, all user-initiated or opt-in:

SignalLegal channelPhase
LinkedIn profile captureExtension reads the DOM the user is viewing — user-initiated, single profile, no automation (the one-click LinkedIn-capture pattern)v1.1
LinkedIn network bulk importUser's own LinkedIn data-export ZIP, opened locally to read only Connections.csv (name, company, position, connected date, sometimes email); direct CSV remains a fallbackv1.1
Device / phone contacts bulk importUser's own exported .vcf file (iPhone/iCloud, Android, Google Contacts all export vCard) — one file, parsed 100% in-browser, nothing scrapedv1.1
Google / Outlook contacts OAuth syncUser connects their own Google or Microsoft account (People API contacts / Graph Contacts.ReadWrite, delegated, explicit consent) — reads only that user's own contacts, and several accounts per provider may be connected. Once connected it is kept current on a schedule, not on demand, with a per-connection opt-out; the write half of the grant is unused until the user separately turns on two-way sync for that connection. No Apple/iCloud equivalent (no contacts API)v1.1
On-device contacts import (mobile)expo-contacts, OS permission-gated, user selects which to import — the phone's own address bookv1.1
Job-change detectionDiff of re-imported LinkedIn exports (Connections.csv inside the ZIP) + news-watchlist hits; email-signature changes once email sync exists. Days-to-weeks latency, partial coverage — accepted trade-offv1.2
"In the news" alertsOpt-in per-contact watchlist (user stars contacts), nightly/weekly Batch API web search, capped per tierv1.2
X/Twitter captureExtension capture of the viewed profile + user-triggered enrichment (web search reaches public X presence); no API monitoringv1.1
Ambient auto-captureEmail/calendar OAuth (Gmail/Outlook) — the only ToS-clean continuous channel; explicit opt-inv1.4
Relationship strengthComputed from the user's own graph (interaction recency/frequency, notes, events) — no external data at allv1.2

The LinkedIn-export path opens LinkedIn's own data-export page and starts a day-1/3/6/7 email nudge to upload the emailed ZIP once it arrives (email-configured instances only; stops as soon as it is imported or after a week). The app filters the archive locally to Connections.csv, still accepts that CSV directly, and keeps the whole flow in the canonical Contacts & sync page. The on-LinkedIn export steps stay as in-app instructions — we can't guide the user on LinkedIn's own site (cross-origin).

Hard lines: no scraping, no session piggybacking, no bulk lookup of people who never consented, no SMS/call-log ingestion (blocked by iOS entirely and by Play Store policy anyway). This is the privacy moat stated as engineering policy — the marketing claim is "one click, one file, nothing scraped behind your back," not "automatic."

6.8 Personal MCP server (v1.4 ecosystem — built 2026-08-02)

Any client that speaks the Model Context Protocol — claude.ai, Claude Desktop/Code, ChatGPT, Cursor — connects to /api/mcp and works the user's own graph on their behalf. Built on mcp-handler v2 + @modelcontextprotocol/server v2, serving the 2026-07-28 MCP spec with a stateless fallback for 2025-era clients.

  • Two credentials, one endpoint. Dhaga is its own OAuth 2.1 authorization server (better-auth's mcp plugin, RFC 8414 + RFC 9728 discovery documents), so a hosted client adds it as a one-click connector behind a normal login and consent screen; local and self-hosted clients instead send the x-api-key personal access token that already exists for the mobile app. No per-client integration is shipped — the tool list, schemas and descriptions are discovered at connect time.
  • Ten tools — six read, four additive write. Read: hybrid keyword+semantic search returning the snippets that matched, contact listing, one contact in full (facts carrying their source_note_id receipt), open follow-ups, warm paths, upcoming important dates. Write: add a note, create a contact, open a follow-up, close one. Every write is an explicit tool call that leaves a source receipt — never a silent graph mutation, so a note an assistant attached is deletable, and the facts derived from it are tombstoned with it, exactly like one typed in the app.
  • No destructive tool exists, deliberately. No delete, merge, bulk action, export or admin surface. A confused or prompt-injected client must not be able to trigger the deletion cascade (contact → notes → facts → edges → embeddings) that §7.5 makes complete on purpose.
  • Reads cost zero AI credits, because there is deliberately no "ask" tool: the connected client is already a model, so it gets raw retrieval with receipts and reasons itself. Only a note charges — it queues the same background extraction a note typed in the app does (1 credit, §8.3), and an account out of credits still keeps the note.
  • No dependency on the hosted-cloud module. Nothing here imports packages/ee, so a self-hosted enterprise deployment serves the identical endpoint (§8.1).

Open: the 2026-07-28 spec deprecates Dynamic Client Registration in favour of Client ID Metadata Documents (CIMD). better-auth 1.6 implements DCR — live, and what today's clients use — but does not advertise CIMD yet, so that lands when better-auth ships it.

6.9 The Me page — your own details, and templates (built 2026-08-22)

Every other table in Dhaga holds the user's data about third parties. /app/me is the one place the user records their own side of the relationship, and it exists because two other things needed it: a template needs a signature, and a shared page needs a sender. Until now the only record of the account holder was the better-auth user row (name + email), which is login plumbing, not a profile.

  • Three tabs — Profile, Company, Templates — selected by ?tab=, so a tab is linkable and the back button works. An unrecognised value lands on Profile rather than erroring. Profile and Company are two halves of one me_profile row: the account holder has one employer at a time for the purpose of a letterhead, so a second table would buy nothing and cost a join.
  • One row per user, enforced by the primary key, because id is the owning user's id. A UNIQUE (user_id) cannot be declared in core (that column does not exist on a self-hosted install — the hosted-cloud module's row-level security adds it), so the key the table already has does the work: the write is one INSERT … ON CONFLICT (id) DO UPDATE. This replaced a lookup-then-upsert on 2026-08-22 after a concurrency audit — a transaction does not serialise two inserts that have no row to collide over, so two overlapping saves both saw "no profile" and both inserted.
  • Templates are document templates and email drafts in one table, separated by a kind discriminator, because they differ only by whether a subject line applies. The 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 this is the cost story: it makes no model call, so it has no credit price, no metering and no row in §8.3. {{me.name}} is a lookup, and the project's standing rule is that if code can answer, code answers. The same function therefore runs in the editor's live preview and on the server with no round trip. (One-tap AI drafting still exists separately, on the contact page, and still costs a credit — §6.5.)
  • The variable vocabulary is closed and enumerated: {{me.*}} name/headline/email/phone/website/linkedin, {{company.*}} name/website/tagline, {{contact.*}} name/firstName/title/company, and {{today}}. Deliberately not an open object-path syntax — the expansion runs server-side over the user's own profile, and a path syntax would be a read primitive pointed at whatever object the renderer happened to be holding.
  • An unknown token is left verbatim, so a typo ({{me.nmae}}) stays visible in the preview instead of silently deleting a line from a letter about to be sent. A known but unfilled variable renders as an empty string.
  • The preview never borrows a real contact. It merges the user's own profile (real, so a signature can be proof-read) with invented sample values for the recipient — putting a third party's details on a screen nobody asked for would be the wrong default for a privacy product.

Not built, and worth stating plainly: nothing yet renders a template against a chosen contact. The {{contact.*}} half of the vocabulary is implemented and tested, but the only surface that expands a template today is the editor's preview, which always substitutes samples. Picking a recipient and producing the finished text is the next step, not a shipped one.

A contact or a company can be published at /s/<token>, readable by someone with no Dhaga account, and every such link expires.

  • Mandatory expiry. Presets are 24 hours / 7 days / 30 days / 90 days, defaulting to 7 days, and there is no "never expires" — no sentinel value, no preset, nothing in the UI. The server clamps anything else to a 365-day ceiling, and an expiry it does not recognise is rejected rather than defaulted: a request whose intent we cannot read must not quietly become a working link.
  • Liveness is checked at read time, on every resolution. Expiry and revocation are one predicate, so revoking is instant with no cache to invalidate and no sweeper that has to have run. Expired rows are kept rather than swept — they are the owner's record of what was shared.
  • Unknown, expired, revoked and dangling all render the identical page. Distinguishing them would confirm to a stranger holding a guessed string that the token was once real and pointed at somebody.
  • Disclosure is opt-in, five toggles, all off by default. A contact link defaults to name, nickname, title, company and job history; a company link to name, domain, sector and aliases. Behind toggles: contact details (emails/phones/links/location), notes, facts, open follow-ups, and — for a company — the people the owner knows there. Each toggled group is capped, so a link shows a current slice rather than an entire history. Never shared at all: tags, the star flag, keep-in-touch cadence, the person/service classification, capture source, signals, card photos, relationship edges, a fact's confidence score, a follow-up's recurrence schedule, closed follow-ups, and the source_note_id receipts that ride on facts, edges, follow-ups and job positions.
  • Enforced by an allow-list projection, not by omitting fields at render time. The shared card is built field by field and never by spreading the row, so a column added to contacts later cannot silently widen an existing link. Card photos are excluded structurally as well: /api/card-image/[id] is session-gated and answers 401 to a visitor.
  • Tenancy is the one correctness constraint. A visitor has no session, so the ordinary tenant scope does not exist yet; share_links is therefore a cross-tenant routing table with an explicit user_id, read unscoped, and every read of the actual record happens inside the owner's scope. It has no foreign key to contacts/companies on purpose — a foreign key would both bypass row-level security and let an outstanding link veto "forget this person" — so a link to a deleted record simply fails closed, and deleting the person is itself a revocation.
  • The only thing a visit records is a count. View count and last-viewed timestamp, for the owner; no visitor identity, no IP, no user agent. A share link must not become a tracker pointed at a third party.
  • The recipient can save the person, not just read them. A shared contact offers Save contact — a .vcf at /s/<token>/vcard that opens the OS add-contact sheet on a phone — plus Call, Email and WhatsApp when contact details were disclosed. The file is built from the same projected card the page renders, never from the database row, so it discloses exactly what the page does: a link with contact details switched off yields a name-only card with no address or number in it. The download re-checks expiry and revocation and does not count as a second view. A company has no vCard.
  • The token stays out of analytics — a 2026-08-22 audit finding, fixed the same day. Web Analytics and Speed Insights are mounted on every route and store the concrete URL of every data point; the URL redactor matched record ids by shape (a UUID), and a share token is base64url, so it was redacted not at all and the live credential was reported on every view and retained past the link's expiry. Redaction under /s is now positional: whatever sits directly under that root is secret regardless of how tokens are encoded later.
  • Not indexable. /s/ is disallowed in robots.txt, the page sets robots: { index: false, follow: false }, and its title and description are fixed strings — deriving them from the record would republish a private person's name into a crawler, a chat app's unfurl cache and a browser history that all outlive the expiry.
  • No AI, no credits. Resolving, rendering and revoking a link are plain database reads and writes.

7. Technical Architecture

7.1 Principles

  1. Local-first. The phone is the source of truth. Everything works offline; cloud is sync + heavy compute, not a dependency.
  2. On-device wherever a free primitive exists (OCR, transcription, embeddings). Cloud LLM only where it adds unique value (extraction, search reasoning, drafting).
  3. Tiered inference. Cheapest capable model per task; batch wherever latency doesn't matter; cache everything cacheable.
  4. Boring storage. Relational tables + vector column. No exotic infra until the graph demands it.

7.2 System diagram

┌──────────── Mobile App (React Native + Expo, iOS + Android) ────────────┐
│  Camera → Vision/ML Kit OCR → contact parser                           │
│  Mic → whisper.cpp transcription                                       │
│  SQLite (source of truth): contacts/events/notes/facts/edges/vectors │
│  sqlite-vec for on-device semantic search                              │
│  Sync engine (field-level LWW) ────────────────────┐                    │
└─────────────────────────────────────────────────── │ ──────────────────┘
                                                     │ E2E-encrypted sync
┌───────── Web App + Browser Extension (shared TS core) ─┐               │
│  Quick-add: paste email sig / article / LinkedIn URL   │               │
│  Extension popup = same quick-add panel + page context │──────────────▶│
│  Full graph browsing & NL search on desktop            │  ingestion API│
└─────────────────────────────────────────────────────────┘              ▼
┌──────────────── Cloud (optional, hosted or self-hosted) ────────────────┐
│  API: Next.js (Vercel) or Node/Fastify — auth, sync, ingestion, billing│
│  Postgres + pgvector (Supabase/Neon/self-hosted): graph + team graph   │
│  Job queue: nightly Batch-API enrichment/change detection, digests     │
│  LLM gateway: routes tasks → model tier, BYO-key support, metering     │
└──────────────────────────────────────────────────────────────────────────┘

Note on web/extension capture: these surfaces have no on-device OCR/transcription needs — their inputs are already text (pasted emails, page DOM, URLs). Ingestion is one structured-extraction LLM call against the same schema the mobile parser uses, so all capture surfaces converge on identical graph writes. The extension reads only the active tab on explicit user click (no background scraping — both a privacy stance and a Chrome Web Store review necessity).

7.3 Stack choices (opinionated)

LayerChoiceRationale
MobileReact Native + ExpoOne codebase for iOS+Android; native modules for Vision/ML Kit/whisper.cpp exist; largest OSS contributor pool
On-device DBSQLite (op-sqlite) + sqlite-vecplanned, not builtOffline-first, vector search on device, trivially exportable. Status: design only. No SQLite dependency exists in apps/mobile today; the mobile client is online-backed with a retry queue. The build-vs-adopt sync decision is still open (§12).
Cloud DBPostgres + pgvectorOne database for relational graph + vectors; Supabase/Neon for hosted, docker compose for self-host
BackendTypeScript (Next.js API routes or Fastify)Shares types with the app; deploys to Vercel or a single container
SyncField-level LWW with per-device vector clocks (or adopt PowerSync/ElectricSQL)Adopt before building; sync is a rabbit hole
TranscriptionDhaga Voice (Moonshine tiny, on-device WebGPU) on web, with a server speech-to-text gateway taking precedence where one is configured; whisper.cpp / Apple Speech on mobileOn-device is free and private but WebGPU-required; the server engine costs a credit a clip and works in any browser
Embeddingsnomic-embed-text / bge-small (on-device or self-hosted)Free at our scale; no per-call vendor cost
LLMClaude Haiku 4.5 for extraction/parsing; Claude Sonnet 5 for search reasoning & drafts; Batch API for nightly jobsSee cost model §9; structured outputs guarantee parseable JSON
Self-host inference optionOllama / vLLM adapter (Qwen/Gemma-class models)The LLM gateway is provider-agnostic; a self-hosted enterprise deployment can plug in local models where no data may leave the network

7.4 Data model (core tables)

contacts(id, name, title, company_id, emails[], phones[], source, created_at, ...)
companies(id, name, domain, sector, enrichment_json)
events(id, name, started_at, ended_at, geohash, calendar_event_id)
event_contacts(event_id, contact_id, scanned_at)
groups(id, name, description, kind: manual|suggested, color, emoji, created_at, archived_at)
group_members(group_id, contact_id, joined_at, left_at, source)   -- left_at NULL = current
note_contacts(note_id, contact_id, source: attendee|manual|extraction)  -- who was in the room
notes(id, contact_id, group_id?, kind: voice|text, transcript, audio_path, created_at)
facts(id, contact_id?, company_id?, group_id?, type, text, confidence, source_note_id, created_at, deleted_at)
edges(id, src_type, src_id, predicate, dst_type, dst_id, source_note_id, created_at)
embeddings(owner_type, owner_id, vector)          -- notes + facts + contact summaries
follow_ups(id, contact_id?, company_id?, group_id?, action, due_at, status, recurrence_rule?)
attachments(id, contact_id?, company_id?, file_name, media_type, byte_size, data_base64, created_at)

The graph is edges; the audit trail is source_note_id on facts/edges. Deleting a note cascades tombstones to derived facts — critical for trust and GDPR.

groups/group_members/note_contacts are the meeting-group half of §6.2, deliberately kept apart from events/event_contacts: an event is an occasion, a group is a set of people, and collapsing them would make one of the two lie. group_members.left_at retires a membership rather than deleting the row, because a membership is a claim about a period of time and the shared notes written during it still exist. note_contacts records who a single note was actually about, which is what lets one note sit on several timelines without being copied. Every contact_id here cascades on delete, so "forget this person" stays a single unconditional delete (§7.5); notes.group_id and facts.group_id do not, which is why a group is archived rather than deleted. facts.company_id now takes hand-typed rows, not only extracted ones. The manual-entry path takes an owner union — {contactId} | {companyId}, guarded at runtime — so a fact can be typed straight onto a company page exactly as it already could onto a person's. A hand-typed fact of either kind carries no source_note_id, queues no extraction job, calls no model and costs nothing (§8.3).

attachments is a new table for files the user attaches to a contact or a company — a contract, a deck, a spreadsheet. One nullable owner column each, mutual exclusion enforced in the repo rather than by a CHECK, matching notes and facts. The bytes live inline as base64 in a Postgres text column, the same shape card_images already uses: no object storage, no new external dependency, nothing leaves the user's own database. Consequences of that choice, all deliberate:

  • A single file is capped at 4 MB (MAX_ATTACHMENT_BYTES). The number is set by the host, not by taste: Vercel Functions reject a request or response body over 4.5 MB with 413 FUNCTION_PAYLOAD_TOO_LARGE before the handler runs, and no config raises that. 4 MB leaves headroom for the multipart envelope. A self-hosted Docker/Node deployment has no such ceiling and may raise it.
  • base64 inflates by ~33%, so 4 MB of file is ~5.3 MB stored. The list read is split from the download read so metadata never drags the blob out of TOAST.
  • Accepted formats are PDF, Word/PowerPoint/Excel, plain text/CSV and JPEG/PNG/WebP. Download always serves application/octet-stream with Content-Disposition: attachment and nosniff, so a stored file can never render in place, and Cache-Control: no-store — a private cache directive would still leave the document in the browser's on-disk cache for an hour on a possibly-shared machine, where removing the file in Dhaga cannot evict it.
  • Every method is rate limited per user, in three separate buckets (upload 20/min, download 30/min, delete 20/min). The size cap bounds one file, not how often it is asked for: each download pulls ~5.3 MB out of Postgres and buffers it, so an unbounded read loop is a database, memory and egress amplifier one signed-in user can point at us. Separate buckets so clearing files never eats the budget for adding them. The counters live in the process, so on a serverless fleet they are per-instance and approximate.
  • File names are truncated to 255 characters at the repo write, extension preserved. The name is echoed into Content-Disposition twice, one copy percent-encoded, so a scripted upload with a 50 KB name would otherwise write a row whose download permanently fails emitting the response header.
  • No AI touches an attachment — nothing is transcribed, summarized or extracted from it. It is storage, priced at zero credits.
  • Deleting a contact or a company deletes its attachments through the same explicit repo cascade the rest of its content uses, and the table is registered for account erasure and for tenant row-level-security isolation.

How attachments reach an export — the JSON dump by reference, the archive by bytes — is in §7.5, along with the archive's one honest ceiling; read it before describing the export as unbounded. Manual company facts, company enrichment, attachments and the archive export are newly built; only manual company facts have an end-to-end browser spec at all (e2e/facts.spec.ts, never executed), and company enrichment, attachments and the archive export have no e2e spec — so no part of the UI described here is browser-verified.

7.5 Privacy & compliance (non-functional requirements)

  • On-device processing by default; cloud calls are opt-in and per-feature.
  • E2E-encrypted sync (user-held key); the hosted service cannot read graph contents.
  • Enrichment is user-triggered per contact or per company, not automatic mass-lookup (GDPR legitimate-interest posture; contacts are data subjects who never consented). Researching a company from its own page runs the same gated, metered action a person does (§8.3) — nothing enriches in the background, on either owner.
  • One-tap "forget this person" — cascades contact, notes, facts, edges, embeddings, attachments, backups, plus their group memberships and their link to any shared note.
  • The one thing that cascade cannot reach, stated rather than glossed: a note about several people is owned by the group, not by any one of them, so forgetting a contact removes their link to it and everything derived about them, but cannot delete the note itself — it is also about the others, and deleting it would erase their record too. The person's name can therefore survive in the body of a note somebody else is still the subject of. This is not specific to groups: it is already true of any note that mentions a third party, and groups only make it more visible. Rewriting what a user wrote, on a third party's behalf, is not something the product will do silently, so the honest posture is to say so — on /privacy and here — rather than to claim an erasure that is broader than the schema delivers.
  • Data export: CSV / vCard / JSON, or the full archive, at any time. No lock-in is a feature and the core product promise — the user can leave with everything, at any time. (A full on-device SQLite file export follows the local-first work above; it is not available today and must not be promised.)
  • The JSON export lists attachments by reference, not by bytes — by design, and it is no longer the only export. It emits each attachment's id, its owning contact or company, fileName, mediaType, byteSize and createdAt, and deliberately not the file data: the dump is assembled as one in-memory JSON string, and inlining file payloads would make it fail outright on a large account rather than merely be large.
  • GET /api/export/archive is the complete, byte-for-byte copy (shipped 2026-08-23). It streams one zip holding dhaga-export.json — the same document the JSON export serves — plus every stored file at attachments/<id>-<file name>, keyed by the attachment id the JSON rows carry so each file reconciles back to its row. Payloads are read one row at a time, each in its own short-lived tenant-scoped transaction, and pushed as the client consumes them, so peak memory is one file rather than the account; the same metadata-only query feeds the JSON manifest and the archive's file list, so the two can never disagree. Files are stored uncompressed (they are already-compressed formats); only the JSON entry is deflated. It has its own rate-limit bucket so it can never eat the budget for the cheap exports. Surfaced at People → Export → archive.
  • The archive's ceiling is real and stated, not implied. A streamed response has no size limit on Vercel (the 4.5 MB cap is on requests), but the function's five-minute wall clock does bound it: an account whose files cannot be read and pushed inside that window cannot be archived in one request. The writer also emits a plain, non-ZIP64 zip, so 65,534 files or 4 GB is a hard format bound — checked from the cheap metadata manifest before a byte is written, and refused with a clear error naming the fallback rather than shipping an archive whose central directory disagrees with its contents. Self-hosted deployments have no duration limit. In every refused case the JSON export plus per-file downloads still work, so no account is un-exportable.

7.6 Web performance (non-functional requirements)

  • Fonts and any decorative/non-critical animation ship self-hosted (next/font/local/next/font/google) and stay off the critical render path (e.g. lazy client-only components via next/dynamic({ ssr: false })) — first paint never blocks on an external font or animation download.
  • Authenticated /app/* navigation (nav switches, contact/event detail) must not re-run the full set of Postgres queries on every click. Add a caching layer (e.g. unstable_cache/revalidateTag, or React cache()) scoped per-user and invalidated on mutation — never a raw TTL alone, since these routes are RLS-scoped per-tenant data and a stale/leaked cache entry is a privacy bug, not just a UX one.

8. Deployment & Sustainability Strategy

8.1 Model: hosted cloud, with enterprise self-hosted on request

Dhaga runs as a hosted cloud service. Enterprises that need the product inside their own infrastructure — for data residency or internal policy — get a self-hosted deployment that Dhaga provisions and supports under an enterprise agreement. The codebase is built to make that a configuration difference, not a separate product.

ComponentWhere it runs
Mobile app, sync server, graph engine, extraction prompts/schemasCore — identical in Dhaga Cloud and in a self-hosted enterprise deployment
Cloud-only modules: multi-tenant isolation, early access, billing, adminpackages/ee — Dhaga Cloud only

Implementation status (2026-07): this split is built, not just planned. Real accounts, capture, notes, graph, search, and export are core and run fully self-hosted with zero packages/ee dependency. Multi-tenancy (Postgres RLS), the early-access gate, the admin panel, and Stripe billing live in packages/ee, gated behind a single DHAGA_HOSTED_MODE flag that self-hosted deployments simply never set. Team graph/SSO (§5.2 v2.0) will land in the same module once built.

Why the split earns its keep:

  1. Trust is the product. A private-network app asking for your contacts, location, and voice notes needs privacy claims you can check. Per-account Postgres row-level security, a receipt (source_note_id) on every AI-derived fact, a deletion cascade that actually completes, and a full export at any time are all verifiable against your own data.
  2. Self-hosted is a deliverable, not a separate product. Because the core carries no packages/ee dependency, an enterprise deployment is the same software on different infrastructure — nothing diverges, and nothing has to be maintained twice.

8.2 Managing LLM cost — the four-layer defense

LLMs are the main marginal cost. Verified current pricing (Anthropic, mid-2026): Haiku 4.5 at $1 / $5 per MTok (in/out), Sonnet 5 at $3/$15, Batch API −50%, prompt-cache reads at ~0.1× input price.

LayerMechanismEffect
1. Don't call an LLMOCR, transcription, embeddings, grouping, graph traversal all on-device/free~70% of user actions cost $0
2. Smallest capable modelHaiku-class for extraction/parsing (they're classification-shaped tasks)5–25× cheaper than frontier models
3. Batch + cacheNightly jobs via Batch API (−50%); shared system prompts marked cacheable (reads ~0.1×)Halves background-job cost. The caching half is not live — our system prompts are hundreds of tokens, under every model's minimum cacheable prefix, so zero cached tokens were observed in §8.3's measurements
4. BYO key / local modelPower users plug in their own API key or Ollama endpoint through the provider-agnostic gatewayTheir usage costs us $0

8.3 Unit economics (measured 2026-07-30)

These are measured, not modelled: 39 real API calls against production prompts, n=3 per action, at Haiku 4.5 $1/$5 per MTok, Sonnet 5 $3/$15, and Anthropic server-side web search $10/1k. Card scan uses the 1024px downscale the client actually uploads.

User actionModel callsModelsin / out tokensCost
Card/badge scan (fields + verbatim transcription)2Haiku ×22,931 / 258$0.0042
Quick-add parse (pasted text → contact)1Haiku1,549 / 129$0.0022
Note processing (facts, relationships, follow-ups)1Haiku2,471 / 660$0.0058
Ask Dhaga (query plan + reasoned answer)2Haiku + Sonnet1,519 / 431$0.0092
Follow-up draft1Sonnet383 / 222$0.0045
Pre-meeting brief1Sonnet543 / 375$0.0073
Deep research / enrichment (web search + synthesis + extraction)2Sonnet + Haiku2,947 / 2,571 (+36k cached, 2.3 searches)$0.0975
Watchlist change scan, per contact per cycle1Haiku, Batch API1,090 / 117$0.0008

Three corrections to the earlier order-of-magnitude estimates, all of which this table supersedes:

  1. NL search costs ~2× the old $0.005 estimate, because the prompt-cache discount it assumed does not happen. Every Dhaga system prompt is a few hundred tokens, far under the minimum cacheable prefix (Haiku 4.5: 4,096 tokens; Sonnet 5: 1,024), so the cache_control breakpoint in packages/core/src/llm/anthropic-client/shared.ts produced zero cached tokens in 33 of 33 non-web-search calls. Layer 3 of §8.2's defense is not live for our own prompts today. Do not model a cached-system discount.
  2. Deep research is the whole cost story, at ~23× a card scan and 95% of it in one call. Most of that is not our tokens: it is 1–3 server-side web searches at $0.01 each plus the search tool loop's own cached context. It is the only action whose price is set by someone else's meter.
  3. Client-side downscaling is worth real money. Sending the raw camera file instead of the 1024px/q80 downscale costs 838 more input tokens and 17% more per scan, with no measured accuracy gain — see CARD_SCAN_MAX_DIMENSION.

The old heavy-user profile (100 cards + 100 notes + 200 searches/month) measures at $2.84/month, above the $1.50–2.50 previously claimed — searches, not cards, are the driver.

Credits. Usage is sold in credits, charged per user-visible action, not per model call: one card scan is one credit whether it takes one round-trip or three. One credit ≜ one card scan ≈ $0.0042; everything else is a whole multiple rounded up (packages/core/src/metering/credits.ts):

ActionCreditsWhy
Card scan · quick add · note · follow-up draft1All within ~1.4× of the anchor
Ask Dhaga · pre-meeting brief2Sonnet reasoning over retrieved context
Deep research20Web search billed on top of tokens. The same action also researches a COMPANY from its own page — one metered action at the same 20 credits, behind the same enrichment entitlement (Pro/Power/self-hosted). It is the same entry point widened, not a second one, so the credit table gained no row. The $0.0975 measured above is the person prompt; the company prompts have not been measured separately
Attachment upload/download · hand-typed fact on a person or a company0No model is called at all. These are storage and CRUD, not AI — they never enter the AI-action ledger, so they consume neither the credit allowance nor the dollar ceiling
Watchlist change scan0Throttled by the watch limit, not by credits — billing it would eat ~125 credits/month for ~$0.10 of Batch inference
Nightly curation sweep — person/service classification, goal match0Throttled by a per-night contact cap, not by credits. ESTIMATED, not measured (the table above is 39 real calls; neither of these has been run against the live API): a 5,000-contact graph is classified once for roughly $2.4 of Batch inference — ~400 credits at the ~$0.006/credit blended ceiling, which is exactly why billing it would be wrong. Re-check once measured

Plan sizing. At a blended ceiling of ~$0.006 of inference per credit, and taking the worst month a user could physically spend an allowance on (all notes, the priciest credit):

⚠ THE POWER ROW BELOW IS SUPERSEDED (2026-08-24) AND HAS NOT BEEN RECOMPUTED. Power's standing price was cut to ₹899 / ₹8,999 ($8.99 / $89.99), so the $12/mo this table prices Power at no longer exists — the annual-plan monthly equivalent is now $7.50, not $12. The cost columns are unaffected (they are inference, not price), but every Power margin figure here and in the arithmetic beneath it — the 47–57% band and the $11.352 net line — predates the reprice and must be recomputed before it is quoted. Pro's row is untouched.

PlanPriceCreditsWorst-case inferenceTypical mixGross margin
Free$010$0.06~$0.05— (a taster we fund)
Pro$4/mo300$1.73~$1.3546–56%
Power$12/mo1,000$5.77~$4.5047–57% (superseded)

REPRICED 2026-08-19, and the margin is the headline. Every price in this table halved (Pro $8 → $4/mo effective, Power $24 → $12), the credit allowances did not move, and the inference columns are the same measured numbers — so the whole reduction lands on margin. Gross margin falls from the 71–77% this section published to 46–57%. The arithmetic, on the same convention as before (annual-plan monthly equivalent, net of the processor fee, over the gross price): Pro at $4/mo nets $3.584, giving ($3.584 − $1.73) / $4 = 46% worst case and 56% typical; Power at $12/mo nets $11.352, giving 47% and 57%superseded 2026-08-24 and not recomputed, Power's annual-plan monthly equivalent being $7.50.

(The table uses annual-plan monthly equivalents for unit economics. Public standing pricing is now Pro at $4.99 month-to-month or $4/month billed $48 yearly — where yearly saves 20% — and, since 2026-08-24, Power at $8.99 month-to-month or $7.50/month billed $89.99 yearly, where yearly saves 17% (12 × ₹899 = ₹10,788 against ₹8,999). The two savings are no longer one number and must never be quoted as "20% on both tiers". Pro is not actually selling at its standing price today: both Pro cadences are on an introductory price, held for each customer's own first twelve months, and the standing figures are the struck-through comparison. Power has no introductory price any more — the 2026-08-24 reprice made ₹899 / ₹8,999 its standing price, so there is nothing to strike through and no Power offer renders.)

(Four introductory prices, decided 2026-08-19 — two of which stopped being offers on 2026-08-24. Both tiers, both cadences, sold as an introductory price held for the buyer's own first twelve months, after which that subscription steps up to the standing table. The two Power rows are no longer offers: the 2026-08-24 reprice made ₹899 / ₹8,999 Power's standing price, so they are simply what Power costs, no term attaches to them and no RAZORPAY_OFFER_POWER_* id is configured. The two Pro rows are unchanged and still charged in INR only; the USD figures are quoted equivalents on the marketing cards and nothing charges them.

OfferPricePer monthAgainst standing
Pro monthly₹199 ($1.99)₹199₹499
Pro yearly₹1,999 ($19.99)₹167₹4,799
Power monthly₹899 ($8.99)₹899now standing (was ₹1,499)
Power yearly₹8,999 ($89.99)₹750now standing (was ₹14,399)

Priced on Razorpay's own fee — ~2% + 18% GST on the fee ≈ 2.36% effective, with no flat per-transaction charge — the offer margins are 22%/39% (Pro monthly), 7%/27% (Pro yearly), 42%/54% (Power monthly) and 31%/45% (Power yearly), worst case then typical mix. (The two Power figures survive the 2026-08-24 reprice unchanged and still correct, because the amount did not move — only its label did — and they are the only Power margins on this page that are not stale.) Once the ESTIMATED ~$0.40/month of uncredited watchlist scanning is counted, Pro introductory yearly is ~6% on the typical mix and loss-making at worst-case usage. Each subscriber carries that for twelve months and no longer, and it is why the watchlist cap moved 25 → 5 rather than the credit allowance moving. The credit allowances are unchanged: free 10, Pro 300, Power 1,000. Each introductory price is a Razorpay Offer over the standing plan rather than a plan of its own — this used to read "Razorpay Plans are immutable, so each offer is a new plan id", and four introductory plan ids were duly created under that model; since 2026-08-23 they are kept resolve-only, so a plan id that could appear on a historical row still resolves to a tier, and nobody ever bought one. Existing subscribers are not migrated — a first-purchase price cannot be switched onto. The four standing plans now exist at the published amounts too, created 2026-08-20 with the env vars repointed on Vercel Production and Preview; the superseded ids are kept in _LEGACY variables for renewal reverse-lookup only, because a subscriber on an immutable old plan id still renews against it. Superseded for Power on 2026-08-24: its standing price is now ₹899 / ₹8,999, so the two Power standing env vars are repointed at the pair of plans created 2026-08-19 as the introductory ones, and the ₹1,499 / ₹14,399 ids join that same _LEGACY set. Pro's two standing plans are unchanged.)

(How an introductory price ends — a per-customer term, not a date. There is no end date in this offer anywhere, and the constant that used to hold one (INTRO_OFFER_ENDS_AT) is deleted rather than merely unused, so no surface can quote a calendar the billing code no longer honours. Two mechanisms replaced it. The term: each buyer holds the introductory amount for their own first twelve months, measured from their own subscription start, so two people who join months apart step up months apart. It is a guarantee, not a lock-in — the customer may cancel in any month, and a yearly subscriber keeps the year they paid for. Availability: a runtime admin toggle, stored in an EE billing_settings table and defaulting to open, decides whether a new customer can still buy one. Closing it stops new introductory purchases and changes nothing for anyone already subscribed; it is reversible, and deliberately not an env var, so the owner can close the offer from a screen at a moment of their choosing without a deploy. The "N sold" figure behind that decision is derived from the subscription and payment records rather than kept as a counter.

The step-up is not something we do — Razorpay does it, and this section used to describe a nightly sweep of ours that is now deleted along with the in-place plan-update call it made. Redesigned 2026-08-23: an introductory purchase is the standing plan with a Razorpay Offer attached when the subscription is created, configured in the Dashboard to discount a limited number of cycles — 12 on a monthly plan and 1 on a yearly one, since a yearly plan bills once a year and twelve cycles would discount twelve years. When those cycles run out Razorpay charges the standing amount by itself: no sweep, no schedule, no call from us, and nothing to book at a cycle end. What makes it work is that the e-mandate registers at the STANDING amount rather than 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 rails: standing ₹499 plan + limited-cycle offer → ₹199 invoice paid, mandate cap ₹499. Razorpay resolves the payment-rail variant of an offer itself, so the code holds one offer id per (tier, cadence) and never selects by rail. Offers can only be created in the Razorpay Dashboard — the API will not create, list or read them — so four environment variables are the only way one reaches the app, and with none configured every surface shows the standing price and every button sells the standing plan, which is the correct default rather than a degraded one. Checkout fails closed: an introductory cadence that reaches checkout with no offer configured is refused, customer-visibly, because silently charging the standing amount behind a button that advertised the introductory one is the one outcome that must not be possible. Since the subscription now sits on the standing plan, the plan id can no longer identify an introductory purchase, so the row records the offer it was bought with in a new nullable intro_offer_id column — the cadence stays monthly or yearly, honest with what Razorpay holds — and that column is what the term-end notice and the banner key off.

Those two risks were tested on 2026-08-20 and BOTH WERE CONFIRMED DEFECTS. The step-up as built could not run, which is why it was replaced. The measurement is kept because it is the evidence the offer model rests on. This paragraph used to call them unverified and guess that the first was "probably fine". That guess was wrong. A real subscription against a ₹199 test plan was authorised end to end by a human in the Razorpay sandbox, once on a card and once on UPI AutoPay, and then the exact call the (since deleted) sweep made was issued:

What was measuredcardUPI AutoPay
max_amount on the registered mandate₹199₹199
PATCH /subscriptions/{id} {plan_id, schedule_change_at:"cycle_end"}400"Only offers can be updated for subscriptions when payment mode is domestic card."400"subscriptions cannot be updated when payment mode is upi"
bare {quantity} update400"Can't update subscription immediately when card mandate is applicable"not attempted
  1. The mandate is capped at the PLAN amount, not at a default. ₹199 on both rails. The ₹99,000 figure this document previously cited was wrong — that SDK default belongs to the registration flow, not to Subscriptions. Even if the plan change were permitted, the ₹499 debit would be refused at charge time.
  2. subscriptions.update is refused on both Indian payment methods — a 400 every time, not an intermittent failure. UPI is stricter than card: a card at least accepts an offer-id update, UPI accepts no update at all.

Caveat, stated rather than buried: this was sandbox, and test-mode mandates never touch real bank rails. But the refusals are structural product constraints returned with explicit payment-mode error messages, and they agree across two independent rails — so they are treated as authoritative unless Razorpay says otherwise. That is the basis, and it is the whole basis.

Nobody was affected. No purchase had ever been completed at an introductory price, so no subscriber was on a term and none was due to step up; the defect was found before it could reach a customer. Both defects are RESOLVED as of 2026-08-23, and this paragraph used to end "do not sell an introductory price until it is resolved".

The direction the errors pointed, since measured and adopted. Razorpay's card error named offers as the only accepted lever, and because UPI refuses updates specifically, an offer had to be attached when the subscription is created rather than later. That shape — selling the standing plan with an offer discounting the first N cycles — was the candidate, gated on whether a Razorpay offer can be scoped that way. It can, and the whole path was then driven end to end on both rails. Both defects dissolve rather than get fixed: the mandate registers at the standing amount, because that is the plan the subscription is on, and nothing ever needs updating. What remains true is only that nothing has been sold at an introductory price yet, and that the offer path was proved in the sandbox rather than in production.

This was not only an introductory-pricing problem — and that half was fixed first. The same call was what ordinary tier changes used, so upgrading Pro → Power was broken for every Indian subscriber, on card and UPI alike. Since 2026-08-21 a plan change no longer makes it: it mints a second, future-dated subscription the customer authorises, and the old one is set to end at the same boundary (§8.4). This paragraph used to end by saying the fix was not applied to the step-up, which still made the refused call. With the sweep deleted the call has no callers left at all, and it is deleted too.

What runs it. The sweep is gone, but the thirty-day warning survives, re-keyed — a customer whose price is about to rise deserves the warning whichever mechanism raises it. The nightly /api/jobs/daily route runs the notice job and the 30-day warning email ships with it. This paragraph used to say the sweep ran before the notice and that the order was load-bearing: it was, because until a change was booked the date was only a prediction. There is nothing to book now, so the notice dates each account from the recorded offer plus the deterministic twelve-month anniversary of its subscription start, and depends on no other job's ordering. An admin drives the availability toggle from /app/admin/subscriptions and the per-customer hold from the user's admin page. The final-month in-app banner is built and mounted, on every /app page and mutually exclusive with the lapsed-plan notice. It reads the same date the email quotes, which is what stops the two warnings disagreeing — though this used to be a change booked at the processor, "stated by the processor rather than predicted by us", and the processor now books nothing. Both channels compute the same deterministic anniversary, and computing it the same way is what keeps them agreeing; "the final month" is the ~30-day window before that date rather than the existence of a booking. It is pure over the plan summary the app shell already loads, so it adds no per-request database read for anyone not on an introductory price, and dismissing it stores that term end's own token, so it re-arms for a later one on its own. Keep it distinct from the plan card, which shows the offer's term to somebody about to buy — a disclosure at the point of sale, not a countdown for somebody already subscribed. Neither warning has ever reached a customer: no purchase has been made at an introductory price, so nothing has been sent and nothing has been shown.)

(The "this price forever" FAQ answer was withdrawn, and it cost nothing. The public billing FAQ used to say an introductory subscriber goes on being charged that amount for as long as the subscription stays active, justified by Razorpay plan immutability. Under the twelve-month term that is false, and the copy has been corrected. A future reader will reasonably ask how a forever promise was withdrawn without grandfathering the people it was made to: there was nobody to grandfather. Verified against the Razorpay API on 2026-08-20, the entire account holds four subscriptions, all on the old ₹899 Pro monthly plan, all with status created — the mandate was never completed on any of them. Zero introductory subscriptions and zero active paid subscriptions have ever existed, so nobody was ever charged under the old promise. That, and not a change of mind about what is fair, is why the reversal was free.)

(Founding Pro — RETIRED 2026-08-19. This paragraph is history. It was one INR-only Razorpay plan, ₹6,999 a year against the then-standard ₹8,499, capped at the first 500 seats, sold from its own founding_yearly cadence rather than as a tier. It is withdrawn: the offer, its seat claim and its UI are deleted, nothing sells it, and it is on no public surface. It was superseded by the four introductory prices above, which do the same job — an early-buyer discount — without a seat ledger, and on both tiers rather than Pro alone.

The reasoning is kept because it is why the next offer is shaped the way it is. Founding Pro was not a first-year teaser (resolved 2026-08, §11 Q6): a founding member keeps ₹6,999 for as long as they stay subscribed, because the Plan carries the amount and createSubscription() books CYCLES_PER_YEAR[period] × 10 cycles of it. The honest bound was that ten-year horizon rather than the word "forever" — Razorpay has no "bill until cancelled", total_count is mandatory, and the subscription completes, ending the entitlement, once it is exhausted. "First 500" was enforced server-side by a founding_seats row claimed at checkout, decided by a UNIQUE seat number rather than a count, so two buyers racing for the last seat could not both win it, and the live count was deliberately never public: "N of the first 500 left" advertised how little had sold. That seat mechanism is exactly what the introductory prices do without — a per-customer term is a cheaper end condition than a ledger: no seat row, no idempotent claim between two buyers racing for the last one, and nothing that can embarrass you by counting. What it costs instead is that this end condition has to be acted on per customer rather than simply arriving on a date — which is the Razorpay offer described above.

Three things deliberately survive the retirement, all because a Razorpay Plan is immutable and a founding subscriber renews against that same plan id forever: the founding_yearly cadence string, whose label the settings page renders (deleting it would crash the page for exactly those customers); the founding_seats table and its DDL, the only sale record for any live founding subscriber; and the plan id's env entry, reverse-lookup only — nothing can buy it, but remove the entry and a renewal webhook grants nothing, silently dropping a paying customer to free.)

(/pricing has an INR/USD toggle, and it is display only. It defaults from x-vercel-ip-country through the existing preferredProcessor() signal (India → INR, everywhere else → USD) and remembers what the visitor picked. Razorpay is the only live processor, so everyone is charged in INR whatever the toggle says: the non-charging currency is labelled an approximate conversion, and the schema.org Offer always advertises the charging currency, never the toggled one — structured data that priced the product in a currency we cannot take would be a lie to a shopping crawler.)

(The standing-price margins above are after Stripe's 2.9% + $0.30 — kept on that convention so the before/after against the old 71–77% is like-for-like, even though Razorpay is the only live processor and its fee is the one the offer figures use. Hosting is not included in either.) A 100-credit Pro tier would clear a far higher margin but ration the product to a sixth of the heavy user profile above — 300 is the number that covers a conference month, and holding it through the repricing is a deliberate choice to buy adoption with margin rather than to ration. The "keeps >70% margin" half of that claim no longer holds at these prices: 300 credits on Pro is now 46–56% standing and thinner on the offer. 1,000 credits for Power was sized against ~$24/mo and sat on $12/mo after the 2026-08-19 cut, which is why Power's worst case fell to 47% — superseded 2026-08-24, when the standing price fell again to ₹899 / ₹8,999 ($7.50/mo on the annual equivalent); that 47% has not been recomputed. Self-hosted stays uncapped. The free tier gets 10 credits — 10 card scans, or 5 scans plus 5 notes, or 5 Ask-Dhaga questions — which costs at most $0.06 per free user per month (10 × the all-notes credit, ~$0.05 on the typical mix). Deep research can never be spent there: enrichment and pre-meeting briefs stay feature-gated to paid plans (PLAN_FEATURES), and one run is 20 credits anyway. That same 10 is the instance-wide default where no plan is in play (self-host, billing not running), and DHAGA_AI_MONTHLY_CAP seeds it (also denominated in credits).

Plan allowances are defined (PLAN_AI_CREDITS_PER_MONTH), runtime-editable per plan by an admin at /app/admin/ai-credits (since 2026-07-30), and — since 2026-07-31 — enforced by default (AI_PLAN_CAP_ENFORCEMENT_DEFAULT = true). The master switch is still there, but turning it off is now an escape hatch for a migration or an incident, not a resting state: with it off, paid plans fall back to the raw billing entitlement (hasUnlimitedAi) and everyone else falls to the instance default. The marketing prerequisite landed first — the pricing page no longer sells "no monthly cap": as of 2026-07-31 it sells the allowance itself, in activities rather than credits ("300 credits — 300 card scans, or 150 scans plus 150 notes, or 15 deep-research runs; about 100 new people a month"), and states plainly that a conference week can spend a Pro month. The 10-credit free taster that copy assumes is now the shipped constant (PLAN_AI_CREDITS_PER_MONTH.free = 10, which FREE_TIER_AI_CREDITS_PER_MONTH derives from so there is one number), admin-editable like any other plan; if the free number moves, the plan copy in apps/web/src/utils/constants/landing/pricing/* moves with it. Two levers work regardless of that switch: an instance-wide promotion ("everyone gets 1,000 credits this month", self-expiring) and an additive grant ledger for making users whole after a bug. Grants only move the ceiling — ai_actions, the sole record of what cloud AI actually cost, is never rewritten. Precedence, highest first: per-user override → active promotion → plan allowance (only when the switch is on and a paid plan is in play) → the instance default (instanceDefaultCap(): the admin-set Free allowance, else DHAGA_AI_MONTHLY_CAP, else FREE_TIER_AI_CREDITS_PER_MONTH), with every active grant added on top of whichever rung won (apps/web/src/lib/ai/metering/cap/index.ts; rung 4 in cap/instance-default.ts). Free users resolve through that instance-default rung, not the plan ladder, so DHAGA_AI_MONTHLY_CAP means the same thing on a self-host and on an instance that has billing — and it is a seed, not an override: the moment an admin sets a number, the stored one wins and the env var stops mattering.

8.4 Revenue streams

  1. Pro (individual): hosted sync + a monthly AI-credit allowance (§8.3 — 300 credits, sold as such on /pricing since 2026-07-31 and enforced by default since the same date) + enrichment + alerts. Monthly or yearly; no one-time tier (see §8.3 — the economics do not hold).
  2. Teams: per-seat, shared graph, SSO, admin. The defensible, expanding revenue line.
  3. Enterprise self-hosted (later): a deployment Dhaga provisions inside the customer's own infrastructure, sold with paid support/SLA. Requested, not downloaded.

Plan-change lifecycle. A subscriber changes tier or cadence from Settings → Plan & billing. Checkout is only ever used to create the first subscription and is refused outright for an account that already has a live one (a second charging subscription would bill the same card twice, and neither processor deduplicates for us). Past that the two processors work differently, and the difference is the whole design of this surface.

On Stripe the change modifies the existing subscription. Upgrades apply immediately and Stripe prorates the item swap; downgrades — a lower tier, or yearly → monthly — are scheduled for the renewal boundary, never applied immediately, because an immediate downgrade makes Stripe credit the unused difference: a liability against revenue already recognised. The customer loses nothing; they keep the higher tier they paid for until it runs out.

On Razorpay a mandate cannot be moved to another plan at all. Testing on 2026-08-20 confirmed that an existing Indian mandate is refused for both a tier change and a cadence change, on domestic card and on UPI AutoPay alike, and that no timing option makes it permitted. That is a constraint of the Indian payment rails, so a plan change here is built as a new mandate instead:

  1. A second subscription is created with a start date equal to the current one's own renewal date, read live from the processor. It raises no invoice and takes no payment until that date.
  2. The customer authorises its mandate in the ordinary checkout window. The mandate registers at the new plan's amount — which is what makes a later step up in price chargeable at all — and authorising takes a small refundable registration debit (₹5 observed). "Nothing is charged today" is therefore false on this rail and appears on no surface.
  3. Only on that authorisation is the old subscription set to end at its renewal date. Abandoning the window leaves the customer exactly as they were.
  4. When the new subscription takes its first charge it becomes the account's subscription of record.

An upgrade is granted free in the meantime — the target tier is switched on immediately and costs nothing until that first charge, because the alternative is a customer who paid to approve a mandate and sees nothing change for up to a year. The grant is derived on every read from the same subscription it rides on, never stored, so it cannot outlive it; a downgrade and a same-tier cadence switch grant nothing, since those customers already have what they paid for. Entitlements, the AI-credit allowance and the inference-dollar ceiling all read the granted tier, and the ceiling follows the new cadence rather than the old one.

There is no undo once the mandate is approved. Discarding a change cancels the second subscription outright while it is unapproved, but is refused afterwards: the old mandate is by then set to end and cannot be brought back, so the only way onward is another change the customer approves again. Cancelling the plan cancels both subscriptions, the pending one first, or a customer told "your plan ends on the 3rd" would be debited for the new plan on the 3rd.

Two consequences worth stating outright. An account can hold two subscription objects at once, so every incoming processor event is routed by subscription id before anything is written. And if the new subscription's first charge fails, the old mandate is already gone and cannot be resumed — the policy is an ordinary lapse, handled by the existing grace window and downgrade path, because an entitlement outcome is the only kind still available.

Built, unit-tested, and never run against a live Razorpay subscription. No end-to-end authorisation, hand-over, discard or cancel-with-a-change-in-flight has been exercised on a real mandate.

Tier dominates cadence when both move: power/yearly → pro/monthly is a downgrade, and pro/yearly → power/monthly is an upgrade.

Cancel is always at the renewal boundary on both processors, behind a confirmation dialog. Stripe cancellations can be undone from the same screen ("Keep my plan"); Razorpay has no resume API, so the dialog says so and restarting later means a new subscription. A change that is merely booked can be dropped at any point before it lands.

Every offer price sits outside this ladder on purpose. Each is a cadence rather than a tier, sold only as a first purchase: an offer buyer is never offered — and cannot ask for — the standing cadence, which would be a silent price rise, and an existing subscriber can never switch onto an offer, which would hand a first-purchase discount to people it was never sized for. That is true of the two live introductory cadences (intro_monthly, intro_yearly — which under the offer model are a checkout selection only: the row that results carries monthly or yearly, because it sits on the standing plan, and records the purchase in intro_offer_id) and was true of the retired founding_yearly before them; the change API refuses any of them as a target before it reads anything.

Every processor state a screen depends on is stored, not fetched. The subscription row carries its cadence, any booked change and the last time a processor confirmed them, so entitlement reads are pure database reads; a separate payments ledger records each confirmed charge, refund, dispute and failure. That ledger is what makes access honest at the boundaries: hosted access is granted only by a payment the processor has confirmed, revoked by a refund or chargeback, and deliberately not revoked by a cancellation — the term was paid for.

8.5 Extensibility

  • Capture parsers and language support are a long tail; the eval suite gates every change to them, so extraction quality is measured rather than asserted.
  • Plugin interface for capture sources (badge formats, email parsers) and export targets (CRMs), so the integrations surface can grow without touching the core.

8.6 Viral growth loops (built 2026-07)

A private CRM has no inherent network effect — nobody else sees your graph — so distribution is engineered as three deliberate, privacy-safe loops (build detail in checklist.md §21):

  • Network Wrapped — a contact-free, proud-to-post share card ("47 people met this month", "12 at this event", "strongest cluster: fintech"), computed deterministically from the user's own graph (no LLM, no metered cost). Every share is a faceless ad; it exposes aggregate counts + category superlatives only, never a third party's name. Server-rendered in feed/story/unfurl formats and free to all users — it's a growth surface, not a paywalled one.
  • Public graph sandbox — the landing lets anyone drag/zoom a real-scale (21k-node) but entirely synthetic network, loaded only on demand. It turns the product's hardest-to-explain value (a living knowledge graph) into a screenshot-worthy toy with zero signup friction, reusing the same sigma.js renderer the app ships.
  • Two-sided referral — a free month of Pro for both advocate and referee (hosted tier); a valid invite also admits the referee past the early-access wall. The one loop that directly compounds paid conversion.

9. Delivery Plan & Milestones

MilestoneScopeTarget
M0 — Spike (2–3 wks)RN app: camera → Vision OCR → Haiku parse → contact saved; prove the capture loop feels magicalWeek 3
M1 — Capture core (4 wks)M1+M2+M3 (scan, grouping, voice notes), SQLite schema, exportWeek 7
M2 — Intelligence core (4 wks)M4+M5+M6 (extraction, graph, NL search)Week 11
M3 — Loop closure (3 wks)M7+M8 (follow-up drafts, backup/export), polish, TestFlight betaWeek 14
Beta50–100 users recruited from one real conference; measure activation (scans day-1) and retention (search usage week-2)Week 14–20
v1.0 — general availabilityHosted cloud open to signups, Pro tier live; enterprise self-hosted deployment available on request~Month 6
v1.1 — Capture everywhereWeb quick-add + browser extension (shared TS core) + badge scanning + enrichment~Month 8

Success metrics (MVP beta):

  • ≥70% of scans require zero manual field correction
  • ≥40% of contacts get a voice/text note attached (the graph's fuel)
  • ≥30% of weekly-active users run at least one NL search
  • Follow-up draft used (copied/sent) for ≥25% of new contacts

10. Risks & Mitigations

RiskLikelihoodMitigation
Capture friction kills retention (the category's graveyard: Evernote Hello, Humin, CamCard)HighObsess over scan-to-saved time (under 5s); voice-first notes; value visible on first event (auto-grouping + instant search)
Business cards decline as a mediumMediumCards are the wedge, not the product — badges, QR, email-forwarding capture ship in v1.x; the graph is medium-agnostic
GDPR exposure from enrichmentMediumUser-triggered enrichment only, no bulk scraping, full deletion cascade, EU data residency option on hosted tier
LLM cost blowout at scaleLowFour-layer defense (§8.2); per-user AI-action metering from day one
A competitor copies the productMediumThe moat is the hosted graph/enrichment pipeline, the capture-quality eval suite and team network effects — not the client code, which is not published
Solo/small-team scope creepHighMVP list is a contract; anything not M1–M8 goes to the v1.x backlog by default

11. Open Questions

  1. Does dropping a one-time tier cost conversions against the incumbent card scanner's AUD $99.99 anchor? Needs willingness-to-pay testing of recurring-only pricing against that anchor and the ~$10/mo enrichment-CRM anchor.
  2. Sync build-vs-adopt: PowerSync/ElectricSQL licensing fit?
  3. Enrichment data sources: which are ToS-safe? Resolved 2026-07 — see §6.7. LinkedIn API is partner-gated and closed to CRMs; X API reads are pay-per-use and uneconomical. Channels: user-triggered web search, LinkedIn export ZIP import + re-import diff, opt-in news watchlist, extension DOM capture. Remaining sub-question: is this enrichment quality enough vs the NFC badge-scanner's claimed 90%?
  4. Browser extension and LinkedIn: confirm legal posture. Resolved 2026-07 — see §6.7. User-initiated, single-profile DOM read of a page the user is viewing (the one-click LinkedIn-capture pattern) is the posture; shipped in the extension. No automation, no bulk collection.
  5. Brand/name: "NetworkPro" is a working title; trademark search needed.
  6. What does year two of Founding Pro cost? Resolved 2026-08, then made moot 2026-08-19 when the offer was withdrawn. Recorded as history — see §8.3. The answer was ₹6,999, the same as year one: a founding member keeps the founding price for as long as they stay subscribed, rather than for a first year only. Nothing in code made year two differ, and that was the desired behaviour rather than an oversight — the Razorpay Plan carries the amount, and createSubscription() sets total_count to CYCLES_PER_YEAR[plan.period] × 10, so a yearly founding subscription is created for ten yearly cycles and every one of them is charged at the Plan's ₹6,999. No dashboard change was needed. The one caveat, stated plainly rather than rounded up to "forever": Razorpay has no "bill until cancelled" — total_count is mandatory — so the subscription completes after that ten-cycle horizon, and a completed subscription ends the entitlement. That answer still governs every founding subscriber who exists, because a Razorpay Plan is immutable and withdrawing an offer does not touch a live subscription; it is why the founding_yearly cadence, the founding_seats table and the plan id's reverse-lookup env entry all survive the retirement (§8.3). The question that was left undetermined is now closed by withdrawal: what a founding member who cancels and re-subscribes later pays. They cannot re-buy founding at all — the offer is off sale — so they buy whatever is then current, at the introductory price if that offer is still open. The lesson carried forward into the introductory prices: an offer bounded by a seat ledger left an open question that a bound on time does not. The replacement offers carry no seat table, no idempotent claim and no re-purchase ambiguity: each is bounded by a per-customer twelve-month term for whoever is already on it, and by a runtime availability toggle for whoever might still buy one (§8.3). A returning subscriber simply buys whatever is on sale the day they come back.

Appendix A — pricing sources: Anthropic API pricing verified 2026-07 (Haiku 4.5 $1/$5 per MTok; Sonnet 5 $3/$15 with intro $2/$10 through Aug 2026; Batch API −50%; prompt-cache reads ~0.1× input, writes 1.25×).

On this page

1. Executive Summary2. Problem Statement3. Target Users4. Competitive Landscape (researched July 2026)4.1 Camp A — Card scanners & digital business cards4.2 Camp B — Personal CRMs4.3 Camp C — Team relationship intelligence (the high end)4.4 Camp D — Capture extensions (sales tooling)4.5 Camp E — Open source4.6 Positioning statement5. Product Scope: MVP vs Full Product5.0 Platform scope5.1 MVP (target: 3–4 months to TestFlight/Play beta)5.2 Full Product (12–18 month horizon)5.3 MVP vs Full Product — at a glance5.4 Considered features backlog (2026-07 review)6. How It Will Be Achieved — Feature-by-Feature Mechanics6.1 Capture (M1)6.2 Auto-grouping (M2)6.3 Notes → Knowledge Graph (M3–M5)6.4 Natural-language search (M6)6.5 Follow-up drafts (M7)6.6 Full-product intelligence (v1.1+)6.7 Source legality — the enrichment-feed auto-sync we can and can't do (researched 2026-07)6.8 Personal MCP server (v1.4 ecosystem — built 2026-08-02)6.9 The Me page — your own details, and templates (built 2026-08-22)6.10 Share links — one record, one expiring public URL (built 2026-08-22)7. Technical Architecture7.1 Principles7.2 System diagram7.3 Stack choices (opinionated)7.4 Data model (core tables)7.5 Privacy & compliance (non-functional requirements)7.6 Web performance (non-functional requirements)8. Deployment & Sustainability Strategy8.1 Model: hosted cloud, with enterprise self-hosted on request8.2 Managing LLM cost — the four-layer defense8.3 Unit economics (measured 2026-07-30)8.4 Revenue streams8.5 Extensibility8.6 Viral growth loops (built 2026-07)9. Delivery Plan & Milestones10. Risks & Mitigations11. Open Questions