← Changelog

SurrealDB canonical layer lands with cross-client visibility — 1,059 persons, 10 orgs, 1 event, 1,245 observations, all client-tagged from day one

Yesterday's [[Joined-People-UI-and-the-Network-First-Pivot]] exploration ended with a clear conclusion: the filesystem-as-substrate posture that has carried augment-it this far stops working the moment we try to blend canonical entity data (LinkedIn truth, refreshable, shareable across clients) with proprietary per-engagement commentary. Tonight that posture changes. SurrealDB Cloud is live on `main/main`, wired through the Node SDK with a working connector, seeded with the 882 humain-vc LinkedIn-network persons from last night's tagged-briefing pipeline AND the 177 reach-edu attendees from a Stand Together event held last month — two clients sharing one canonical schema with not a single row of leakage between them, courtesy of a `client_access` array materialized on every entity. Plus the spec for the UI that closes the loop — a per-event enrichment surface where the operator turns 177 email-only sparse rows into named persons + properly-tagged organizations one row at a time. The fact log (`observations` table) captures every claim with provenance — who said what, when, on behalf of which client — so the next time we refresh from LinkedIn or pull a new attendee list, nothing overwrites an operator's hand-curated note. The bus that left yesterday with a network-first pivot is back tonight with the data substrate to make the pivot real.

Why Care?

For nine months augment-it has lived on the filesystem. Markdown frontmatter for structured data; JSON for machine outputs; YAML for hand-edited config; git for the audit log. It is a beautiful posture and it has held up far longer than anyone had a right to expect, but it broke last week against a real architectural blend: how do you keep a canonical fact ("Charlene Kuo, GP at Acme Capital, located in NYC, refreshed nightly from LinkedIn") in the same view as a proprietary commentary ("met Charlene at SOSV '24, soft yes on the Series A, intro via Jane") without conflating them?

Markdown forces a choice: co-locate them in one file and watch git resolve canonical refreshes against operator-edited prose every night, or split across two files and rebuild a relational layer out of glob patterns. Yesterday's exploration named the problem; tonight we shipped the substrate.

SurrealDB Cloud on main/main now holds the canonical layer end-to-end. Two clients' worth of data — humain-vc's 882-person LinkedIn network and reach-edu's 177-person event — share one schema, with a client_access array on every entity that ensures reach-edu's workspace never sees humain-vc's data and vice versa. The operator types WHERE client_access CONTAINS 'reach-edu' and gets exactly what reach-edu's eye should be on. No tenant fragmentation, no data duplication, no leakage.

If you are a small-firm consultant who has been wondering whether you could build your own boring CRM out of off-the-shelf parts: yes, you can, and the parts cost ~$1/day on the multi-tenant tier of a modern cloud database.

What's New?

  • SurrealDB Cloud connector working end-to-end. HTTP/curl + Node SDK both round-trip cleanly. Five env vars in .env; one helper for the v2 SDK's connect → signin → use dance.

  • Five canonical tables seeded with real data:

    tableshapecount
    personsidentity rows for humans1,059
    organizationsidentity rows for firms/foundations10
    locationsnormalized location strings59
    eventsfirst-class entity for gatherings1
    observationsthe fact log (subject / predicate / object / observed_at / source / client)1,245
  • Cross-client visibility via a materialized client_access: string[] array on every entity. Three forms of "touched" (entered, updated, currently available) collapse into queries against the same observation log. No separate access table.

  • Two new specs in context-v/:

  • A working gatsby-events ingest pipeline that didn't exist 24 hours ago. Auto-extractor browser snippet failed against Gatsby's virtualized DOM; we pivoted to copy-paste, built a tolerant parser, and got all 177 attendees in cleanly. Same shape will work for any virtualized-table data the host won't give us API access to.

How It Works

The canonical / proprietary split, in one table-shaped picture

                            ┌─────────────────────────────────┐
                            │   SurrealDB main/main           │
                            │   (canonical layer)             │
                            │                                 │
   reach-edu workspace ─────┤   persons[client_access:        │
                            │     ["reach-edu", "humain-vc"]] │
   humain-vc workspace ─────┤                                 │
                            │   organizations, locations,     │
                            │   events, observations          │
                            └─────────────────────────────────┘

   reach-edu workspace ─────►  clients/reach-edu/  (filesystem,
                                                    proprietary commentary,
                                                    never crosses clients)

   humain-vc workspace ─────►  clients/humain-vc/  (same shape, per client)

