dhaga.docs
Roadmap & ideas

Build timeline

How Dhaga was built, day by day — a public timeline derived from the project's git commit history, starting 2 July 2026.

Coding on Dhaga started on 2 July 2026 with a single commit, initial scoping. This page is a public, curated summary of what got built when, derived directly from the project's git history — 847 commits across 49 active days between 2 July and 24 August 2026.

In the spirit of the project's "fail loud, don't overclaim" rule, this is a milestone-level summary, not a commit-by-commit dump: hundreds of the 847 commits were merges, fixes, doc passes, and polish that don't each earn a line. Every bullet below traces to a real commit subject; if something isn't here, it either hasn't shipped or didn't rise to milestone level. For where each feature actually stands today — shipped vs. in progress vs. planned — read the roadmap and the build checklist.

Day 1 — scoping, brand, and the shared core (2 Jul)

  • Initial scoping; a landing page with a scroll-story product demo, the brand system, docs shell, and config.
  • Mobile mockups laying out the real app anatomy across every phone screen.
  • A build checklist derived from the BRD.
  • packages/core: the shared Zod schemas, the LLM gateway, and a heuristic parser, wired up as npm workspaces.

Day 2 — the whole web loop, in one day (3 Jul)

The single biggest burst of feature work. In 24 commits, the entire capture → graph → search → draft loop landed on web:

  • Web app shell: auth, a PGlite + Drizzle data layer, and contacts CRUD.
  • Quick-add extraction, sessions, notes, and facts-with-receipts, plus AI metering from day one.
  • Natural-language search, follow-up drafts, export (CSV/vCard/JSON), and the "forget this person" cascade.
  • Graph browser and contact connections (M5).
  • Home dashboard with keep-in-touch reminders and "nearby in your network."
  • M6: local embeddings + pgvector hybrid search, query understanding, and warm-path finding.
  • Email (Resend) with post-event digests and user-triggered web enrichment.
  • The browser extension (MV3) and the /api/capture endpoint.
  • Hosted Postgres via DATABASE_URL (the Vercel unlock), plus a vitest + in-memory PGlite test suite.
  • Pre-meeting briefs and voice notes on web; card-photo scanning in quick-add (M1, web vision path).
  • The Telegram bot, outbound webhooks, PWA install, and API bearer-token auth.

Cloud, self-host, auth, and the mobile spike (4–5 Jul)

  • The multi-tenant SaaS platform — accounts, RLS, admin, billing (Dhaga Cloud / packages/ee).
  • A docker compose self-host path aligned with the docs.
  • Extension packaged for the Chrome Web Store; graph seeding via CSV import + confirm-only name-cluster suggestions.
  • Better Auth expanded: social login, passkeys, 2FA, magic link, OTP, and phone/username sign-in.
  • The M0 mobile spike: an Expo app with camera capture and on-device OCR.
  • GSAP scroll animations and the Geist Pixel font on the landing page.
  • Proactive intelligence: relationship decay/strength scoring and job-change + news signal detection.
  • Web search moved behind a provider-agnostic SearchClient gateway, mirroring the LLM gateway, so the nightly signal sweep isn't tied to one vendor.
  • Every recency-sensitive LLM prompt now gets sent today's date; a glass dock (voice/camera/file) for quick-add and mobile capture.

Hardening, the core-build gate, and M2 (7–8 Jul)

  • Checklist drift corrected against the actual code; Vercel deploy marked done.
  • CI now verifies the core builds without the Dhaga Cloud module — the enterprise self-hosted-deployment guarantee, enforced.
  • The roadmap doc; a crop-review step added to card scan on both web and mobile; regression coverage for warm-path finding and strength scoring.
  • Shared request/response contracts extended from /api/capture out to the contacts, export, card-image, access-request and jobs routes, so packages/core owns the types every client consumes.
  • M2 auto event grouping: geohash + time session clustering (BRD §6.2).

The audit marathon (11–12 Jul)

Two of the busiest days (34 + 40 commits) were a sustained correctness and security sweep — a rhythm of fix, then document the pass:

  • A new capture channel shipped first: LinkedIn QR capture, deliberately built to prefill the add-person form rather than infer a name from a URL. A mobile voice-dictation path was written alongside it, against the platform's local recogniser, but it has never been verified on a device and has not shipped.
  • Data-loss and race fixes across the stack: heuristic-parse Unicode/dedup, CSV/vCard export on bare-CR fields, applyExtraction mid-loop DB failures, the findOrCreateCompany insert race, and upsertSubscription (Stripe webhook) TOCTOU.
  • Privacy/isolation fixes: forgetContact leaving signals rows undeleted (GDPR erasure), and missing RLS tenant isolation on the signals table (cross-tenant IDOR).
  • First audit passes over apps/mobile, the browser extension, and the packages/core LLM/search gateways.
  • Nightly signal detection moved to the Anthropic Batch API.
  • Graph APIs, a modular search repo, and the SearchPalette-driven graph explorer, including virtualized graph navigation.

