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.
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.
| Area | Change since this morning | Δ |
|---|---|---|
| Company | New 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 bridge | companies_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 canon | New 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 else | Person, Project, Document, Message, License-gap, Trade, Sector, Project-type, Geography, Logo — all identical to this morning. | SAME |
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 outright — companies_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.
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.
| Object | In the code today — current receipt | Δ | Verdict |
|---|---|---|---|
| Company | NEW companies_v2 = global firm + "Company Brain" enrichment; per-party data on company_relationships (unique org+company). Legacy companies still present during backfill. graphNodeId → Neo4j. | CHANGED | HARDEN FIX KEYS |
| Person | contacts.ts:82 canonical lower(email) unique; :85 name+company fallback. Unchanged. | SAME | HARDEN |
| Project | projects.ts: GLOBAL, sourceHash unique :39, lat/lng + locationEvidence. Hash key intact; agent matcher now LLM-on-candidates. | SAME | GLOBAL ALREADY |
| Document content | documents.ts:85 contentHash, indexed 3 ways — same PDF in two orgs = same hash, still two blobs. Unchanged. | SAME | PROMOTE |
| Message identity | scanned-emails.ts:91 internetMessageId + references threading — global by design. Unchanged. | SAME | GLOBAL ALREADY |
| License | Still absent — no license/DOPL/credential field in any of the 211 tables. Only plan-room + VoIP integration creds exist. | SAME | CREATE |
| Trade | trades.ts:3 slug unique global; projects.trades still loose JSONB, not FK'd. Unchanged. | SAME | HARDEN |
| Market sector | market-sectors.ts:3 slug unique global; projects.marketSectors loose JSONB. Unchanged. | SAME | HARDEN |
| Project type | projects.ts:26 varchar(64) free-text — no enum, no table. Spelling drift still guaranteed. | SAME | CREATE |
| Geography | location-utils + project-location.service normalize; lat/lng on projects; no place entity. Unchanged. | SAME | PROMOTE · LATER |
| Organization (Logo) | organizations global (~136 inbound FKs); selfCompanyId→Company; companyV2Id ADDED (will replace it); accounts.landedOrganizationId intact. | CHANGED | GLOBAL 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.
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.
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.
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.
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.
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.
Canonicalization still exists three times with three match rules — but for the first time, one of them points at another.
| System | Lives in | Matches on | Δ / gap |
|---|---|---|---|
| SQL resolution engine | web_server · modules/business-intelligence/resolution/entity-resolution.engine.ts | weighted signals (name .30, domain .25 auto, phone/geo/trade .15) | same rules; domain auto-merge landmine; still no review queue |
| BI knowledge graph | agents · 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 matcher | agents · project-resolve.service.ts + projects.sourceHash | hash (SQL side) + LLM disambiguation on candidates | hash 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.
| Move | Why now | |
|---|---|---|
| NOW | Defuse ①②③ inside the v2 rebuild — name-as-signal (drop the two canonical-name uniques), domain-requires-name-agreement, review queue, UNMERGE | The table is being built this week — fixing the rules now costs a diff; fixing after backfill costs a migration + cleanup of already-fused rows |
| NOW | Create License (state+number, class, status, expiry, qualifier → Company) and ingest DOPL against it | Still the one deterministic industry key; 6,822 Utah records ready, Idaho harvest underway |
| NOW | FK the loose taxonomies (projects.trades / marketSectors → tables; projectType → enum) | Still free-text entropy; the tables still exist |
| NEXT | Make graphNodeId load-bearing — one resolution service feeds both the SQL canon and the graph | The bridge exists; wire it into a single write path so the two can't drift |
| NEXT | Promote Document content (canonical content row; per-org reference) | Same plan set from GC + 3 subs = one analysis, four views |
| LATER | Corporate-family hierarchy · probabilistic matching · place entities · shared reputation tier | Wait 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.