Version: 1.0 Date: 2026-07-09 Owner: kee (mars) Audience: every vertical that writes to the MARS canonical layer — reit, credit, insurance, signals, bios, intl, lp, or any future spin-off.
Every vertical inevitably needs to mint entities into MARS's canonical layer — companies, persons, investors, advisors, institutions. Get the match-vs-mint decision right and integration is free. Get it wrong and you contribute to the same class of pain we've absorbed repeatedly on the mars side:
target.root_domain == acquirer.root_domain and we minted separate canonicals for what was ONE companyEvery one of those cost real cleanup effort. This doctrine exists so new verticals never contribute to that list.
If you remember nothing else, remember these 5:
is_sentinel_*() filter — use it.identity_decisions_v2. With methodology_version + one-sentence reason. Reversibility is a hard requirement, not a nice-to-have.merge_entities.py supports all 5 canonical kinds — use it.Before every INSERT into a canonical table, run the resolution chain:
entity_resolution_pending=TRUE; low or no match → mint new.You NEVER go straight to INSERT. If your code does INSERT INTO companies_v2 (...) VALUES (...) without first checking, you're the problem.
Name matches are necessary but not sufficient. For every candidate ranking > 0, pass Qwen a rich verification prompt:
Qwen must cite which signals matched. If reasoning is only "same name" → treat as no-match.
Reference prompts by entity kind:
- Companies: mars_feed/resolve_pending_companies_qwen.py
- Persons: mars_feed/adv_bio_pilot.py
- Investors: mars_feed/qwen_investor_families.py
Every canonical entity kind has its own sentinel filter. Reject at the extractor OR at the ETL layer, but always BEFORE the resolution chain runs.
| kind | sentinel filter | source |
|---|---|---|
| companies | sentinel_names.is_sentinel_name() |
mars_feed/sentinel_names.py (40+ patterns) |
| persons | is_person_sentinel() |
bios_pipeline/MINTING_PEOPLE.md Rule 1 |
| investors | reuse is_person_sentinel variant |
for firm-name placeholder ("Family Office", "Undisclosed VC") |
| advisors | reuse is_person_sentinel variant |
for firm-name placeholder ("Advisor Firm A", "TBD") |
| institutions | small custom list | for "University X", "The College", "Business School" |
Sentinel rows are hard NULL / skip, not "mint with warn tier". They pollute downstream aggregates.
identity_decisions_v2Schema (already exists):
mars.identity_decisions_v2 (
entity_kind text, -- 'company' | 'person' | 'investor' | 'advisor' | 'institution' | 'funding_deal' | 'merger_deal'
entity_id uuid, -- primary
related_entity_id uuid, -- for merge or link decisions
decision text, -- e.g., 'match', 'mint', 'merge_candidate', 'known_distinct'
reason text, -- one-sentence justification
methodology_version text, -- e.g., 'signals-lawsuit-v1', 'reit-mint-v1'
created_at timestamptz
)
Every match, mint, or merge decision by your ETL writes here. Consumers (health audits, cleanup sweeps, MCP tools) inspect this ledger. Six months from now when someone asks "why does this company have three canonical rows?" — the answer is one SQL query away.
Reversibility contract: any decision your vertical wrote can be reverted by filtering on your methodology_version tag and running the inverse. Never write in a way that isn't reversible.
Even with rules U1-U4, some dupes slip through — name variants you didn't catch, edge cases, races. Every vertical runs a weekly dedup sweep on the rows minted under its methodology tags:
merge_entities.py (registry supports all 5 canonicals — see [[ref-merge-entities-tool]])Reference doctrine: [[ref-qwen-verified-dedup-pattern]] — proven 4× in one 14h session (07-07/08) on funding dupes + mega-round + investor aliases + phantom investors. $0 cost, 0 human review, 0 false positives.
Canonical table: mars.companies_v2(company_id UUID PRIMARY KEY)
Strong identifiers (partial UNIQUE indexed):
| column | source | UNIQUE index |
|---|---|---|
cik |
SEC EDGAR (public companies) | companies_v2_cik_uq |
ticker |
exchange ticker | companies_v2_ticker_uq |
primary_domain |
firm website | (btree, not UNIQUE — multiple rows may share for holdcos) |
Sentinel filter: mars_feed/sentinel_names.py — 40+ exact strings + 4 regex patterns. Rejects "Unknown Target", "TBD Health", "Confidential Parent", "Unnamed KOSDAQ-listed company", etc. Extend the EXACT_SENTINELS frozenset when new patterns surface.
Normalization: mars_feed/name_aliases.py:normalize_name() — STRICT suffix-only version safe for blind cross-row matching. normalize_name_aggressive() exists but false-positives on "Pinnacle Technology Solutions" / "Pinnacle Financial Corporation" — only use when you have CIK or domain confirmation.
Match strategy (Rule U1 for companies):
1. CIK — WHERE cik = %s → definitive
2. Ticker — WHERE ticker = %s → definitive
3. Primary domain — WHERE primary_domain = %s (may return multiple; disambiguate)
4. Name (strict) — WHERE LOWER(primary_name) = LOWER(normalize_name(%s))
5. Name (variants) — WHERE %s = ANY(name_variants) — do this BEFORE trigram. It's deterministic, free, and catches the "Kroger" → "The Kroger Co." class that trigram won't reliably catch. Query pattern (per the GIN + LIMIT 1 planner trap in CLAUDE.md — bare @> ... LIMIT 1 on <200K rows picks Seq Scan):
sql
WITH found AS MATERIALIZED (
SELECT company_id FROM companies_v2 WHERE name_variants @> ARRAY[$1]
) SELECT * FROM found LIMIT 1
MATERIALIZED hides the LIMIT from the planner → Bitmap Index Scan on companies_v2_name_variants_gin, ~5ms cold. Reit surfaced this as a class 2026-07-13.
6. Trigram fuzzy — similarity(LOWER(primary_name), LOWER(%s)) >= 0.85
7. Qwen verify on the candidate set with rich signals — but see the fund/vehicle warning in Rule U2 below. "Is Blackstone Real Estate Partners the same entity as Blackstone Europe Real Estate?" is world knowledge, NOT context Qwen can read out of a headline. For the fund/vehicle/regional-arm class, layer a deterministic core-token guard on top of Qwen — auto-attach only when the two names share a distinctive core AND the pick is the only candidate with that core; otherwise log merge_review_needed (NOT merge_candidate — see the §identity_decisions_v2 gotcha).
Reference impl (v1 legacy path): mars_feed/etl_company_id.py:find_or_create_company(). New verticals should NOT call this directly — use the mint API (mars-feed/mint/) which handles the strong-ID ladder + external_ids match + near-collision + reversibility uniformly.
find_or_create_company now accepts CIK-only calls (no domain). Historically returned no_domain and blocked any SEC-filer path where the company's website wasn't known — insider's Form 4 mints and adrian's Form D mass-mint had to route around this. The v1 news-mint pipeline (only remaining caller) still passes a domain, so this is a widening, not a behavior change.Post-mint dedup: mars_feed/cleanup_company_name_collisions_tier_a.py — same-brand merge candidates + CIK-conflict resolution. Run weekly on rows minted under your methodology.
Canonical table: mars.persons_v2(person_id UUID PRIMARY KEY)
Strong identifiers:
| column | source | UNIQUE index |
|---|---|---|
crd |
ADV 2B supplements, Form D reps, Schedule A/B (individual CRD, distinct from firm CRD in investors_v2.crd) |
persons_v2_crd_uq (added 2026-07-09) |
linkedin_url |
firm websites | pending — task #156 |
cik |
SEC filings that name individuals (13D reporting persons) | check for UNIQUE index |
FULL doctrine: bios_pipeline/MINTING_PEOPLE.md (10 rules, drop-in code for sentinel filter, name normalization with 20-entry nickname map, multi-signal Qwen prompt).
Reference impl:
- mars_feed/adv_bio_pilot.py — end-to-end (proven 2026-07-08)
- mars_feed/canonical_resolvers.py:resolve_or_mint_officer_person — runtime resolver used by Phase G
- mars_feed/etl_form_d_officers.py — bulk mint at 1.4M-row scale
When to link vs mint: same person on multiple firms is common. Employer overlap in prior_employers[] should trigger match, not separate mints. Rule 4 of MINTING_PEOPLE.md covers this.
Canonical table: mars.investors_v2(investor_id UUID PRIMARY KEY)
Strong identifiers:
| column | source | UNIQUE index |
|---|---|---|
crd |
ADV Part 1A (firm CRD, distinct from person CRD in persons_v2.crd) |
check \d mars.investors_v2 |
adv_cik |
ADV filings | check |
Sentinel filter: use the person-sentinel variant + specific firm placeholders ("Family Office", "Undisclosed VC", "Series-Only", "Endowments and foundations" — this last one is a real ExtractExtracted-from-article-prose contamination we cleaned up 2026-07-07).
Normalization: mars_feed/canonical_resolvers.py:INV_SUFFIX_PATTERNS — strips LLC / Inc / LP / GP / GmbH / etc. Reuse; don't fork.
Match strategy:
1. CRD — definitive for RIA-registered
2. Name (strict) after suffix strip
3. Trigram fuzzy on LOWER(primary_name) with sim ≥ 0.85
4. Name variants array match
5. Family parent match (if candidate's parent_investor_id matches yours or vice versa, both are same-family variants)
6. Qwen verify with (name / RAUM / registration_type / family_canonical / recent-deal-count)
Family rollup pattern: sub-vehicles get family_canonical_name + parent_investor_id pointing to the family head. See the 2026-07-08 a16z + Andreessen Horowitz cleanup as the canonical example.
Reference impl: mars_feed/canonical_resolvers.py:resolve_or_mint_investor + mars_feed/enrich_investors_with_adv.py.
Post-mint dedup: mars_feed/cleanup_investor_dupes_tier_a.py — CRD-conflict + fund-series detection. Run weekly.
Canonical table: mars.advisors_v2(advisor_id UUID PRIMARY KEY)
Strong identifiers: no external strong IDs (M&A advisor firms don't have a single canonical ID like CIK). Match relies on name + fuzzy + Qwen.
Sentinel filter: reuse investor variant.
Normalization: mars_feed/canonical_resolvers.py:ADV_SUFFIX_PATTERNS (extends investor with LLP / PLLC / P.C. suffix strip — common in law firms).
Match strategy: 1. Name (strict) after suffix strip 2. Trigram fuzzy with sim ≥ 0.85 3. Name variants match 4. Qwen verify with (name / practice area if known / deal frequency)
Reference impl: mars_feed/canonical_resolvers.py:resolve_or_mint_advisor.
Canonical table: mars.institutions_v2(institution_id UUID PRIMARY KEY)
Universities, business schools, accelerators, professional bar associations. Used by person_education_v2 bridge and (in principle) any future "person attended X" or "person licensed by Y" pattern.
Strong identifiers: none universal. Some universities have a IPEDS ID or a DUNS but we don't ingest those.
Sentinel filter: small — "University", "The College", "Business School", "N/A" alone.
Normalization: strip "University of" prefix variance ("University of California, Berkeley" vs "UC Berkeley" vs "Berkeley"), handle "MIT" vs "Massachusetts Institute of Technology".
Match strategy: name variants + trigram fuzzy + Qwen verify with (name / geography / degree types).
Reference impl: mars_feed/canonical_resolvers.py:resolve_or_mint_institution + mars_feed/migrate_institutions_to_canonical.py.
identity_decisions_v2 ledgerConsumed by every cleanup sweep, health audit, and MCP-facing tool. Standard decisions your vertical writes:
| decision | meaning |
|---|---|
match |
resolution chain found existing canonical, bio/attribute attached |
mint |
no match, new canonical row created |
merge_candidate |
executable verb — merge_entities.py will fuse these on the next weekly run. Only use when the merge decision is FINAL (Qwen SAME + guard passed, or human-adjudicated). |
merge_review_needed |
resolver flagged, NOT yet adjudicated. merge_entities.py does NOT consume this. Use whenever "same or different" is still open (Qwen said SAME but the guard failed / candidate is ambiguous / needs Grok-web escalation). |
merge |
executed by merge_entities.py (write from mars, not your code) |
known_distinct |
Qwen verified these are DIFFERENT entities; don't re-consider |
crd_conflict_distinct |
scraped entity has different strong ID than name-matching existing; DIFFERENT people |
sentinel_name_nulled |
detected as sentinel after mint; primary_name → NULL, row preserved for bridges |
Every write includes methodology_version (your <vertical>-<source>-v<n> prefix) + one-sentence reason field.
⚠️ Gotcha (reit surfaced 2026-07-13): merge_candidate is a load-bearing executable verb, not a note-to-self. A vertical that writes "candidate" in the everyday English sense — "this is a candidate to review later" — will get silent merges on the next merge_entities.py --entity <kind> --apply run. Reit nearly shipped this bug: their first cut logged fund-vs-vehicle ambiguities (Blackstone REP vs Blackstone Europe RE) as merge_candidate, which would have fused two distinct entities on the weekly sweep. Use merge_review_needed for anything pre-adjudication; promote to merge_candidate only when the merge decision is final.
Run weekly (cron or timer) against your minted rows. Reference [[ref-qwen-verified-dedup-pattern]]:
# Pseudocode — see mars_feed/cleanup_investor_dupes_tier_a.py for full template
candidates = sql_detect_suspected_dupes(entity_kind='company',
methodology_version=YOUR_TAG)
verdicts = qwen_batch_classify(candidates) # SAME_ENTITY | DIFFERENT | UNCERTAIN
for pair in verdicts:
if pair.verdict == 'SAME_ENTITY':
merge_via_merge_entities_py(pair.keep, pair.absorb)
log_decision('merge', ..., methodology_version=YOUR_TAG)
elif pair.verdict == 'DIFFERENT':
log_decision('known_distinct', ..., methodology_version=YOUR_TAG)
Cost: ~$0 (Qwen on bench3). Wall clock: seconds to minutes depending on candidate count.
merge_entities.py executormars_feed/merge_entities.py — atomic merger tool. Consumes merge_candidate decisions from identity_decisions_v2 and executes:
pg_catalog.pg_constraint (auto-adapts to new bridge tables — reit's real_estate_deals_v2.company_id was picked up on day 1 with 0 code changes)--verifySupports all 5 canonical kinds out of the box. You do NOT write your own merge code. When your dedup sweep decides "SAME_ENTITY", it queues a merge_candidate decision and lets merge_entities.py execute.
See [[ref-merge-entities-tool]] for full doctrine.
Add to mars_feed/health.py:
primary_name matches a sentinel pattern (should be 0)entity_resolution_pending=TRUE rows > 7 days old under your methodologyEach assertion follows the check_<vertical>_<metric>() shape. Include in the daily 13:00 UTC email digest.
Rare. In practice, every vertical's entities fit one of the 5 existing kinds:
Consider a NEW canonical kind ONLY when: 1. Your entity type has significantly different attributes AND 2. No existing kind can be extended without polluting other verticals' queries AND 3. You'll have >10K rows AND 4. Cross-vertical consumers will want to reference this kind
If ANY of those fails, extend an existing kind. Adding external_ids JSONB attributes or adding sparse columns to an existing canonical is almost always the right move.
If ALL of those pass, team-mail mars slug with a design proposal BEFORE writing any code. mars owns the canonical layer.
When a new vertical spins up:
<vertical>-<source>-v<n> methodology tag schememars_feed/health.py ACCEPTED_PREFIXES tuplebios_pipeline/MINTING_PEOPLE.md too — 10 rules go deepidentity_decisions_v2 with your methodology tagmars slug on first-100-rows landing so we can spot-checkInsider found both of these during the 179K Form-4 mint on 2026-07-12. Neither is a bug in psycopg2 per se; they're behavioral defaults that silently corrupt your writes or your logs if you don't know them.
register_uuid() before iterating any uuid[] columnWithout psycopg2.extras.register_uuid(), psycopg2 returns a uuid[] column as its string representation: '{a1b2c3-...,d4e5f6-...,...}'. Looks like a list. Isn't.
company_ids = row['company_ids'] # str, not list
set().update(company_ids) # iterates CHARACTERS
# → {',', '-', '0', '1', ..., '{', '}'} ← silent corruption
execute_values catches type mismatches at INSERT, but a lenient TEXT[] or JSONB column will swallow the garbage. Rule: register once at every connection open.
import psycopg2, psycopg2.extras
conn = psycopg2.connect(...)
psycopg2.extras.register_uuid() # do this every time
cur.rowcount after paged execute_values reports only the LAST pagepsycopg2.extras.execute_values(cur, "INSERT ...", data, page_size=500)
print(f"inserted {cur.rowcount}") # prints 500, even if data had 179,544 rows
execute_values batches into pages of page_size and issues one INSERT per page. cur.rowcount reflects the most recent statement only.
If the count is load-bearing (health check, status email, log line, decision-log summary), do NOT trust cur.rowcount. Two safe patterns:
# Option A: verify from the table
cur.execute("SELECT COUNT(*) FROM my_table WHERE methodology_version=%s", (TAG,))
n_actually_inserted = cur.fetchone()[0]
# Option B: use execute_batch instead — cur.rowcount is accurate but slower
psycopg2.extras.execute_batch(cur, "INSERT ...", data, page_size=500)
Insider hit this when their mint printed "MINTED 544" for what was actually a 179,544-row insert. Nothing broke — the numbers just looked wrong in the status output, which is arguably worse (you trust the wrong number and act on it).
register_uuid() is also required to INSERT a scalar uuid.UUID into a uuid columnTrap 1 covers reading uuid arrays. Trap 3 is the write side of the same coin: passing a uuid.UUID object as a parameter to a uuid-typed column silently fails without registration.
cur.execute(
"INSERT INTO mars.person_work_history_v2 (history_id, ...) VALUES (%s, ...)",
(uuid.uuid4(), ...)
)
# → psycopg2.ProgrammingError: can't adapt type 'UUID'
# → cur.rowcount == 0
The error is caught silently if you're wrapping in a try/except-per-row (a common shape for defensive inserts) — the whole run reports 0 inserted with no exception surfaced. Fix, per Trap 1: psycopg2.extras.register_uuid(pg) right after psycopg2.connect(...). Do it at every connection open, not just when you know you're reading arrays.
Discovered 2026-07-20 during qwen_work_history_from_bios.py first-run — every INSERT of a fresh uuid.uuid4() history_id failed silently and the sweep applied 0 rows until the registration was added.
ARRAY_AGG(uuid_col) returns a Python string of the postgres array literal, not a listSame class as Trap 1 but worth calling out for the ORM-adjacent code that uses ARRAY_AGG for aggregation:
SELECT p.person_id, ARRAY_AGG(b.bio_id ORDER BY ...) AS bio_ids
FROM ...
person_id, bio_ids = cur.fetchone()
# bio_ids is: '{6f5dfdfe-c84c-4490-8bb0-bbe9786630bb,244e51d2-1cdd-...,...}'
# NOT: [UUID('6f5dfdfe-...'), UUID('244e51d2-...'), ...]
primary = uuid.UUID(bio_ids[0]) # → ValueError: badly formed hexadecimal UUID string
# bio_ids[0] is the single character '{'
Same root cause as Trap 1 (register_uuid missing) but doubly nasty because the failure mode is a slice-a-string error rather than an obvious type mismatch. Fix options in order of preference:
1. STRING_AGG(col::text, ',' ORDER BY ...) — return a text-comma-list, split(',') on it. Trivial, no type registration.
2. Skip the array entirely — look up the value at apply-time via a separate query keyed by the primary. This is what qwen_work_history_from_bios.py ended up doing (lookup_primary_bio_id(cur, person_id) per-row).
3. Register the array type: psycopg2.extras.register_uuid() — works but doesn't cover uuid[] specifically without extra ceremony.
Also discovered 2026-07-20 alongside Trap 3.
[[ref-canonical-entity-pattern]] — the general canonical template + 4-pass resolver[[ref-qwen-verified-dedup-pattern]] — 5-step reusable dedup doctrine[[ref-entity-quality-doctrine]] — 5-layer defense (extractor + ETL + resolver + sweep + dashboard)[[ref-merge-entities-tool]] — atomic merger executor[[ref-inferno-tier-system]] — Qwen (free) → Haiku ($0.003) → Grok-web ($0.08) tier chainbios_pipeline/MINTING_PEOPLE.md — persons kind, deep-dive with 10 rules + drop-in codeMARS_PLATFORM_CONTRACTS.md — the 10 non-negotiable platform contracts every vertical honorsEvery entity → strong-ID match first; name-based candidates + Qwen verify next; sentinel-filter at write; log every decision; weekly dedup sweep.
Everything else is elaboration.