The data layer is cross-client by design. One row per real-world human. Sharing is the point — when reach-edu's operator surfaces Charlene Kuo at an event and humain-vc's operator has Charlene on their LinkedIn network, they're looking at the same row. But each workspace reads through a client_access CONTAINS $workspace_slug filter, so they each see only the slice they've touched.

Proprietary commentary stays on the filesystem under clients/<slug>/. The two layers join by slug. Same shape the Joined People UI and the Network First Pivot exploration recommended; tonight is the implementation.

Every observation carries a client field

SQL
CREATE observations SET
  id          = rand::uuid::v7(),
  subject     = persons:⟨X⟩,
  predicate   = "invited_to",
  object      = events:⟨Y⟩,
  observed_at = d'2026-05-21T00:00:00Z',
  source      = "gatsby-events:reachuniversity:rNyU5vd8fYZsr4UVtsXXt1",
  client      = "reach-edu";   -- ← the load-bearing addition

The client field is the workspace slug from clients/<slug>/. Every writer script reads it from --client CLI arg (or SURREAL_CLIENT env) and aborts loudly if missing — no implicit writes, no silent leakage.

Every entity carries a denormalized client_access array refreshed at write time:

JS
{
  id: persons:<uuid>,
  email: "brett.hinkey@charleskochfoundation.org",
  client_access: ["reach-edu"],         // sorted by first-touch time
  first_touched_by: "reach-edu",
  last_touched_by:  "reach-edu",
  last_touched_at:  "2026-06-16T03:02:40Z",
}

When humain-vc eventually surfaces Brett (LinkedIn first-degree, second event, whatever), the same row's client_access becomes ["reach-edu", "humain-vc"] and both workspaces see them. No second row.

Org name family: two names, one slug

A real entity goes by multiple names depending on context, and we want both at hand:

JS
{
  complete_name:     "Columbia Southern University",   // formal, source of slug
  conventional_name: "Columbia Southern",              // what people actually say
  slug:              "columbia-southern-university",   // durable join key
}

For tonight's bulk ingest both fields land at the operator-typed q2_company value ("Columbia Southern University"). The enrichment surface lets the operator split them — set conventional_name to "Columbia Southern" while complete_name keeps the formal — wherever orgs are edited (create_org verb, future inline edits, agent edits, direct Surrealist edits). The fields are a pair, not primary-plus-optional. A surface that only edits one of them is broken.

Persons stay simpler. Humans don't typically have the conventional/formal split orgs do.

Under The Hood

The Gatsby paste-and-parse pipeline (because the auto-extractor failed)

Gatsby Events publishes attendee lists as "public table tabs" — JS-rendered, client-side virtualized grids. We tried Firecrawl first; it returned the first 28 of 177 rows (only the visible-on-screen slice — virtualization defeats one-shot scraping). We wrote a browser snippet that auto-detects the table shape and accumulates rows on scroll; it failed against Gatsby's specific DOM (sticky-column + absolute-positioned cells keyed by offset and CSS top: pixels — none of the four shapes we probed for matched).

So we did what the operator was going to do anyway: copy-paste. The user copied chunks of the on-screen table; we built a parser tolerant of both row orderings the selection could produce (rsvp-event-before-email vs email-before-rsvp), of header/footer junk getting glued to data rows, of orphan rows at chunk boundaries, and of inline company names that appeared mid-row without blank-line separators.

Fourteen pastes later we had all 177 attendees in a clean JSON file, deduped by email, with their rsvp_event + warnings + (where filled) company + position. The parser script (scripts/gatsby-parse-paste.mjs) is general enough to handle any future Gatsby event — the shape is theirs, not specific to Turning-Jobs-Into-Degrees.

The lesson: when a structured-data API isn't on offer and the table is virtualized, paste-and-parse is the cheaper path than browser-snippet-walks-the-DOM. We documented it that way in Joined People UI and the Network First Pivot but tonight we lived through the proof.

The data, one event-row at a time

For each of the 177 attendees the loader (scripts/surreal-write-event-attendees.mjs) writes:

  • One persons row keyed by email (UNIQUE index), with client_access = ["reach-edu"], source = "gatsby-events".

  • One has_email observation linking the person to their email string.

  • One funnel observation based on the parsed rsvp_event:

    • "Invited Apr 20" → predicate = "invited_to", observed_at = d'2026-04-20'

    • "Visited May 18" → predicate = "visited_event_page", observed_at = d'2026-05-18'

    • "Bounced May 14" → predicate = "email_bounced", observed_at = d'2026-05-14'

    • (luciano-decastro had no rsvp_event captured, so no funnel observation — the loader survives the gap)

  • For 11 attendees whose Gatsby q2_company field was filled (NCAD ×2, Numinar, Strada, etc.): a new organizations row (deduped by slug) and one affiliated_with observation linking person to org.