Home/graph UX, light mode, and voice engines (13–14 Jul)

  • Home reworked into a unified activity feed; dashboard and table workflows improved; Vercel Speed Insights + Analytics added.
  • A pluggable OpenAI-compatible LLM client in core; "sessions" renamed to events with on-demand network retrieval.
  • Light mode across the app and marketing site; landing pricing grounded in real LLM-call costs; founding tier reframed as annual. (History only: the founding tier was retired in August 2026 and is no longer sold; prices were cut at the same time. See the roadmap.)
  • Voice dictation gained an on-device Whisper fallback and a real-time streaming Whisper engine — both replaced by Moonshine eleven days later (see 25–26 Jul).
  • A BYPASSRLS role had made every row-level security policy inert on the hosted deployment — found by direct inspection, confirmed against real data. The boot path now refuses to start on such a role rather than serve unscoped data, and create-app-role.sql was made idempotent with a lock_timeout so a fresh deployment gets a correctly scoped role in one step.

Rich contacts, the knowledge graph, and docs groundwork (15–17 Jul)

  • Colour/emoji/tags on groups with a searchable events table; a first-run onboarding walkthrough (driver.js); optimistic UI + branded loaders.
  • Note extraction and enrichment moved to background jobs; calendar integration + daily people suggestions.
  • A rich contact model: multiple jobs, labeled contact methods, an edit flow; graph relationships with confirm-ambiguous-person linking.
  • The full-graph WebGL knowledge graph with custom entities and caching tiers; the bento-grid home dashboard (v1 → v2 with a daily briefing).
  • Worst-case hardening verified live at 20,000 contacts: a renderer-lifecycle crash on revalidation, deep links giving up against a stale cached payload, a transaction-pooler configuration that silently broke tenant scoping (now a fail-loud boot guard), and the browser-side graph cache stamped with its owner's user id so switching accounts can't instant-paint the previous one.
  • Landing scroll made cheap (SVG filter, textPath, canvas glow); unchanged schema DDL skipped on serverless cold starts.

Library-first refactors and the docs site (20–21 Jul)

  • Connection-pool exhaustion fixed; a SearchIndex gateway for near-realtime in-app search.
  • A deliberate library-first turn: a pluggable data-fetching gateway on TanStack Query, and a DataTable on TanStack Table + nuqs URL state.
  • The Fumadocs docs site at /docs, themed to the brand, with content across seven sections and a TypeDoc-generated @dhaga/core API reference.
  • The internal security-audit journal removed in favour of a SECURITY.md + follow-ups.

Landing refresh, the blog, and security sweeps (22–23 Jul)

  • Tenant connection reuse + hot-path indexes; app-shell and node-ontology caching across /app navigation, plus a version-keyed cache over the full-graph payload — the heaviest read in the product — so a write changes the key and a stale payload is impossible.
  • A problem-first hero with refreshed mockups; Playwright screenshot tooling; a visual README; false "on your phone / offline" privacy claims corrected.
  • A pluggable server rate limiter (rate-limiter-flexible) + TanStack Pacer client debounce.
  • The engineering + solutions blog at /blog with share/comment/quote engagement; per-domain case-study demo accounts; the "Using Dhaga" user guide with screenshots; blog/docs segregation + an SEO suite.
  • Security: postcss XSS patch, calendar OAuth state bound to session (CSRF), a 13-finding review sweep, and per-page admin authz (defense in depth). Mobile failed captures now persist as a FIFO queue.

The 51-commit day (24 Jul)

