MARS Entity Minting Doctrine

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.

Why this document exists

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:

Every one of those cost real cleanup effort. This doctrine exists so new verticals never contribute to that list.

The one-page summary

If you remember nothing else, remember these 5:

  1. Match first with STRONG IDs where present. CIK / CRD / LinkedIn URL / ticker — a hit on a partial-UNIQUE-indexed strong ID is a definitive match. Skip Qwen entirely. If your source carries a strong ID, populate it at mint time.
  2. Multi-signal candidate search + Qwen verify — NEVER name alone. Name alone is how the 4× Jim Baird problem happens. Always pass Qwen employer / expertise / date / education / geography signals along with the name.
  3. Sentinel filter at write time. Reject placeholder names ("Team Member", "TBD", "Unknown Target", "Confidential Parent") BEFORE any DB query. Every canonical has an is_sentinel_*() filter — use it.
  4. Log every match/mint decision to identity_decisions_v2. With methodology_version + one-sentence reason. Reversibility is a hard requirement, not a nice-to-have.
  5. Run a weekly Qwen-verified dedup sweep on rows minted under YOUR methodology tags. Even with rules 1-4 tight, some dupes slip through. merge_entities.py supports all 5 canonical kinds — use it.

The universal 5 rules

Rule U1 — Match first, mint only on no match

Before every INSERT into a canonical table, run the resolution chain:

  1. Strong ID lookup (partial UNIQUE indexed column — see per-entity table below). Single hit → done. Two hits → substrate violation, file bug.
  2. Name-based candidate search returning multiple candidates ranked by activity (row count, membership size, recency).
  3. Qwen multi-signal verify on the candidate set. Confidence bands: high → attach; medium → attach + flag 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.

Rule U2 — Multi-signal Qwen verify — NEVER name alone

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

Rule U3 — Sentinel filter at write time

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.

Rule U4 — Log every decision to identity_decisions_v2

Schema (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.

Rule U5 — Weekly Qwen-verified dedup sweep

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:

  1. SQL candidate detector: find suspected dupes (same normalized name + overlapping strong-ID space, or nearby dates, or shared bridges)
  2. Qwen classifier: SAME_ENTITY / DIFFERENT / UNCERTAIN with reasoning
  3. Auto-apply SAMEs via merge_entities.py (registry supports all 5 canonicals — see [[ref-merge-entities-tool]])
  4. Log every decision to identity_decisions_v2

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.

Per-entity kind chapters

companies_v2

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. CIKWHERE cik = %s → definitive 2. TickerWHERE ticker = %s → definitive 3. Primary domainWHERE 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 fuzzysimilarity(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.

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.

persons_v2

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.

investors_v2

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.

advisors_v2

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.

institutions_v2

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.

Common operations

The identity_decisions_v2 ledger

Consumed 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.

Post-mint Qwen dedup sweep

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 executor

mars_feed/merge_entities.py — atomic merger tool. Consumes merge_candidate decisions from identity_decisions_v2 and executes:

Supports 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.

Health check assertions

Add to mars_feed/health.py:

Each assertion follows the check_<vertical>_<metric>() shape. Include in the daily 13:00 UTC email digest.

When to invent a new canonical entity kind

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.

Integration checklist for new verticals

When a new vertical spins up:

Appendix — psycopg2 traps every vertical will hit

Insider 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.

Trap 1 — register_uuid() before iterating any uuid[] column

Without 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

Trap 2 — cur.rowcount after paged execute_values reports only the LAST page

psycopg2.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).

Trap 3 — register_uuid() is also required to INSERT a scalar uuid.UUID into a uuid column

Trap 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.

Trap 4 — ARRAY_AGG(uuid_col) returns a Python string of the postgres array literal, not a list

Same 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.

The one-line summary

Every 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.