DueChip + .due-chip--{bucket}/--due-*-* slots in the chip-var enumeration;
note the stacked investor-picker and the source_row_id -> investor_id
reminder-link convention (PATCH can't reassign).
Re-author the mobile Reminders surface to the dc anatomy: title + urgency
summary line + gradient add (drop Active/Done/All tabs); four urgency buckets
(Overdue/Today/This week/Later) with colored dots; urgency-colored DueChip
pill (new --due-* theme vars, dark+light); collapsible Completed section
(done+cancelled, tap-check reopens). Load all statuses in one call, split
client-side.
Swipe-right opens a snooze preset sheet (+1/+3/+7/+14d) replacing the fixed
+7d. The add flow gains a stacked searchable investor picker over the
canonical grid that writes source_row_id -> a real server-resolved
investor_id link (replacing the free-text label that never linked; team-task
= no investor). Edit stays read-only for the investor (PATCH can't reassign).
Add a shared SortPill + SortSheet (label+hint option rows) and per-surface sort
tables:
- Grid: Name / Pipeline stage / Committed / Last contact / Priority, applied in
the displayed memo (name is the tiebreak; staleness ranks longest-since-contact
first, no-activity treated as most stale; committed uses the fund rollup).
- Pipeline: Name / Amount / Last activity / Priority, sorted within each stage.
"Last activity" uses opp.updated_at as a recency proxy until the Pipeline card
wires true last-contact recency (8f).
- Contacts: drop the investor/prospect type tabs (the prospect type is unused);
add a Priority sort alongside Name A-Z/Z-A and Last contact.
contact_grid_signals() now also surfaces the linked investor's priority flag,
injected on both contact read paths (same derive-on-read contract as committed /
pipeline_stage), powering the Contacts Priority sort. Extended
test_contacts_grid_signals.py covers it; 39/39 backend green.
Grid detail (G6): replace the single row.notes blob with a NoteTimeline fed by
a new investor-level read, GET /api/communications?source_row_id=<grid row id>
(filter maps the grid row -> fundraising_investors.source_row_id ->
fundraising_contacts.contact_id -> communications, soft-delete-respecting;
cancelled-flag fetch + commsReload after a log). Note-logging now uses the
shared LogCommunicationSheet, retiring the bespoke noteForm select sheet and the
dead .fs-note-log style.
New MobileQuickLog: a shell mobile-top-bar pencil reachable from every tab —
two-step sheet (pick investor via search + recent-first pool -> inline log form)
writing through the one-row /api/fundraising/log-communication path.
source_row_id and contact_id are kept mutually exclusive in
handle_list_communications so a future caller passing both can't get the empty
intersection. Guarded by test_grid_comm_timeline.py (cross-contact aggregation,
investor isolation, soft-delete); 39/39 backend green.
8a — Grid card: existing-LP earmark corner-triangle (replaces left-border), right-side
PRIORITY pill (replaces the rejected star), 4-stage chip, zero-commit dim; detail star ->
"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP ring + stage pill
+ recency; disposition badge dropped. New backend contact_grid_signals() injects derived
read-only committed/pipeline_stage on GET /api/contacts and /api/contacts/{id} (existing-LP
ring + stage pill); read-only directory, so no strip-point. DESIGN.md §4/§8 reconciled.
8b — Contacts and Pipeline detail surfaces converted from full-screen to drag-dismiss bottom
sheets matching the .dc.html anatomy: Contacts gets an email-copy pill, Log/Email actions, and
an Organization card; Pipeline gets stat tiles, an inline move-stage list, and a notes timeline
+ Log sheet. Both log via POST /api/communications; BottomSheet gains a `stacked` prop to layer
the Log sheet over a detail. Reviewer fixes: cancelled-flag fetch guards (stale-response race),
keyed single-contact signals query, multi-investor dedup test.
All deploy-pending (no s9pk built); not device-tested. 38/38 backend tests green.
design/phase8-conformance.md — per-surface anatomy + deltas + line refs built
against the .dc.html defaults (not the screenshots), as the 8a–8i build reference.
Corrects the screenshot read (drop disposition badges + contact footer; 4-stage
funnel + swipe dirs already correct). Records two Grant decisions (2026-06-19):
Pipeline existing-LP uses the earmark (unify with Grid, overriding the dc star);
Contacts drops the investor/prospect type tabs (prospect type unused) but keeps a
Priority-flag sort. Refresh AGENTS.md Current state; ROADMAP Phase 8 points at the spec.
This session audited the mobile surfaces vs the Claude Design export
(functional-parity + visual-conformance) and corrected the design source of
truth: the *.dc.html prototypes at their default data-props
(compact/dark/plex/earmark) are canonical, not the screenshots (option-history).
Adds the 9-phase Phase 8 conformance plan to ROADMAP, a durable gotcha to the
Design convention, and prunes Current state to the live state.
Route remaining hardcoded UI colors through themed :root vars so they flip
under [data-theme="light"], finishing the P6 light-theme migration.
- Mobile: bottom-tab-bar -> --nav-bg; toast bottom-center above the tab bar,
rising in via a new slideInUp keyframe.
- Money: --money for the kanban/pipeline-stage/stat amount literals.
- Badges: remap the legacy Material .badge-* family onto the brand
StageChip/--chip-* + --badge-priority/-danger slots, retiring the second
palette (DESIGN sections 2/7).
- Desktop-light: logout/danger buttons, table hover/stripe/footer, sidebar
& header gradients, scrollbars, toast states, context-menu danger, thesis
banner/accents, grid selection & drag indicators, skeleton loaders.
28 new themed vars (theme-stable ones dark-only); dark appearance preserved.
Frontend-only. Verified via CSSOM render-smoke, var resolution, brace check;
reviewer approve-with-nits (toast-rise fixed). Unverified on a real device.
Also refresh AGENTS.md Current state and add a ROADMAP legacy-usage-sweep note.
Adds the editable half of BRIEF §3a's mobile grid set: rename an investor
and add/edit/remove its contact pills from the mobile detail sheet.
New POST /api/fundraising/update-row is the one-row read-fresh-modify-write
twin of log-communication: it mutates only the target row's name/contacts in
the canonical grid blob server-side, then bumps the version + re-syncs the
relational tables. It never accepts a whole-grid payload, so a stale mobile
client can't clobber concurrent edits to other rows (the reason mobile avoids
the whole-grid PUT). _sanitize_fundraising_contacts whitelists the known pill
fields as the trust boundary; removing a pill is soft on the classic contacts
directory (only the grid pill + fundraising_contacts row drop).
Frontend: MobileFundraisingGrid gains an Edit bottom-sheet (name input + pill
editor with client-side dedup); money stays desktop-only. New CSS is
theme-var-only so it flips in light mode.
Verified: test_fundraising_update_row.py (24 assertions, real HTTP), full
suite 37/37, render-smoke + a 375px jsdom interaction harness green.
Ship the light palette behind a :root[data-theme="light"] switch; dark
stays the default and brand identity. A pre-paint boot script applies
localStorage.venture_crm_theme (no flash, no prefers-color-scheme), and an
app-wide toggle lives in the desktop sidebar footer + the mobile top bar,
both driven by one theme state in App.
Method keeps dark mode byte-identical: :root grew to 44 themed color slots
whose dark values equal the original literals, then 319 hex literals were
migrated to var() across the JSX inline region and the <style> block. The
StageChip is now className-based (.stage-chip--{stage}); PIPELINE_STAGE_CHIP
is removed. Every light tint (stage/recency/note/priority/reminder/money)
uses the designer's exact values from the full Claude Design export
(store.js + the four *App.dc.html DCLogic palettes), now committed as
provenance under design/_imports/2026-06-19_zip-file/ (zip + screenshots
gitignored).
Mobile surfaces + chrome are fully var-based, so mobile light is complete.
Desktop light has known rough edges (bespoke <style> shades, the legacy
off-palette .badge-* family, dark-tuned shadows) folded into a new Phase 7
design-conformance pass.
Verified: render-smoke green; a jsdom interaction harness on the authed
shell exercised the toggle (boot-dark -> light+persist+relabel -> dark);
dark-identity, theme-parity, and no-undefined-var checks all green. Not yet
checked on a real phone/browser.
Completes the four mobile-first surfaces. Both phases follow the established
useIsMobile() wrapper → Mobile*/Desktop* pattern; desktop is untouched (only
renamed Desktop*). No backend change.
P4 Pipeline (MobilePipeline): CSS scroll-snap swipe between the four stages +
count-forward segmented control + page dots; per-card ‹/› stage move and
tap → full-screen opp detail with a stage-picker sheet. Opp-centric — shares
PATCH /api/opportunities/{id}/stage with the desktop board and the Grid's
stage edit; view + advance-stage only (removal stays on the Grid/desktop board).
P5 Reminders (MobileReminders): urgency-grouped list over /api/reminders
(Overdue/Due soon/Later/Done/Cancelled) with an Active/Done/All filter; each
row is a pointer-drag swipe (left = mark done, right = snooze +7d, keeping
status open and pushing due_date, per the desktop rationale); tap → create/edit
BottomSheet (investor is create-only, matching the backend PATCH field set).
formatDueShort/reminderDueDelta fix the desktop formatter mis-rendering future
due dates.
Verified: render-smoke + throwaway jsdom 375px interaction harnesses (12/12
each); reviewer passes applied — notably P5 pointercancel no longer fires a
spurious mark-done. Deploy-pending (no s9pk built); not yet tested on a phone.
Adds the mobile-first Fundraising Grid (<768px): a lean MobileFundraisingGrid
that reads /api/fundraising/state once and renders an investor card list over
the active view (name, committed $, pipeline-stage chip, staleness-colored
recency, Existing-Investor accent, Priority corner; graveyard muted) with a
bottom-sheet view picker and search. Tap a card -> full-screen detail with
read-only commitments/contacts/notes plus edit sheets: log a note, pipeline
stage, set a reminder, and a "+ New" investor create flow with client-side
dedup typeahead.
All writes go through the targeted one-row endpoints (log-communication,
pipeline link, opportunities stage PATCH, reminders) — NEVER the whole-grid
PUT, which would race the multi-user grid (BRIEF §3a). FundraisingGridPage is
now a useIsMobile() wrapper over the renamed-but-untouched desktop grid and
the new mobile one (rules-of-hooks-safe; desktop unchanged).
Backend: inject a read-only opportunity_id into grid rows
(opportunity_id_by_source_row; added to both strip points) so the mobile detail
can PATCH a linked opp's stage directly. Earliest-opp-wins ordering keeps it
consistent with pipeline_stage and the link's canonical pick.
Editing an existing investor's name + contact pills stays read-only here
(deferred to P3b — needs a narrow per-row PATCH + pill editor).
Tests: test_grid_pipeline_link extended (opportunity_id inject/strip/round-trip);
36/36 backend green, render-smoke green.
Builds the mobile-first Contacts surface (<768px): a read-only A-Z directory
(sticky last-name letter headers) + segmented All/Investors/Prospects tabs +
pinned search -> full-screen detail (info with tap-to-copy email, opportunities,
comm history) -> a sort bottom-sheet. Contacts stays read-only on mobile per
design/BRIEF.md §3b (create/edit live on the Grid).
Lands the shared mobile primitives, deferred from Phase 1 and designed against
this first consumer (no dead code): <BottomSheet> (built on the Phase-1
.bottom-sheet CSS; scrim/Escape/pointer drag-to-dismiss) and useIsMobile()
(768px matchMedia). ContactsPage becomes a rules-of-hooks-safe wrapper that
mounts MobileContactsPage or the renamed-but-untouched DesktopContactsPage, so
desktop is unchanged. New CSS is JS-gated to the mobile component; grew the
:root/mobile var set per DESIGN §9 instead of hand-picking hexes.
Verified: render-smoke green + a throwaway jsdom interaction harness mounting
the real app at 375px (list/grouping/sort-sheet/detail/back, 14/14). Deploy-
pending (folds into the next s9pk with P0/P1/view-reorder).
Phase 1 mobile foundation (additive, no desktop change): :root mobile vars, a
4-tab bottom nav bar + mobile account/logout popover wired into App, a
bottom-sheet CSS primitive, and .mobile-only/.desktop-only utilities -- all
display:none >=768px. The <BottomSheet> React component + useIsMobile() + the
per-surface 15px type bump are deferred to Phase 2 (first use); light theme to
Phase 6.
Review hardening (fresh-eyes pass on the Phase 0+1 diff): validate stage in
handle_create_opportunity + handle_update_opportunity against PIPELINE_STAGES --
the narrower 4-stage enum makes a stale-client write of a legacy value invisible
to the report ORDER BY CASEs and unsettable from the UI. Use the canonical
pipelineStageLabel in the opportunity detail select; document the intentional
graveyard omission in the existing_investor / staleness helpers.
Tests: stage-validation regression in test_grid_pipeline_link.py + empty
source_row_id guard in test_pipeline_stages_v2.py; 36/36 green, render-smoke green.
Collapse the inherited 6-stage opportunity funnel to the locked 4-stage
per-investor funnel (lead -> engaged -> diligence -> commitment), terminal at
commitment. Migration 0007 remaps existing stage values (outreach/meeting ->
engaged, due_diligence -> diligence, committed/funded -> commitment) and
archives the stray 'lost' value (the grid row is left intact). Inject read-only
existing_investor (total_invested>0), last_activity_at, and staleness
(''/'aging'>=30d/'stale'>=60d) into the grid GET, stripped on write. Frontend:
4-stage chip tints + Pipeline board / opp-form / mock on the new enum.
The visible desktop existing-investor star + staleness recency column + the
Stale saved view are deferred to mobile Phase 3 (data is injected + test-locked
now, so that phase stays pure-frontend). Longshot was already retired by prior
cleanup -- no-op.
Tests: test_pipeline_stages_v2.py (migration remap + derivation boundaries) +
updated grid-pipeline-link / soft-delete / nl_query; 36/36 green, render-smoke
green, fresh-DB migrate clean.
Triaged eight one-off ideas (2026-06-18) into ROADMAP; #6 (spark-control
dashboard card) routed to standards/INBOX. Sharpened the pipeline-stages
idea into a locked spec (2026-06-19): 4-stage per-investor funnel
(Lead/Engaged/Diligence/Commitment), auto-derived Existing-Investor flag,
Priority+Graveyard disposition (Longshot dropped), staleness as a derived
recency overlay + W1b Matrix nudge (never auto-demotion), one global stale
threshold, and the card-presentation decisions. AGENTS Current-state notes
the built-pending view reorder + the captured batch.
Scaffold design/ for the frontend's first design contract, extracted
as-built from index.html (document-as-is):
- DESIGN.md: 9-section brand brief (dark venture-CRM look, IBM Plex,
single #3b82c4 accent) + tokens.tokens.json (DTCG, from :root + an
inline-style census).
- BRIEF.md: the mobile-first redesign packet. Mobile = 4 surfaces
(Grid, Pipeline, Reminders, Contacts) in a bottom tab bar; the rest
desktop-only. Grid view-switching first-class; narrow on-the-go edit
set (name, contacts, notes/comms/outreach log, stage, reminders) +
create-investor, all via the canonical grid path (Contacts stays
read-only). Includes a backend-reality callout: no field-level write
(whole-grid versioned PUT vs the targeted log-communication path),
stage is a separate two-call opportunities flow, pill removal has no
undo, dedup typeahead is client-side.
- brand/ assets, inspiration/ provenance.
Wire the AGENTS.md Design line so any agent reads the contract before
UI work.
Both NL-query intents counted/listed a user's ENTIRE captured sent corpus
(internal, vendor, personal mail) rather than only email to a matched investor
— they were missing the `EXISTS email_investor_links` gate that recent_emails
and the Communications panel's query_email_activity use. Their own docstrings
said "investor emails", so the behavior was wrong, not just loose.
Add the matched-only gate to both, mirroring query_email_activity. The runner
test now seeds an unmatched sent email and asserts it is excluded (without the
fix comms_by_user returns 3 not 2, this_week 2 not 1) — the prior fixture
linked every email, so the leak went uncaught.
Also documents the matched-only rule in the nl-query guide, and refreshes the
AGENTS.md Current state (v93 deployed; this fix pending a v94 s9pk since the
intents run on the box, not the bot).
Read-only natural-language query over the curated nl_query endpoint, answered
in-thread. Two entry points (room-per-purpose model): a dedicated Q&A room
(MATRIX_QUERY_ROOM) where every top-level message is a question, plus the
?/@bot trigger in the intake room as a cross-room convenience. Both routes hit
the same handle_query -> crm_client.nl_query -> POST /api/query/nl; translation
runs on the box's local model, nothing leaves the box, and there is no write
path so no approval gate applies.
Pure logic (trigger parsing, answer rendering) in query.py with offline tests;
async room wiring in bot.py (live-smoke only, per the bot's convention).
Bot-side only, ships on the Spark via git pull + restart. Depends on the
box-side /api/query/nl endpoint, which lands with the v93 s9pk (reminders + W2):
until v93 is installed the Q&A surface 404s, so the bot deploy is staged to
follow that install.
Read-only "ask the database in plain English" backend. Translation runs on
the local Qwen via Spark Control (question -> {intent, slots}); nothing leaves
the box, no Claude and no redaction boundary (the simplification chosen after
pressure-testing). The safe surface is a curated catalog of ~12 hand-written
parameterized queries; a slot validator is the trust boundary (no generic SQL,
no dynamic identifiers). POST /api/query/nl + GET /api/query/catalog, gated
require_bot_or_admin, read-only, audited. Soft-delete-correct per table.
Local Qwen translated 12/12 real example questions correctly against the live
Spark. Web "Ask" box and Matrix bot still to come (steps 4-5).
Durable updates after the email-proposal review session:
- AGENTS.md: roles admin/member -> admin/member/bot; add a Conventions entry on
the bot role and the reach(role)-vs-autonomy(approval gate) principle.
- matrix-intake guide: rewrite the bridge section to final behavior (redact_thread
whole-thread redaction, the Element 'show deleted' client-setting dependency for
full clearing, the redact_resolved.py backfill tool, deploy gotchas).
- Current state rewritten lean (14->8 bullets); test count 27->30.
The bot was granted a redact/mod power level in the review room, so it can now
clear a resolved thread entirely, not just the card: redact_thread redacts the
card root then scans recent history for its m.thread replies (the human's yes/no
+ any bot messages) and redacts those too, so decided threads drop out of the
threads view, not only the main timeline. Drops the in-thread confirmation on a
successful decision (the thread clearing is the ack; a confirmation would keep
the thread alive). redact_resolved.py extended to also clear replies of already-
resolved threads for the one-time backfill. Bot-only; no s9pk change.
Three post-smoke refinements to the Matrix email-proposal review:
1. Dash separators (bot): every card/reply is framed with a dash rule top and
bottom so threads stop bleeding together vertically on mobile.
2. Remove decided threads (bot): on a conclusive approve/dismiss from either
surface, the bot redacts the card (client.room_redact) so the room clears
down to only undecided items. Redacting the bot's own card needs no power;
the web->Matrix path now redacts instead of posting a closure note.
3. Clearer note wording (server v91 + bot): the proposed grid note now names who
emailed whom -- "{teammate} emailed {investor}" (outbound) / "{sender} emailed
the team" (inbound) -- instead of an ambiguous "Sent"/"Received". Outbound
detection also matches our corporate domain (public providers excluded), so a
teammate's mail from a non-enrolled @ten31.xyz address no longer reads as
"Received". Going-forward only; no schema change. The card drops its bare
direction label since the note now carries the relationship.
Tests updated; 30/30 green, render-smoke green.
v89 added the 'bot' role for the Matrix email-review bot's endpoints but kept it
out of the UI, leaving no click-path to assign it. Add 'bot' to the Settings ->
Admin edit-user role dropdown (the teammate-invite form stays member/admin only —
provisioning an agent account is an admin re-classification of a dedicated user,
not a teammate invite). The backend update validator already accepts 'bot'.
Frontend-only, no schema change.
The email-capture "proposed grid notes" gain two review surfaces:
1. Inline source email — each proposed-note card on the Email Capture page
gets a "View email" toggle that lazily fetches the existing
GET /api/email/detail and shows from/to/cc/date/subject + scrollable body,
so a reviewer can judge the note against the email it was drafted from.
2. CRM->Matrix review bridge — the CRM (box, stdlib, no matrix-nio) can't post
to Matrix, so the intake bot (Spark) PULLS: GET /api/intake/email-proposals
returns to_post/open/to_close work-lists; the bot posts a review card
(metadata + snippet + draft note) to a dedicated review room
(MATRIX_EMAIL_REVIEW_ROOM) and relays in-thread yes / no / NL-edit
(POST .../{id}/decide, note revised via local Qwen). Decisions sync both
ways: web decide -> bot announces + closes the thread; Matrix decide -> the
web panel's ~25s poll clears the card. State lives CRM-side in the new
email_proposal_matrix side row (email-integration migration 0003, additive
+ idempotent CREATE TABLE IF NOT EXISTS), so it survives a bot restart.
Adds a 'bot' role (authenticated, never admin; require_bot_or_admin) to gate
the email-proposal endpoints rather than handing the bot full admin — the
principled base for the coming agentic capabilities. Role controls reach;
the draft->approve gate still controls autonomy (a human approves every write).
Deploy split: endpoints + migration + role + frontend ship in the s9pk; the
bot poll loop + review-room handling ship on the Spark. The bot's CRM user
must be flipped member->bot and joined to the review room (one-time).
Tests: backend/test_email_proposal_matrix.py + matrix_intake/test_email_proposals.py
(30/30 suite green, render-smoke green, migration verified twice on a DB copy).
Box and repo on v0.1.0:88; the +Pipeline -> board -> advance-stage -> remove
round-trip is verified on the box. Pipeline adoption is closed out; ROADMAP
item marked done and the Next list advances to the spark-control intake card.
Opportunities are now born only from a fundraising-grid investor row
("+ Pipeline"), which matches how the team works — they live in the grid,
not on the board. The old "+ New Opportunity" button created a deal by
picking a contact, a path that contradicts the grid-is-canonical model and
the contact-vs-investor framing.
Remove the button, its create-by-contact modal, the now-dead handler/state,
and the Pipeline page's unused /api/contacts fetch. Replace the button with a
muted "Add deals from the Fundraising Grid" hint. The board is now a view +
stage-management surface. Frontend-only; no backend or schema change.
Render-smoke green.
Box and repo now on v0.1.0:87: StartOS chain ...86->87 clean,
0005_grid_pipeline_link.sql applied on the box, server up on :8080.
Next is an in-room/board live-smoke of the Add-to-Pipeline round-trip.
The fundraising grid (canonical) now drives the classic opportunities
Pipeline board, instead of the board being a disconnected second data-entry
surface. An "Add to Pipeline" row action creates a durably-linked opportunity
via the new opportunities.fundraising_investor_id (migration 0005, additive +
reversible), reusing the grid's already-synced contact — retiring the
POST /api/contacts side-door — and mapping the grid lead to the opp owner.
Ownership is split so the two stay reconciled: the grid owns whether the link
exists and the seed; the board owns stage/probability/owner. The link endpoint
is idempotent (one live opp per investor; a re-link never reseeds funnel
fields). "Is in pipeline?"/"what stage?" are derived from a live opp join and
injected as read-only grid columns on read, stripped on write, so they never
persist or dirty the autosave. Remove-from-pipeline soft-deletes the opp and
leaves the grid row fully intact; deleting an investor from the grid archives
its orphaned opp.
Also fixes the standing soft-delete leak in handle_pipeline_report and the
dashboard pipeline aggregates, which counted tombstoned opportunities.
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.
Bot now runs as a docker-compose service on the Spark (verified live, listening). Docs (matrix-intake guide ops, ROADMAP, AGENTS Current state) updated. Also logs the live-smoke parse bug (teammate read as investor -> team-roster fix), the spark-control dashboard-card handoff, and the long-term dedicated-repo extraction.
Box installed to 0.1.0:86 (migration chain ...85->86 clean, candidates endpoint verified live); bot pulled + restarted on the Spark. Only the Matrix live-smoke remains.
Close the two locked post-deploy enhancements for the Matrix intake bot.
Fuzzy matching (server-side, ships in the s9pk): new find_intake_candidates in
server.py returns ranked deterministic near-matches (difflib name similarity +
token-set Jaccard, legal-suffix-aware, + email Levenshtein <= 2); GET
/api/intake/match now returns {match, candidates}. The bot surfaces a numbered
shortlist so a near-duplicate (Charlie/Charles, Acme Capital vs Acme Capital LLC,
a one-char email typo) is confirmed by a human instead of silently creating a
second investor. Exact match still auto-attaches; fuzzy candidates are never
auto-attached. The optional LLM-judge re-rank is deferred.
Conversational edits (bot-side, ships on the Spark): any in-thread reply that
isn't yes/no/edit field=value is treated as a natural-language revision and
re-run through local Qwen (parse.revise). Email integrity is preserved -- a
changed address must literally appear in the instruction; the model's email
field is structurally unreachable. No-op revisions re-prompt.
Docs/current-state brought current; 27/27 backend tests green.
New backend/matrix_intake/ runs as its own process (matrix-nio isolated from the
stdlib CRM): local-Qwen parse via Spark Control → in-thread human approval
(yes/edit/no) → write through the CRM's own log-communication endpoint, tagged
source=matrix_intake. Adds read-only GET /api/intake/match (returns grid row id,
no-duplicate contract); threads provenance through handle_log_fundraising_communication.
Reviewer-passed: pop-before-commit closes a double-approve race; edit-grammar fix.
Text-only v1; business-card photo (M3) deferred (no Spark vision model).
26/26 tests green; live Matrix smoke pending deploy.
Fix AGENTS.md access wording (ClearNet/StartTunnel + app-level auth, not
LAN/Tailscale). Add the StartOS icon bug to Known debt and the email-sync-status
error as Next #8. Add ROADMAP sections for Matrix-bridge grid intake (next,
high priority) and an admin-vs-all-users UI audit.
Communications tab (search/query roadmap items 1 & 2):
- Fix the investor dropdown: the facet only listed grid investors, so it
came back empty whenever email matched a classic contact or org domain
(no grid id — the common case). It now mirrors the email list, resolving
each link to a typed identity (fund:/org:/contact:/addr:) with precedence
grid -> org -> contact -> address; investor_id accepts the typed key
(bare id = fund: for back-compat) and an unknown prefix matches nothing.
- Add a date-range filter and a click-to-expand full-body view
(GET /api/email/detail, admin, soft-delete-gated; body_text only, never
raw remote HTML).
- Add a "Search content" mode: GET /api/email/search wraps the ingest
hybrid_search over the Qdrant email index (doc_type=email), hydrated and
soft-delete-filtered against SQLite (canonical), 503 if Spark/Qdrant down.
Daily digest:
- Settings -> Admin builds a digest over a chosen window (last 24h or since
a date) as an in-app preview before sending (POST /api/admin/digest/preview),
so the local-Spark summarizer can be verified on demand even on a quiet day.
Manual send uses the same window; neither advances the daily cursor, so a
preview never suppresses the scheduled digest.
Code-only, migrations no-op. 22/22 backend tests, render-smoke pass.