Perch · Data Model · The Canonical Objects

The Perch Canon

Which objects deserve one record per real-world thing — answered from the code, not from theory. We read all 193 tables across Perch's three services, traced every dedup and merge mechanism already built, and pressure-tested the rules against 13,239 real Utah contractor records.

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. The surprise from reading the code: Perch already builds toward this — three separate canonicalization systems exist today (a SQL resolution engine, a global knowledge graph, and a project matcher), complete with a merge log and alias table. What's missing isn't machinery. It's one unified canon with one doctrine — plus the industry's real identity key (the contractor license), and fixes to three verified landmines that will silently fuse different companies together as the data grows.

193tables read across the platform (131 tenant-scoped · 48 global · 14 dual)
11objects that should be canonical
3parallel canon systems already in the code — not yet unified
3landmines verified in source + proven against real Utah data
1missing gold key: the state contractor license

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? Get it right and Perch compounds into a defensible industry graph. Get it wrong and agents put the wrong fact on the wrong card, pipeline counts inflate, and the reputation network can never be trusted.

B

The map — one canon, everything else points at it

Both
WHAT FEEDS IT THE CANON · one per real thing WHO USES IT Scanned email · calls · SMS Documents · plan sets Lead gen · cross-match Imports · manual · CRM Harvest: DOPL · Procore · AGC Encounters evidence + provenance Resolution key match → review queue merge log · reversible Companykey: license # › domain+name Personkey: email › name+company Project (the build)key: sourceHash · geo+name Document contentkey: contentHash (sha) Message identitykey: internetMessageId License ▸ createkey: state + license # Trades Sectors Project type ▸ Geography + alias table · merge log · review queue Logo (tenant org) subscription · members → logins selfCompanyId → its own canonical Company ● Edges (relationships — sticky facts live HERE) accounts → Company (pipeline/CRM) contacts → Person project_participants · bid_invitations org_relationships (GC ↔ sub graph) notes · calls · MEDDPICC · scores Work (what a Logo does in Perch) jobs → Project ● (already canonical!) scopes · takeoffs · estimates · bids contracts · RFIs · field ops Events (happened once / recomputable) workbench items · messages · alerts lead scores · usage · audit The one rule that keeps every lego on the right card: canonical objects hold only universal facts — everything sticky (notes, calls, deals, scores) attaches to a tenant's edge, never the canon.

● = the link already exists in the schema. organizations.selfCompanyId (a Logo knows its canonical Company) and accounts.landedOrganizationId (a won account becomes a Logo) close the loop between the canon and tenancy — Brock's Company/Logo model is already wired at both ends.

C

The eleven canonical objects

CTO detail · CEO skim col 1 + verdict

Verdicts: HARDEN already canonical in code — fix keys/rules · PROMOTE machinery exists — elevate to true canon · CREATE doesn't exist yet · GLOBAL ALREADY fine as-is.

ObjectWhat it isIn the code today (receipt)Canonical keyVerdict
CompanyA real construction firmcompanies: nullable-org "global graph" rows, canonical-name SQL fn + GLOBAL unique idx, resolution engine, merge log, aliases, encounterslicense # (add) › domain+name › reviewHARDEN FIX KEYS
PersonA human (≠ a login)contacts: canonical lower(email) unique; name+company fallback; bi-graph matches cross-org by email/nameemail › name+companyHARDEN
ProjectThe physical build/siteprojects: GLOBAL table, sourceHash unique, lat/lng + locationEvidence; LLM project-resolve; cross-match runs org×projectsourceHash · geo+nameGLOBAL ALREADY
Document contentA plan set / spec (the bytes)documents.contentHash indexed 3 ways — same PDF in two orgs = same hash, still two blobscontentHashPROMOTE
Message identityAn email that exists once in the worldscanned_emails.internetMessageId (RFC-2822) + references threading — global by designinternetMessageIdGLOBAL ALREADY
LicenseA state contractor credentialabsent — zero license fields in 193 tables; DOPL feed exists outside Perchstate + license #CREATE
TradeWhat work a firm doestrades table (slug unique, global) — but projects.trades is loose JSONB, not FK'dslugHARDEN
Market sectorWhat market a build servesmarket_sectors global table; same loose-JSONB gap on projectsslugHARDEN
Project typeOffice / retail / tilt-up…free-text varchar(64) — no enum, no table; spelling drift guaranteedslug (new)CREATE
GeographyPlace: city/county/statelocation-utils normalizes city/state; lat/lng on projects; no place entity — fine for nowstate+county+city (later: place id)PROMOTE · LATER
Organization (Logo)A tenant — a Company granted accessorganizations global (139 inbound FKs); selfCompanyId→Company; subscriptions = active/paused/churned statesid + selfCompanyId linkGLOBAL ALREADY

