# Venture CRM Roadmap (Airtable Replacement)

## Current status
- Premium Airtable-like frontend grid exists and is actively iterating.
- Backend now has production-grade APIs for:
  - `GET /api/fundraising/state`
  - `PUT /api/fundraising/state` (with optimistic version check)
  - `GET /api/fundraising/export`
  - `POST /api/fundraising/backup`
  - `POST /api/fundraising/restore-preview`
  - `POST /api/fundraising/restore`
  - `GET /api/fundraising/backups`
  - `GET/PATCH /api/fundraising/backup-policy`
  - `GET /api/fundraising/relational-summary`
- New DB tables:
  - `fundraising_state`
  - `fundraising_investors`
  - `fundraising_contacts`
  - `fundraising_funds`
  - `fundraising_commitments`
  - `fundraising_views`
  - `app_settings`
- Grid saves/restores now sync into relational fundraising tables automatically.
- Formula engine is now sandboxed (no `eval`/`new Function`) with expanded function support.
- Automation engine v1 added:
  - Rule table + toggle API
  - List memberships (`main`, `follow_up`, `graveyard`, `longshot`, `all`)
  - Automation run log
- Collaboration/reliability additions:
  - Unified activity feed API (`audit` + `automation` + `backup`)
  - Backup integrity verification API
  - Better version-conflict metadata (`updated_at`, `updated_by`)
- Security hardening additions:
  - Basic IP rate limiting (login and write APIs)
  - Configurable CORS origin (`CRM_CORS_ORIGIN`)
  - Production secret enforcement (`CRM_ENV=production` requires `CRM_SECRET_KEY`)
  - Security status API + go-live checklist (`SECURITY.md`)

## Phase 1 (Production foundation)
1. Persist grid + views on backend
- Wire frontend fundraising grid reads/writes to `/api/fundraising/state`.
- Keep localStorage only as emergency fallback.
- Add autosave debounce and conflict handling (`expected_version`).

2. Admin-invite auth model
- Disable self-register for non-admin users.
- Add admin-only invite/create-user endpoint.
- Keep role model: `admin`, `member`.

3. Deployment and remote access
- Add `docker-compose` for one-command launch.
- Reverse proxy + TLS option (Caddy/Traefik) for non-Tailscale deployments.
- Recommended for your use case: Tailscale private access to laptop host.

4. Data safety and operations
- Automated nightly SQLite backups and restore test script.
- Add `/api/fundraising/export` for JSON snapshot export.
- Add health/readiness checks.

## Phase 2 (Airtable parity)
1. Advanced views
- Multi-condition filter groups (AND/OR groups)
- Multi-column sorting
- Pinned/frozen columns
- Personal vs shared views

2. Formula engine v2
- Add functions: `SUM`, `MIN`, `MAX`, `ROUND`, `ABS`, `CONCAT` (done)
- Type-aware formulas and better errors
- Dependency graph and recalculation rules

3. Activity + audit
- Record-level change history in UI
- Last modified by / at fields
- Restore archived rows

## Phase 3 (Team workflow and automation)
1. Tasks/reminders tied to investors/contacts
2. Automation rules (graveyard/follow-up triggers)
3. Email/communication integrations (optional)
4. Granular permissions (if team grows)

## Backlog (post-Phase-1 agentic)

### Data-model cleanups (deferred from the v0.1.0:104 session)

- **Retire `contacts.contact_type` — DONE (logical), deployed v0.1.0:106 (2026-06-21).** The Investors/Prospects tabs + TYPE badge are gone; desktop Contacts shows a grid-derived **Status** (existing-LP badge + pipeline-stage chip via `contact_grid_signals`), the dashboard `total_lps`/`total_prospects` now count grid investor entities (committed>0 vs $0, graveyard + 'Untitled Investor' blank-row excluded; also fixed a `total_contacts` soft-delete leak), and no code reads/writes the column. **Left:** the column is physically inert (`DEFAULT 'prospect'`); a physical DROP is deferred to a signed-off table-rebuild migration (SQLite no-drop-column; `contacts` is FK-referenced) — same retire-then-drop path lp_profiles took (v78→v104). Folds into the comms-unification rebuild below or its own later migration. 45/45 + new dashboard assertions, render-smoke + reviewer APPROVE.

- **Consolidate `contacts` ↔ `fundraising_contacts` into one linked model.** Goal (Grant): everyone in `contacts` maps to a `fundraising_investors` row (an individual maps to their own row). Today `contacts` is the canonical person directory (FK target for `communications`/`opportunities`); `fundraising_contacts.contact_id` (migration `0004`) points INTO it; the mobile Contacts page reads `contacts`. Three populations: **A** linked (grid pill ↔ contact), **B** `contacts`-only (imported prospects / manual adds — need a grid row), **C** pill-only (`fundraising_contacts.contact_id IS NULL` — need a contact row). **Census-first:** before designing any migration, count A/B/C on the box — Grant runs the SQL himself (he is **not** providing a DB copy), so hand him a counts-only script. The census decides whether this is a ~20-row cleanup or a ~300-row structural migration with `communications`/`opportunities` repointing. Then Grant reconciles B (add grid rows/pills) and C (add contact rows) and ensures all are linked. **(v0.1.0:105) A TEMPORARY admin census ships to read A/B/C off the box without shell access: `GET /api/admin/contacts-census` (`handle_contacts_census`) + a Settings → Admin "Run census" button, mirroring `backend/scripts/contacts_census.sql` (counts only). DELETE the endpoint + route + button after the numbers are captured — all tagged `TEMPORARY` in code.**