Final observation breakdown for reach-edu:

predicatecount
has_email177
invited_to169
email_bounced4
visited_event_page3
affiliated_with11

The same shape extends cleanly to event attendance, conference rosters, donor lists, and anything else operator-pasted from a private dashboard. We pay the parse cost once per source-shape; the loader is reusable.

Backfill humain-vc was the trickiest call

The 882 persons + 881 observations from the LinkedIn-network walker (yesterday's work) had no client field, no client_access array. Two options:

  1. Backfill with client = "humain-vc" — the LinkedIn capture was unambiguously a humain-vc workflow. One-shot SurrealQL UPDATE, idempotent.

  2. Treat as legacy — document the cohort, only enforce client-tagging on new writes.

We went with (1). Backfill ran in 1.1 seconds total across all four entity types. Verification at the end: reach-edu's workspace sees 177 persons; humain-vc's sees 882; neither sees the other. Identical canonical layer, different read predicates.

Things named explicitly so they don't drift in later

The new specs are tight about what's out of scope:

  • No bulk auto-enrichment in the sparse-person surface. Operator learns the verbs by hand first; if a deterministic pattern emerges, then we automate.

  • No fuzzy name-matching across clients. humain-vc's 882 LinkedIn rows and reach-edu's 177 event rows have zero overlap by construction. add_profile and create_org default to create, not match. When same-human-across-clients shows up later, a same_as observation predicate handles it — but not before a real instance forces the design.

  • Search is "open a new tab," not a connector. When the operator wants to look someone up they use Google or DuckDuckGo directly. The enrichment surface pre-fills the query for one click; the operator reads results their normal way and pastes back what they found.

  • Persons stay simpler than orgs. No conventional_name field — humans don't typically have the colloquial-vs-formal split.

What's Next

The data substrate is in. The UI specs are in. The next coherent slice is the enrichment surface itself — a new micro-frontend under apps/person-enrichment/ that reads from events:<id>, walks un-enriched attendees one at a time, exposes the three verbs, and writes the operator's edits back as observations.

Two prerequisites before the surface lands:

  1. personal_email_domains table on Surreal, seeded with ~25 common providers (gmail, yahoo, me.com, etc.). Both the enrichment surface AND a follow-on script that runs email-domain → org inference will read from it. Spec says it lives in SurrealDB so scripts and the database stay in sync; no file-vs-DB drift.

  2. One more loader pass that walks the 114 reach-edu attendees with org-domain emails (114 = 177 − 63 personal), upserts an organization per unique domain, and writes affiliated_with observations with qualifiers: {kind: "email-domain"}. The 4 Sterling-Foundations spellings (sterlingfoundations.com, sterling-foundations.com ×2 + 1 more) test the multi-domain matching logic.

The surface itself can land in pieces — Step 1 (event picker) + Step 2 (per-row view + create_org + add_profile) first; fill_out_query follows once the URL-paste pattern earns its complexity.

Files Changed

Specs and plans:

  • context-v/plans/Canonical-Entity-Registry-on-SurrealDB-Cloud.md — the SurrealDB plan, updated with the client-visibility section and org name family

  • context-v/specs/Client-Tagging-on-Canonical-Writes.md — new, the cross-cutting pattern every writer follows

  • context-v/specs/Sparse-Person-Enrichment-Surface.md — new, the per-event UI for turning emails into named persons + orgs

Scripts (all under scripts/, all SurrealDB-related):

  • surreal-smoke-test.mjs — connectivity probe (helper)

  • surreal-write-persons.mjs — bulk persons loader, now --client-aware

  • surreal-write-locations.mjs — bulk locations loader, now --client-aware

  • surreal-backfill-located-in.mjs — located_in observation backfill, now --client-aware

  • surreal-materialize-persons-locations.mjs — materializes persons.location + persons.observations.locations

  • surreal-write-event.mjs — event writer (one row at a time)

  • surreal-write-event-attendees.mjs — new, the loader that ingests the 177 reach-edu attendees

  • surreal-backfill-client-tagging.mjs — new, one-shot backfill of client_access on the 882 humain-vc cohort

  • gatsby-parse-paste.mjs — new, tolerant paste-and-parse for virtualized gatsby tables

Browser snippets:

  • tools/browser-snippets/gatsby-events/gatsby-events-table-to-rows.js — DevTools paste-in for any future gatsby table where the auto-DOM walker happens to work

Dependencies:

  • package.json + pnpm-lock.yaml — added surrealdb ^2.0.3 (the Node SDK)

See Also