Perch · Data Model · The Canonical Objects · v2

The Perch Canon, Re-read

Same eleven objects, checked again against the current dev branch after today's commits. The doctrine held: ten of the eleven are unchanged. The one that moved is the most important — Company is being rebuilt as companies_v2 plus a relationship edge, almost exactly the model this document recommended this morning. And all three landmines are still armed — one of them now copied word-for-word into the new table.

Δ

What moved since this morning

Read this first

The canon didn't drift — it advanced toward the recommendation. Today's commits stood up companies_v2 ("one canonical row per real-world firm") and company_relationships ("one party's private CRM view… replaces the old accounts + deals tables"). That is the facts-on-edges doctrine, now in the schema: a shared Company identity, with every sticky per-tenant fact moved onto an edge. A new graphNodeId on the Company even begins bridging the SQL canon to the Neo4j graph. What did not move: the three landmines. All still armed — and landmine ① is now reproduced verbatim inside companies_v2. The license key is still absent; there is still no review queue and no unmerge.

211pgTable definitions now (was 193) — +18, mostly CRM-unification, billing, site-visit, account-360
10 / 11canonical objects unchanged vs this morning
1rebuilt: Company → companies_v2 + company_relationships edge
3 / 3landmines still armed — zero defused
0license table · review queue · unmerge — still none of the three
AreaChange since this morningΔ
CompanyNew companies_v2 (global identity + enrichment "Company Brain") and company_relationships (the tenant edge holding status/pipeline/MEDDPICC/scores/notes). organizations.companyV2Id added to point at it; will replace selfCompanyId.CHANGED
Canon ↔ graph bridgecompanies_v2.graphNodeId stores the Neo4j node id — the first real link between the SQL canon and the BI graph.NEW
Schema size~193 → 211 table definitions. CRM unification, billing line-items, site visits, account-360 modules landed.CHANGED
Project matcher (agents)Hash-based dedup dropped from the agent path; the LLM now only disambiguates pre-filtered candidates. projects.sourceHash still the global key on the SQL side, so the object stays hash-keyed.CHANGED
Sales-discovery canonNew call-graph.service.ts canonicalizes objections / pains / golden-quotes per org (Call→Objection→Pain edges) — the discovery data from the dialer getting a graph home.NEW
Three landmines①②③ all still present at their same locations. ① now also lives inside companies_v2.UNCHANGED
Everything elsePerson, Project, Document, Message, License-gap, Trade, Sector, Project-type, Geography, Logo — all identical to this morning.SAME
A

The answer in one paragraph

CEO + CTO

Perch has eleven objects that should be canonical — one record per real-world thing, shared across every customer. This morning the finding was that Perch builds toward this. Tonight it's stronger: Perch has started building it outrightcompanies_v2 is a clean canonical Company, and company_relationships is the tenant edge the doctrine calls for. What's still missing is the same short list: one unified canon (three matchers still run independently), the industry's real identity key (the contractor license), and fixes to the three landmines that will silently fuse different firms together as the data grows.

Why a CEO should care: every agent answer, every Workbench card, every network effect Perch will ever sell rests on one question — when two customers talk about "IMS Masonry," is that one record or two? The v2 rebuild gets the shape right. The landmines decide whether the matching can be trusted. Shape without safe matching still fuses the wrong firms — just in a cleaner table.

C

The eleven canonical objects — current state

CTO detail · CEO skim col 1 + Δ

Verdicts unchanged: HARDEN already canonical — fix keys/rules · PROMOTE machinery exists — elevate · CREATE doesn't exist yet · GLOBAL ALREADY fine as-is. The Δ column is movement since this morning.

ObjectIn the code today — current receiptΔVerdict
CompanyNEW companies_v2 = global firm + "Company Brain" enrichment; per-party data on company_relationships (unique org+company). Legacy companies still present during backfill. graphNodeId → Neo4j.CHANGEDHARDEN FIX KEYS
Personcontacts.ts:82 canonical lower(email) unique; :85 name+company fallback. Unchanged.SAMEHARDEN
Projectprojects.ts: GLOBAL, sourceHash unique :39, lat/lng + locationEvidence. Hash key intact; agent matcher now LLM-on-candidates.SAMEGLOBAL ALREADY
Document contentdocuments.ts:85 contentHash, indexed 3 ways — same PDF in two orgs = same hash, still two blobs. Unchanged.SAMEPROMOTE
Message identityscanned-emails.ts:91 internetMessageId + references threading — global by design. Unchanged.SAMEGLOBAL ALREADY
LicenseStill absent — no license/DOPL/credential field in any of the 211 tables. Only plan-room + VoIP integration creds exist.SAMECREATE
Tradetrades.ts:3 slug unique global; projects.trades still loose JSONB, not FK'd. Unchanged.SAMEHARDEN
Market sectormarket-sectors.ts:3 slug unique global; projects.marketSectors loose JSONB. Unchanged.SAMEHARDEN
Project typeprojects.ts:26 varchar(64) free-text — no enum, no table. Spelling drift still guaranteed.SAMECREATE
Geographylocation-utils + project-location.service normalize; lat/lng on projects; no place entity. Unchanged.SAMEPROMOTE · LATER
Organization (Logo)organizations global (~136 inbound FKs); selfCompanyId→Company; companyV2Id ADDED (will replace it); accounts.landedOrganizationId intact.CHANGEDGLOBAL ALREADY