- **Retire `organizations` — re-home email domain matching onto the investor, then drop the table (Grant 2026-06-21).** Traced in code: organizations are largely vestigial. `_upsert_contact_from_fundraising` auto-creates an org **named after the investor** (`server.py:805`) for every grid investor — individuals included ("John Smith" the org) — with no website/email, so they're pure name-clones. There is **no Organizations page/nav** (a full CRUD API exists but is unsurfaced); the only visible role is the "Organization" display label on Contacts + opportunities (`organization_name`), which duplicates the investor. FK'd by `contacts.organization_id` + `opportunities.organization_id` (both `ON DELETE SET NULL`) + a soft `email_investor_links.organization_id`. **The one genuinely non-redundant function is email DOMAIN matching** (`matcher.py:110-122` indexes `organizations.email`/`website`, so `@acmecapital.com` resolves to a known firm when the sender isn't a saved contact) — but only for orgs that carry a website/email, which the auto-created clones do **not**. Plan: (1) **re-home the domain signal onto `fundraising_investors`** (add a `website`/`domain` field) and domain-index investors instead of orgs — the domain is **derivable from the investor's contacts' email domains**, so a one-time **backfill script with human review/approval** likely suffices (LLM-in-the-middle via Spark Control optional, probably unnecessary; an investor whose contacts span several domains is resolved case-by-case at approval); (2) **stop auto-creating name-clone orgs** in `_upsert_contact_from_fundraising`; (3) drop the "Organization" display in favor of the investor linkage (FKs `SET NULL` cleanly); (4) retire the `organizations` table — logical first, then a signed-off drop (same retire-in-place pattern as `contact_type`/`lp_profiles`). **Census check first:** how many orgs carry a real website/email (live matching coverage) vs. auto-created name-clones — sizes whether step 1 replaces a live capability or a near-dead one. **Third instance of the grid-is-canonical theme** (alongside the consolidation + the notes-blob→communications work); sequence together.

### Retire the notes blob → unify ALL activity into `communications` (SPEC, Grant 2026-06-21)
*A multi-session structural change. **End goal:** `communications` is the single source of truth for every touchpoint with an investor; the grid's free-text "Notes / Communication / Outreach" blob is **deleted entirely** (no archive — but only after the retrofit is verified). Grounding for the decision below was traced in code 2026-06-21.*

**The problem (two writable stores, one endpoint).** `POST /api/fundraising/log-communication` (`server.py:3389`) writes BOTH a normalized `communications` row (the real record) AND, when `append_note` (default true), one appended line to the grid blob (`fundraising_investors.notes`, the row's `notes` longtext — also synced to `fundraising_investors.notes`). This dual store has real bugs, not just mess:
- **One-way drift:** editing/soft-deleting a `communication` does NOT update/remove its blob line; editing the blob directly creates no communication. Neither store is trustworthy alone.
- **Soft-delete leak:** a deleted communication's text lives on in the blob, which is shown in the grid AND fed to the Spark grounding corpus — "deleted" content isn't gone (violates the soft-delete-everywhere rule).
- **No per-entry structure** in the blob: no author, no type/filter, no edit/remove of a single line, no precise dates.
- **Email fragmentation (the key finding):** approving an email-activity proposal appends a one-line summary to the **blob** (`_append_grid_note`, `server.py:6754`), and emails are **never** inserted into `communications`. So the per-investor **timeline** (`GET /api/communications?source_row_id` → `NoteTimeline`) shows logged calls/notes but **NOT emails**. The blob is currently the ONLY place email-as-a-touchpoint is unified onto the investor. (The daily digest reports email activity via its own separate `email_*`-table path, `digest_builder.collect_investor_activity`; the grounding corpus already pulls full matched-email bodies directly from `emails`, capped ~4000 chars — so grounding doesn't depend on the blob for emails.) **Consequence: retiring the blob REQUIRES unifying emails into `communications` first, or the timeline loses email activity.**

**Schema change — `communications` becomes investor-anchored (LOCKED with Grant 2026-06-21).** `communications` is a **leaf table** (verified: nothing FK-references it), so ONE rebuild migration does everything:
- **Add `fundraising_investor_id` → NOT NULL** (the anchor; backfill existing rows via the `contact_id → fundraising_contacts → fundraising_investors` join). This is path b: a touchpoint is fundamentally with the *investor entity*.
- **Relax `contact_id` → nullable** (the specific person, when known). Lets us log "reached out on the org's LinkedIn / emailed the website alias" before a named contact exists; attach the person later.
- **Drop the dead fields** (Grant, never used / too tedious): `duration_minutes`, `attendees`, `outcome`, `opportunity_id`. (Pipeline decides *who* to contact; a comm needn't be assigned to an opp.)
- Migration is additive-where-possible but the NOT-NULL relax + column drops need the SQLite 12-step table rebuild; ship a `.down.sql`, run against a **copy** of `crm.db` first.
- **Data-hygiene goal (path a, separate):** every investor SHOULD have ≥1 contact — even a placeholder ("Harvard Endowment") or a found name. Encouraged, not enforced at the comm level. Pairs with the contacts↔`fundraising_contacts` consolidation above.

**`log-communication` field set (simplified, desktop == mobile — Grant).** Types: **`email/call/meeting/note`** (drop `text` — redundant with `note`, Grant). Fields: type, **communication date (defaults today, backdatable)**, body (free text — fold the old `outcome`/attendees prose in here), and **`next_action` + `next_action_date`** which, when set, **auto-create a reminder** (the W1 `reminders` table) on the investor. Remove the duration/attendees/outcome/opportunity inputs. **Same fields on desktop and mobile** (one shared form).

**Email → `communications` unification.** Repoint email-proposal approval (`decide_email_activity_proposal`) to create an **`email`-type communication** instead of appending to the blob. Full email body stays in the `emails` table; the communication is the touchpoint record + links back. Then the timeline shows emails, and consumers stop needing a parallel email path.
  - **Match resolution → comm fields.** The matcher (`email_integration/matcher.py`) writes an `email_investor_links` row resolved at up to three levels: `fundraising_investor_id` + `fundraising_contact_id` (a grid pill, via `exact_email`), `contact_id` (a classic contact), or `organization_id` **only** (`domain_match`, suppressed whenever any exact hit exists). On approval, set `communications.fundraising_investor_id` (required) from the link's investor and `communications.contact_id` (nullable) from the link's resolved person (pill → `fundraising_contacts.contact_id` → classic `contact_id`, else null). **Two cases legitimately have NO person** — a domain/org-level match (the sender's domain matches a known org but that person isn't a contact) and an investor-alias address — which is precisely why `contact_id` is nullable. A classic-contact-only match resolves a contact but **no investor until that contact maps to a grid investor** (the path-a consolidation closes this) — so email-unification and the consolidation are coupled. **The proposal row needs a resolved-contact field:** `email_activity_proposals` carries only `investor_id` today (it just appends to the blob), so add the resolved `contact_id` so approval can populate the communication.

**The grid column → derived view + Log button.** "Notes / Communication / Outreach" becomes a **read-only derived view** (render the latest N communications for the row inline), with a quick **Log button** (reuse the existing log-communication modal) for scratch entries — replacing inline free-text editing. The detail timeline already reads from `communications`.

**Retrofit (LLM-assisted, two-tier, then delete the blob).** Parse each investor's blob into `communications`:
- **Structured auto-appended lines** (`2026-06-20 [call] Jane Smith: …`, generated by log-communication) → **deterministic** parse, round-trips exactly (date, type, contact-name→contact_id, summary→subject). No LLM needed.
- **Legacy free-text** → feed the blob to the **local LLM via Spark Control** (never Claude — raw LP notes are Tier-2 sensitive; same local-only basis as the digest / intake / grounding-minimize) and ask it to split it into a set of `{date, type, summary}` communication entries, OR fall back to **one blanket `note`-comm** when it can't structure it. Much of the free text already carries explicit dates ("May 25 2025 — got a call", "Jun 16 2024 — sent an email"), so per-event reconstruction is realistic, not just a generic dump.
  - **Missing-year caveat (Grant):** entries over the years often omit the year ("contacted Jan 10"). The model can infer it from surrounding entries + the blob's roughly-chronological append order, but it **won't be perfectly precise** — expect gaps. So **flag inferred dates as low-confidence** (don't assert a wrong precise date) and **surface the suggested logs for human review/edit before committing** (fits the draft→approve guardrail). The blanket-fallback comm dates to `notes_last_modified`.
- The whole retrofit is **idempotent + reversible**, run against a **copy** of `crm.db` first. Once verified, **DROP the blob entirely** (the `notes` grid column + `fundraising_investors.notes`) — Grant: no archive, full deletion, but only after the retrofit is confirmed correct.

**Sequencing (safe, not big-bang):**
1. **Extend the census** (the consolidation's temporary endpoint) to also count: investors with notes but **zero contacts**, and blobs that are pure-structured vs. contain legacy free-text — sizes the retrofit + the contactless gap.
2. **Schema rebuild** of `communications` (investor_id NOT NULL + contact_id nullable + drop dead fields), backfill investor_id.
3. **Unify emails** into `communications` (repoint proposal approval) + simplify the log-communication form (+ reminder auto-create) + same desktop/mobile fields.
4. **Retrofit** blobs → comms (DB copy, verified).
5. **Cut over:** grid column → derived view + Log button; grounding drops the notes source; stop writing the blob.
6. **Delete** the blob (column + relational mirror) once everything reads from comms.

**Open / depends-on:** the contacts↔`fundraising_contacts` consolidation (path a) and the W1 reminders table (next_action → reminder). The `contact_type` physical-drop migration can ride the same `communications` rebuild window.

### Captured tweaks (Matrix, 2026-06-18/20)
*Small UI/UX + capture-quality items captured via Matrix; not yet scheduled.*

- **[P2] Backup history (Settings) defaults minimized, chevron-expand, pinned to the bottom** — it's rarely viewed, so it shouldn't take prime space. Frontend-only (`frontend/index.html`). (2026-06-18)
- **[P2] Preserve the active tab across a page refresh** — a refresh currently snaps back to the top/default tab. Persist the selected tab (e.g. localStorage / URL hash) and rehydrate on load. Frontend-only. (2026-06-18)
- **[P2] Email capture matches an investor on `To:`/`From:` only, not `Cc:`** — today if an investor's address appears anywhere on a message landing in a team mailbox (including when a teammate is merely cc'd on an outbound reply to the investor), it logs a spurious "received from investor" entry. Restrict the investor-link match to the to/from headers so a cc doesn't create a phantom inbound note. `backend/email_integration/` matching (see `docs/guides/email.md`). (2026-06-20)

### Follow-ups/reminders + NL search + bot grid-mutations (agreed plan, 2026-06-18)
*Agreed with Grant 2026-06-18. Three workstreams, sequenced **W1 → W2 → W3**. **Overarching constraint (Grant):** the dominant risk is **leaking LP data (names, $, notes, contacts) to third-party LLMs — NOT write-safety.** A wrong number is recoverable; investor substance reaching Claude is not. Consequences: W2 keeps LP rows off Claude (only the question text + schema vocabulary leave the box; entity names resolved locally); W3 keeps bot mutation-parsing on local Qwen. Because this DB *logs* commitments/pipeline but doesn't move money, a bot mutation is low-stakes → **any team member may approve one in Matrix**; the guardrail is "the bot can't silently mass-change numbers," enforced by the per-mutation human approval gate, not a tight money gate.*

**W1 — Reminders & follow-ups — BUILT + tested locally 2026-06-18 (v0.1.0:92, deploy pending).** First-class tickler tied to the grid: `reminders` table (in-app migration `0006`; logical FK to `fundraising_investors.id` + denormalized name, like `0005`), full CRUD (`GET/POST/PATCH/DELETE /api/reminders`; soft-delete; status open/done/snoozed/cancelled; assignee; `source` human/bot/automation), a read-only **derived `reminder_status` grid column** (overdue/due_soon/open — injected like `pipeline_stage`, **filterable so the follow-up view can later key off reminders instead of the binary `follow_up` checkbox**, per Grant), an orphan reconciler (`reconcile_grid_reminders` — cancels reminders when their investor leaves the grid, the pipeline reconciler's twin), a **Reminders** nav page (filter/complete/snooze/edit/delete + create), a Dashboard **"Reminders Due"** card, a **"Reminders due"** daily-digest section, and a per-investor **`last_activity_at`** recency rollup (the shared building block W2's "not nurtured" query needs). Pure local CRM — no LLM path, no leak surface. Tests: `test_reminders.py` + digest reminders test; **31/31 suite green, render-smoke green**. **Deploy:** needs an s9pk build + install (version bumped to 92); get authorization first.
- **W1b (deferred fast-follow):** nurture-gap automation — a daily job flags "committed / in-pipeline + no activity in N days + no open reminder" → auto-suggests a reminder (`source='automation'`, human confirms). Build once the recency rollup is proven in practice.
- **Left untouched (deliberate):** the grid `follow_up` checkbox + automation list-memberships, and `communications.next_action_date` + `/api/outreach/radar` — reminders are the new richer layer; folding those into it is a later cleanup, not now.

**W2 — Natural-language query (read-only). BACKEND BUILT + tested + validated locally 2026-06-18; web/Matrix UI pending.** = the **"Email/communication search + NL query → item 3 (NL→safe structured query)"** below, now sequenced second and **redesigned** (see below). Subsystem detail: `docs/guides/nl-query.md`.

- **Approach changed from the original "Claude behind redaction + a validated filter-AST" to LOCAL-ONLY + a named-intent catalog (decided with Grant 2026-06-18).** Rationale: (a) the dominant risk is LP data reaching a vendor — running translation on the **local Qwen via Spark Control** keeps the question on the box entirely (same basis as intake/digest), so there is **no Claude path and no redaction boundary** to manage, which is both simpler and safer; (b) a generic SQL/AST compiler was over-built for the real need — instead there are **~12 curated, hand-written, parameterized "named queries"** (`backend/nl_query/intents.py`) each with typed slots, and the **slot validator** (`runner.validate`) is the whole trust boundary (no dynamic identifiers, no raw SQL). The LLM only maps a question → `{intent, slots}`; its output is still validated, so a hallucinated intent is rejected. **Results never go to any model** (deterministic local render). Both design choices were pressure-tested by independent review agents before building.
- **As built:** `backend/nl_query/` (`intents.py` catalog, `runner.py` validator/executor + audit, `translate.py` local-Qwen translator, `try_questions.py` dev harness). `POST /api/query/nl` (`{question}` or direct `{intent,slots}`) + `GET /api/query/catalog`, `require_bot_or_admin`, read-only, audited (`audit_log` `entity_type='nl_query'`). Soft-delete-correct per table (`fundraising_*` has no `deleted_at` — `graveyard` is the axis; emails via a live `email_account_messages` sighting; reminders/opps/comms via `deleted_at`). Builds on W1's `last_activity_at`. Tests: `nl_query/test_nl_query.py` + `test_translate.py` + `test_nl_query_endpoint.py` (34/34 suite green).
- **Validation:** the local Qwen translated **12/12 of Grant's real example questions** correctly (right intent + slots, incl. "3 months"→90, sent/received→direction) against the live Spark — settles local-only; Claude not needed. Translation quality on messy/typo/no-match inputs shakes out in live use.
- **Remaining:** **step 4** = web "Ask" box in the Communications tab (calls the endpoint, renders rows + the interpreted query); **step 5** = Matrix `@bot <question>` (thin client of the endpoint; the 2-admin review room means a full-book dump is acceptable, so no bulk-result cap — only a light anti-flood truncation). Reads need no approval gate. Then deploy with reminders (v92) as **v0.1.0:93**.

**W3 — Bot grid-mutations behind a Matrix approval gate.** Generalize the email-proposal scaffold (`email_proposal_matrix` + propose→post→decide→apply) into one `agent_proposals` table (kind discriminator + JSON payload + target). Bot proposes set-commitment / assign-fund / change-stage / set-reminder; a human approves/edits/rejects in Matrix (**any member**); then apply. **Surgical, version-checked mutations — never blob RMW:** stage rides the existing `opportunities` link + validated stage endpoint; reminders write the W1 table; set-commitment/assign-fund need a version-checked single-cell upsert into the grid blob. Triggers the deferred **scoped service-token** item below (per-mutation-kind allowlist on the bot credential; money/merge/delete always require human approval regardless of scope — the autonomy axis). Parse on local Qwen, not Claude.


### Matrix-bridge intake for the fundraising grid — M1+M2+M3 BUILT & LIVE
*Requested 2026-06-16. **M1+M2 live since v0.1.0:86 (2026-06-17); M3 (business-card photo) shipped & live 2026-06-20** — code in `backend/matrix_intake/`, guide at `docs/guides/matrix-intake.md`. M3 unblocked once the Spark Control daily-driver model became vision-capable: the bot OCRs a card via Spark Control's `/v1/chat/completions` multimodal passthrough (same `CRM_CHAT_MODEL`), then runs the existing intake flow; captures contact name/email/title/city/LinkedIn/phone/mobile (server half of phone/mobile = s9pk v0.1.0:98). Remaining: ongoing on-device card testing (OCR accuracy on small-in-frame cards). Next major build is **Pipeline adoption** (see below).*

Use the **matrix-bridge** repo's pattern to listen on a dedicated ten31-database Matrix room. Send a message (with an optional business-card photo) and a local LLM **via Spark Control** parses it into the fundraising-grid schema and **auto-creates the investor entity + contact row**. For an existing investor, send a meeting note and it **appends an interaction-log entry**. Approval gate: the bot replies in Matrix with the proposed add/edit; the user approves / rejects / edits in-thread before the write commits (keeps the draft→human-approve guardrail).
- Fits the "grid is canonical" direction (writes land in `fundraising_*`) and the never-send-autonomously rule (in-thread human approval before any write).

**Locked design (2026-06-16, approved) — build now, M1 then M2:**
- **Separate component, shared scaffold:** new `backend/matrix_intake/` (its own process; lifts matrix-bridge's connect/prime-then-listen/threaded-reply plumbing). `matrix-nio` is isolated to this component's `requirements.txt` — it never enters the stdlib CRM runtime. Keeps the CRM write credential + LP data out of the general-purpose matrix-bridge bot (blast-radius + data-sovereignty), and lets the two iterate independently. Runs on the Spark (placement settled against `standards/guides/placement.md` at deploy).
- **~~v1 = text-only~~ — M3 business-card photo SHIPPED (2026-06-20).** The Spark Control daily-driver model is now vision-capable (multimodal `image_url` passthrough), so card→text→fields works end-to-end. Transcribe-then-reuse (vision OCRs to text; the existing text extractor pulls fields) preserves the email/phone integrity rules. See the matrix-intake guide.
- **Parse:** local Qwen via Spark Control `/v1/chat/completions` (temp 0, JSON-only), reusing the existing Spark client pattern (`backend/redaction`/`backend/ingest`).
- **Approval handshake (the one stateful piece):** in-memory pending-proposal store keyed by Matrix thread root; user replies **yes / edit field=value / no** in-thread. Satisfies never-write-autonomously; exempt from "agents draft, humans send" (internal data entry, like the digest).
- **CRM-side:** `POST /api/intake/investor` (service-auth) creates a new investor+contact **through the existing grid-save path** (so relational sync + audit + backup-on-write happen as with a UI edit; bot never does whole-blob RMW) or appends a meeting note to the interaction log for an existing investor; `GET /api/intake/match?q=` fuzzy-matches via the existing entity-resolution/email-matcher. New investor needs no fund at intake.
- **Phases:** M1 = scaffold + parse + in-thread propose, **no writes** (proves Matrix↔Spark). M2 = intake endpoint + match + write-on-approve + tests. **M3 = business-card photo (SHIPPED 2026-06-20).**

**Post-deploy enhancement — fuzzy match + in-thread confirm (Grant, 2026-06-17). DEPLOYED & LIVE 2026-06-17 (v0.1.0:86; box migration chain …85→86 clean, `candidates` endpoint verified); Matrix live-smoke pending.** Today `find_intake_match` is **exact-after-normalization** (`_normalize_text` = lowercase+strip), so near-misses — "Charlie" vs "Charles" (same last name), "Acme Capital" vs "Acme Capital LLC", a one-character email typo — return no match and the bot proposes a **new** investor, risking a duplicate the human approves without realizing a near-match exists. The existing in-thread approval gate is useless against this because the human is never *shown* the near-match. Fix: matcher returns **ranked fuzzy candidates** (deterministic pre-filter: normalized name similarity / token overlap + email edit-distance ≤ ~2), surfaced in-thread for the human to confirm or pick, with the **local Spark LLM optionally re-ranking/judging the shortlist** (good at Charlie/Charles + legal-suffix equivalence; fed only the shortlist, never the whole LP list). Keeps the approval gate but makes it effective against duplicates. Land **after** the live smoke — net-new logic + reply grammar + tests; the current exact match is safe and its failure mode (a duplicate) is recoverable via the existing entity-merge subsystem (`backend/entity_*.py`).
- **As built:** `find_intake_candidates` in `server.py` (deterministic — stdlib `difflib` name similarity + token-set Jaccard, legal-suffix-aware via `_strip_legal_suffix`, + email Levenshtein ≤ 2; ranked, ≥0.62, top 5). `GET /api/intake/match` now returns `{match, candidates}`. Bot: a new `_stage="disambiguate"` shortlist (`proposals.render_disambiguation` / `interpret_disambiguation` / `attach_to_candidate` / `promote_to_new`) — human picks a number / `new` / `no`. **The optional LLM-judge re-rank was deliberately deferred** (the deterministic filter already surfaces the named cases; an LLM judge is the right *pruner* for shortlist noise — build if the deterministic ranking proves too noisy in practice). Tests: `test_intake_endpoints.py` (server fuzzy cases), `matrix_intake/test_proposals.py` (disambiguation grammar), `matrix_intake/test_crm_client.py` (candidate shape).

**Post-deploy enhancement — conversational (LLM-mediated) edits (Grant, 2026-06-17). DEPLOYED & LIVE 2026-06-17 (bot-side; pulled + restarted on the Spark `modelo32`); Matrix live-smoke pending.** Today an in-thread correction uses a rigid grammar (`edit field=value`). Let a free-form reply that isn't `yes`/`no`/a literal `edit …` be treated as a natural-language revision instruction: send {current proposal + the instruction} back through local Qwen (`spark.py`, the same parse leg — no Claude, no scrub) and re-render the revised proposal card for approval (e.g. "add that we met on June 14" → updated Note). Keeps the draft→human-approve gate (the human still confirms the LLM's revision) and subsumes `edit field=value` as a deterministic fast path. Thread the instruction text into `normalize`'s source so the email-integrity rule still holds (a revised email must appear in the original message or the instruction). Pairs naturally with the fuzzy-match item above — build both as one conversational-UX pass after the smoke. (Parsing of free-form *intake* messages already works today via the Qwen parse leg; this item is specifically about the *edit/refine* turn.)
- **As built:** `parse.revise` + `_apply_revision` (offline-testable; the approval-stage `else` branch in `bot.py` routes any non-yes/no/edit reply here). `parse_message` now stashes `_source_text` so revise can re-check email integrity against {instruction + original}; the model's email field is never trusted. No-op revisions are caught via `proposals.same_fields` (re-prompt, not a false "Updated"). **Known v1 limit:** revise edits fields but does not re-run the matcher on a mid-thread firm rename. Tests: `matrix_intake/test_parse.py` (revise merge + email integrity + match-id preservation).

**Managed service — DONE (container) 2026-06-17; dashboard card deferred to a spark-control session.** The bot ran as a bare `nohup` process (silently died on a Spark reboot). Now it's a **docker-compose service** (`docker-compose.yml` at the repo root + `backend/matrix_intake/Dockerfile`; `restart: unless-stopped` → survives reboot; image bundles `backend/matrix_intake` + the stdlib `backend/ingest` Spark client; `.env` mounted read-only). Cutover done on the Spark (nohup stopped, container `matrix-intake` up + listening). **Still bare `docker`/SSH-managed** — a spark-control dashboard card (Update/Restart/Stop/Logs tile like `matrix-bridge`) is a separate task in the spark-control repo: see `docs/handoffs/add-intake-bot-to-spark-control.md`.

**Parse mis-identifies the investor when the message names an internal teammate (found in live-smoke, 2026-06-17).** *"jonathan is chatting with wyoming soon about fund commitment"* → the bot picked **jonathan** (a colleague/CRM admin) as the investor and offered a Jonathan/Nathan fuzzy shortlist, when the investor is **Wyoming**. Root cause is upstream of matching: local Qwen has no notion of who's internal, and mis-read the sentence role. **Fix (cheap, high-confidence, near-term):** feed the parse prompt the ~5-person team roster + the frame *"messages are written by a team member about a prospect; a named team member is the person doing outreach, never the investor"* (roster from a config value or a small read — not the admin-gated `/api/users`, since the bot is a member). Offline-testable (stub the model). **Bigger design (deferred, needs more failure samples):** the user's idea of routing inputs through the LLM *with grid context* for entity resolution — feasible (local Qwen, same as the digest, never Claude) but feed a **bounded shortlist, not the full ~400-name grid** (a small model dilutes on a haystack); pairs with the deferred LLM-judge. Also exposes a missing concept: the **internal deal owner** (Jonathan), which the bot doesn't model. Get 3–5 more real intake messages before re-architecting; the roster fix lands regardless.

**Long-term — extract the intake bot to its own repo (recommended, not yet done).** Containerizing from this monorepo is the pragmatic now-state, but the bot is a genuinely separate deployable (own process, own `matrix-nio` dep, own lifecycle); its only CRM coupling is the HTTP API (a clean network contract) plus ~40 lines of stdlib Spark client (cheap to vendor). The tell: the spark-control Update button would run `git reset --hard origin/main` on the **whole CRM clone** — wrong blast radius. `matrix-bridge` is already a dedicated repo; mirror it. The extraction is a migration (new Gitea repo, move code + tests + guide, vendor the client, re-point the Spark deploy), so it's deferred until worth the lift — do it *before* wiring the spark-control card if both land in the same push.

### In-app camera business-card intake — DONE & live (v0.1.0:100, device-confirmed 2026-06-20)
*Shipped: a camera button in the mobile top bar (left of the quick-log pencil) → take/choose a photo → vision-transcribe → parse → fuzzy-match → edit/approve/reject, surfaced as an inline mobile sheet (`source="app_card"`, form-field edits only, any-authenticated-member). The reusable core is nio-free and already reachable from the CRM (`server.py` imports `llm`; `matrix_intake/parse.py`+`spark.py` import no `matrix-nio`), so it landed as **one endpoint** (`POST /api/intake/card`) + **one mobile component** — no bot refactor / new dep / migration; reuses the New-investor sheet pre-filled + the `.quicklog-btn svg` icon-sizing fix. History: commits `463f624` (feature) / `622d454` (handoff). (Plan doc removed — git history is the record.)*

### Scoped service-credential auth path for automated CRM writers
*Surfaced 2026-06-17 while deploying the Matrix intake bot. **Decision: defer — the bot uses a dedicated member username/password for now.** The CRM has no API-key/service-token path; its only auth is username+password → JWT. A dedicated **member** login is appropriately scoped against what matters operationally (no admin: can't manage users, reset data, or change settings) and unblocks the live smoke today.*

**Accepted residual risk (why this is worth revisiting):** a member credential is far broader than the bot's actual need (two endpoints: `GET /api/intake/match`, `POST /api/fundraising/log-communication`). A member can **read the entire LP/prospect database** — the exact data this system exists to keep off third-party servers — plus broad member-level *write* within the fundraising domain (could create/append on any investor). The credential lives in a `.env` on the Spark, so a Spark compromise leaks read-access to all LP data. Mitigating context: own-infra, LAN-local; the Matrix bot is the **first out-of-process API writer** (the digest runs in-process with direct DB access), so there is exactly **one** consumer today → building a token-scope framework now is premature (YAGNI).

**Right long-term design:** a hashed, revocable **service token** with a per-route **scope allowlist** (intake-match + log-communication only), minted/revoked from the admin panel, replacing the bot's member login. Revocation then kills the token without rotating a reused human password.

**Build trigger:** when a **second** out-of-process automated writer appears, OR before **any** automated writer is reachable beyond the LAN — whichever comes first. Build it once, properly, at that point.

### Admin-only vs. all-users web-UI surface — audit
*Requested 2026-06-16 (idea, P2).* Have the **explorer agent** report which web-UI functionality is visible only to admins vs. to all users (member role) — a map of the role-gated surface across `frontend/index.html` and the backend route auth checks. Useful input for the consolidation/permissions work.

### Daily activity digest (email to the team)
*Requested 2026-06-15. **Phase A deployed** (v0.1.0:76). **Phase B deployed & verified live in v0.1.0:77 (2026-06-16)** — digest content + Spark summarization + daily scheduler + by-investor section + admin-panel control + on-demand send. Auto-send defaults OFF until an admin enables it in Settings → Admin. **v0.1.0:83 (built, deploy pending): in-app windowed preview** — Settings → Admin builds a digest over a chosen window (last 24h or since a date) and shows it before sending (`POST /api/admin/digest/preview`), so the **real Spark summarizer can be verified on demand** even on a quiet day (the fixed last-24h `send-now` couldn't); manual send uses the same window and never touches the daily cursor.*

**Decisions (locked 2026-06-15):** recipients = **all active admins**; summarization = **Spark-LLM narrative** (never Claude — un-anonymized substance stays local); granularity = **grouped by user** (→ per investor).

**Send transport — DECIDED 2026-06-15: Gmail domain-wide delegation** (not SMTP). The box's existing service-account grant (which powers email capture) **includes `gmail.compose`**, which authorizes `users.messages.send` — verified by a token-mint probe **and a live `messages.send` to grant**. So the digest sends through the account the CRM already uses: **no app password, no new account, no admin change.** The narrow `gmail.send` scope is *not* granted, so the sender must request `gmail.compose`.

**Phase A — DONE:** (v0.1.0:75) `configureDigestSmtp` Start9 action + `docker_entrypoint.sh` `SMTP_*` export + `backend/smtp_send.py` + admin `POST /api/admin/digest/test-email` (recipient-restricted to the admin set — not an open relay) + Settings button. (v0.1.0:76, redeploy pending) `backend/email_integration/gmail_send.py` (`users.messages.send` via DWD/compose) + `backend/digest_mailer.py` (**Gmail-DWD preferred, SMTP fallback**); the endpoint + button route through it; sender = `CRM_DIGEST_SENDER` else first active admin. Tests: `test_smtp_send.py`, `test_smtp_endpoint.py`, `test_gmail_send.py`.

**Phase B — DONE (2026-06-15/16):** `backend/digest_builder.py` builds **two sections** — *by team member* (per-user **Spark** narrative + both directions, with a deterministic fallback) and *by investor* (team-wide, inbound + outbound, deduped per email, structured). Soft-delete filtered throughout. `backend/email_integration/digest_scheduler.py` is an always-on daily thread that re-reads a **DB-backed policy** each cycle and sends once/day at the configured hour to all active admins (window cursor in `app_settings`). Control moved out of env into the **admin panel**: `app_settings.digest_policy` + `GET/PATCH /api/admin/digest/policy` + a Settings → Admin **enable toggle + send-time dropdown** (env vars only seed the first-boot default). Plus admin `POST /api/admin/digest/send-now` + a "Send Digest Now" button. Decisions settled: **6 PM default**, **always-send** (empty-day note), **per-user narrative + by-investor section**, **in-app control** (not StartOS). Tests: `backend/test_digest_builder.py`. Detail: `docs/guides/email.md`.

Have the CRM send a **daily digest email** summarizing each registered user's activity — primarily **who emailed which investors and the substance of those emails** — to the fund principal (and eventually other admins). Scales with the synced-user count: 2 users synced today, ~5 eventually.

- **Source data:** the captured email-activity already flowing through the Gmail DWD propose→approve pipeline (`backend/email_integration/`), keyed per registered user → per investor/contact. Optionally fold in other CRM activity (audit feed, automation runs, new opportunities) later.
- **Send path is NEW capability.** Today nothing leaves the box — the system only *captures* Gmail and *creates drafts*. This needs outbound SMTP. StartOS 0.4 has a system-wide SMTP account (since v0.4.0-beta.9): the user configures it once for the whole server and services read it via `sdk.getSystemSmtp(effects).const()`, which returns a `T.SmtpValue` (`host`, `port`, `from`, `username`, `password`, `security`). Wire the digest sender to that rather than hardcoding any account. *Implementation path (researched 2026-06-15, our SDK pin `^0.4.0-beta.66`):* model a `manageSMTP` action on [gitea-startos](https://github.com/Start9Labs/gitea-startos) / [vaultwarden-startos](https://github.com/Start9Labs/vaultwarden-startos) — a three-way `selection` (system / custom / disabled) built on `sdk.inputSpecConstants.smtpInputSpec`, persisted to `storeJson`, with `main.ts` injecting `SMTP_HOST/PORT/USER/PASS/FROM/SECURITY` env vars into the daemon `exec` block (same shape as the existing `setAnthropicApiKey.ts` action). The Python sender reads them via `os.environ` and opens `smtplib.SMTP`/`SMTP_SSL`. **"Custom SMTP" is a dedicated per-package account, fully independent of the server's system SMTP** — the custom branch never calls `getSystemSmtp`, so the digest can send through its own provider even on a box with no system account configured (confirmed in both reference packages). This is the likely fit here: a digest-only mailbox separate from anyone's Gmail. Note StartOS 0.4 dropped the old `Config`/`Properties` manifest spec — SMTP config is an **action + storeJson**, not a manifest config field. **SDK note (verified 2026-06-15):** our pin `^0.4.0-beta.66` resolves to exactly `0.4.0-beta.66` (caret on a prerelease stays within the `0.4.0` tuple), whose SMTP surface — `getSystemSmtp` → `T.SmtpValue {host, port, from, username, password, security}`, `inputSpecConstants.smtpInputSpec` (providers gmail/ses/sendgrid/mailgun/protonmail/other; selection disabled/system/custom), `smtpShape`, `smtpPrefill` — is **byte-identical** to the 1.5.3 reference packages (verified from published tarballs; repo `node_modules` is absent). Build against beta.66 as-is — **no SDK bump needed** (moving to 1.x is a major-track change with broad blast radius across `startos/`, and nothing about SMTP justifies it).
- **Analysis runs on Spark, never Claude.** The digest is deliberately **un-anonymized** (real LP names + email substance), so any summarization/analysis must go through **Spark Control to local models** — this is the one path that intentionally bypasses the scrub→Claude→re-hydrate boundary, because keeping the substance local is the whole point. Never route digest content to Claude.
- **Exempt from "agents draft, humans send."** That rule governs outward LP/prospect contact. This is an internal ops digest to the team's own inboxes — a different category — so an automated daily send here does not violate the draft-only guardrail. State this explicitly at build time.
- **Scheduling:** a daily cron, naturally co-located with the existing `backend/email_integration/scheduler.py` sync cadence.
- **Soft-delete:** every aggregate/read in the digest must filter `deleted_at IS NULL` (see the standing soft-delete rule).

Open design questions (settled at build time): send time = **6 PM box-local** (configurable in the admin panel), covering the ~24h window up to send; empty days = **always send** with a "no activity" note; summary granularity = **one per-user narrative** plus a **by-investor structured section** (inbound + outbound, team-wide) added 2026-06-16; enable/time live in the **admin panel** (DB-backed), not StartOS actions.

### Email/communication search + natural-language query
*Requested 2026-06-16. Three increments, **sequenced 1 → 2 → 3** (1 and 2 first as a quick increment; 3 is a separate, larger build after). Origin: Grant asked whether we can query "emails sent to a specific investor" / "activity by user," and floated NL queries like "existing investors who have committed capital across our funds that we haven't emailed in a while."*

**Status: items 1 & 2 SHIPPED in v0.1.0:83 (built + verified locally 2026-06-16, deploy pending).** The Communications tab now has the structured activity surface (item 1: typed/fixed investor dropdown, mailbox + direction + **date-range** filters, free-text, **click-to-expand full body** via `GET /api/email/detail`) and a **"Search content"** semantic mode (item 2: `GET /api/email/search` over the Qdrant email index). The dropdown-empty bug (the facet only listed grid investors) was the v83 fix — it now mirrors the list across grid/org/contact matches. **Item 3 (NL→SQL) remains** — the larger, separate build below. Detail: `docs/guides/email.md`.

**Context — the data is captured but currently has NO front-end.** The entire Gmail email schema (`emails`, `email_threads`, `email_investor_links`, `email_account_messages`, `email_activity_proposals`, …) exists and is populated by the DWD capture pipeline, but is surfaced **nowhere** in `frontend/index.html` today (only as inputs to the daily digest). So all three items below are about making already-captured data queryable/visible. Email bodies of *matched* emails are already chunked + embedded into Qdrant with `{lp_id, lp_name, doc_type:"email", date_ts}` metadata.

**Caveat that shapes all three — the two-model join.** "Emails to an investor" link to the **fundraising grid** (`email_investor_links.fundraising_investor_id`); "committed capital" lives in the grid too (`fundraising_commitments`, multi-fund). But manually-logged `communications` and `lp_profiles` (single-fund) live in the **classic** model, and the two models are only bridged by fuzzy email/name matching (no authoritative join key). Any query spanning "committed capital" + "email recency" must reckon with this. Prefer the grid side as the higher-signal source (matcher already does).

**1. Activity query endpoints + panel — DONE (v0.1.0:83).** Delivered as the **Communications tab** rather than the originally-sketched `/api/activity` endpoints: `GET /api/email/activity` (`db.query_email_activity`) returns the actual records filterable by investor / mailbox / direction / **date range** / free-text, and `GET /api/email/detail` expands the full body. Answers "emails to investor X" and "what has mailbox Y sent" interactively. Soft-delete filtered throughout; investor identity is typed (`fund:`/`org:`/`contact:`) so org/contact-only matches resolve and are pickable. *(The `collect_user_activity()`/`collect_investor_activity()` digest helpers remain the by-user/by-investor pivot source; a dedicated per-user pivot UI was not needed for the answer Grant wanted, which the mailbox+direction filters already give.)*

**2. Email content search box — DONE (v0.1.0:83).** A **"Search content"** toggle in the Communications tab → `GET /api/email/search?q=` wraps `backend/ingest/search.py:hybrid_search` filtered to `doc_type='email'`; hits are hydrated + soft-delete-filtered against SQLite (canonical) and link back to the full body. Semantic/lexical search over email *content* ("find where we discussed the mining deal"), distinct from item 1's structured filters. 503 (clean "unavailable") when Spark/Qdrant is unreachable.

**3. Natural-language → safe structured query — SUPERSEDED & BUILT as W2 above (2026-06-18).** The design constraints below (especially "LLM = Claude behind the redaction boundary" and the validated-AST shape) were **revisited and changed** during the build: translation runs on the **local Qwen** (no Claude, no redaction), and the safe surface is a **named-intent catalog**, not a generic query AST. See the W2 entry above and `docs/guides/nl-query.md` for what shipped; the original framing is kept here for provenance. _An LLM translates a plain-English question into a **safe, read-only** DB query against the CRM, for relational/analytical questions that semantic search *cannot* answer — Grant's example ("committed across funds AND not emailed in a while") is joins + aggregates + recency, not a text-topic match. Original design constraints (locked at request time):_
  - **LLM = Claude behind the redaction boundary** (better at text-to-SQL than local Qwen; the scrub→Claude→re-hydrate path already exists for the PII concern). Not Spark — Spark Control offers embeddings/rerank/RAG + local chat, but **no text-to-SQL**.
  - **Safety is the hard part, not the parsing.** Do NOT hand the LLM open-ended SQL against the live DB (soft-delete leaks, injection, runaway scans). Constrain it: read-only connection/view, a curated/parameterized query surface or a validated query AST, soft-delete-filtered views, row/time caps. Treat as its own designed feature with its own tests.
  - Must reckon with the two-model join caveat above (capital lives in the grid; recency from email links).

### Consolidate on the fundraising grid as canonical; retire vestigial classic-CRM surfaces
*Decided 2026-06-16. The CRM carries two stacked models: the original generic CRM (contacts / lp_profiles / opportunities / manual communications) and the fundraising grid + email capture. The team uses the grid; most classic surfaces are un-adopted (verified on the box: Pipeline + Communications empty, Contacts auto-populated from the grid). **Decision: the fundraising grid + email capture is the canonical system of record;** prune or repurpose the rest rather than maintain a parallel half-empty CRM.*

**Retire `lp_profiles` + LP Tracker — DONE & deployed live (v0.1.0:78, 2026-06-16).** 21/21 backend tests green, `py_compile` clean; installed to the box (`installed-version`→`0.1.0:78`, migration chain …77→78 clean, server up on :8080).
- Removed the orphaned `LPTrackerPage` component + the `lp-tracker`→`fundraising-grid` redirect (frontend).
- Removed the `/api/lp-profiles*` endpoints (list/get/create/update) and their handlers, the unused `lp-breakdown` report + route, the contact-dossier LP display (frontend + the `lp_profile` block in `handle_get_contact`), and the demo-seed LP block.
- **Dashboard KPIs repointed:** "Total Committed" now sums `fundraising_investors.total_invested` (the canonical grid rollup), **excluding graveyarded investors** so the headline reflects live committed capital — a deliberate divergence from `/api/fundraising/relational-summary`, which sums all rows. "Total Funded" dropped — the grid has no funded-vs-committed concept and the frontend never rendered it. (If a funded/wired status is wanted later, that's a new grid feature, not a revival of lp_profiles.) Regression-guarded by `test_dashboard_report.py`.
- **Left in place (intentional):** the empty `lp_profiles` table + index (no destructive drop, per never-hard-delete); the contact-delete soft-delete cascade; the `--reset-all-data` clear; and the inert MOCK_MODE `mockDb.lp_profiles` fixtures (dev-only fallback, never hits the backend — its dashboard mock still reads mock lp_profiles, a known dev-only divergence from the real backend). Updated `test_soft_delete_reads.py` to drop the now-removed `lp_profile` assertions (kept its org `total_funded` opportunities-aggregate checks).

**Adopt the Pipeline — wire it to the grid. — DONE: DEPLOYED & live-smoked 2026-06-18 (v0.1.0:88; migration chain …86→88 clean, `0005_grid_pipeline_link.sql` applied on the box, server up; the full +Pipeline → board → advance-stage → remove round-trip is verified on the box).** *(Was: second build after the Matrix-bridge intake.)*
- Pipeline (`opportunities`) is fully built and functional but unused. Keep it: it's the one classic surface that tracks something the grid doesn't — a forward-looking deal funnel (stage, `expected_amount × probability`, owner, close date) vs. the grid's actual committed dollars + flags.
- New idea (Grant, 2026-06-16): let users **flag an investor in the grid as a pipeline opportunity** (a grid column/control) so it **auto-creates / syncs an `opportunities` row** that loads into the Pipeline board. Design the grid↔pipeline link (which fund seeds it? what sets stage/expected amount? keep them reconciled). Turns Pipeline from a disconnected second data-entry surface into a view driven by the canonical grid.
- Revisit the stray contact-create side-door (the "Create Opportunity" modal `POST /api/contacts`) once the grid-driven flow exists.

**As built (decisions locked with Grant 2026-06-17):** UX = **row action + seed modal** ("Add to Pipeline" per grid row → captures primary contact / target fund / expected amount / stage / probability). The durable link is `opportunities.fundraising_investor_id` (**migration 0005**, additive + reversible); "is in pipeline?" / "what stage?" are **derived from a live opp join**, never a denormalized flag (no drift). **Ownership split:** the grid owns whether the link exists + the seed; the **board owns stage/probability/owner/close/next-step** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent: one live opp/investor, re-link returns the existing one). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** — the `POST /api/contacts` side-door is **gone**. Grid `lead` → opp owner (fallback acting user). Two **read-only** grid columns (Pipeline action + Pipeline Stage) injected on read; their row values are stripped on write so they never persist or dirty the autosave. **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row is left fully intact** (Grant's explicit ask). Deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, called after `sync_fundraising_relational`). **Folded in:** the P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py` (link/idempotent/round-trip/guards/unlink-intact/re-link/orphan/aggregates), 28/28 suite green, render-smoke green. **Deploy:** server-side → needs an **s9pk build + install** (v87); get authorization first.
- **Follow-up (v0.1.0:88, frontend-only, DEPLOYED & verified 2026-06-18):** retired the Pipeline page's **"+ New Opportunity"** button + its create-by-contact modal — an opportunity is now born **only** from a fundraising-grid investor row ("+ Pipeline"), matching how the team works (they live in the grid). The board is now a view + stage-management surface; button replaced with a muted "Add deals from the Fundraising Grid" hint. Removed the dead handler/state + the page's unused `/api/contacts` fetch.
- **Deferred (not built):** no write-back of committed dollars into grid fund cells (grid stays canonical for committed $); a graveyarded investor with a live opp still shows its stage (deliberate — a live deal is a live deal).

**Keep the Contacts table — as the read-only per-person directory it already is.** Confirmed 2026-06-16: the grid models **investor entity → many people** correctly today. The grid "contacts" column is a multi-pill editor; each pill syncs to a `fundraising_contacts` row AND its own classic `contacts` row (5-person family office → 1 investor + 5 contacts, linked via `fundraising_contacts.contact_id`, migration 0004). The Contacts page is **read-only for creation** (header: "added from the Fundraising Grid"; no New-Contact button), edit-only via the detail slide-over — the desired flow already holds. Email capture already rolls **multiple people up to one investor** (matcher indexes each pill's email separately, all → same `fundraising_investor_id`; `email_investor_links` records both investor and specific person). No build here — future email-surfacing UI should present comms grouped by investor across all its people.

**Legacy-usage sweep — what's still wired, what's dead, what can be deleted (Grant, 2026-06-19; after mobile feature-complete).** Phase 7's `.badge-*` remap surfaced that several legacy badge classes (lead/outreach/meeting/due-diligence/committed/funded) have no — or near-no — live JSX caller, and Grant flagged other likely-dead legacy bits (e.g. the `investor type` field). Once the mobile redesign is feature-complete, do a systematic pass: for each legacy component / CSS class / endpoint / DB field, determine whether it still renders or runs anywhere (JSX callers, `do_GET`/`do_POST` dispatch, DB reads), then soft-retire/delete the genuinely-dead ones (never hard-delete data — archive per convention; `refactor-scout`/`janitor` can inventory). Start list: the orphaned `.badge-*` classes, `investor type`, and other classic-CRM remnants not reachable from the grid-canonical flow.

### Front-end: pre-compile JSX, drop runtime Babel (optional, larger)
*Logged 2026-06-16 during the v0.1.0:82 vendor+SRI work. The scoped fix shipped: React/ReactDOM/Babel are now vendored + SRI-pinned and served same-origin, and a jsdom render smoke check gates every build (`docs/guides/packaging.md`). This is the bigger alternative we deliberately deferred.*

Today the app ships `@babel/standalone` (~3 MB) and transforms ~5k lines of inline JSX **in the browser on every page load**. A build step that pre-compiles the JSX to plain JS would (a) eliminate the runtime-transform blank-screen class entirely (no Babel in production), and (b) load much faster. **Cost:** it introduces a build step, which contradicts the current **"No build step"** convention (single `frontend/index.html`, inline-Babel React) — so this is a real architecture change, not a tweak. Weigh only if page-load size/latency or render robustness becomes a felt problem; the render-smoke gate already de-risks the status quo. If taken: keep the source `index.html` editable, emit a compiled artifact into the s9pk, and keep the smoke check pointed at the built output.

### One-off feature batch (Grant, 2026-06-18)
*Eight one-off ideas, triaged against the backend 2026-06-18. **Cross-cutting guardrail:** anything framed as "auto-add / auto-forward / auto-suggest" lands as a **proposal surfaced for human approval** (reuse the `email_proposal_matrix` propose→Matrix→decide rails), never a silent write — per "agents draft, humans approve." #1 is built (deploy pending); #6 is a spark-control task (→ INBOX); the rest are scoped backlog. #2/#4/#7 reuse existing rails (email-proposal loop + W2 NL-query) — they're "wire a new source into an existing pipeline," not greenfield.*

- **1. Drag-reorder fundraising grid views — BUILT (frontend; deploy pending), 2026-06-18.** The sidebar view list is now drag-reorderable (HTML5 DnD mirroring the column-reorder idiom: `moveViewBefore` + `draggingViewId`/`dragOverViewId` in `frontend/index.html`). Order persists via the grid page's **existing autosave** (`views` is already in its snapshot + deps → `PUT /api/fundraising/state` → `views_json`), the same path rename/delete use — **no backend change.** Render-smoke green; the in-app drag interaction itself not yet browser-tested. **Known edge (same as existing rename/delete):** reordering while *off* the grid page only updates localStorage and is re-hydrated from the backend on next grid mount — reorder while viewing the grid. **Deploy:** needs an s9pk build + install.

- **2. [P2] Suggest new contacts from digested emails (outreach detector).** When a captured *outbound* email goes to an address not already in `contacts`/the grid and looks like outreach, propose adding it as a contact. Hangs off the existing email capture + `email_proposal_matrix` / `/api/intake/email-proposals` review rails — net-new is the detector + "looks like outreach" criteria (exclude vendors / newsletters / internal domains). Lands as a **proposal**, not an auto-add.

- **3. Pipeline stages + investor flags/labels — sharpened into a LOCKED SPEC (2026-06-19).** Was "new pipeline stages"; the design conversation collapsed it into a 4-stage per-investor funnel + auto-derived Existing-Investor flag + staleness overlay/nudge. **Full locked spec: see "Pipeline stages + investor flags/labels — LOCKED SPEC" below.**

- **4. [P2] Squarespace website form-submissions → DB (near-term, high value).** Parse `form-submission@squarespace.info` capture emails — structured Name / Email / Company / LinkedIn / Location / comments (see the website-lead screenshots, Grant 2026-06-18) — and feed them into the proposal flow. Deterministic parser (fixed format) + existing proposal rails = relatively contained. **Guardrail:** despite the "auto-added" ask, land each lead as a **Matrix proposal → one-tap approve**, not a silent insert (same pattern as email proposals). Real leads (e.g. Matt Baas, Vikrum Tatla) are currently only living in an inbox.

- **5. [P3] Matrix voice note → Spark Control transcription → intake.** matrix-nio receives an audio/voice event → download + decrypt the media → **Spark Control** transcription endpoint (Whisper-class — **confirm it exists; external dep**) → feed the text into the existing local-Qwen intake parse + disambiguation. Never call a Spark directly (Spark Control only). Larger; gated on the transcription endpoint existing.

- **6. → INBOX (spark-control repo, not this one). Dashboard card for the crm/intake bot** (Update/Restart/Stop/Logs tile like `matrix-bridge`). Already noted under the Matrix-intake "Managed service" item + `docs/handoffs/add-intake-bot-to-spark-control.md`; captured to `standards/INBOX.md` to confirm/do in a spark-control session.

- **7. [P2] Intake: "query the LLM when the name doesn't match."** Extend the disambiguation grammar (today: number / new / no — see screenshot) with a `search: <text>` option that runs the read-only **W2 NL-query** to locate the real existing investor when the typed name doesn't fuzzy-match a candidate. Builds on the existing NL-query + intake rails; keeps the human approval gate.

- **8. [P2] Email capture learns from approve/reject (scope down to rules v1).** Use the already-logged approve/reject decisions to pre-suggest a decision. **v1 = deterministic, not ML:** detect `List-Unsubscribe` / `Precedence: bulk` (newsletters) + a learned denylist of rejected sender addresses/domains → pre-mark / auto-suggest reject (e.g. recurring non-investor newsletters). Don't build a classifier until the rules prove insufficient.

### Pipeline stages + investor flags/labels — LOCKED SPEC (Grant, 2026-06-19)
*Sharpened from the inherited 6-stage funnel (lead/outreach/meeting/due_diligence/committed/funded) over a design conversation 2026-06-18/19. Supersedes one-off batch item #3. **Locked — ready to build on green-light.** Grounding (verified): the grid's only labeling today is 3 boolean flags (priority/follow_up/graveyard) + a derived longshot + the `lead` **owner** column; there is **no investor type field**; "existing investor" is implicit in `total_invested > 0`; the 6-stage pipeline lives on classic `opportunities` and only applies to rows explicitly "+Add to Pipeline"'d; saved views are driven off the flags, not stage.*

**Conceptual frame — three orthogonal axes (were conflated):**
- **A. Relationship** — existing-LP vs prospect → collapsed to a single **auto-derived "Existing Investor" flag** (below). No prospect/lead/advisor sub-types: leads become prospects fast, and there are no advisors in this grid.
- **B. Disposition flags** — keep **Priority** (the focus set) + **Graveyard** (truly dead). **Drop Longshot** — labeling something longshot is already half-giving-up, overlaps graveyard, and doesn't earn a third bucket. Everything not Priority/Graveyard is the neutral middle.
- **C. Pipeline stage** — the active-raise funnel (below), per-investor.

**1. Funnel = 4 stages, per-investor, terminal at Commitment:** `Lead → Engaged → Diligence → Commitment`
- **Lead** — identified + first contact (cold outreach, a logged first meeting, or a website inbound); one-directional so far.
- **Engaged** — a **two-way** conversation exists (they replied / there's a back-and-forth). *(Boundary confirmed with Grant: two-way, not "a second person at the firm.")*
- **Diligence** — substantive: follow-up calls/meetings or data-room access.
- **Commitment** — terminal. On commit → hand off to fund admin + record the $ in the grid fund cell; the pipeline's job is done.
- **No Funded** (fund admin owns post-commitment; the Existing-Investor flag is effectively the "closed" signal). **No Meeting** (an activity, not a position). **No Lost** stage (the Graveyard flag covers dead).
- **Start at any stage** — a known LP re-solicited for a new fund drops straight into Engaged/Diligence, not Lead.

**2. "Existing Investor" = auto-derived flag** from `total_invested > 0`, injected read-only like `pipeline_stage` (never a maintained column); rendered as a star/indicator (esp. mobile). Orthogonal to stage — a re-solicited LP shows the star **and** a live stage at once. Lifecycle: prospect runs Lead→…→Commitment → $ recorded in the grid cell → they light up as an Existing Investor.

**3. Staleness — a derived overlay on the stage + a Matrix nudge, NEVER an auto-demotion.** Governing principle: **derive-and-display freely; mutate state only via a human.**
- A quiet deal does **not** change stage. Staleness shows on the **last-contact recency value** (the grid row's / mobile card's "2d ago"): light-grey when fresh → **amber → red** by days since `last_activity_at`, appending "stale" once it crosses the threshold (e.g. "35d stale"). The stage chip stays clean; the warning rides the recency line. The **same `last_activity_at` source drives the desktop grid and the mobile card**, so both color-code automatically.
- **Why not auto-flip off Engaged/Diligence:** it re-couples axes B+C, silently destroys information ("stalled mid-diligence" vs "never engaged"), is a silent un-approved mutation (against the human-in-the-loop guardrail), and creates a perverse "log junk to stay alive" incentive.
- The "auto" part is the **nudge = W1b nurture-gap** (see the W1/W2/W3 backlog; this refines its target set to **Engaged/Diligence**, not Commitment): daily job flags "in pipeline (Engaged/Diligence) + no activity > threshold + no open reminder" → bot **suggests a reminder**, a human confirms → re-engage (logging a comm resets `last_activity_at`) **or** consciously graveyard. The system nudges; the human acts. Deals never silently fall off.
- **Stale threshold: ONE global threshold (locked 2026-06-19).** Not stage-aware for v1 (Diligence-trips-faster was considered and deferred). Pick the amber/red day-counts at build.
- **Stale-as-a-view:** also a saved grid view keyed on `last_activity_at` (e.g. >90d, not graveyarded) — distinct from the per-stage overlay; both reuse `last_activity_at`, no new field.

**Accepted tradeoff (per-investor, not per-fund — Grant's call):** re-soliciting an existing LP for a new fund reuses their single opportunity (set fund + reset stage) — you won't see "Funded Fund I / Diligence Fund III" as two simultaneous pipeline entries. The grid's per-fund $ columns remain the record of which funds an investor is in; the pipeline shows only the *current* raise. (Per-fund stage was considered and deferred as a bigger build.)

**Concrete change set (cost asymmetry: labels/overlays are cheap; the enum is the one-time expensive bit):**
1. **Enum:** `PIPELINE_STAGES = ['lead','engaged','diligence','commitment']` (`server.py:1833`) + the ~8 mirror sites: report ordering CASEs (`server.py:3782/3859`), `nl_query/intents.py:34/37`, frontend kanban (`index.html:4168`, mock `:2174`), opp-form `<option>`s (`:7732`), and the `'funded'/'lost'` filters in `total_funded`/`pipeline_value` (`server.py:2721/3766/3877`).
2. **Data migration** of existing `opportunities.stage`: `outreach,meeting→engaged`; `due_diligence→diligence`; `committed,funded→commitment`. Reconcile the stray `lost` value (not in the settable enum) to graveyard-flag semantics.
3. **Existing-Investor flag:** derive from `total_invested > 0`, injected read-only (grid column + mobile star).
4. **Drop Longshot:** remove the derived `longshot_followup` + its deprecated view filter.
5. **Staleness overlay:** green/amber/red on the injected `pipeline_stage` by `last_activity_at`, + the stale saved view.
6. **Nudge:** specialize **W1b** to Engaged/Diligence in-pipeline deals.

Items 3–6 are cheap (derived/read-time/frontend, reuse `last_activity_at`, no migration); items 1–2 are the deliberate one-time enum + migration.

**Card presentation (mobile + grid, locked 2026-06-19):**
- **Stage chip** = one of the 4 stages, shown **only when the row is in the pipeline** (most grid rows aren't — no chip / a faint "+ Pipeline" affordance otherwise).
- **Top-right corner** = the **Priority** disposition only (star/pill when flagged, empty otherwise). Graveyard rows live in the Graveyard view / render muted — not a corner badge.
- **Existing Investor** (auto-derived, `total_invested > 0`) = its own distinct indicator (star by the name or a left accent — **not** a per-card banner; keep it restrained per `design/DESIGN.md`).
- **Last-contact recency** carries the staleness color (grey→amber→red, "Nd stale").
- This **replaces the design-mockup's INVESTOR/PROSPECT category chip** — we have no prospect/investor *type*; that two-value badge was the tool deriving committed-$>0, which is exactly our Existing-Investor flag. Feeds `design/BRIEF.md` §3a.

### Mobile-first implementation — backlog (design landed 2026-06-19)
*The `/design` round-trip is complete: the contract now describes the mobile-first system
(`design/DESIGN.md` §8 + the `mobile` token group), provenance + per-surface interaction model
are in `design/_imports/2026-06-19/`, and the input brief is `design/BRIEF.md`. This is the gap
between that contract and the current desktop-only `frontend/index.html` — the implementation
backlog. **Scoped 2026-06-19 (plan below); not yet started.**
The comps are signed-off prototypes, **not drop-in** (Claude Design runtime, seed data) — each
surface is re-authored in the app's React idiom and wired to the **real API**.*

#### Mobile PWA — installable home-screen app — BUILT 2026-06-20 (deploy pending)

**Option A (iPhone-first, no service worker).** Makes the app installable to the iOS home
screen and launch **standalone** (full-screen, no Safari chrome, dark themed status bar,
splash). Shipped: `frontend/manifest.webmanifest` (`display:standalone`, `start_url:/`,
`theme_color`/`background_color` = the brand base `#0b1118` already reserved for this in
`design/tokens.tokens.json`); square icons generated from `ten31-app-icon.svg` (full-bleed
`#0b1118` + white "T31", maskable-safe) → `icon-192.png`/`icon-512.png`/`apple-touch-icon.png`
(180); `<head>` gains `rel=manifest`, `theme-color`, the `apple-mobile-web-app-*` metas
(status bar `black` — opaque, so content never slides under the notch), `apple-touch-icon`,
and `viewport-fit=cover` (so the tab bar's existing `env(safe-area-inset-bottom)` clears the
home indicator). One pre-auth backend route serves `/manifest.webmanifest` as
`application/manifest+json` (`backend/server.py`); icons serve via the existing `/assets/`
handler. **No service worker** — on iOS the install prompt doesn't exist regardless (A2HS is
always manual via Share), standalone display needs none, and a cache-first SW would reintroduce
the stale-shell class the render-smoke gate guards against. Verified: render-smoke green +
live-curl (manifest + icons 200 pre-auth, correct content-types). **Deploy:** ships in the
next s9pk with the mobile phases.
- **Known minor:** the iOS status bar is fixed `black` at launch (can't follow the in-app
  light/dark toggle); a barely-perceptible seam vs the `#0b1118` app. Acceptable; dark is default.
- **Deferred (not needed for iPhone):** a network-first service worker → Android's "Install"
  prompt + faster relaunches; the JSX-precompile build-step (ROADMAP below) is the better lever
  if relaunch speed is ever a felt problem.
- **Adjacent issue (not PWA, noted while here):** a phone in **landscape** can exceed the 768px
  breakpoint and render the *desktop* layout; `orientation:portrait` in the manifest hints at
  this but iOS ignores it for home-screen apps. Revisit if it bites during device-testing.

#### Phase 8 — conform to the FINAL Claude Design mockups (mobile) — **NEXT SESSION (scoped 2026-06-19)**

*Phases 0–7 built the mobile surfaces + light theme. Phase 8 closes the gap to the **final** design + functional parity. Two independent agent passes ran 2026-06-19 (functional-parity + visual-conformance); their findings + the source-of-truth correction below drive this plan.*

**⚠️ Anchor on the `.dc.html` prototypes at their DEFAULT props — NOT the screenshots.** The Claude Design export (`design/_imports/2026-06-19_zip-file/Venture-CRM mobile redesign/`) ships parameterized `*.dc.html` prototypes whose `data-props` **defaults are the landed decisions** (`variant:compact`, `theme:dark`, `font:plex`, **`lpFlag:earmark`** — see `GridApp.dc.html:320`). The PNG `screenshots/` are **option-history** (rejected/stale prop combos: INVESTOR/PROSPECT disposition badges, a 6-stage MEETING/FUNDED funnel, the star flag). **The per-surface build reference is `design/phase8-conformance.md`** — a re-anchored visual-conformance spec (anatomy + deltas + line refs, built against the `.dc.html` defaults); it supersedes the original screenshot-anchored pass. **The functional-parity agent report stands** (built from `store.js` + `.dc.html` wiring, not screenshots). *(General learning promoted to `standards/guides/design.md` Phase C.)* **Per-surface existing-LP signal: Grid + Pipeline = earmark corner-triangle** (Grant override 2026-06-19 — the dc used ★ for Pipeline; unify on the earmark) **· Contacts = avatar ring.** **Contacts (decided):** drop the investor/prospect type tabs (prospect type unused), but **keep a Priority-flag sort** as an enhancement beyond the dc.

**Authoritative final investor card** (from `GridApp.dc.html:84–105`, the `<sc-for>` card; supersedes the screenshot "3-zone" card): card = `--panel` bg, 1px `--border`, radius 10, padding 12×14, `--shadow-card`, gap 8. **Existing-LP = an earmark corner-triangle** top-left (18px `--accent` dog-ear via the border trick), *not* a star, *not* a left-border. Row 1: investor **name** (16px/600, left) · **PRIORITY** badge right (mono 10px pill, **only if flagged — no INVESTOR/PROSPECT disposition badge**). Row 2: committed **`$amount`** (mono 15px, left) · **4-stage pipeline-stage chip** (mono 11px pill, `lead/engaged/diligence/commitment`). Row 3: **recency** "2d ago" (mono 12px). **No contact-name footer band** (that was an older screenshot variant). The current app card is close — 8a is mostly: swap the existing-LP signal to the earmark corner-triangle, move priority to the right-side PRIORITY badge, confirm `$` placement.

**Sequenced plan (each = a reviewable commit; lands on `main` deploy-pending; Grant device-tests the whole set at the end):**
- **8a — Card re-author (Grid + Contacts)** to the spec above + earmark; **reconcile `design/DESIGN.md` §8 + tokens to this final card** (the contract may still describe the older card). Closes visual G1/G2/C2 (re-anchored).
- **8b — Detail-surface actions:** Contacts detail → bottom-sheet w/ copy-email, **Log communication**, **Open-in-Grid**, org/stage/committed summary; Pipeline detail → **comms timeline + "+ Log"**; add Open-in-Grid cross-nav to Reminders too. Closes funct GAP 2/3/5, visual C1/P1.
- **8c — Global quick-log pencil** in the mobile top bar (Grid + Contacts) → pick investor/contact → `log-communication`. Closes funct GAP 1, visual S3.
- **8d — Sort controls** (Grid + Pipeline) at Claude-design detail (name/stage/amount/staleness/priority). Closes funct GAP 4, visual G4.
- **8e — Reminders parity:** grid reminder = **read/edit/clear/create** the investor's existing reminder w/ date presets; swipe-snooze → **snooze sheet** w/ presets (not fixed +7d); **investor picker** on the Reminders-tab create sheet — **easy: the `reminders` table already has `investor_id` and `POST /api/reminders` already accepts it (server.py:3761); the standalone create sheet just omits it** (NOT a backend limit — the functional agent mislabeled it); due-chip pill + urgency dot + "Today" bucket. Closes funct DIV 1/2/3, visual R1/R2/R4/R5.
- **8f — Pipeline card + nav:** labelled `‹ Lead`/`Engaged ›` footer buttons + ★/recency on card; **clickable pager dots**; per-stage segmented tint. Closes visual P2/P3/P4, funct pager-dots. *(Pipeline **accordion mode** explicitly **NOT wanted** — Grant: a prototype variant we never used.)*
- **8g — Add-investor stage + priority:** the create sheet sets an initial pipeline stage + priority (create → `pipeline/link` at chosen stage; priority on the row). **Grant-approved product decision.**
- **8h — Loose ends:** grid create-sheet **duplicate matches tappable** → open existing investor; **logging a "Note" resets staleness** (Grant-approved — confirm `log-communication` recency rule does this); remove/handle the **dead `'snoozed'` status** (snooze = keep `open` + push due_date by design, yet still in the edit dropdown).
- **8i — Shell:** bottom-tab **SVG line icons** (replace emoji glyphs) + the **`·Ten31·` wordmark** in the top bar (currently shows the page title). Closes visual S1/S2.

**Effort note:** almost all of 8a–8i is **client wiring against endpoints that already exist** — only 8g (create→link) and 8e (reminder fields) touch create/link flows, both already-built endpoints. No new backend tables. Verify each phase with the jsdom/CSSOM harness + a commit. To see the prototypes rendered (optional): `support.js` needs `window.React` + a local server — render `*.dc.html` at default props (don't trust the screenshots).

**Prerequisite — inline-style→CSS migration: SCOPED 2026-06-19 — much smaller/divisible than
the "~1,300 inline styles" framing suggested.** Ground truth from `index.html`: **370** total
`style={{}}` objects (not 1,300), against an existing **1,861-line `<style>` block** (with
`:root` vars + ~all the `.nav-item`/`.sidebar`/`.table` classes + **4 media queries already**,
incl. a `min-width` one) and **1,088 `className=` usages** — the app is already majority
class-based. Two consequences:
- **The responsive migration that gates mobile is only ~114 inline styles**, confined to the four
  mobile surfaces + shell: FundraisingGrid **70**, Reminders **18**, Contacts **17**, Pipeline **7**,
  App shell **2**. The other **240** inline styles live on desktop-only pages (Settings 104,
  Outreach/Email/Status 57, Thesis 44, Comms 31, Dashboard 4) that are **absent on mobile**, so they
  never block it. → **Not a monolithic blocker; it divides per-surface** and folds into each surface's
  build (no upfront sweep).
- **Two separable axes, not one.** (1) *Responsive* = layout-bearing inline styles → CSS classes +
  `min-width` queries (the ~114 above; gates mobile layout). (2) *Theming* = inline **hex → `var()`**
  so `[data-theme="light"]` can re-bind them — **183 hex literals** in the JSX region, app-wide but
  mechanical (precedent: the design guide's inline-hex→`var()` field notes); gates the **light theme**
  only. Sequence them apart.

**Data-layer dependency — the locked pipeline-stages/flags spec** (see the section above) lands
**first, standalone (Phase 0 below)**: the mobile cards render the 4-stage chip, the auto-derived
Existing-Investor star, and the staleness overlay, all of which need the stage enum + migration +
`total_invested>0` derivation + the `last_activity_at` ramp. Building the cards before the data
layer means hardcoding against a model that's about to change.

**Implementation plan (sequenced; decisions confirmed with Grant 2026-06-19) — fold the per-surface
migration into each surface's build, behind one shared foundation step. No upfront sweep.**

- **Phase 0 — Pipeline-stages/flags data layer — BUILT + tested locally 2026-06-19 (deploy pending).**
  The locked spec above. **Shipped:** enum → `['lead','engaged','diligence','commitment']`
  (`server.py`) + all mirror sites (report CASEs/filters, `total_funded`→`commitment`,
  `nl_query/intents.py`); reversible migration **`0007_pipeline_stages_v2`** (outreach/meeting→engaged,
  due_diligence→diligence, committed/funded→commitment, stray `lost`→archived; up+down verified on
  synthetic data — the live DB has 0 opps so it's a real no-op there); backend injection of
  `existing_investor` (`total_invested>0`), `last_activity_at`, and `staleness` (`''`/`aging`≥30d/
  `stale`≥60d, boundaries inclusive) into the grid GET + stripped on write (`_computed_row_values` +
  frontend `stripComputedRows`); frontend enum sites (Pipeline board, opp-form, mock) + a 4-stage
  `pipeline_stage` chip with DESIGN tints. **Drop Longshot (spec item 4) was already done** by prior
  cleanup (vestigial empty column + strip code) — left as-is (still cleans legacy blobs). Tests:
  `test_pipeline_stages_v2.py` (migration remap + derivation values/boundaries) + updated
  `test_grid_pipeline_link`/`test_soft_delete_reads`/`nl_query`; **36/36 suite green, render-smoke
  green, fresh-DB migrate clean**. **Deferred to Phase 3 (co-lands with the mobile cards, where the
  card design specifies them):** the *visible* desktop rendering of the existing-investor star + the
  staleness-colored recency column + the seeded "Stale" saved view — the data is injected and
  test-locked now, so Phase 3 is pure frontend. W1b nudge specialization is a separate fast-follow.
  **Deploy:** needs an s9pk build + install (**authorize first**).
- **Phase 1 — Shared mobile foundation — BUILT 2026-06-19 (deploy pending).** Shipped: `:root` mobile
  vars (`--mobile-tab-bar-h`/`--mobile-touch-target`/`--mobile-input-h`/`--mobile-sheet-radius`/screen-pad +
  fonts + `--text-subtle`/`--border-strong`); CSS for the safe-area-aware **`.bottom-tab-bar`**, the
  **`.bottom-sheet`/`.sheet-scrim`/`.sheet-handle`** primitive (styling), and `.mobile-only`/`.desktop-only`
  utilities — all `display:none` on desktop so **zero desktop change**; the **4-tab bottom bar**
  (Grid·Pipeline·Reminders·Contacts → `setPage`) + a **mobile account/logout popover** wired into `App`
  (sidebar already CSS-hidden <768px). Render-smoke green. **Deliberately deferred:** (a) the
  **`<BottomSheet>` React component + `useIsMobile()` hook** → Phase 2, designed against their first real
  consumer (no dead code); (b) the **13→15px type bump is per-surface**, not a global body rule — `body`
  has no base font-size, so it lands as each surface is re-authored (Phases 2–5); (c) the
  `[data-theme="light"]` block → Phase 6 (dead without the toggle). Browser-interaction (the bar on a real
  phone) untested, like view-reorder.
- **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid) — BUILT 2026-06-19 (deploy pending).**
  Read-only A–Z directory (sticky letter headers, sorted/sectioned by last name) + segmented
  All/Investors/Prospects tabs + pinned search → **full-screen read-only detail** (`.fs-detail`, promotes
  the slide-over: contact info w/ tap-to-copy email, opportunities, communication history) → **sort
  BottomSheet** (the sheet primitive's first, read-only consumer: Name A–Z / Z–A / Recently-contacted —
  restores the column-sort the card list loses). Proves the list→detail→sheet pattern + per-surface
  migration mechanics on the lowest-risk surface before the crux. *(Reordered ahead of the earlier "Grid
  first" draft.)* **Lands the shared primitives** (deferred from Phase 1, designed against this first
  consumer — no dead code): **`<BottomSheet>`** (scrim/Escape/**pointer drag-to-dismiss**, mount enter/exit
  animation, built on the Phase-1 `.bottom-sheet` CSS) + **`useIsMobile()`** (768px `matchMedia`; surfaces
  swap via a rules-of-hooks-safe wrapper — `ContactsPage` → `Desktop`/`MobileContactsPage`, **zero desktop
  change**). This surface's **15px body bump** lands on `.mobile-screen`. Writes: **none** — Contacts is
  read-only on mobile per `BRIEF.md` §3b (create/edit live on the Grid). Grew the `:root`/mobile var set
  (`--bg-input`, `--accent-light`, mobile card/control radii + card/screen/detail-title fonts) per DESIGN §9.
  Verified: render-smoke green + a throwaway jsdom interaction harness (mounted the real app at 375px,
  stubbed `/api/contacts` — list/grouping/sort-sheet/detail/back all asserted, 14/14). **No browser/real-phone
  check yet** (same deferral as Phase 1 + view-reorder). **Deploy:** folds into the next s9pk build.
- **Phase 3 — Fundraising Grid (the crux). P3a BUILT 2026-06-19 (deploy pending); P3b (name/pill edit) deferred.**
  Split confirmed with Grant 2026-06-19: P3a ships the readable + already-write-supported surface now;
  editing an existing investor's **name + contact pills** is **P3b** (needs a new narrow per-row PATCH +
  a pill-editor UI — `log-communication` can't rename/edit pills, and the whole-grid PUT is forbidden on
  mobile).
  - **P3a (built):** lean **`MobileFundraisingGrid`** (separate component — the desktop grid's debounced
    whole-grid-PUT autosave would race on every mobile edit, so it's NOT reused; `FundraisingGridPage` is
    now a `useIsMobile()` wrapper → `Desktop`/`Mobile`, desktop untouched). Card list over the **active
    view** (ported the desktop view-filter predicate — graveyard/follow-up/lead flags + columnFilters — to
    a shared pure helper so it can't drift), tappable view-name → **view-picker sheet**, search, the locked
    **card model** (name · committed $ via `formatMoneyMobile` · stage chip · staleness-colored recency ·
    Existing-Investor left-accent · Priority corner; graveyard muted). Full-screen detail (read-only:
    commitments/funds, contact pills, notes) + **edit sheets**: **log a note** (`log-communication`),
    **pipeline stage** (linked → `PATCH /api/opportunities/{id}/stage` via the new injected `opportunity_id`;
    unlinked → `pipeline/link` then it; + remove-from-pipeline), **set a reminder** (`POST /api/reminders`),
    and **`+ New` investor** (`log-communication` + `create_investor_if_missing`, client-side dedup
    typeahead). **Never whole-grid `PUT /state`.** Backend: one small hook — read-only **`opportunity_id`**
    injected into grid rows (`opportunity_id_by_source_row`, added to both strip points), so the detail can
    PATCH the linked opp directly. Tests: `test_grid_pipeline_link` extended (opp_id inject/strip/round-trip),
    36/36 green; render-smoke green; a throwaway stateful jsdom harness drove the real surface at 375px
    (view filter, picker, detail, stage-PATCH, log-note, reminder, create+dedup — 18/18). **No real-phone
    check yet** (same deferral as P1/P2). **Deploy:** folds into the next s9pk.
  - **P3b (deferred):** `POST /api/fundraising/update-row` (version-safe single-row name/contacts mutation,
    +test) + the bottom-sheet **pill editor** (add/edit/remove pills, client-side dedup). Then name + pills
    become editable on an existing investor, completing BRIEF §3a's editable set.
- **Phase 4 — Pipeline — BUILT 2026-06-19 (deploy pending).** Lean **`MobilePipeline`** (separate
  component; `PipelinePage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`, desktop kanban
  untouched, just renamed `DesktopPipelinePage`). **Swipe-between-stages:** a count-forward segmented
  stage control (`.pipeline-seg`) + a horizontal **CSS scroll-snap** container of four full-width stage
  pages (`.pipeline-swipe`/`.pipeline-stage-page`) + page dots; tapping a segment scrolls to its page,
  scrolling syncs the active segment/dots. Each card shows opp name · contact·org · expected $, with
  per-card **‹/› stage move** (`PATCH /api/opportunities/{id}/stage`, disabled at the lead/commitment
  boundaries) — the kanban "advance" without opening the detail. Tap a card → full-screen `.fs-detail`
  (read-only `OpportunityDetailPanel`-equivalent fields via `MobileDetailRow` + a `StageChip`) with a
  **stage-picker `BottomSheet`**. **Opp-centric** (operates on the same `opportunities` rows + stage
  endpoint as the desktop board and the Grid detail's stage edit), amounts read-only; **no Existing-Investor
  star** (opps carry `fundraising_investor_id` but not `total_invested`). Removal/deletion stays on the
  desktop board + the Grid detail's "remove from pipeline" — the Pipeline tab is **view + advance-stage
  only**. A `reviewer` pass was applied (reset the detail's stage-sheet open-state on back; `moveStage`
  awaits the PATCH). Verified: render-smoke green + a throwaway jsdom 375px harness drove the real surface
  (seg counts, stage pages, segment/dot sync, ‹/› move re-bucketing, detail + stage-sheet PATCH, back — 12/12).
  No real-phone check yet (same deferral as P1–P3a). Reuses the P2/P3a primitives directly; **no backend
  change.** **Deploy:** folds into the next s9pk.
- **Phase 5 — Reminders — BUILT 2026-06-19 (deploy pending).** Lean **`MobileReminders`**
  (`RemindersPage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`; the desktop page renamed
  `DesktopRemindersPage`, otherwise untouched). **Urgency-grouped list** over `/api/reminders`
  (Overdue → Due soon → Later → Done → Cancelled buckets via `reminderBucket`; group headers carry the
  overdue-red/due-soon-amber tint) with a compact **Active/Done/All** segmented filter + **`+ New`**.
  Each row is a **`ReminderRow`** pointer-drag swipe (own per-row drag state): **swipe-left → mark done**,
  **swipe-right → snooze +7d** (threshold 70px; snooze keeps status `open` and pushes `due_date`, mirroring
  the desktop's "no wake mechanism" rationale), a **tap → create/edit `BottomSheet`** (title · due date ·
  investor *(create-only free-text label — PATCH can't change investor, matching the backend)* · assignee
  *(if `/api/users` is readable)* · details · status *(edit-only)* · Delete). Vertical-dominant drags release
  to list scroll; non-swipeable (done/cancelled) rows stay tap-to-edit. Added `formatDueShort`/`reminderDueDelta`
  (local-midnight delta — the desktop `formatDate` mis-renders FUTURE dates). A `reviewer` pass was applied
  (**`pointercancel` no longer fires a spurious mark-done** — the key fix; stray drag on a non-swipeable row
  recovers as a tap; cancelled gets its own bucket header). **No backend change.** Verified: render-smoke
  green + a throwaway jsdom 375px harness (grouping/counts, swipe done + snooze PATCH, tap→edit prefilled,
  create POST, Done-filter reload — 12/12). No real-phone check yet (same deferral as P1–P4). **Deploy:**
  folds into the next s9pk.
- **Phase 6 — Light theme + toggle — BUILT 2026-06-19 (deploy pending).** App-wide light theme
  behind a `:root[data-theme="light"]` switch; **dark stays the default** (a pre-paint boot script
  in `<head>` reads `localStorage.venture_crm_theme`, no `prefers-color-scheme` auto-switch). Toggle
  is **app-wide**: a labeled control in the desktop sidebar footer + a sun/moon icon in the mobile
  top bar, both driven by one `theme` state in `App` (single source of truth). Authoritative color
  pairs came from the **full Claude Design export** (`design/_imports/2026-06-19_zip-file/`, incl. the
  previously-missing `store.js` + the four `*App.dc.html` `DCLogic` palettes) — every stage/recency/
  note/priority/reminder/money tint uses the designer's exact dark+light values, not guesses.
  - **Method (zero dark-mode regression by construction):** grew `:root` to a full token set (44
    themed color slots) whose **dark values equal the originals byte-for-byte**, so `var(--x)`
    resolves identically in dark — verified (16 exact-match vars asserted == their original hex).
    Migrated **319 hex literals → `var()`** across the JSX inline region *and* the `<style>` block
    (a context-aware script for the unambiguous structural ones; targeted edits for the
    context-dependent semantic/chip helpers — `StageChip` is now className-based off
    `.stage-chip--{stage}`, `PIPELINE_STAGE_CHIP` deleted). All mobile surfaces + chrome are fully
    var-based → **mobile light is complete**.
  - **Known light rough edges (desktop only — for the conformance pass below, NOT mobile):** a
    handful of bespoke `<style>`-block desktop shades (login glow/gradients `#101926`/`#4a9adf`,
    scrollbar `#36506a`, desktop table row-hover `#172435`, dashboard KPI green `#10b981`) and the
    legacy off-palette `.badge-*` family (`#ec407a`/`#ff9800`/etc., partly vestigial old-stage
    badges) stay dark-tuned in light; **shadows** also stay dark-tuned (not yet tokenized). A few
    desktop one-off shades were *consolidated* to the nearest token (small intentional dark deltas:
    e.g. `#2a3a4d`→border, `#d9a15f`→due-soon, `#f3b2b2`→danger-text) — enumerate when polishing.
  - **Verified:** render-smoke green (Babel transform + jsdom mount); a throwaway jsdom interaction
    harness mounted the **authed shell** and exercised the toggle (boot-defaults-dark → click→light
    +persist+relabel → click→dark, 7/7); theme parity + dark-identity + no-undefined-var checks all
    green. **No real-phone / real-browser check yet** (same deferral as P1–P5 — verify on a device).
    **Deploy:** folds into the next s9pk.

- **Phase 7 (NEW) — Full design-conformance pass against the complete Claude Design export
  (`design/_imports/2026-06-19_zip-file/`).** Per Grant (2026-06-19): make sure *everything we
  implement* matches what he built in Claude Design — **all buttons, colors, spacing, and
  functionality**, across all four surfaces + the light theme. Concretely: (1) run `design-checker`
  now that the surfaces exist; (2) reconcile remaining drift (the P6 desktop light rough edges
  above — bespoke `<style>` shades, the legacy `.badge-*` family, themed shadows; plus any
  button/interaction deltas vs the `*App.dc.html` comps + the ~25 `screenshots/`); (3) re-pull
  anything still cloud-only. This is the "conform to the design" sweep that the per-surface builds
  (P2–P6) deferred.

**Note on `design-checker`:** not run for this round-trip — it audits *existing* UI conformance,
and the desktop UI still conforms to §1–7 (unchanged). The mobile gap is greenfield
implementation (captured here), not conformance drift, so there's nothing for it to flag yet; run
it after the mobile surfaces exist.

## Definition of done for "Airtable substitute" v1
- Team can manage all investors in one master table
- Saved views replicate current Airtable workflows
- CSV import from Airtable is reliable and repeatable
- Data persists safely and supports multi-user access
- Auth is invite-only and backups are automated
