Soft-delete + source-count diagnostics; thesis v4 (0.1.0:47)

- DELETE handlers soft-delete (set deleted_at) + cascade contact -> opps/comms/lp
  instead of hard-deleting (guardrail #3); list queries filter deleted rows.
- ingest: chunking excludes soft-deleted records; qdrant delete-by-source-id;
  sync prunes soft-deleted records' vectors incrementally.
- /api/system/status returns raw source-record counts for sanity-checking.
- docs/thesis-seed-v4.md (no "bet" language, scarcity-forward, freedom-tech as
  a banner option, tightened pillars, reworked segments + edge).

Soft-delete verified via the running HTTP server (delete -> hidden + row kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-05 12:20:38 -05:00
parent bdf9bec4ff
commit 3c31b1e8a5
8 changed files with 144 additions and 17 deletions
+5 -5
View File
@@ -91,7 +91,7 @@ def build_chunks(conn):
# communications # communications
for r in conn.execute("""SELECT id, contact_id, type, subject, body, outcome, next_action, communication_date for r in conn.execute("""SELECT id, contact_id, type, subject, body, outcome, next_action, communication_date
FROM communications"""): FROM communications WHERE deleted_at IS NULL"""):
lp, lp_name, person = _contact_lp(r["contact_id"], person_canon, org_canon, name, contact_org) lp, lp_name, person = _contact_lp(r["contact_id"], person_canon, org_canon, name, contact_org)
parts = [p for p in (r["subject"], r["body"], r["outcome"], r["next_action"]) if (p or "").strip()] parts = [p for p in (r["subject"], r["body"], r["outcome"], r["next_action"]) if (p or "").strip()]
chunks.append(_mk(f"communications:{r['id']}", lp, lp_name, person, chunks.append(_mk(f"communications:{r['id']}", lp, lp_name, person,
@@ -99,21 +99,21 @@ def build_chunks(conn):
"\n".join(parts), "communications", r["id"])) "\n".join(parts), "communications", r["id"]))
# contacts.notes # contacts.notes
for r in conn.execute("SELECT id, notes, updated_at FROM contacts WHERE notes IS NOT NULL AND notes <> ''"): for r in conn.execute("SELECT id, notes, updated_at FROM contacts WHERE notes IS NOT NULL AND notes <> '' AND deleted_at IS NULL"):
lp, lp_name, person = _contact_lp(r["id"], person_canon, org_canon, name, contact_org) lp, lp_name, person = _contact_lp(r["id"], person_canon, org_canon, name, contact_org)
chunks.append(_mk(f"contacts.notes:{r['id']}", lp, lp_name, person, chunks.append(_mk(f"contacts.notes:{r['id']}", lp, lp_name, person,
"contact_note", to_epoch(r["updated_at"]), r["notes"], "contacts", r["id"])) "contact_note", to_epoch(r["updated_at"]), r["notes"], "contacts", r["id"]))
# lp_profiles.notes # lp_profiles.notes
for r in conn.execute("""SELECT lp.id, lp.contact_id, lp.notes, lp.updated_at for r in conn.execute("""SELECT lp.id, lp.contact_id, lp.notes, lp.updated_at
FROM lp_profiles lp WHERE lp.notes IS NOT NULL AND lp.notes <> ''"""): FROM lp_profiles lp WHERE lp.notes IS NOT NULL AND lp.notes <> '' AND lp.deleted_at IS NULL"""):
lp, lp_name, person = _contact_lp(r["contact_id"], person_canon, org_canon, name, contact_org) lp, lp_name, person = _contact_lp(r["contact_id"], person_canon, org_canon, name, contact_org)
chunks.append(_mk(f"lp_profiles.notes:{r['id']}", lp, lp_name, person, chunks.append(_mk(f"lp_profiles.notes:{r['id']}", lp, lp_name, person,
"lp_note", to_epoch(r["updated_at"]), r["notes"], "lp_profiles", r["id"])) "lp_note", to_epoch(r["updated_at"]), r["notes"], "lp_profiles", r["id"]))
# opportunities (description + next_step) # opportunities (description + next_step)
for r in conn.execute("""SELECT id, contact_id, name, description, next_step, updated_at for r in conn.execute("""SELECT id, contact_id, name, description, next_step, updated_at
FROM opportunities"""): FROM opportunities WHERE deleted_at IS NULL"""):
lp, lp_name, person = _contact_lp(r["contact_id"], person_canon, org_canon, name, contact_org) lp, lp_name, person = _contact_lp(r["contact_id"], person_canon, org_canon, name, contact_org)
parts = [p for p in (r["name"], r["description"], r["next_step"]) if (p or "").strip()] parts = [p for p in (r["name"], r["description"], r["next_step"]) if (p or "").strip()]
chunks.append(_mk(f"opportunities:{r['id']}", lp, lp_name, person, chunks.append(_mk(f"opportunities:{r['id']}", lp, lp_name, person,
@@ -121,7 +121,7 @@ def build_chunks(conn):
# organizations.description # organizations.description
for r in conn.execute("""SELECT id, description, updated_at FROM organizations for r in conn.execute("""SELECT id, description, updated_at FROM organizations
WHERE description IS NOT NULL AND description <> ''"""): WHERE description IS NOT NULL AND description <> '' AND deleted_at IS NULL"""):
lp = org_canon.get(r["id"]) lp = org_canon.get(r["id"])
chunks.append(_mk(f"organizations.description:{r['id']}", lp, name.get(lp), None, chunks.append(_mk(f"organizations.description:{r['id']}", lp, name.get(lp), None,
"org_note", to_epoch(r["updated_at"]), r["description"], "organizations", r["id"])) "org_note", to_epoch(r["updated_at"]), r["description"], "organizations", r["id"]))
+13
View File
@@ -48,3 +48,16 @@ def upsert(points):
def count(): def count():
status, data = _req("POST", f"/collections/{COL}/points/count", {"exact": True}) status, data = _req("POST", f"/collections/{COL}/points/count", {"exact": True})
return (data or {}).get("result", {}).get("count") return (data or {}).get("result", {}).get("count")
def delete_by_source_ids(source_ids):
"""Delete all chunks belonging to the given CRM source records (by payload
source_id) — used to prune soft-deleted records from the index."""
ids = list(source_ids)
if not ids:
return None
status, data = _req("POST", f"/collections/{COL}/points/delete?wait=true",
{"filter": {"must": [{"key": "source_id", "match": {"any": ids}}]}})
if status not in (200, 201):
raise RuntimeError(f"delete points -> {status}: {data}")
return data
+16
View File
@@ -60,6 +60,18 @@ def _state_set(conn, key, value):
(key, value, _now())) (key, value, _now()))
def _deleted_source_ids(conn, since):
"""CRM records soft-deleted since the watermark — their chunks get pruned."""
ids = set()
for tbl in ("contacts", "organizations", "opportunities", "communications", "lp_profiles"):
try:
for r in conn.execute(f"SELECT id FROM {tbl} WHERE deleted_at IS NOT NULL AND deleted_at > ?", (since,)):
ids.add(r["id"])
except Exception:
pass
return ids
def _changed_source_ids(conn, since): def _changed_source_ids(conn, since):
changed = set() changed = set()
for tbl, model in _CHANGE_TABLES: for tbl, model in _CHANGE_TABLES:
@@ -91,6 +103,10 @@ def run(db, recreate=False, fuzzy=False, batch=32):
if last is None or recreate: if last is None or recreate:
mode, target = "full", all_chunks mode, target = "full", all_chunks
else: else:
# Prune chunks of records soft-deleted since the last sync.
deleted = _deleted_source_ids(conn, last)
if deleted:
qdrant_io.delete_by_source_ids(deleted)
changed = _changed_source_ids(conn, last) changed = _changed_source_ids(conn, last)
mode, target = "incremental", [c for c in all_chunks mode, target = "incremental", [c for c in all_chunks
if (c["source_model"], c["source_id"]) in changed] if (c["source_model"], c["source_id"]) in changed]
+25 -8
View File
@@ -2012,7 +2012,7 @@ class CRMHandler(BaseHTTPRequestHandler):
(SELECT MAX(communication_date) FROM communications WHERE contact_id = c.id) as last_contact_date (SELECT MAX(communication_date) FROM communications WHERE contact_id = c.id) as last_contact_date
FROM contacts c FROM contacts c
LEFT JOIN organizations o ON c.organization_id = o.id LEFT JOIN organizations o ON c.organization_id = o.id
WHERE 1=1 WHERE 1=1 AND c.deleted_at IS NULL
""" """
args = [] args = []
@@ -2197,7 +2197,13 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.send_error_json("Contact not found", 404) return self.send_error_json("Contact not found", 404)
_sync_contact_to_fundraising_state(conn, row_to_dict(existing), actor_user_id=user['user_id'], remove=True) _sync_contact_to_fundraising_state(conn, row_to_dict(existing), actor_user_id=user['user_id'], remove=True)
conn.execute("DELETE FROM contacts WHERE id = ?", (contact_id,)) # Soft-delete (guardrail #3 — never hard-delete): mark deleted_at and
# cascade to the contact's opportunities, communications, and lp_profile.
_ts = now()
conn.execute("UPDATE contacts SET deleted_at = ?, updated_at = ? WHERE id = ?", (_ts, _ts, contact_id))
conn.execute("UPDATE opportunities SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
conn.execute("UPDATE communications SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
conn.execute("UPDATE lp_profiles SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
log_audit(conn, user['user_id'], 'contact', contact_id, 'delete') log_audit(conn, user['user_id'], 'contact', contact_id, 'delete')
conn.commit() conn.commit()
conn.close() conn.close()
@@ -2213,7 +2219,7 @@ class CRMHandler(BaseHTTPRequestHandler):
SELECT o.*, SELECT o.*,
(SELECT COUNT(*) FROM contacts WHERE organization_id = o.id) as contact_count, (SELECT COUNT(*) FROM contacts WHERE organization_id = o.id) as contact_count,
(SELECT COALESCE(SUM(commitment_amount), 0) FROM opportunities WHERE organization_id = o.id AND stage = 'funded') as total_funded (SELECT COALESCE(SUM(commitment_amount), 0) FROM opportunities WHERE organization_id = o.id AND stage = 'funded') as total_funded
FROM organizations o WHERE 1=1 FROM organizations o WHERE 1=1 AND o.deleted_at IS NULL
""" """
args = [] args = []
if params.get('search'): if params.get('search'):
@@ -2314,7 +2320,7 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.send_error_json("Organization not found", 404) return self.send_error_json("Organization not found", 404)
conn.execute("UPDATE contacts SET organization_id = NULL WHERE organization_id = ?", (org_id,)) conn.execute("UPDATE contacts SET organization_id = NULL WHERE organization_id = ?", (org_id,))
conn.execute("DELETE FROM organizations WHERE id = ?", (org_id,)) conn.execute("UPDATE organizations SET deleted_at = ?, updated_at = ? WHERE id = ?", (now(), now(), org_id))
log_audit(conn, user['user_id'], 'organization', org_id, 'delete') log_audit(conn, user['user_id'], 'organization', org_id, 'delete')
conn.commit() conn.commit()
conn.close() conn.close()
@@ -2333,7 +2339,7 @@ class CRMHandler(BaseHTTPRequestHandler):
LEFT JOIN contacts c ON op.contact_id = c.id LEFT JOIN contacts c ON op.contact_id = c.id
LEFT JOIN organizations o ON op.organization_id = o.id LEFT JOIN organizations o ON op.organization_id = o.id
LEFT JOIN users u ON op.owner_id = u.id LEFT JOIN users u ON op.owner_id = u.id
WHERE 1=1 WHERE 1=1 AND op.deleted_at IS NULL
""" """
args = [] args = []
if params.get('stage'): if params.get('stage'):
@@ -2524,7 +2530,7 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close() conn.close()
return self.send_error_json("Opportunity not found", 404) return self.send_error_json("Opportunity not found", 404)
conn.execute("DELETE FROM opportunities WHERE id = ?", (opp_id,)) conn.execute("UPDATE opportunities SET deleted_at = ?, updated_at = ? WHERE id = ?", (now(), now(), opp_id))
log_audit(conn, user['user_id'], 'opportunity', opp_id, 'delete') log_audit(conn, user['user_id'], 'opportunity', opp_id, 'delete')
conn.commit() conn.commit()
conn.close() conn.close()
@@ -2541,7 +2547,7 @@ class CRMHandler(BaseHTTPRequestHandler):
FROM communications cm FROM communications cm
LEFT JOIN contacts c ON cm.contact_id = c.id LEFT JOIN contacts c ON cm.contact_id = c.id
LEFT JOIN users u ON cm.created_by = u.id LEFT JOIN users u ON cm.created_by = u.id
WHERE 1=1 WHERE 1=1 AND cm.deleted_at IS NULL
""" """
args = [] args = []
if params.get('contact_id'): if params.get('contact_id'):
@@ -2810,7 +2816,7 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close() conn.close()
return self.send_error_json("Communication not found", 404) return self.send_error_json("Communication not found", 404)
conn.execute("DELETE FROM communications WHERE id = ?", (comm_id,)) conn.execute("UPDATE communications SET deleted_at = ?, updated_at = ? WHERE id = ?", (now(), now(), comm_id))
log_audit(conn, user['user_id'], 'communication', comm_id, 'delete') log_audit(conn, user['user_id'], 'communication', comm_id, 'delete')
conn.commit() conn.commit()
conn.close() conn.close()
@@ -3492,6 +3498,17 @@ class CRMHandler(BaseHTTPRequestHandler):
except Exception: except Exception:
out['pending_merge_candidates'] = None out['pending_merge_candidates'] = None
out['index_job'] = entity_jobs.get_status() if entity_jobs else None out['index_job'] = entity_jobs.get_status() if entity_jobs else None
# Raw source-record counts, so the resolved canonical numbers can be
# sanity-checked against what's actually in the CRM.
try:
out['source_counts'] = {
'contacts': conn.execute("SELECT COUNT(*) FROM contacts WHERE deleted_at IS NULL").fetchone()[0],
'organizations': conn.execute("SELECT COUNT(*) FROM organizations WHERE deleted_at IS NULL").fetchone()[0],
'fundraising_investors': conn.execute("SELECT COUNT(*) FROM fundraising_investors").fetchone()[0],
'fundraising_contacts': conn.execute("SELECT COUNT(*) FROM fundraising_contacts").fetchone()[0],
}
except Exception:
out['source_counts'] = None
conn.close() conn.close()
self.send_json({"data": out}) self.send_json({"data": out})
+52
View File
@@ -0,0 +1,52 @@
# Ten31 Thesis — Seed v4
*Revision after Grant's v3 feedback. Changes made: removed the "in bitcoin longer than almost anyone" claim from the throughline (not true); leaned harder into scarcity as the core idea; stopped treating freedom tech as a throwaway fourth item and instead tried it as the connecting theme across three domains (an experiment to react to); cut the kitchen-sink list in pillar 2; dropped the generic "special founders" framing entirely; rewrote the edge around being sought out and pursuing exclusive opportunities (not just "first look"); rewrote the segment angles per your notes. Also: no em dashes and no "we do X, not Y" phrasing, since those read as AI-generated.*
---
## The one-liner (two options to react to)
> **Option A (scarcity-forward):** Ten31 invests in the infrastructure of scarcity. We back the bitcoin, energy, and AI companies that produce and secure the scarce resources these markets are built on.
> **Option B (freedom-tech as the banner):** Ten31 invests in freedom technology. We back the bitcoin, energy, and AI companies building the foundation for a more sovereign, less centralized economy.
*(Option B treats "freedom tech" the way a16z treats "American Dynamism": one strong banner that the three domains sit underneath, rather than a vague fourth bucket. It's a bigger swing. Worth deciding which framing you want before we go deeper.)*
## The throughline
Bitcoin, AI, and energy are three of the largest growth markets of the next decade, and they run on the same scarce resources: cheap energy, computing power, and money that cannot be printed at will. As these markets grow, the value collects in whoever supplies and secures those scarce inputs. Ten31 invests in that infrastructure. We have strong conviction in where this is going, and we put capital behind the companies building the foundation underneath it.
## What we invest in (3 pillars)
**1. Scarcity is the whole opportunity.**
Every one of these markets is bottlenecked on something scarce. AI and bitcoin both compete for cheap energy and compute. Both settle on money that is hard to produce. The companies that own and supply the scarce side of that equation capture the value as demand grows. That is where we invest.
**2. We invest in foundational infrastructure with real revenue.**
The companies that generate energy, secure capital, and power computation. These are real businesses earning real money from real demand today, not stories about what might happen later.
**3. Founders seek us out, and we lead deals others never see.**
This is our edge, and it is concrete. Founders in this space come to us because of our experience and our genuine alignment with bitcoin. We pursue and lead opportunities that are exclusive to us and not available to the broader market. People in this ecosystem know our track record and want us on their side.
## Why it holds up under questioning
Are these large, growing markets? Yes, and obviously so. Do they share the same scarce inputs? Yes, energy and compute are the bottleneck for both AI and mining, and sound money is the settlement layer. Has anyone built a strategy across all of it with our position in the ecosystem? Very few have. Are these real businesses? Yes, with real revenue.
## The proof
$200M+ deployed across two funds into 30+ of the strongest companies in the space, including Strike in bitcoin financial services and Start9 in personal datacenters and edge AI, plus energy and mining infrastructure. Fund III continues the same strategy.
## Per-segment angle
- **Bitcoin-native HNWIs (the OGs).** Bitcoin only wins if people build on it. Holding is not enough. You care about making bitcoin succeed, and so do we. We put capital behind the companies that turn bitcoin from an asset into a working economy, and that is how it actually wins. *(Lead with the mission of making bitcoin succeed, not our tenure. These investors may have been in longer than us, and money is not their only motivation.)*
- **Institutions.** Exposure to the bitcoin, energy, and AI buildout through a team whose institutional investing experience is unmatched in this space, particularly Grant's. The secular trends are real, and we have the discipline and background to invest in them at an institutional standard.
- **Family offices.** A long-horizon allocation to growth markets grounded in real, revenue-generating businesses, run by a team with deep experience and credibility in the space.
- **Smaller accredited ($100k).** The same thesis our most convicted investors back, at an accessible entry point.
- **AI and energy operators.** You live the scarcity of energy and compute every day. We invest across the stack that supplies it.
## Voice
Direct, concrete, confident, conviction-driven. Plain sentences a serious LP can verify in their head. Real examples and numbers. Avoid abstract philosophy, avoid "betting"/"gambling" language entirely, avoid em dashes and "X, not Y" constructions, and avoid kitchen-sink lists.
---
*Two decisions for you: (1) Option A vs Option B for the framing, and whether to elevate "freedom tech" to the banner or keep it implicit. (2) Whether pillar 3 (being sought out / exclusive deals) is the edge you want to lead with. React and we go to v5.*
+3 -2
View File
@@ -11,8 +11,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:42 (Gmail integration) / 0.1.0:43 (Gmail POST-body hotfix) // * 0.1.0:42 (Gmail integration) / 0.1.0:43 (Gmail POST-body hotfix)
// * 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action) // * 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action)
// * 0.1.0:45 (Phase-1 thesis system; dual approval; merge review; in-app index) // * 0.1.0:45 (Phase-1 thesis system; dual approval; merge review; in-app index)
// * Current: 0.1.0:46 (packaging fix: ship full backend so migrations run + endpoints work) // * 0.1.0:46 (packaging fix: ship full backend so migrations run + endpoints work)
export const PACKAGE_VERSION = '0.1.0:46' // * Current: 0.1.0:47 (soft-delete instead of hard-delete; source-count diagnostics)
export const PACKAGE_VERSION = '0.1.0:47'
export const DATA_MOUNT_PATH = '/data' export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080 export const WEB_PORT = 8080
+3 -2
View File
@@ -7,8 +7,9 @@ import { v_0_1_0_43 } from './v0.1.0.43'
import { v_0_1_0_44 } from './v0.1.0.44' import { v_0_1_0_44 } from './v0.1.0.44'
import { v_0_1_0_45 } from './v0.1.0.45' import { v_0_1_0_45 } from './v0.1.0.45'
import { v_0_1_0_46 } from './v0.1.0.46' import { v_0_1_0_46 } from './v0.1.0.46'
import { v_0_1_0_47 } from './v0.1.0.47'
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_0_1_0_46, current: v_0_1_0_47,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45], other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46],
}) })
+27
View File
@@ -0,0 +1,27 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Soft-delete + diagnostics release.
// * The CRM DELETE endpoints now SOFT-delete (set deleted_at) instead of
// hard-deleting (CLAUDE.md guardrail #3), cascading to a contact's
// opportunities/communications/lp_profile. List queries filter deleted rows
// out, so deletes still disappear from the UI but nothing is destroyed.
// * The ingest pipeline excludes soft-deleted records from chunking and prunes
// their vectors from Qdrant on incremental sync (delete-by-source-id).
// * /api/system/status now also returns raw source-record counts so the
// resolved canonical numbers can be sanity-checked.
// No data migration; the deleted_at columns already exist (migration 0001).
export const v_0_1_0_47 = VersionInfo.of({
version: '0.1.0:47',
releaseNotes: {
en_US: [
'Records are now soft-deleted instead of permanently destroyed (deletes',
'disappear from the UI but are recoverable), the search index prunes',
'deleted records, and the System Status data now includes raw source-record',
'counts so the resolved entity numbers can be checked.',
].join(' '),
},
migrations: {
up: async () => {},
down: async () => {},
},
})