The intake bot's server-side dependencies — GET /api/intake/match (new-vs-
existing lookup) and `source` provenance on log-communication — shipped in
source with 7ad0ee7 but were never packaged. The box ran v83 (pre-7ad0ee7),
so the bot's match calls 404'd: a note on an existing investor would have
created a duplicate, and writes weren't tagged matrix_intake. Bump + build +
install verified live (installed-version 0.1.0:84, clean 83->84 migration,
match endpoint now resolves by name and email). No schema change.
Also log the conversational (LLM-mediated) edit enhancement in ROADMAP.
normalize()'s email regex matched non-@/non-space runs, so "Name <addr>"
(the most common contact format) yielded "<addr"; only trailing punctuation
was stripped, never leading. Tighten the regex to standard local@domain.tld
so the bare address is extracted from <…>, (…), and trailing-period forms.
Found via the live-deploy pre-flight. Add a regression test.
Also log two intake backlog items in ROADMAP: the scoped service-credential
auth path (deferred; bot uses a member login for now) and fuzzy match +
in-thread confirm (post-deploy).
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.
Record the v82 vendor+SRI + render-smoke work in durable docs: packaging guide
gains the verified-build gate + re-vendor instructions; Current state rewritten
and compressed for v82; ROADMAP logs the deferred pre-compile-JSX alternative.
React/ReactDOM/Babel were loaded from the unpkg CDN at runtime — react@18
and react-dom@18 weren't even exact-pinned, and none had SRI. A CDN swap (or
react auto-resolving a new 18.x) could blank the whole app with no change on
our side: exactly the v78/v79 blank-screen class. It also made the self-hosted
box depend on outbound internet to render.
Vendor the three libs into frontend/assets/vendor/ (React 18.3.1, ReactDOM
18.3.1, @babel/standalone 7.29.7) and load them same-origin with sha384
integrity attributes. They now ship inside the s9pk (Dockerfile already COPYs
frontend/; server.py serves /assets/* with the path-containment check), so a
CDN can never swap prod deps again and no outbound fetch is needed at runtime.
Add start9/0.4/render-smoke.mjs: a jsdom render smoke check that (1) runs the
shipped Babel over the app's inline JSX and asserts a classic, non-module,
parseable script (the v79 ESM-import regression), and (2) mounts the app in
jsdom and asserts the login UI renders (the v78 blank-screen class). Wired into
the default `make` goal so every package build is gated on the frontend
actually rendering — closing the "verified live via curl only" gap. jsdom is a
build-time devDependency, not shipped in the image.
The email-activity panel surfaced every captured message, including cold/
unknown-sender email with no investor association. Gate query_email_activity
on EXISTS(email_investor_links) so the panel shows only email tied to a known
investor/contact. Capture is unchanged — unmatched email is still stored
(metadata-only) and will appear automatically if its sender is later added as
an investor; this is a read-side filter only.
Graveyard investors are unaffected (their email has a link), so they remain
visible/searchable as an audit surface, hidden only from the filter picker.
The Communications tab is now an admin-only search over captured Gmail
(email_* tables), part of consolidating on the fundraising grid + email
capture as the canonical system of record.
- New GET /api/email/activity (admin-enforced server-side): filter by
investor / mailbox / direction with free-text search over subject,
snippet, and sender. Query logic in db.query_email_activity.
- Soft-delete honored on the per-mailbox sighting (emails carry no
deleted_at; deletion lives on email_account_messages).
- Direction decided at the email level (outbound if the sender is one of
our mailboxes), mirroring digest_builder.
- Graveyard investors are hidden from the filter dropdown (CRM-wide
graveyard=0 convention) but their email stays visible in the list and
findable by free-text search — this is an audit surface.
- Communications page rewritten to render the panel; the classic manual
"Log Communication" form is retired (the grid context menu remains the
manual-log path). Nav item + page are admin-only.
- Tests: email_integration/test_email_activity_panel.py (filters,
per-sighting soft-delete, roll-ups, graveyard handling, route 401/403);
full suite 22/22. Frontend render verified via a jsdom mount smoke test
plus the pinned classic-runtime Babel transform.
Code-only, no schema migration (version migrations are no-ops).
Record the Babel-pin fix + root cause, the 3 newly admin-gated GET endpoints, the corrected deploy-verification convention (browser render, not curl/health), and the re-ordered Next list (vendor+SRI, auth regression test, email-activity panel in the admin-only Communications tab).
The web UI rendered a blank screen for every user. Root cause: the page
loaded @babel/standalone from unpkg with no version pin, so the CDN silently
served Babel 8.0.0. Babel 8 defaults @babel/preset-react to the automatic JSX
runtime, which prepends `import {jsx} from "react/jsx-runtime"` to the compiled
output. An ESM import is illegal in this classic (non-module) inline <script>,
so the browser rejected the whole bundle and React never mounted — hence the
blank screen. The prior "verified live" checks were server-up/curl, which can't
catch a browser-render failure.
- Pin @babel/standalone@7.29.7 (its preset-react defaults to the classic
React.createElement runtime). Verified via headless render: app mounts, login
screen renders, no console error. Follow-up: vendor + SRI-pin the CDN libs so
a third party can't swap our front-end deps in production again.
- Close three server-side admin gaps surfaced by a permissions audit — endpoints
that were UI-hidden from members but not API-enforced: GET /api/users,
/api/email/status, /api/email/accounts now require_admin. Removed the now-dead
non-admin mailbox-row filter. 21/21 backend tests green; py_compile clean.
The fundraising grid + email capture is the canonical system of record. lp_profiles
was a superseded single-fund model with no reachable create/edit path, and the LP
Tracker page was already orphaned (no nav entry + a redirect bouncing it to the grid).
- Remove /api/lp-profiles* endpoints + handlers, the unused lp-breakdown report,
the contact-dossier LP section, the demo-seed LP block, and (frontend) the
LPTrackerPage component + its lp-tracker->fundraising-grid redirect.
- Dashboard "Total Committed" now sums fundraising_investors.total_invested
(graveyarded investors excluded) instead of the orphaned lp_profiles table, which
read ~$0. "Total Funded" dropped: the grid tracks commitments, not a funded amount,
and the frontend never rendered it.
- Leave the empty lp_profiles table/index, the contact-delete soft-delete cascade,
and the --reset-all-data clear in place (never-hard-delete).
- Tests: add test_dashboard_report.py; update test_soft_delete_reads.py. 21/21 green.
Docs-only: packaging guide notes start-cli install is silent on success (verify
with installed-version/logs); AGENTS.md adds the operational-toggles-in-the-admin-
panel convention and tightens the digest Current state.
installed-version on the box -> 0.1.0:77; migration chain ran through 76->77;
server up on :8080 with the digest scheduler running (policy-controlled, auto-send
off by default). Docs-only.
Sends a once-a-day internal email to all active admins summarizing each team
member's email activity per investor, plus a team-wide by-investor view
(inbound + outbound, deduped). Narratives are generated on the LOCAL Spark
model, never Claude — the digest is intentionally un-anonymized, so substance
stays on Ten31 infra. This is an internal ops email, exempt from the
'agents draft, humans send' rule (which governs outward LP contact).
- backend/digest_builder.py: per-user + per-investor activity queries
(soft-delete filtered), per-user Spark narrative with a deterministic
fallback, two-section plain-text body, and the DB-backed policy resolver.
- backend/email_integration/digest_scheduler.py: always-on daily thread that
re-reads the policy each cycle and sends once/day; window cursor in
app_settings so a missed day rolls forward.
- server.py: POST /api/admin/digest/send-now and GET/PATCH
/api/admin/digest/policy; scheduler wired into main().
- Control lives in Settings -> Admin (enable toggle + send-time dropdown),
not StartOS actions; env vars only seed the first-boot default.
- Tests: backend/test_digest_builder.py.
Extend docs/guides/email.md paths: frontmatter (and its AGENTS.md index entry) to
include backend/digest_mailer.py and backend/smtp_send.py, so the guide auto-loads
when editing the outbound-digest send path — not just backend/email_integration/**.
Portability-checker: compliant.
- docs/guides/email.md: new "Outbound mail — the daily digest" section (Gmail-DWD
primary → SMTP fallback; gmail.compose send capability; the internal-digest
exemption from the agents-draft rule).
- AGENTS.md: add digest env names (CRM_DIGEST_SENDER, SMTP_*); consolidate the
v75/v76 deploy bullets into one current bullet; drop finished v74 narrative.
Non-blocking items from the v76 reviewer pass. No redeploy needed — the box runs
v76 and its happy path is unaffected; these ride the next build:
- digest_mailer.send_digest: when Gmail is enabled but no sender resolves
(CRM_DIGEST_SENDER unset and no admin email), raise NoTransport so the caller
returns a clear 400 instead of a generic 502.
- gmail_send.send_via_gmail: wrap OSError/URLError (timeout/DNS) as a RuntimeError
("Gmail API unreachable: ...") to match the HTTPError handling; include the
sender in the HTTPError message for debuggability.
- credentials.py: correct the now-stale GMAIL_COMPOSE_SCOPE comment (the digest
mailer sends with this scope; only outreach drafts never send).
- test_gmail_send.py: add the HTTPError->RuntimeError branch, default_sender DB
fallback (+None case + env override), and the send_digest SMTP-tag path.
19/19 backend tests green.
The box's existing service-account domain-wide-delegation grant already includes
gmail.compose, which authorizes users.messages.send — verified 2026-06-15 by a
token-mint probe and a live messages.send to grant. So CRM-originated mail can
send through the account that already powers email capture: no SMTP account, no
app password, no admin change.
- backend/email_integration/gmail_send.py: send_via_gmail() impersonates a
domain user and POSTs users.messages.send (reuses credentials.py + the compose
scope; mirrors compose.py's REST pattern).
- backend/digest_mailer.py: send_digest() prefers Gmail DWD when enabled, falls
back to smtp_send otherwise. Sender = CRM_DIGEST_SENDER else first active admin.
- server.py: the admin test endpoint now routes through digest_mailer (so the
Settings button sends via DWD on the box with zero SMTP config). Recipient
restriction to the admin set and no-leak error handling preserved.
- test_gmail_send.py: build/send + transport routing (provider + urlopen faked).
19/19 backend green; s9pk typechecks.
SMTP (v75) stays as the fallback transport. Send-path decision + scope finding
recorded in ROADMAP.md and AGENTS.md.
Surface the digest test-send endpoint as a clickable admin control so it can be
exercised on the box without curl. Calls POST /api/admin/digest/test-email and
toasts the result (or a 'configure SMTP first' hint). JSX parse-checked.
Groundwork for the daily activity digest: give the CRM an outbound mail path.
Today nothing leaves the box (Gmail capture + drafts only), so this adds a
dedicated, per-package SMTP account independent of any StartOS system-wide SMTP.
- configureDigestSmtp Start9 action: writes host/port/from/username/password/
security to /data/secrets/smtp/* (password piped over stdin, never argv/env;
per-field files, owner-only) — mirrors the setAnthropicApiKey pattern.
- docker_entrypoint.sh reads those at boot and exports SMTP_* (operator env wins).
- backend/smtp_send.py: stdlib smtplib wrapper reading SMTP_* (one code path for
dev .env and the box); starttls/tls/none modes.
- POST /api/admin/digest/test-email (admin-only): proves the pipe. Recipients are
restricted to the active-admin set — an arbitrary `to` is rejected, so the
endpoint is not an open relay; send failures are logged, not echoed (an SMTP
auth error can carry the credential).
- Tests: test_smtp_send.py (sender), test_smtp_endpoint.py (gating + relay
restriction + no-leak). 18/18 backend green; s9pk typechecks.
Analysis/summarization for the digest body (Phase B) will run on Spark, never
Claude — the digest is deliberately un-anonymized. Decisions + Phase B plan in
ROADMAP.md.
Verification against published tarballs confirms ^0.4.0-beta.66 resolves to
0.4.0-beta.66 and its SMTP API surface (getSystemSmtp/SmtpValue,
inputSpecConstants.smtpInputSpec, smtpShape, smtpPrefill) is byte-identical to
the 1.5.3 reference packages. Build against beta.66 as-is; no SDK bump needed.
Research confirms StartOS 0.4 supports per-package SMTP credentials fully
independent of the server's system account: the "custom" branch of the
manage-smtp action (gitea-startos and vaultwarden-startos both) never calls
getSystemSmtp and works on a box with no system SMTP. Record this as the likely
fit (a digest-only mailbox), built on sdk.inputSpecConstants.smtpInputSpec, plus
the SDK version caveat (our ^0.4.0-beta.66 pin vs the references' 1.x).
Fold the research into the daily-digest item: outbound mail uses the StartOS
0.4 system SMTP account via sdk.getSystemSmtp(effects).const(), delivered to the
Python process as env vars from the daemon exec block (gitea-startos pattern,
mirroring the existing setAnthropicApiKey action). Notes the action+storeJson
config model that replaced the 0.3 manifest Config/Properties spec.
Records the requested per-user daily digest (who emailed which investors +
email substance) as a post-Phase-1 backlog item. Notes the three build-shaping
constraints: the new outbound-SMTP dependency (no current send path), the
Spark-only / never-Claude analysis rule for the un-anonymized substance, and
the internal-digest exemption from the "agents draft, humans send" guardrail.
thesis-seed v1–v4 are superseded by v5 (the version seeded by
thesis_seed.py) and had no inbound references. refresh_seed.sh and
seed/README.md are 0.3.5-era seed-snapshot helpers the 0.4 entrypoint
no longer uses (DEPLOY_040 labels both LEGACY). data/test_write was a
stray 0-byte write-probe. Folder-rename housekeeping; no runtime change.
Cross-repo git-hygiene audit remediation: surface ~/Projects/standards/INBOX.md items at session start, and switch .gitignore to the deny-by-default .claude/* block (shared wiring allow-listed) plus the canonical secrets/env lines — per standards/portability.md.
Lock in the three v0.1.0:74 security/privacy fixes with regression tests, and
fix a same-class soft-delete leak surfaced while writing them.
- backend/test_assets_traversal.py: boots the real server, proves /assets/
path-traversal vectors (incl. a real decoy file and the live crm.db, plain
and URL-encoded) 404 and leak nothing, while a legit asset still serves 200.
- backend/test_soft_delete_reads.py: get-by-id 404s soft-deleted rows and
nested + list-view aggregates exclude soft-deleted children.
- backend/mcp/test_outreach_redaction.py: an unknown free-prose name is
tokenized away from the Claude payload but re-hydrated locally, and the path
fails closed (no Claude call) when the local NER model is down.
- backend/run_tests.py: aggregate runner (each backend/**/test_*.py in its own
subprocess); replaces the manual for-loop. 16/16 green.
A reviewer pass on the tests confirmed the soft-delete filter was missing from
list-view aggregate sub-selects: org contact_count/total_funded and contacts
comm_count/last_contact_date counted soft-deleted rows. Add `deleted_at IS NULL`
to those four (server.py) and regression-cover them.
The reports subsystem (dashboard/pipeline/LP-breakdown, ~16 aggregate queries)
has the same leak and is logged as P2 for a dedicated pass. Not yet built or
deployed — bump the package version before the next s9pk build.
The crm-preview launch config hardcoded an absolute machine path for
CRM_FRONTEND_DIR, breaking the preview on any other clone path. Derive
it from $PWD at launch time so the repo is self-contained. Clears the
sole blocker from the portability-standard audit.
ensure_positioning_framings adds 5 Architect framings to the core
positioning variant group alongside Option A/B, so the group holds 7
candidates and choose_variant retires 6. The two thesis tests still
asserted the pre-framings count of 2 — the tests were stale, not the
seed. Realign them, document the 2+5=7 seed structure in the thesis
guide, and refresh AGENTS.md Current state (13/13 tests green).
Fixes from the 2026-06-12 full-eval (P0 + two P1s); code-only, no schema
change. Without these the "private CRM" premise was breachable on the LAN:
- P0: the /assets/ route joined the request path onto FRONTEND_DIR without
normalizing '..' (get_path/urlparse pass it through), so an unauthenticated
GET /assets/../../data/crm.db read any file the process could — the LP DB,
the JWT signing secret (-> admin-token forgery), the Gmail key. Add a realpath
containment check that 404s anything resolving outside FRONTEND_ROOT.
- P1: the LP-outreach drafter built its redaction Boundary with no ner_fn, so
unknown people/firms in raw email bodies reached Claude in the clear. Pass the
local-Qwen NER backstop (ner_fn=_ner_local), matching architect_grounding;
fails closed via the existing scrub_unavailable path if the local model is down.
- P1: get-by-id handlers leaked soft-deleted records by direct ID. Add
deleted_at IS NULL to every get-by-id path — contacts, organizations,
opportunities, lp_profiles — and to the nested related-data sub-selects in
the contact/opportunity detail payloads, matching the list-handler convention.
Bumps the package to v0.1.0:74 (utils.ts + versions/v0.1.0.74.ts + graph).
Full report in EVALUATION.md; remaining P2/P3 triaged in AGENTS.md Current state.
Move subsystem mechanics (migrations, thesis gate, redaction, ingest,
email, packaging) out of AGENTS.md into docs/guides/<topic>.md, each
scoped by paths: frontmatter and symlinked from .claude/rules/ so Claude
Code lazy-loads them. AGENTS.md keeps whole-repo facts and universal
guardrails plus a one-line index per guide. Fix the inaccurate
".claude/ is gitignored" note — it is tracked.
Replace the hardcoded immense-voyage.local with $START9_BOX_HOST so the
real host lives only in local start-cli context, not the repo. Add a
Current state section for fast session orientation.
Add a concise day-one AGENTS.md (stack, exact build/run/test/deploy
commands, directory layout, conventions, Always/Never). Preserve the
existing CLAUDE.md project constitution as docs/ten31-constitution.md
(referenced from AGENTS.md) and point CLAUDE.md -> AGENTS.md so Claude
Code loads the canonical guide.
Swap the dead "scarcity as the connecting idea" / bitcoin-as-settlement
spine for the v2.0 reserve-asset spine (bitcoin = apex non-debasable
reserve asset; debasement = forcing function; AI = abundance engine;
throughline is an asset-value/capital-flow claim, not settlement; three
seams Energy<->Compute, Debasement<->Bitcoin, AI<->Data-Ownership)
everywhere it was still encoded in live code, the seed, and the docs.
- architect_agent.py / outreach_agent.py: both system prompts carried
"scarcity as the connecting idea" and shipped settlement framing into
every generated draft; rewritten to the reserve-asset spine.
- thesis_seed.py: THROUGHLINE, PILLAR_1, the AI/energy-operator segment
angle, and THESIS_V2 corrected and voice-cleaned (no em dash / "X, not
Y" / "bet"). PILLAR_2/3 (real revenue, founder access) kept.
- ensure_thesis_v2_promoted / revert_thesis_v2_promotion: make the v2.0
spine the working APPROVED spine and re-ground/clean the core nodes,
deployment-state-invariant (structural targeting, not body text) and
fully reversible (captures prior body/title/status/deleted_at). NODE
level only: never sets a thesis_version canonical (guardrail #4); no
hard deletes (guardrail #3). Wired into init_db after the v2 candidate
stage.
- docs/thesis-handoff.md replaced wholesale with the complete v2.0 doc;
Ten31_Agentic_Build_Plan.md + PHASE_1.md throughline glosses updated.
The v2.0 spine remains an unratified draft from the signal-engine
workstream: canonical freeze stays the partners' dual sign-off, and
Appendix-A conviction/exposure figures stay Grant's working read.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Incorporates the signal-engine workstream's v2.0 thesis correction: the spine is bitcoin
as the apex NON-DEBASABLE RESERVE ASSET (debasement = forcing function, AI = abundance
engine), NOT "infrastructure settles on bitcoin" (the settlement/payments claim — Strike's
payments thesis died in backtest). thesis_seed.ensure_thesis_v2_candidate stages the
v2.0 root/forcing-function, throughline, the verifiable-vs-contrarian decomposition, and
the 3 seams (Energy↔Compute, Debasement↔Bitcoin, AI↔Data-Ownership) as CANDIDATE nodes
under the core line (idempotent sentinel; provenance + "unratified, exposure unconfirmed"
on the section). Nothing canonical (guardrail #4). docs/thesis-handoff.md gets a
SUPERSEDED-spine banner pointing to v2.0.
NOT done (gated on partner ratification): the live THROUGHLINE/PILLAR_1 constants and
architect_agent.py's system prompt ("scarcity as the connecting idea") still encode the
old spine — until ratified+updated, Vary/Revise/outreach regenerate the old framing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(1) Voice: _voice_examples now picks the sender's prior sent emails OF THE SAME PURPOSE
(PURPOSE_PATTERNS keyword cues per outreach type), larger sample (8) weighted by purpose
then recency — not just recent. meta carries on_topic for transparency.
(2) Tier-B sending (gmail.compose now authorized in Workspace DWD). New
email_integration/compose.py create_outreach_draft: mints a compose-scoped DWD token for
the sender (credentials._mint/access_token_for parameterized by scope; GMAIL_COMPOSE_SCOPE),
builds an RFC822 message, and POSTs gmail.drafts.create into the SENDER's mailbox — as an
in-thread reply (threadId + In-Reply-To/References, recipient = matched LP address) when
there's an active thread, else a fresh email. NEVER sends — the human sends from Gmail
(guardrails #4, #6). Route POST /api/outreach/gmail-draft; UI "Create Gmail draft" button +
"Open Gmail Drafts" link. Tests: test_compose.py (parse/reply-target/RFC822+threading).
Message construction unit-verified; the live drafts.create runs on the box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Voice upgrade. draft_outreach now learns the SENDER's voice: the codified rules PLUS a
few-shot of that user's own recent sent emails (_voice_examples; from_email = the
sender, de-identified in the same scrub batch as the recipient context, reference-only).
The response returns which of the sender's emails were used (subject + date + recipient),
shown in the UI as "Voice based on: …" — transparency to avoid the black-box problem.
Falls back to rules-only with a clear note when the user has no captured sent email.
Context restructured: _context groups the investor's email by thread and labels the most
recent thread as the "Active conversation (what you are replying to)" with earlier emails
as background, so replies stay on-topic instead of dredging old threads.
Sender email resolved in handle_outreach_draft (users table by user_id). Test extended
(active/background split, voice examples + meta, no-sender fallback). Fixed a UI bug the
preview caught: the manual Draft button was onClick={draft}, which passed the click event
as the investor arg after draft() gained params -> circular-JSON error; now onClick={()=>draft()}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>