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:
@@ -91,7 +91,7 @@ def build_chunks(conn):
|
||||
|
||||
# communications
|
||||
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)
|
||||
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,
|
||||
@@ -99,21 +99,21 @@ def build_chunks(conn):
|
||||
"\n".join(parts), "communications", r["id"]))
|
||||
|
||||
# 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)
|
||||
chunks.append(_mk(f"contacts.notes:{r['id']}", lp, lp_name, person,
|
||||
"contact_note", to_epoch(r["updated_at"]), r["notes"], "contacts", r["id"]))
|
||||
|
||||
# lp_profiles.notes
|
||||
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)
|
||||
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"]))
|
||||
|
||||
# opportunities (description + next_step)
|
||||
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)
|
||||
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,
|
||||
@@ -121,7 +121,7 @@ def build_chunks(conn):
|
||||
|
||||
# organizations.description
|
||||
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"])
|
||||
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"]))
|
||||
|
||||
@@ -48,3 +48,16 @@ def upsert(points):
|
||||
def count():
|
||||
status, data = _req("POST", f"/collections/{COL}/points/count", {"exact": True})
|
||||
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
|
||||
|
||||
@@ -60,6 +60,18 @@ def _state_set(conn, key, value):
|
||||
(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):
|
||||
changed = set()
|
||||
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:
|
||||
mode, target = "full", all_chunks
|
||||
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)
|
||||
mode, target = "incremental", [c for c in all_chunks
|
||||
if (c["source_model"], c["source_id"]) in changed]
|
||||
|
||||
+25
-8
@@ -2012,7 +2012,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
(SELECT MAX(communication_date) FROM communications WHERE contact_id = c.id) as last_contact_date
|
||||
FROM contacts c
|
||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
||||
WHERE 1=1
|
||||
WHERE 1=1 AND c.deleted_at IS NULL
|
||||
"""
|
||||
args = []
|
||||
|
||||
@@ -2197,7 +2197,13 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
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)
|
||||
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')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -2213,7 +2219,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
SELECT o.*,
|
||||
(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
|
||||
FROM organizations o WHERE 1=1
|
||||
FROM organizations o WHERE 1=1 AND o.deleted_at IS NULL
|
||||
"""
|
||||
args = []
|
||||
if params.get('search'):
|
||||
@@ -2314,7 +2320,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.send_error_json("Organization not found", 404)
|
||||
|
||||
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')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -2333,7 +2339,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
LEFT JOIN contacts c ON op.contact_id = c.id
|
||||
LEFT JOIN organizations o ON op.organization_id = o.id
|
||||
LEFT JOIN users u ON op.owner_id = u.id
|
||||
WHERE 1=1
|
||||
WHERE 1=1 AND op.deleted_at IS NULL
|
||||
"""
|
||||
args = []
|
||||
if params.get('stage'):
|
||||
@@ -2524,7 +2530,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
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')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -2541,7 +2547,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
FROM communications cm
|
||||
LEFT JOIN contacts c ON cm.contact_id = c.id
|
||||
LEFT JOIN users u ON cm.created_by = u.id
|
||||
WHERE 1=1
|
||||
WHERE 1=1 AND cm.deleted_at IS NULL
|
||||
"""
|
||||
args = []
|
||||
if params.get('contact_id'):
|
||||
@@ -2810,7 +2816,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
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')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -3492,6 +3498,17 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
out['pending_merge_candidates'] = 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()
|
||||
self.send_json({"data": out})
|
||||
|
||||
|
||||
@@ -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.*
|
||||
@@ -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: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)
|
||||
// * Current: 0.1.0:46 (packaging fix: ship full backend so migrations run + endpoints work)
|
||||
export const PACKAGE_VERSION = '0.1.0:46'
|
||||
// * 0.1.0:46 (packaging fix: ship full backend so migrations run + endpoints work)
|
||||
// * 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 WEB_PORT = 8080
|
||||
|
||||
@@ -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_45 } from './v0.1.0.45'
|
||||
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({
|
||||
current: v_0_1_0_46,
|
||||
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],
|
||||
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, v_0_1_0_46],
|
||||
})
|
||||
|
||||
@@ -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 () => {},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user