← Corpus / augment-it / plan
Canonical Entity Registry on SurrealDB Cloud — just write records
SurrealDB Cloud is up at main/main with three SCHEMALESS tables (persons, organizations, affiliations). Goal: write records from tonight's CSV+JSONL into those tables. SCHEMALESS stays. No field discipline up front.
- Path
- plans/Canonical-Entity-Registry-on-SurrealDB-Cloud.md
- Authors
- Michael Staton
- Augmented with
- Claude Code on Claude Opus 4.7 (1M context)
- Tags
- Plan · Augment-It · SurrealDB · Canonical-Layer
Canonical Entity Registry on SurrealDB Cloud
State
- Instance up at
rustic-forest-…aws-use1.surreal.cloud, scopemain/main. - Tables already exist, all SCHEMALESS:
persons,organizations,affiliations(a RELATION edge frompersons↔organizations). - HTTP API + Node SDK both round-trip cleanly (see
scripts/surreal-smoke-test.mjs). - Credentials in
.envat repo root. Source withset -a; . ./.env; set +abefore running scripts.
SCHEMALESS stays
Ad-hoc variability rules. LinkedIn shapes differ per profile, Crawlbase
shapes have shifted twice this week. No DEFINE FIELD discipline up
front. Write what we have, query what’s there.
The only structural commitment: slug as the join key. Person’s LinkedIn vanity slug, org’s LinkedIn company slug, school’s LinkedIn school slug. The proprietary commentary on the filesystem joins to canonical by this same slug.
Connect
v2 SDK pattern (the auth: option in connect() doesn’t work — the
WebSocket handshake passes but every subsequent query runs as
anonymous):
import { Surreal } from 'surrealdb';
const db = new Surreal();
await db.connect(process.env.SURREAL_URL);
await db.signin({
username: process.env.SURREAL_USER,
password: process.env.SURREAL_PASS,
});
await db.use({
namespace: process.env.SURREAL_NS, // 'main'
database: process.env.SURREAL_DB, // 'main'
});
Write records
One script: scripts/surreal-ingest-canonical.mjs. Walks
clients/*/inputs/*.jsonl. For each line:
- Upsert one
personsrow keyed by slug (derived fromprofile_url). Write whatever fields are present in the Crawlbase JSON. Drop nothing. - Upsert one
organizationsrow per company/school referenced in the experience/education arrays, keyed by org slug (derived from the LinkedIn company/school URL when present; from a slugified name otherwise). RELATEoneaffiliationsedge per experience/education entry. Carry whatever shape the source has (title, dates, role kind).
Idempotent: re-running over the same JSONL upserts, doesn’t duplicate. Slug is the dedup key.
Organization name family
Every organizations row carries two name fields plus a slug, because
real entities almost always have a formal name and a different colloquial
shorthand, and the operator needs both at hand for search, display, and
correctness:
| field | example for Columbia Southern University |
|---|---|
complete_name | "Columbia Southern University" (formal/canonical; source of truth for slug) |
conventional_name | "Columbia Southern" (the shorthand people actually say in conversation; what shows on a credibility card or in a briefing doc) |
slug | "columbia-southern-university" (derived from complete_name, durable join key) |
SCHEMALESS so the fields appear the moment we start writing them. The
initial bulk ingest from the gatsby attendee data set both to the
operator-typed q2_company value (one source of truth, no inference);
the enrichment surface lets the operator split them — e.g. set
conventional_name to “Columbia Southern” while complete_name keeps
“Columbia Southern University”.
Editability discipline: every place orgs are edited — the
enrichment surface’s create_org verb, future records-list inline
edits, agent-driven edits via the chat, direct SurrealDB edits via the
Surrealist UI — exposes both fields together. They’re a pair, not a
primary-plus-optional. A surface that only edits one of them is broken.
Persons stay simpler — name is what we know them as, no
conventional/complete split needed (humans don’t typically have a
“colloquial-vs-formal” name distinction the way orgs do; nicknames are
handled differently if they ever come up).
Open
- Org-slug derivation when no LinkedIn URL is present. Lean: slugify the name, accept that two genuinely-distinct orgs with similar names may collide, reconcile later. Decide when we hit a real collision.
- Whether to add UNIQUE indexes on
persons.slugandorganizations.slugfor hard-fail dedup. Lean yes — costs nothing in SCHEMALESS mode and enforces the join contract.
Client visibility — every write carries a client slug
The canonical layer is cross-client by design (one persons row per
real-world human, shareable across reach-edu + humain-vc + future
clients). But each write into canonical needs to record which client
caused it, and each entity needs to surface which clients have
touched it, so workspace-scoped reads return the right subset.
[[Client-Tagging-on-Canonical-Writes]] is the spec. Three small mechanics:
- Every observation has a
clientfield carrying the workspace slug. - Every canonical entity (persons / organizations / events) has a
materialized
client_access: [...]array, plus indexed. - Every workspace-scoped read filters by
WHERE client_access CONTAINS $workspace_slug.
The plan’s writer scripts (surreal-write-persons.mjs,
surreal-backfill-located-in.mjs,
surreal-materialize-persons-locations.mjs,
surreal-write-event.mjs) need to be updated to stamp client on
every write before any new event ingest happens. The existing 882
persons + 881 observations get backfilled as client = "humain-vc"
(the LinkedIn capture workflow’s owner).
See also
- [[Joined-People-UI-and-the-Network-First-Pivot]] — addendum 5 is the parent of this plan.
scripts/surreal-smoke-test.mjs— the connectivity probe.