Deliberately NOT canonical — and that's correct: accounts (edge), jobs (work, already edged to Project), scopes/bids/estimates/contracts (work), knowledge units + company brain (a Logo's private IP — sharing is a product decision, not a default), workbench items/messages/scores (events). User (login) is global via email+supabaseId and stays distinct from Person.

D

Three landmines — verified in source, proven on real data

Both — this is the urgent part

We didn't just read the rules — we ran 13,239 real Utah contractor records (DOPL + Procore + AGC) through a conservative resolution pass and checked Perch's current rules against what the field data actually does.

① The global company-name uniqueness constraint forces false merges

companies_canonical_name_unique is enforced on the normalized name across the entire platform — no org in the index. But company names legitimately collide: our Utah pass found 98 same-name, different-firm collisions (two unrelated "Naycor Construction"s; Cache Valley Electric's separate entities). Under this constraint the second real company either can't exist or silently becomes the first.

Where: schema/companies.ts:101 · Fix: uniqueness belongs to the resolution engine + review queue, not a name constraint. Names are match signals, never identity.

② Auto-merge on exact domain match fuses corporate families

match-signals.ts auto-merges two companies the moment their domains match exactly. Field data says shared domain ≠ same company: stakerparson.com spans 5 distinct firms (Western Rock, Jack B. Parson, Hales Sand & Gravel…), burbachexteriors.com spans 4, one Nevada phone line spans 6. Our pass held 321 shared-domain/phone cases for review — every one a would-be false merge under the current rule.

Where: resolution/match-signals.ts:68,158 · Fix: demote domain to a strong signal that requires name agreement; shared-infrastructure hits route to a corporate-family review queue (link as parent/child — never fuse). Contact email auto-merge is fine and should stay.

③ One login can only ever belong to one organization

organization_members.userId is globally unique — a person can be a member of exactly one Logo, forever. The field contradicts this: owner-operators run multiple entities (one Las Vegas family operates six sibling LLCs on one phone line), estimators change firms, and consultants span several. Today each case means duplicate human identities.

Where: schema/organization-members.ts:18 · Fix: a deliberate decision, not an accident of a unique index — either embrace single-org logins as policy or lift the constraint to (organizationId, userId). Flag for the tenancy roadmap.
The doctrine, in one line

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 the merge log Perch already built (entity_merge_log) needs only an UNMERGE to complete it.

E

Three canons today → one canon

CTO

The real architectural finding: canonicalization exists three times, with three different match rules and no shared doctrine.

SystemLives inMatches onGap
SQL resolution engineweb_server · business-intelligence/resolution/weighted signals (name .30, domain .25 auto, phone/geo/trade .15)domain auto-merge landmine; no review queue
BI knowledge graphagents · knowledge/bi-graph.service.ts (Neo4j)"globally canonical — matched by NAME regardless of org"name-only matching; separate store, can drift from SQL
Project matcheragents · project-resolve + projects.sourceHashhash + LLM (name, city, state, contractor)the healthiest of the three — the pattern to standardize on

Recommendation: one resolution service, one key hierarchy per object, one review queue, one merge log — the SQL engine and the Neo4j graph become views of the same canon instead of independent opinions. The Project pattern (global table + deterministic hash + assisted matching + org-scoped work edging into it) is the house pattern; Company and Document should be shaped exactly like it.

Sequence (architecture now, algorithms later)

MoveWhy now / why wait
NOWDefuse ①② (drop name-unique constraint in favor of the engine; demote domain auto-merge) · add the review queue · add UNMERGECheap, prevents silent data corruption that grows with every ingested email
NOWCreate License (state+number, class, status, expiry, qualifier → Company) and ingest the DOPL corpus against itThe one deterministic industry key; 6,822 Utah records ready to load — other states are a data-acquisition roadmap, not a schema change
NOWFK the loose taxonomies (projects.trades / marketSectors → tables; projectType → new enum)Free-text taxonomy is quiet entropy; tables already exist
NEXTUnify the three canons behind one service; encounters become the universal provenance feedStructural, medium effort, huge agent-correctness payoff
NEXTPromote Document content (canonical content row; per-org document references it)Same plan set uploaded by GC + 3 subs = one analysis, four views — direct compute savings
LATERCorporate-family hierarchy (versioned parent_of edges) · probabilistic matching · place entities · the shared reputation tierWait for data volume to force them — the D&B playbook, not day-one machinery
F

How we know — the homework

Credibility
Nodes are globally canonical — matched by name regardless of which org originally discovered them. precon_agents · knowledge/bi-graph.service.ts — Perch's own code, already declaring the ambition. This document is the plan for finishing it safely.