The single busiest day in the history — imports, monetization, voice, viral growth, and multi-image scan all landed:

  • vCard (.vcf) contact import and OAuth contact connectors (Google + Outlook) with mobile expo-contacts import.
  • The app made fully usable with no LLM, plus progressive page streaming — the free tier does real work with zero cloud AI.
  • Admin subscription controls, per-user AI credits, and a follow-up date picker.
  • Voice: pluggable Moonshine STT + phonetic teaching (web + mobile) and a tap-to-fix transcript review that auto-learns.
  • Multi-image card scan: front + back and leaflet pages fold into one contact (web + mobile).
  • Growth: Network Wrapped, a public graph sandbox, and two-sided referral; a unified "AI proposes, code disposes" confirmations centre.
  • Search collapsed to one round-trip. Six keyword sources became a single UNION query after the fan-out was found to deadlock the tenant pool — 1097ms across seven serial round-trips down to 170ms in one.
  • A 10-post "Guides" SEO cluster and 5 "Dhaga vs X" comparison posts.

Production stability and the E2E suite (25–26 Jul)

  • A run of production-stability fixes: pool exhaustion, cross-contact merge, graph crash, and header overlap; graceful AI degradation; streaming progress for extraction and Ask Dhaga.
  • Pool timeouts resized for reality: a cold cross-region handshake measured 6–7 seconds against a 3-second bound, so essentially every cold connect was timing out. Timeouts were widened, retuned once app and database were co-located, and made env-overridable without a redeploy.
  • Bare references (e.g. "his son") rendered as renameable placeholders; a Saved page (Starred + Watching) with star favourites; home reorg with metrics + tile sparklines.
  • An ActionForm resilience sweep wrapping 30 fire-and-forget forms; Moonshine-only voice with a silence gate.
  • Deep connection hygiene: one scoped DB connection per action, no DB connection held across the Ask-Dhaga LLM stream, pooler-portable RLS.
  • A committed Playwright E2E suite covering all /app flows; hardened CI npm-install retries.

Merge ops, messaging capture, and the quick-add hub (27–28 Jul)

  • Multi-photo card-scan capture UX; inline add-a-person from the Add-relationship dialog.
  • An import-page onboarding step + LinkedIn export reminders; merge & bulk ops for contacts plus a companies management page.
  • Inbound messaging capture — forward contacts/notes to WhatsApp & Telegram (CORE, provider-agnostic gateway).
  • Note capture: one metered parse now also classifies whether free text is a note about someone already in the graph, then routes it — a confident match attaches, an ambiguous one raises an inline confirmation with candidates.
  • A follow-up calendar view with in-app-bell + email reminders; a global nav quick-add with manual entry on every capture screen; two-pane Ask Dhaga with source receipts on a right rail.
  • Affiliations became first class. positions gained a relation predicate, so the graph derives one edge per position — works at / worked at / studied at — instead of a single synthetic current-employer edge. Education capture, a bulk affiliation editor, and company aliases recorded automatically on merge landed with it.
  • Mobile fixes: graceful voice fallback when WebGPU is unavailable (iOS Safari), a card-scan silent-fail fix, and decluttered mobile nav.

