Files
ten31-database/AGENTS.md
T
Keysat d6250f74d0 Require a due date on all reminder creation (v0.1.0:103)
A date-less reminder has no urgency — it lands in the "Later"/"No date" bucket,
out of the overdue/today/this-week rollups and the daily digest — so every
create flow now pre-fills the due date to +1 week (editable) and blocks an empty
save. Shared reminderDefaultDue() helper; edit paths also pre-fill the default
for legacy date-less reminders.

Surfaces:
- Mobile: add-investor sheet (date auto-fills when you start the optional
  reminder), standalone Reminders "New reminder", Grid-detail "Set a reminder".
- Desktop: Reminders page "+ New reminder", grid reminder modal.

Server still accepts a null due_date by design (bot/automation callers); this is
a human-UI requirement. Frontend-only; no schema/migration/dependency change.
2026-06-20 16:51:03 -05:00

118 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ten31 Venture CRM + Agentic System — AGENTS.md
**The foundation is a self-hosted venture-fund CRM** — a purpose-built fundraising tool that replaced Airtable to (1) keep sensitive LP/prospect data off third-party servers, (2) drop subscription cost, and (3) fit the fund's workflow: managing ~150 existing LPs, tracking 250+ prospects, and running the capital-raise pipeline. Core CRM domain: contacts (investor/prospect/advisor), organizations, opportunities (the deal pipeline), and communications; investor commitments live in the canonical `fundraising_*` grid (the legacy single-fund `lp_profiles` table was retired in v0.1.0:78). The fund (Ten31, ~$200M AUM, bitcoin/energy/AI thesis) runs it on a Start9 box, accessed over ClearNet (StartOS StartTunnel) with app-level user auth by a team of ~5 (Tailscale is not in use). Schema/API tour: `docs/crm-overview.md`.
**The agentic system is new functionality built on top of that CRM** — an in-house AI layer to widen the fundraising funnel, sharpen the thesis, and automate outreach drafting. Frontier reasoning runs on Claude (Agent SDK/API); privacy-sensitive and bulk work runs on local DGX Spark models via the **Spark Control** gateway. **Phase 0/1 — no live outward-facing agents; agents draft, humans send.**
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for
> items tagged `(CRM)` and surface them before proposing next steps; triage with `/triage`.
## Stack (versions that matter)
- **Python 3.11, standard library only at runtime.** The CRM is one monolith, `backend/server.py` (~5k lines): a stdlib `http.server.ThreadingHTTPServer` + hand-written `CRMHandler` with manual path dispatch (`do_GET`/`do_POST`). **Not FastAPI.** `backend/requirements.txt` lists FastAPI/SQLAlchemy/Alembic/Pydantic/pytest-style deps but **none are imported at runtime** (vestigial).
- **SQLite** at `data/crm.db` (WAL, `foreign_keys=ON`), opened per-request via `get_db()`. Schema via ordered migrations.
- **Frontend:** single `frontend/index.html`, inline-Babel React. **No build step.**
- Optional runtime deps, used only if present: `bcrypt`, `PyJWT` (`jwt`), `cryptography` (Gmail module).
- **MCP + ingest** (in the Docker image, not the bare CRM): `mcp==1.2.0` (FastMCP, `backend/mcp/server.py`), `fastembed==0.4.2`, `anthropic`, `cryptography==42.0.5`.
- **Packaging:** StartOS 0.4, TypeScript SDK (`@start9labs/start-sdk`) under `start9/0.4/startos/`. Live target is `start9/0.4/`.
- **Local models** (bge-m3 embeddings, bge-reranker-v2-m3, `/api/search`, Qdrant): always via Spark Control. Contract: `docs/EMBEDDINGS.md`. The chat model (`CRM_CHAT_MODEL`, the daily-driver Qwen) is **vision-capable** — Spark Control's `/v1/chat/completions` is a dumb passthrough, so OpenAI **multimodal** `image_url` data-URIs work unchanged (used by the intake bot's business-card OCR; reuse `llm.chat_vision`).
## Commands
```bash
# Run locally (dev, port 8080; or ./start.sh <port>) — runs python3 backend/server.py
./start.sh
# Run prod-mode (beta) — requires CRM_SECRET_KEY
./start_beta.sh
# Sanity-check edits (there is no compiler/build for the CRM)
python3 -m py_compile backend/server.py
# Run ONE test (tests are standalone scripts with `if __name__ == "__main__"`; no pytest installed)
python3 backend/redaction/test_scrub_leak.py # substitute any backend/**/test_*.py
# Run all tests (aggregate runner — runs each backend/**/test_*.py in its own subprocess)
python3 backend/run_tests.py # add substrings to filter, e.g. `... soft_delete redaction`
# Build + install the s9pk — BUMP THE VERSION FIRST. See docs/guides/packaging.md.
cd start9/0.4 && make
```
- **Migrations** apply automatically at startup (`backend/core_migrations.py`, `schema_migrations` ledger). See `docs/guides/migrations.md` before adding one.
- **Lint:** none configured.
## Directory layout (day-one)
- `backend/server.py` — the CRM monolith: HTTP handler, route dispatch, `init_db()`, auth (username/password → HS256 JWT, roles admin/member/bot).
- `backend/core_migrations.py` + `backend/migrations/NNNN_*.sql` (+ paired `.down.sql`) — additive schema migrations, applied at startup.
- `backend/thesis_seed.py` — Thesis Workshop seed + idempotent `ensure_*` one-time seeders, wired in `server.init_db()`.
- `backend/thesis_review.py` — thesis version review/approval (human dual sign-off → canonical).
- `backend/mcp/``architect_agent.py` (Claude thesis copilot), `architect_tools.py`, `outreach_agent.py` (LP draft assistant), `architect_grounding.py`, `crm_tools.py`, `server.py` (FastMCP).
- `backend/email_integration/` — Gmail capture via domain-wide delegation + Tier-B draft creation (`compose.py`).
- `backend/redaction/``scrub.py` + `client.py`: the scrub→Claude→re-hydrate privacy boundary.
- `backend/ingest/` — chunk→embed→Qdrant + retrieval modes.
- `backend/entity_*.py` — entity resolution/merge (the two-investor-model reconciliation).
- `backend/nl_query/` — read-only natural-language query (W2): `intents.py` (curated parameterized query catalog), `runner.py` (slot validator = trust boundary), `translate.py` (local-Qwen question→{intent,slots}). See the nl-query guide.
- `backend/matrix_intake/` — Matrix intake bot (separate process; `matrix-nio`, isolated to this component): typed message → local-Qwen parse → in-thread approve → write via the CRM's own `log-communication`. See the matrix-intake guide.
- `frontend/index.html` — the entire UI.
- `docs/` — architecture, phase plans, contracts, runbooks (see Deeper docs). `docs/guides/` — scoped subsystem rules (see below).
- `start9/0.4/` — StartOS package (`startos/utils.ts` holds `PACKAGE_VERSION`).
- `data/crm.db` — the live DB (gitignored). `.env` / `.env.example` — config (`.env` gitignored).
## Scoped guides
Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude/rules/` symlinks (scoped by `paths:` frontmatter). **Read the guide before editing that area:**
- **Migrations or seeders** (`backend/migrations/`, `core_migrations.py`, `thesis_seed.py`) → `docs/guides/migrations.md`
- **Thesis logic** (`backend/thesis_*.py`, `backend/mcp/architect_*.py`) → `docs/guides/thesis.md`
- **Redaction or any MCP/Claude path** (`backend/redaction/`, `backend/mcp/`) → `docs/guides/redaction.md`
- **Ingest / retrieval** (`backend/ingest/`) → `docs/guides/spark-ingest.md`
- **Email capture / drafts + digest send** (`backend/email_integration/`, `backend/digest_mailer.py`, `backend/smtp_send.py`) → `docs/guides/email.md`
- **Building or deploying the s9pk** (`start9/`) → `docs/guides/packaging.md`
- **Matrix intake bot** (`backend/matrix_intake/`) → `docs/guides/matrix-intake.md`
- **Natural-language query** (`backend/nl_query/`) → `docs/guides/nl-query.md`
## Conventions
- **Investor model — the grid is canonical (since v0.1.0:78).** The `fundraising_*` grid is the **system of record**: an investor entity (row) → many contact "pills" → per-fund commitments. The classic `contacts` table is a **read-only per-person directory**, auto-populated from the grid — create/edit people in the grid, not the Contacts page. Email capture rolls multiple people up to one investor. The legacy single-fund `lp_profiles` model is **retired** (empty table kept, per never-hard-delete). Reconciling grid ↔ classic `contacts` to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`. **Derived read-only columns** (`pipeline`, `pipeline_stage`, `opportunity_id`, `reminder_status`, `existing_investor`, `last_activity_at`, `staleness`) are computed live and **injected on GET, never persisted** — any new one MUST be added to BOTH strip points (`server.py` `_computed_row_values` + frontend `stripComputedRows`) or it dirties the autosave / leaks into the blob. **Exception — the contact-pill email-heal** (`fundraising_contact_emails_by_row`, injected in `handle_get_fundraising_state`, v0.1.0:99): it fills a *blank* pill `email` from the linked classic contact and deliberately has **NO** strip point, because `email` is a real blob field, not a computed column — the next one-row save legitimately persists the recovered value (it's a self-healing backfill; don't "fix" it by adding a strip point). Pipeline stage is the 4-stage funnel `lead→engaged→diligence→commitment` (`PIPELINE_STAGES`), terminal at commitment.
- **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **opportunities/pipeline** aggregates were fixed in v0.1.0:87 (`handle_pipeline_report` + dashboard pipeline metrics now filter `deleted_at`), but the **reports** subsystem's **communications-side** aggregates (dashboard `recent_comms`/`comms_this_month`/`meetings_this_month`, activity report) still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py` (+ `test_reminders.py` for the reminders read paths, incl. the recency rollup whose email-activity liveness signal is `email_account_messages.deleted_at`, not `emails`). (Thesis has a subtlety here — see the thesis guide.)
- **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin).
- **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`.
- **Agent/bot API access — three roles now (`admin`/`member`/`bot`).** `require_admin` is the only hard gate; everything else is "authenticated" (member, admin, *and* bot all pass). The **`bot` role** (added v0.1.0:89) is authenticated-but-never-admin: `require_bot_or_admin` gates agent-facing endpoints (e.g. `/api/intake/email-proposals*`) so a bot credential reaches *only* what it needs, never user-management/settings/security. Provision it via Settings → Admin edit-user dropdown (kept out of the teammate-invite form). **Two axes to keep separate as more agent capability lands:** the role controls *reach* (which endpoints); the per-feature human draft→approve gate controls *autonomy* (acting unattended). Money/merge/delete mutations stay behind the approval gate regardless of role. Don't build a finer capability/scope system until real NL-mutation endpoints exist to scope against.
- **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and `design/tokens.tokens.json` and conform to them. The **mobile-first redesign landed** (Claude Design round-trip distilled into the contract 2026-06-19): the authority for mobile/responsive work is **`DESIGN.md` §8** + the tokens `mobile` and `color.light` groups; `design/BRIEF.md` is the input brief and `design/_imports/2026-06-19/` the provenance + per-surface interaction reference (the comps are Claude Design runtime prototypes — re-author each surface in the app's React idiom + real API, not drop-in; **the design source of truth is each `*.dc.html` at its DEFAULT `data-props` (compact/dark/plex/earmark — see `GridApp.dc.html` `data-props`), NOT the `screenshots/` PNGs, which are option-history (rejected/stale combos: INVESTOR/PROSPECT disposition badges, 6-stage MEETING/FUNDED funnel, star flag). Don't anchor on the screenshots** (cost a re-scope 2026-06-19; general learning in `standards/guides/design.md` Phase C)). A **light theme is built (P6)**: it lives in `:root[data-theme="light"]` (set by a pre-paint boot script from `localStorage.venture_crm_theme`; dark is the default), with an app-wide toggle in the desktop sidebar footer + the mobile top bar. **Colors are theme vars now — any new UI color MUST use a `:root` var (grow the set if needed), never a literal, or it won't flip in light** (chips/badges flip via `.stage-chip--{stage}`/`.due-chip--{overdue,today,week,later}` + the `--chip-*`/`--note-*`/`--badge-priority-*`/`--rem-*`/`--due-{overdue,today,week,later}-*`/`--money`/`--recency-*`/`--due-soon` slots; authoritative dark+light pairs are in the Claude Design export `design/_imports/2026-06-19_zip-file/` `store.js` + `*App.dc.html`). Mobile light is complete; desktop has known unthemed shades (Phase 7). (Note: inline `style={{}}` objects can't respond to media queries; responsive layout belongs in the CSS `<style>` block. The **mobile foundation primitives are built** — CSS: `.bottom-tab-bar`, the `.bottom-sheet` primitive, `.mobile-only`/`.desktop-only`, `:root` mobile vars; React (Phase 2): **`<BottomSheet>`** (scrim/Escape/drag-to-dismiss) + **`useIsMobile()`** (768px) + the **`MobileDetailRow`**/`.fs-detail` full-screen-detail + `.contact-card`/`.az-header` list patterns — **build new mobile surfaces on these** (P3 Grid reuses them directly; swap surfaces via a rules-of-hooks-safe `useIsMobile()` wrapper that mounts a `Mobile*`/`Desktop*` pair, never a per-component hook toggle). The inline-style→CSS migration is **scoped, per-surface** (~114 styles across 4 surfaces+shell, not ~1,300), folded into each surface's build; see `ROADMAP.md`.) **Phase 8 card/detail primitives (reuse these, don't reinvent):** `EarmarkCorner` (existing-LP corner triangle; `inline` variant for the org card), the `priority-pill`/`lp-pill` text pills, `StageChip` (+ `sm`), `NoteTimeline`, `LogCommunicationSheet`, **`SortPill`/`SortSheet`** (mono pill + label+hint option sheet, 8d — drive with a per-surface `{id,pill,label,hint}` table, e.g. `GRID_SORTS`/`PIPELINE_SORTS`/`SORT_OPTIONS`), **`MobileQuickLog`** (shell top-bar quick-log pencil), **`DueChip`** (8e — urgency-bucketed reminder due pill, `.due-chip--{bucket}` colors); `<BottomSheet>` takes a **`stacked`** prop to layer a sheet opened over another sheet (e.g. the Log sheet over a detail, or the 8e investor-picker over the add sheet). **Mobile reminder writes link a real investor via `source_row_id` → server-resolved `investor_id`** (the 8e add-flow picker; create POSTs `source_row_id`, never a free-text name — the old label never linked); **`PATCH /api/reminders` can't reassign the investor** (edit shows it read-only). The mobile **Contacts + Pipeline detail surfaces are drag-dismiss bottom sheets** (8b) that log via **`POST /api/communications`**; the **Grid detail stays full-screen** (its dc default) and reads its **notes timeline** via **`GET /api/communications?source_row_id=<grid row id>`** (investor-level: maps grid row → `fundraising_investors.source_row_id``fundraising_contacts.contact_id` → comms, soft-delete-respecting). **The Contacts read path injects derived read-only `committed` + `pipeline_stage` + `priority` + `source_row_id`** (`contact_grid_signals()` — existing-LP ring + stage pill + Priority sort + the **8h Open-in-Grid deep-link target**, the linked investor's grid row id, present for any grid-linked contact even zero-commit) on **both** `GET /api/contacts` and `/api/contacts/{id}`; this needs no strip-point (the directory is read-only, never written back as a row) — unlike the grid's injected columns. **The opportunities list (`GET /api/opportunities`) likewise injects derived read-only `existing_investor`** (same `contact_grid_signals` committed>0 → the **8f** Pipeline-card earmark, agreeing with the detail "Existing LP" pill) **+ `last_contact_date`** (`MAX(communication_date)` on the deal's contact, `deleted_at`-filtered → card recency + Staleness sort); no strip-point (opp writes use a field allowlist, never a blob PUT). **Phase 8f Pipeline-card primitives (reuse, don't reinvent):** the card reuses `EarmarkCorner`/`priority-pill`/`StageChip`/recency; new patterns are the **horizontal-scroll segmented stage control** (`.pipeline-seg-tab--{stage}` whose `.active` tint comes from `--seg-{bg,text,border}` aliased to that stage's `--chip-*` vars) + label-with-count-badge, the **labeled ` Prev`·`Next ` card move footer**, and **tappable page-dots** (active = 22px accent bar). **Phase 8g add-investor primitives:** the mobile "New investor" sheet gained an optional **`.stage-pick`** chip picker (a "Not in pipeline" button + the 4 `StageChip`s; default off — Grant) and a framed **`.sheet-toggle-opt`** Priority toggle, plus an optional reminder (title + progressive due-date). Submit (`submitCreate`) orchestrates one-row calls in order — create (`log-communication`, which now honors an optional `priority` **only on its create-if-missing branch**) → optional pipeline link at the picked stage → optional `POST /api/reminders` keyed on the new row's `source_row_id`; the link/reminder steps are non-fatal. **Phase 8h Grid-detail primitives:** the full-screen Grid detail uses **`.detail-tap-card`** — the dc tappable card (panel + chip/note inline + chevron) — for both **G4** (Pipeline stage: stage chip + "Change "/"Add " → stage sheet) and **G5** (dedicated Reminder card: title + `DueChip`, or "No reminder set" → reminder sheet). The G5 card is fed by a fetch of the **soonest active reminder** (`GET /api/reminders?source_row_id=<rowId>&status=active`, ordered due_date ASC) keyed on the open investor (same cancel-guard pattern as the G6 comms timeline); the `reminder` state is **tri-state** (`undefined` = still loading → card disabled + "Checking reminders…", so a tap can't open the sheet and POST a duplicate before the fetch lands; `null` = none; object = edit). Tapping opens the sheet pre-filled and **edits via `PATCH /api/reminders/{id}`** when one exists (else POST). G6 (notes timeline) was already built. **Open-in-Grid deep-link (8h):** an "Open investor in Grid" button on the **Contacts, Pipeline, and Reminders** detail surfaces calls a shared shell handler **`openInvestorInGrid(rowId)`** (prop `onOpenInGrid`, threaded through each page's viewport wrapper), which sets a **one-shot `gridUiAction = { type:'open-investor', rowId }`** (the same action slot the desktop import/save-view flows use, but an object the desktop grid's string-equality branches ignore) and switches page; `MobileFundraisingGrid` consumes it on mount (`setSelectedId(rowId)``onUiActionHandled()` clears it; the row resolves once `reload()` lands). Each surface supplies the grid row id from its own injected `source_row_id` and gates the button on it (hidden when absent): **Contacts** via `contact_grid_signals` (contact→investor); **Pipeline** via the opportunities-list join on the durable `fundraising_investor_id` (the deal's actual investor, not the contact-earmark path); **Reminders** via the reminders-list join on `r.investor_id`. **Phase 8i shell primitives (Phase 8 complete):** **`BottomTabIcon`** (the dc bottom-tab SVG line-icons keyed by tab name — grid=2×2 rects / pipeline=3 bars / reminders=clock / contacts=person, from `GridApp.dc.html:585`; strokes/fills are `currentColor` so each inherits its `.bottom-tab` color and flips active→`--accent` + across light/dark, replacing the prior emoji glyphs) + the **`.mobile-wordmark`** `·Ten31·` top-bar mark (mono 15/600, `GridApp.dc.html:51`) which **replaces the page title on mobile** (the `.header-title` is now `desktop-only`, the wordmark `mobile-only`). The dc top bar's other slots — quick-log pencil + theme + account cluster — already existed (`MobileQuickLog`/`ThemeToggle`/`account-btn`), so 8i was just icons + wordmark. **Post-8 mobile-feedback primitives (v0.1.0:101102, Grant device round):** **`ClearableInput`** (inline ✕ clear — wrap any single-line mobile search/picker field; the ✕ + its right-padding appear only when non-empty); **`<BottomSheet>` now auto-lifts above the on-screen keyboard** (visualViewport bottom-offset + height-cap — don't re-implement keyboard handling per-sheet); the **Grid→Contacts deep-link** `openContactDetail(contact)` → one-shot `contactsUiAction = {type:'open-contact',email,name}` consumed by `MobileContactsPage` (the *reverse* of Open-in-Grid; matches a loaded contact by email then name, else filters the list — threaded as `onOpenContact` to `MobileFundraisingGrid`, whose contact pills are now tappable: name → deep-link, email → `mailto:`); the mobile **Pipeline is a full-height flex column** (`.pipeline-screen`: the swipe area is `flex:1` and fills everything above the now bottom-pinned `.pipeline-dots`, each `.pipeline-stage-page` scrolls its own cards — needs the `.content` flex chain to keep a definite height). **Pipeline `expected_amount` is now editable on mobile** (the Grid-detail add-to-pipeline stage sheet feeds it into `pipeline/link`; the Pipeline card detail edits it via `PUT /api/opportunities/{id}` — authenticated, `expected_amount` is in the field allowlist). Grid *fund-commitment* amounts stay desktop-only/read-only (unchanged). **`MobileEmailBell` (#6 — admin-only, `isMobile && admin`-gated so the hidden desktop top bar never polls):** a bell left of the camera with an iPhone-style count badge — a **THIRD surface over the same `email_activity_proposals`** as the web Email Capture panel + the Matrix review room, reusing `GET /api/activity/proposals` + `POST /api/activity/proposals/{id}/approve|dismiss` (all `require_admin`). Sync is automatic and bidirectional: an app decision flips the proposal status and the bot's existing poll redacts the Matrix thread; a Matrix/web decision drops the proposal from the 45s-polled pending list, clearing the badge. Edit-then-approve, no LLM round-trip (like the web panel). **Reminders require a due date (v0.1.0:103):** **every** create surfaces pre-fills the date to +1 week (editable) via the shared **`reminderDefaultDue()`** helper and blocks an empty save — a date-less reminder has no urgency (it falls to the Later/"No date" bucket, out of overdue/today/this-week + the daily digest). Covered: mobile (add-investor sheet, standalone Reminders "New reminder", Grid-detail "Set a reminder") **and desktop** (`DesktopRemindersPage` "+ New reminder", the desktop grid reminder modal `submitReminder`). Edit paths also pre-fill the default for *legacy* date-less reminders. (Server still accepts a null `due_date` by design — for bot/automation callers; the requirement is a human-UI rule.)
- **Installable PWA (shipped v95, Option A — iOS-first, no service worker):** the app adds to the iOS home screen and launches standalone via `frontend/manifest.webmanifest` + the `<head>` `apple-mobile-web-app-*`/`theme-color`/`viewport-fit=cover` metas + icons (`ten31-app-icon.svg``icon-192`/`icon-512`/`apple-touch-icon`, regenerated with macOS `qlmanage -t -s <px>` since there's no ImageMagick/rsvg). `backend/server.py` serves `/manifest.webmanifest` (`application/manifest+json`) as a **pre-auth** route next to `/`, `/index.html`, `/assets/*` — the browser fetches the manifest + icons *before login*, so **any change to that routing or the head metas MUST keep them reachable pre-auth**. **No service worker by design** (iOS A2HS needs none; a cache-first SW would reintroduce the v78/v79 stale-shell class). **Zoom is locked off (v97):** the viewport meta carries `maximum-scale=1.0, user-scalable=no` for a native feel — this *also* suppresses iOS's auto-zoom-on-focus for sub-16px inputs, so **don't "fix" a small mobile input by bumping it to ≥16px; the viewport already handles it** (mobile body is 15px by contract). Trade-off: page pinch-zoom is off (OS accessibility zoom still works) — acceptable for this internal tool, revisit if scope widens. Detail + the deferred SW/landscape items: `ROADMAP.md` "Mobile PWA".
- **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user.
## Always
- **Verify before shipping:** `python3 -m py_compile` the edited files; for DB logic, run the change against a **copy** of `data/crm.db`, never production.
- **Keep real LP data out of Claude:** develop only on code/schema/synthetic-or-locally-redacted data; route any real record substance through `backend/redaction` first.
- **Get explicit user authorization before any production deploy/install** to `$START9_BOX_HOST`.
## Never
- **Never treat Qdrant (or any derived index) as source of truth** — the CRM/SQLite is canonical and rebuildable-from.
- **Never hard-delete** CRM records or thesis history — soft-delete/archive only.
- **Never let an agent send email, post, or contact an LP autonomously** — agents draft; a human approves and sends.
- **Never set a `thesis_version` canonical from code/seeds** — that is human dual sign-off.
- **Never call a Spark directly** — go through Spark Control (`SPARK_CONTROL_URL`).
- **Never commit secrets, `data/crm.db`, `.env`, or `data/backups/`** (all gitignored). Scan staged files before committing. (`.claude/` *is* tracked — `launch.json` and `rules/` symlinks ship with the repo; keep local-only settings in `.claude/settings.local.json`.)
- **Never bulk-export the LP list** to any third party; send only minimal non-sensitive context to Claude.
- **Never assume FastAPI / SQLAlchemy / pytest** are in play — they sit in `requirements.txt` unused; runtime is stdlib + SQLite.
- **Never add a `Co-Authored-By` / "Generated with" trailer** to commits or PRs — commits are the user's.
## Deeper docs
- Full constitution + guardrails: `docs/ten31-constitution.md`
- Architecture & rationale: `docs/Ten31_Agentic_Build_Plan.md`
- Retrieval/embeddings contract: `docs/EMBEDDINGS.md`
- CRM schema/API tour: `docs/crm-overview.md`
- Current thesis handoff: `docs/thesis-handoff.md`
- Operations & runbooks: `docs/OPERATIONS.md`, `docs/go-live-runbook.md`, `docs/gmail-enablement-runbook.md`
## Current state
_**Box live at v0.1.0:102 (deployed + verified 2026-06-20); v0.1.0:103 built + committed, install pending.** Clean migration chain (…→102, all no-op/frontend-only), server up on :8080. This session = a **mobile-UX feedback batch from Grant's device testing** (101 #15, 102 #6 bell) + **103 reminders-require-a-date**. **The fundraising grid + email capture is the canonical system of record.** History: git log + `start9/0.4/startos/versions/`._
- **Mobile UX batch (Grant device feedback) — BUILT + LIVE (v0.1.0:101102, 2026-06-20), on-device pass pending.** Six items (durable detail in the Design bullet → "Post-8 mobile-feedback primitives"): [1] ✕-clear on search/picker fields (`ClearableInput`); [2] tappable Grid contact pills (name→Contacts deep-link, email→mailto); [3] grid search already matched contact names — verified, no change; [4a] full-height Pipeline swipe area with bottom-pinned dots; [4b] editable pipeline `expected_amount` (add-to-pipeline + card detail, `PUT /api/opportunities/{id}`); [5] bottom sheets lift above the keyboard (visualViewport); [6] **`MobileEmailBell`** — admin-only email-approval bell, a third surface over `email_activity_proposals` that auto-syncs with the web panel + Matrix room.
- **Reminders require a due date — BUILT + COMMITTED (v0.1.0:103), install pending.** **Every** create surface (mobile add-investor / standalone Reminders / Grid-detail, **and desktop** Reminders page + grid modal) pre-fills the date to +1 week (editable) and blocks an empty save (`reminderDefaultDue()`); edit paths pre-fill it for legacy date-less reminders too. Detail in the Design bullet.
- **Verification: render-smoke green** (build-gated — JSX transforms + app mounts), reviewer-agent **APPROVE, no blockers** across all batches + a holistic pass (nits applied: ClearableInput conditional padding, bell `busyRef` double-submit guard, disabled-button dimming, reminder edit-path default-fill). All new work is **frontend-only — no schema / migration / dependency change**, so backend is untouched (43/43 backend tests still green from v100). New UI behavior is **live-smoke / on-device only** (jsdom can't drive touch/keyboard/mailto).
- **Next:** (A) **Install v0.1.0:103 to the box** (102 is live; 103 built + committed, not yet installed). (B) On-device gate — Grant: the six v101102 items on the phone (✕ clears; contact name→contact & email→mail app; Pipeline swipe-anywhere + dots-at-bottom; amount round-trips; keyboard-lifted picker; **bell end-to-end** approve→Matrix clears) + the v103 date requirement (mobile + desktop). (C) Carried from v100: #7 real-card spot-checks + the standing mobile light/dark + PWA-install gate.
- **Open / risks:** `.pipeline-screen { height:100% }` leans on the `.content` flex chain for a definite height — confirm the swipe area fills + scrolls on Grant's iOS (resolves on iOS 16+; no speculative patch applied). Bell + amount-edit are admin/live-smoke only. Carried: **Claude/Architect path unverified live on the box**; vision OCR can misread a small-in-frame card (`mara.com→marac.com`, temp 0); phone/LinkedIn land on the contact record, not the grid pill; PWA iOS status bar fixed `black` in light theme; doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.