The v2 rebuild collapses a distinction this table drew a box around this morning: accounts was "the edge," companies "the canon." company_relationships now IS that edge, explicitly — it "replaces accounts + deals + per-org pipeline fields from companies." The model is becoming the code.

D

Three landmines — re-verified, all still armed

Both — still the urgent part

Each claim re-read in the current source. The field evidence behind them (13,239 real Utah records → 98 same-name collisions, 321 shared-domain/phone families) hasn't changed — and neither has the code that trips on it.

① Global company-name uniqueness forces false merges — now in two places

companies_canonical_name_unique is still enforced platform-wide on the normalized name (companies.ts:101) — and the new table copied it verbatim: companies_v2_canonical_name_unique on canonical_company_name(name) (companies-v2.ts:92). Field data still shows 98 same-name / different-firm collisions (two unrelated "Naycor Construction"s). Under this constraint the second real company can't exist or silently becomes the first — now true of the v2 canon too.

Where: companies.ts:101 · companies-v2.ts:92 · Fix: uniqueness belongs to the resolution engine + a review queue, not a name index. Names are match signals, never identity. Fixing it in v2 now, before backfill completes, is far cheaper than after.

② Auto-merge on exact domain match still fuses corporate families

The resolution engine still auto-merges the instant two domains match exactly — { name:'domain', weight:0.25, autoMerge:true } (match-signals.ts:66), fired gate-free at engine step 3 (entity-resolution.engine.ts:78). Field data: stakerparson.com spans 5 distinct firms, one Nevada phone line spans 6; 321 shared-infrastructure cases would false-merge under this rule. Note companies_v2 now carries a first-class domain column + index — so the v2 canon feeds this same matcher.

Where: resolution/match-signals.ts:66,158 · engine step 3 :78 · Fix: demote domain to a strong signal that requires name agreement; route shared-infrastructure hits to a corporate-family review queue (parent/child, never fuse). Contact-email auto-merge is fine and stays.

③ One login can still only ever belong to one organization

organization_members.userId is still globally .unique() (organization-members.ts:18) — a person can be a member of exactly one Logo, forever. Owner-operators run multiple entities (one Las Vegas family runs six sibling LLCs on one line), estimators change firms, consultants span several. Each case still forces a duplicate human identity.

Where: organization-members.ts:18 · Fix: a deliberate tenancy decision, not an accidental index — either embrace single-org logins as policy or lift the constraint to (organizationId, userId).
The doctrine, unchanged

A false merge is a cross-tenant incident; a false split is a cheap later fix. Deterministic keys auto-resolve, everything ambiguous goes to a review queue, every merge is logged and reversible — and entity_merge_log still exists but still has no UNMERGE. The v2 rebuild is the right moment to add all three: the review queue, the unmerge, and name-as-signal.

E

Three canons today → one — and the first bridge just appeared

CTO

Canonicalization still exists three times with three match rules — but for the first time, one of them points at another.

SystemLives inMatches onΔ / gap
SQL resolution engineweb_server · modules/business-intelligence/resolution/entity-resolution.engine.tsweighted signals (name .30, domain .25 auto, phone/geo/trade .15)same rules; domain auto-merge landmine; still no review queue
BI knowledge graphagents · knowledge/bi-graph.service.ts (Neo4j)"globally canonical — matched by NAME regardless of org" (still name-only)name-only; now reachable via companies_v2.graphNodeId
Project matcheragents · project-resolve.service.ts + projects.sourceHashhash (SQL side) + LLM disambiguation on candidateshash dedup moved to the caller; still the healthiest pattern

The new bridge: companies_v2.graphNodeId is the first structural link tying the SQL canon to the Neo4j graph — the two "independent opinions" can now become views of the same canon. That's the unification this section recommended, started. It also raises the stakes on the landmines: once these systems share a spine, a false merge in one propagates to both.

Sequence — unchanged, but item 1 is now cheaper and more urgent

MoveWhy now
NOWDefuse ①②③ inside the v2 rebuild — name-as-signal (drop the two canonical-name uniques), domain-requires-name-agreement, review queue, UNMERGEThe table is being built this week — fixing the rules now costs a diff; fixing after backfill costs a migration + cleanup of already-fused rows
NOWCreate License (state+number, class, status, expiry, qualifier → Company) and ingest DOPL against itStill the one deterministic industry key; 6,822 Utah records ready, Idaho harvest underway
NOWFK the loose taxonomies (projects.trades / marketSectors → tables; projectType → enum)Still free-text entropy; the tables still exist
NEXTMake graphNodeId load-bearing — one resolution service feeds both the SQL canon and the graphThe bridge exists; wire it into a single write path so the two can't drift
NEXTPromote Document content (canonical content row; per-org reference)Same plan set from GC + 3 subs = one analysis, four views
LATERCorporate-family hierarchy · probabilistic matching · place entities · shared reputation tierWait for data volume to force them — the D&B playbook
Global Company entity — one canonical row per real-world firm. Private/per-party data lives on company_relationships, NOT here. precon_web_server · schema/companies-v2.ts — the header comment on the new table. This morning's recommendation, now written into the code by Brock's team.