Light-mode palette, the logo, two-way sync, and AI discoverability (29 Jul)

  • Light mode audited to WCAG AA and repaired. The light palette itself was the bug: secondary text sat at 4.03:1 and focus rings at ~1.4:1, control borders had no value clearing the 3:1 minimum, and /docs + /blog links rendered amber at 1.83:1. Darkened --brand-fog/--brand-ember/--brand-seam, added --brand-line for control boundaries that clear 3:1, and moved focus rings to ember. Dark mode is unchanged — verified by probing computed tokens in both themes.
  • One rule, enforced everywhere: amber is a fill, never light-mode text. ~160 text-amber call sites became text-ember, and every Tailwind red-* error colour became text-destructive. .dark now defines --brand-ember: var(--brand-amber), so a single declaration decides the accent in both themes instead of each call site guessing.
  • The logo is visible again. ThreadMark hard-coded its two endpoint dots to #f3ede2 — the dark-mode cream — which is 1.03:1 on the light ground. Now currentColor, so it inherits correctly at all 11 render sites.
  • Colour is genuinely centralised. The primary-button gradient moved out of four components into --brand-amber-lift/--brand-amber-sink, and the shadcn semantic layer is declared on :root, .dark rather than :root alone — previously a forced-dark subtree (camera capture, photo cropper) inherited light semantics, leaving focus rings invisible inside the overlay.
  • The knowledge graph got a per-theme node/edge palette (dark fills sat at 1.8–2.7:1 on the light canvas, and the whole edge mesh vanished at 1.28:1).
  • Map view shipped on a GeocodingClient gateway with a Nominatim implementation — the only free provider whose terms permit storing the coordinates a cache exists to hold — a shared geocode_cache, a self-throttled 1 req/sec limiter, and a MapLibre canvas on OpenFreeMap tiles with explicit attribution. Three polynomial-backtracking regexes of one shape were cleared out of the geocoding module, and MapLibre's worker was self-hosted after v6 stopped inlining it.
  • Note extraction learned to write a real position row with a source_note_id receipt.
  • Two-way contact sync. A contact_links join with a base snapshot turns reconciliation into a real three-way merge rather than a lossy overwrite — no platform exposes per-field modification times, so comparing each side against the base is the only way to know which side actually moved. Multi-value fields union by entry key; contested scalars are held as a reviewable conflict rather than pushed outward.
  • Two-way calendar: full event read and write into a secondary Dhaga calendar, with capability derived from the OAuth scope already stored, so default scopes stayed byte-identical and no existing connection silently gained access.
  • llms.txt/llms-full.txt are now generated from content/** by npm run generate:llms-txt rather than hand-maintained — they had gone stale within six days, and llms-full.txt was ordered by URL, so any consumer that truncated it got all blog and no docs. robots.ts now also excludes /wrapped/ and /r/, and theme-color is media-scoped per theme instead of pinned to the dark ink.

Server-side sync, metering by action, and address books without a phone (30–31 Jul)

The biggest architectural stretch of the month: usage stopped being counted per model call, and the address book stopped needing a phone in the loop.

  • Metering moved to the action. withAiAction() makes the user-visible action the billing unit, folding every model call inside it into one row whose tokens are the sum. The measurement behind it — 39 real API calls — also exposed an unmetered query-plan call and proved prompt caching was dead code at our prompt sizes.
  • Notifications and time zones: a notifications table written when a background job reaches a terminal state, a bell merging derived and persisted items, per-user IANA time zones, and recurrence maths for important dates kept dependency-free and Hermes-safe.
  • Server-side Google and Outlook contact sync closed the Android gap, where a contact created on the handset never reached the account. It reuses the same reconcile engine the mobile client drives, behind a plain ContactSyncTarget. Microsoft is declared deliberately narrower, because mapping its fixed field slots anyway would read back short and be honoured as a deliberate deletion.
  • Bulk seeding via a scoped vCard export of exactly the contacts a push would offer, so the platform's own importer does the copying and sync does the matching. Three silent data-loss paths surfaced on the way, and blanks on a first link became silence rather than disagreement: 1,400 conflict rows on a 700-contact seed became zero.
  • Per-user theme and font presets for the app, emitted as a style block from the layout so there is no extra client JS and no flash of the default palette; every preset is held to AA in both modes by test.
  • Card scan latency halved to ~3.1s after five variants were benchmarked against a real card — the verbatim transcription, not the image, was the cost, and it now runs on the far side of the save. Three email jobs were found reading settings on an unscoped connection, which under RLS meant they could never have sent for a hosted user.

The MCP server, grouping, and ranked attention (1–2 Aug)

  • A personal MCP server at /api/mcp, exposing ten tools split read/write across a read module and a write module so the blast radius of the write half is obvious in review. Writes are additive only — no delete, merge, bulk, export or admin tool — because a prompt-injected client must not be able to trigger the irreversible cascade.
  • Two credential paths, since the client families cannot share one: OAuth 2.1 through Better Auth's mcp() plugin with RFC 8414 and RFC 9728 discovery, and the existing API key for local clients. A presented-but-invalid bearer is a hard 401, never a quiet fall-through.
  • Home's Today list replaced two sequential buckets with one additive score over six reason-bearing terms, plus two modifiers that can lift someone into view but can never be the displayed reason. Due-ness now reads real last touch, so a note or an event scan resets the clock.
  • A dedicated Groups page for suggested clusters, location tagging alongside company tagging, and a manual Group-by bulk action — force-overwrite there, fill-empty-only for the automatic clusters, because a hand-picked selection is a direct statement.
  • Relationship types gained full CRUD with a usage guard, Map was promoted into the primary navigation, and the person page was reorganised around a sticky actions card.
  • Name clustering stopped splitting only on whitespace, and the Notes statistic stopped counting automatic capture receipts as notes the user wrote.
  • The landing page was refocused around a single conversion path with role-specific pages and a visual feature explorer.

Goals, noise suppression, and installable agent skills (3–5 Aug)

Home's Today tile was reactive maintenance — it answered who was decaying or had something happening. Nothing in the product knew what the user was actually trying to do.

  • Goal-driven curation. A plain-language objective resolves into a finite cohort from the user's own graph. Retrieval stays deterministic; only the judgement of whether a person fits is a model call. It landed as a ninth additive term in the suggestion score, at weight 28, reserving no slots, and "done" is derived rather than stored — a member drops out once their real last touch passes when they joined — so markReachedOut and its six callers needed no change.
  • Goals resolved only in the nightly cron at first, so a deployment without one never matched at all. Matching moved inline on save, then to an explicit on-demand request run deliberately outside mutation(): holding a tenant connection across a model call is the pool-exhaustion shape from July.
  • Address-book noise suppression. A nightly Batch sweep classifies untouched rows person / service / unknown, and surfaceableContact became the single definition of "a row Dhaga may nominate" — applied to six proactive surfaces and to nothing the user navigated to on purpose.
  • A second spend ceiling, measured in inference cost rather than credits, now backs the metering system, computed from tokens already recorded on ai_actions rather than estimated.
  • Installable agent skills served from public/.well-known/skills/. MCP negotiates the tool surface; nothing in that negotiation tells a client to search before it needs an id, to quote the note a fact came from, or to refuse to invent a connection. The protocol carries the tools, a skill carries the judgement.
  • Eleven tenant-scoped tables had isolation policies but no isolation spec, and the coverage assertion was gated on a live database, so drift accumulated with nothing going red. A database-free test now asserts the pairing and fails with the missing table names.
  • A feedback widget in the app nav — named columns rather than a jsonb blob, so adding a forbidden field can never be a zero-diff change, with query strings stripped server-side.
  • Connection pressure on Home: 25 serial round trips became 19 with a budget test, and the free/busy read stopped making an external provider call while holding the tenant transaction.

Chat capture repaired, and the model plans while the code writes (6–9 Aug)

The batch walk had processed forwarded messages one at a time with a carried cursor, so no model call ever saw two messages at once — which is how a bio followed by "create a new contact" produced a person literally named "Unnamed contact" whose real note no screen could render.

  • Inbound capture's DONE had never worked, for anyone. A correlated subquery interpolated its column unqualified, so the join bound to the wrong table and every batch counted zero items — which two user-facing decisions gated on. Replaced with a LEFT JOIN + GROUP BY; attribution stopped riding a positional cursor, and ambiguity moved out of chat into the confirmations inbox, where a batch can raise several.
  • Whole-batch planning. DONE now derives every message to text, plans the entire batch in one structured-output call, and applies that plan deterministically. An id outside the offered candidate pool is treated as a hallucination and the person is created instead of written onto a stranger. There is deliberately no fallback to the old walk: degrading silently would rebuild the wrong graph while reporting success.
  • Scan-to-link — one QR per configured channel encoding a deep link that carries the token, adopting qrcode.react rather than hand-rolling. Forwarded photos, which messaging had always downloaded and dropped, are now kept and hung off the note, so deleting the note hard-deletes the image.
  • A capture log: every batch keyset-paginated with each message's verdict, persisted at write time rather than reconstructed, since once the resulting contacts are edited there is no way back to what happened. A shared, PII-safe logging module landed in packages/core beside it — a log may describe the code that failed, never the values that flowed through it.
  • Tags were the last extraction output with no provenance. A contact_tag_sources table gives each tag a source_note_id while contacts.tags stays the denormalised read model, and a NULL source means the user added it by hand, which pins it.
  • Note reassignment: a note moves and everything keyed to it moves with it — facts, follow-ups, positions, tags, edges, confirmations, card images, embeddings — re-pointed by source_note_id rather than deleted and re-extracted, so it costs no credits and preserves user-edited facts.
  • Row-by-row write loops were replaced with single statements. forgetContacts had run a roughly 18-statement cascade per contact with two nested per-note loops — 50 contacts averaging 10 notes was about 1,500 statements in one transaction, the longest connection hold in the codebase.
  • One daily brief replaced five independent nightly mailers plus the export nudge, with one idempotency record keyed on the recipient's local day and every section keeping its existing opt-in. Alongside it: the 150-line file rule enforced across 30 files, failure counters on nightly jobs (total failure had been byte-identical to a quiet night), the onboarding tour rebuilt around a real state machine with the first test coverage it has ever had, and the browser extension gained a settings panel for the instance URL and API key alongside a default host — its version numbering was reset two days later, because the extension has never been published.
  • Subscriptions and checkout. The paid path landed as machinery rather than copy: recurring subscriptions behind a webhook, signup gated on payment, and self-serve cancellation and tier changes in place. Lapse handling followed within days — a plan past its boundary is decided at read time rather than by a cron, with a grace window so a webhook delayed by minutes cannot strip access from someone who has just been charged.

Conversational search, verbatim notes, and companies as peers (10–12 Aug)

Two correctness problems opened the week with the same shape: a system treating a person's own language as machine input.

  • Conversational search ranking rebuilt. The fuzzy-name score now counts only the margin above its threshold, so a coincidence stays decisive when nothing else matches and negligible when something does; the shared tokenizer drops Postgres' English stoplist, so all three halves of keyword search finally agree on what a content word is.
  • Search sources widened with generated tsvectors on positions and entities, edge-attributed notes and a nickname expression index — every one a further branch of the existing UNION, so the keyword phase stays one round trip on one connection. Query planning now returns up to six domain synonyms from the same extraction call, with no extra model call.
  • Notes became verbatim. The planned-note schema lost its body field entirely: a note names the message sequences it is made of, and the text is assembled in code. Overlapping notes merge by intersecting their directives, never first-wins — plan order is not evidence about the user's text.
  • The browser extension was rebuilt around a side panel, with its own page_scan prompt and schema in packages/core, metered as its own action. Host access moved entirely to runtime grants after the browser's overlap rules silently withheld a declared permission, leaving every request failing as a console-only CORS error.
  • A company detail page, with company_id on notes and facts and a company owner on embeddings. Search hits now carry {id, kind}, so a company answers as itself and no company fact is ever attributed to a person.
  • Tags became real rows (tags, contact_tags, tag_aliases), with contacts.tags kept as a derived cache behind a backfill token that is unconstructable outside its module: under RLS the boot-time recompute sees no rows, so an unguarded write would have replaced a tenant's real tags with an empty array.
  • An administrative access log written inside the two authorisation chokepoints, so an unlogged admin surface would have to be an unauthorised one. It fails closed, and stores identifiers only with names joined at read time, so the trail never becomes a second copy of the user directory under weaker controls.
  • Note permalinks with two-way receipt highlighting, where ?note=<id> is both the deep link and the selection; capture requests bounded at the platform's own edge limit so three identical failure messages read as three causes; and a Playwright spec asserting that no route scrolls sideways at 375, 768 and 1280.

One plan board, one colour system, and the calendar programme (13–16 Aug)

  • Tasks, calendar and follow-ups merged into /app/plan. The three old routes redirect, and a guard test now fails the build if anything points at a retired one — seven action files had been revalidating redirect stubs, so completing a task left the visible page stale while every unit test passed.
  • Dhaga's own event store (local_calendar_events) — a server-side table, so scheduling works for a user who has connected no calendar account, and for a deployment with no OAuth configured at all — plus a row per calendar with show/hide and a colour stored as a palette token rather than hex, so a theme change re-tints existing events. due_has_time carries optional time-of-day as a column, not an inference from a non-midnight timestamp.
  • One --brand-amber. The app, the landing page and the mock had resolved three different oranges. Every accent now ships as a fill/text pair, because for a given ground a colour's contrast as fill and as text multiply to a constant near 17 — one value provably cannot clear 4.5:1 as both.
  • Theme Studio: presets, mix-and-match palettes, a picker per role with a note on what that colour actually drives, and a reset. defaultTheme moved from dark to system, so a visitor lands in whichever mode their OS is in and light stopped being a secondary path.
  • Two-way contact sync made opt-in, and lossless when off. Skipping the push naively destroys data: the reconcile base was recorded as the merged value whether or not the write landed, so two runs later the engine would have concluded the remote had moved and silently reverted the user's own edit.
  • The calendar programme. Three research passes preceded any code, and their conclusion was that the gap was our own code rather than the library — around 20 free options were never passed and four bundled plugins sat unused. Phase 0 retired the second clock (working hours now resolve by bisecting the local day, which is what makes :30 and :45 offsets expressible at all); Phase 1 switched on what we already owned; Phase 3 linked events to people through a shared emailKey(), so attendee matching agrees with import and merge dedupe by construction, and unmatched addresses are never stored, logged or counted.
  • GET /api/calendar/pulse now owns the free/busy refresh and is the only thing that re-reads a connected calendar for Home's snapshot; its token hashes the user's local day plus the snapshot's intervals and titles, deliberately not a timestamp. The two contact connectors converged onto the sync engine with the legacy importer deleted rather than kept alongside, and a revoked grant now flips to needs_reconnect from a 401 at the sync-HTTP seam.
  • A sweep against a live account closed six reports, four of which turned out to be something other than they looked like: a demo calendar fixture gated at connect but never re-checked on the read path, extraction under-reporting itself in three ways, and — the one that mattered most — one definition of a confident name match, moved into packages/core and imported by both the web quick-add and the inbound-messaging router, since keeping the predicate in the callers is what let them drift.

Depth on the pages people live in, and an audit of what we could claim (17–19 Aug)

Three reported problems turned out to share a shape: the product had breadth but no depth on the pages a returning user actually spends time on.

  • One message, one note. Contested-claim detection in the inbound messaging planner went plan-wide, backed by partial unique indexes so a re-run cannot write a second row, and a single claim step replaced three check-then-write races.
  • Note-selection extraction — a container-scoped, touch-aware text-selection hook feeding fact, follow-up and relationship writers that thread source_note_id through as an optional argument, leaving every existing manual caller unchanged.
  • Keyset paging on the contact page for facts, follow-ups and notes: first page server-rendered, limit+1 to learn there is a next page without a COUNT, and a separately fetched anchored note so a ?note= permalink to an old note still resolves.
  • /app/import became a real page, and the setup checklist was re-cut around fill the graph / teach the loop / make tomorrow useful.
  • A primary-source review of six competitors doubled as a line-by-line audit of our own claims. Several did not survive it, and two could only be fixed by building the thing we had been claiming.
  • Self-serve account deletion, built because /terms already promised it. The finding was the foreign-key audit: per-tenant tables take user_id from the RLS DDL as a bare text column with no foreign key, so deleting the login alone would have orphaned the whole graph. Every account-owned table is now deleted explicitly, children first — 46 of them at the time — with a parity test that fails if a new table is added without either joining the list or being excluded from it with a stated reason.
  • Edge receipts. Relationships and derived affiliation edges now store and surface the note they came from; an AI-drawn edge with no receipt is indistinguishable on the canvas and unfalsifiable.
  • A privacy defect found while writing the disclosure: product analytics stored concrete URLs, so table-filter query strings were leaving the browser. Query and fragment are now stripped and ids reduced before send, pinned by six tests, and NEXT_PUBLIC_SITE_URL now fails a production build when unset rather than silently canonicalising every page to a preview hostname.

Photograph the product, then trust the picture (20 Aug)

The demo screenshots on the profession pages were selling a product their own images refuted.

  • The capture script stopped photographing the viewport and started clipping a document region, asserting real content before saving, hiding half-configured account chrome, and fitting the graph camera to the hero node — a stale PNG that looks fine is worse than a reported failure.
  • Capture moved to a headed browser after measuring that headless returns a null WebGPU adapter, so every previous shot had photographed a degraded product with an on-device-voice-unavailable banner across it.
  • Seed relationships and facts now take an explicit note index, so a fact cites the note it actually came from rather than always note 0 — the demo had been quietly disproving the receipts claim it existed to demonstrate.
  • Reminder timing rebuilt. The model now returns a structured due_time beside the verbatim hint and deterministic code combines day, time and the user's stored IANA zone into an instant. Messaging-born reminders are mirrored to the connected calendar at capture time, with dismissal and re-subjecting both syncing, closing the two lifecycle holes that eager write-out opens.
  • Home's due-check-in tile was found orphaned in an earlier refactor — nothing imported it — and was remounted from a single mount point at every width; the landing mock's coach-marks were renumbered from their final laid-out positions so the printed digit, the DOM order a screen reader hears and the order the eye takes them became one thing.

Measure the model, hold the state still, and let a slice of the graph leave (21–23 Aug)

Two failures with the same root: something was being assumed rather than measured.

  • Both of the card scan's model calls moved to the reason tier. Re-run against the failing photographs the cause was unambiguous and it was not resolution: the extraction model got every field of both cards wrong where the reason model read the identical 408px pixels perfectly. The card is now read twice and the two reads compared, with every disagreeing atom surfaced for the user. Stepping the same cards down through 500/400/320/256/200px established where reading actually breaks — phone digits fail first and quietly, because a digit string has no redundancy to reconstruct from.
  • Plan board state moved into the URL via nuqs, and the real fix for the page-loss bug was removing the pagination writer: TanStack's autoResetPageIndex defaults to true and had been silently overriding the hand-rolled reset. The default plan view became width-keyed with a mobile-first server snapshot, so seven columns never render at 375px.
  • Duplicate detection got its own full-name key. The page had been calling the community-tag suggester, which drops the given name by design — correct for tagging, catastrophic as a duplicate detector.
  • A /trust page: a consent-free storage inventory with the exemption basis on every row, /.well-known/security.txt as a route so its Expires field cannot rot, and a measurement opt-out that gates both the analytics component and the direct graph-load beacon. listAccessesForSubject was added to the admin gate contract so a user can read their own access log, routed through the gate rather than the Dhaga Cloud module so a core-only build still compiles and honestly returns none.
  • The goal engine was rebuilt around its own surface — storing model rejections is what unfreezes the nightly pass, since recall now excludes every already-judged contact and each run reaches new people. The cohort also stopped competing as a term in Home's score: capped at three candidates where every other source got ten, it lost almost every contest it entered.
  • Share links render a read-only page to someone with no account, with a mandatory expiry, revocation, and content toggles that all default off — three at first, five once facts and open follow-ups were added the next day. What a shared page shows is an explicit allow-list projection, never a spread, so receipts, signals, card photos and cadence have no field to land in. The new tables split on a tenancy decision: the profile and templates are tenant tables, while share_links is a cross-tenant routing table, because a public page has to resolve which user owns a link before any tenant scope can exist. The token is redacted from analytics by path position rather than by shape, since the existing redactor masked only UUID-shaped segments.
  • Company facts, enrichment and attachments. addFact took an owner union guarded at runtime, and createExtractionJob became idempotent per owner — which also closed a double-billing hole on the person path. Attachment bytes are base64 in Postgres rather than object storage, so a self-hosted deployment gains no external dependency, and downloads force octet-stream with nosniff and no-store, since serving a stored .html or .svg back under its own media type would be stored XSS.
  • A streamed zip export driven from pull rather than start keeps one file in memory at a time and refuses past the writer's non-ZIP64 limits rather than emitting a corrupt archive. Server-action refusals are now returned rather than thrown, because the framework redacts a thrown message before it leaves the server — so a customer stuck at a dead button finally reads the sentence explaining the way out.

The history stops here, mid-week: 24 August is a partial day, not the end of an arc. Four long-running branches converged on main at once, so most of the day's commits are the merges that reconciled them.

  • Meeting groups shipped as a full vertical slice: groups, group_members and note_contacts tables, group_id on notes, facts and follow-ups, a fourth owner in assertSingleOwner, and bespoke RLS policies whose WITH CHECK stops a cross-tenant group_id from being planted — without which a RESTRICT foreign key from another tenant would make account erasure fail forever inside its single transaction.
  • A contact's timeline became one UNION over their own notes and note_contacts, deliberately never reading group_members: joining a group must not backfill conversations onto a page for a room that person was never in. Groups archive rather than delete, so a shared note can never be orphaned or silently erased for several people at once.
  • Group suggestions are deterministic and model-free — recurring attendee sets ranked above one-offs, merged at Jaccard ≥ 0.6 against an anchor so sets cannot chain into a group nobody attended, dismissable and de-duplicated against live groups.
  • Two defects worth remembering, both invisible to typecheck and to every test: applyGroupNoteExtraction had no caller, and once it did, nothing posted the worker route for a group-owned job — correct, wired, and never driven. Both are now driven and pinned by mutation-checked tests.
  • Consent moved ahead of the OAuth handshake. One dialog now asks how much access to grant before leaving for the provider, and getAuthUrl gained a write? flag so one-way sync requests a read-only scope instead of the write scope both providers had been requesting unconditionally. The answer rides the signed OAuth state, never a callback query parameter, and two_way_sync is set from that intent rather than derived from the granted scope — one provider unions in earlier grants, so a scope-derived value could flip two-way back on.
  • ExtractionStatus moved into a shared home and swapped its contact/company union for a scope discriminator, so a new owner with no status route is a compile error rather than a 404.
  • Merge archaeology of the day: a duplicate-symbol trap where deleting the apparent duplicate would have compiled green and silently reverted its touch-target fix, a widened addFact signature that tsc — not the tests — caught, and DDL chunk ordering, where the groups chunks must follow the core DDL and group extraction must follow groups. All 49 tenant-table entries — the tables carrying isolation policies, a different set from the account-owned tables the erasure clears, since the cross-tenant routing tables are deliberately absent from it — were re-checked against their isolation specs, the pairing a two-branch merge is most likely to break.

That's just under eight weeks — and 24 August is a partial day, the last one in the history rather than the close of a week. For what's shipped, in progress, and planned next, see the roadmap; for the exhaustive feature list, the build checklist.

For the same work told as user-facing releases, week by week, see the changelog.

On this page