← Context/ specs
Client tagging on canonical writes — every observation carries the client that produced it, and every canonical entity carries the materialized set of clients that can see it
The canonical layer is cross-client by design — a materialized `client_access` array is how each workspace still sees only its own.
Why this exists
The canonical layer is cross-client by design —
Canonical Entity Registry on SurrealDB Cloud put it that way to
avoid duplicating the same human across N client databases when the
fundraising consultancy serves N clients. One real-world human gets one
persons row; multiple clients can each independently encounter,
update, and reference them.
But sharing the row doesn't mean every client sees it. A person reach-edu surfaced at the Turning-Jobs-Into-Degrees event shouldn't clutter humain-vc's workspace until and unless humain-vc surfaces them too (LinkedIn, separate event, separate consulting context). We need to log:
Who entered this entity into canonical (first client to surface them).
Who has updated this entity since (any client that's edited a field or added an observation).
Who currently has visibility (which workspaces should show this entity in their worklist UIs).
All three are facets of one underlying signal: a client touched this entity at some point. They're not three separate fields — they're three queries against the same observation log.
Pattern
Every observation carries a client field
When any script or surface writes an observation, the SurrealQL mutation includes the current workspace slug:
CREATE observations SET
id = rand::uuid::v7(),
subject = persons:⟨X⟩,
predicate = "has_email",
object = "foo@bar.com",
observed_at = time::now(),
source = "gatsby-events:reachuniversity:rNyU5vd8fYZsr4UVtsXXt1",
client = "reach-edu"; -- ← the load-bearing additionSchema-less; the client field is a plain string slug matching the
folder under clients/.
Materialized client_access array on every canonical entity
Per the redundancy ethos (feedback redundancy over normalization), every canonical entity caches the set of clients that have touched it. Computed from the observation log and refreshed on every write:
// persons table
{
id: persons:<uuid>,
// …other fields…
client_access: ["reach-edu", "humain-vc"], // sorted by first-touch time
first_touched_by: "reach-edu", // convenience
last_touched_by: "humain-vc", // convenience
last_touched_at: "2026-06-15T03:02:40Z",
}organizations and events carry the same trio of fields. locations
might too if useful (deferred — locations are arguably never
client-specific).
One index per entity for fast filter
DEFINE INDEX IF NOT EXISTS client_access
ON persons FIELDS client_access; -- not UNIQUE — multi-valued array
DEFINE INDEX IF NOT EXISTS client_access
ON organizations FIELDS client_access;
DEFINE INDEX IF NOT EXISTS client_access
ON events FIELDS client_access;SurrealDB indexes array fields with one entry per element; reads
filtering by WHERE client_access CONTAINS 'reach-edu' hit the index
directly.
The three forms of "touched" are three queries
All against the same observation log:
-- Who first entered this person?
SELECT VALUE client FROM observations
WHERE subject = persons:⟨X⟩
ORDER BY observed_at ASC LIMIT 1;
-- Who's updated this person?
SELECT DISTINCT client FROM observations
WHERE subject = persons:⟨X⟩;
-- Who currently has them in their worklist (= same as "updated since
-- some retention threshold," for now we just use "ever touched"):
SELECT VALUE client_access FROM persons:⟨X⟩; -- O(1), from the cacheSame data, three lenses. No separate access table.
What this means for the writes already shipped
The existing 882 persons rows (from
scripts/surreal-write-persons.mjs — the LinkedIn-network walker) and
881 located_in observations (from
scripts/surreal-backfill-located-in.mjs) were written before this
spec landed. They have no client field on their observations and no
client_access array on their persons rows.
Two options for them:
Backfill. A small script that walks the 882 persons and 881 observations, stamps both with
client = "humain-vc"(the LinkedIn walker was a humain-vc workflow), and refreshes the materializedclient_accessarrays. Idempotent. Run once.Treat as legacy. Leave them un-tagged, document the cohort ("anything created before 2026-06-15 was a humain-vc-only ingest"), and only enforce the client field on new writes.
Lean (1) — backfill is small and gives uniform shape, and the LinkedIn capture work was unambiguously humain-vc work.
What this means for surfaces that read canonical
Every workspace-scoped surface gets a WHERE client_access CONTAINS
$workspace_slug predicate baked in. Workspaces as Tenant Primitive
already plumbs the active workspace slug through every chat turn and
every script; reusing the same plumbing for read-time filtering is the
cheap part.
The Sparse Person Enrichment Surface worklist becomes:
SELECT * FROM persons
WHERE id IN (
SELECT VALUE subject FROM observations
WHERE object = events:⟨...⟩ AND predicate IN ["invited_to", "attended"]
)
AND client_access CONTAINS $workspace_slug
AND name IS NONE;The enrichment surface running in reach-edu workspace never even sees humain-vc's LinkedIn-network rows, even though they live in the same canonical layer.
"Available to" as an explicit predicate, later
Today's model treats "client X can see this entity" as equivalent to "client X has ever touched this entity." That's right for v0.
When sharing-without-touching becomes a real workflow (e.g., reach-edu's
operator says "humain-vc should see this person too because we know
they're a relevant LP"), we add a predicate made_available_to with
object = clients:<slug>. The materializer reads both the
ever-touched set AND the made-available-to set when refreshing
client_access. The read predicate stays unchanged.
Deferred — when a real sharing-without-touching case shows up. No speculative infrastructure.
Composes with
Canonical Entity Registry on SurrealDB Cloud — the data layer this pattern modifies. The
observationstable gets aclientfield (schema-less, no DEFINE needed), thepersons/organizations/eventstables get aclient_accessarray, and the index gets added.Workspaces as Tenant Primitive — the source of the workspace slug on every write. The shell already passes
ctx.workspace_slug(a.k.a.ctx.client_id) on every chat turn; scripts already source.envper workspace. Reusing.Sparse Person Enrichment Surface — the first surface that needs this; the worklist query depends on it.
Joined People UI and the Network First Pivot addendum 5 — storage substrate; the client_access pattern is the easy-mode answer to "how does the canonical layer stay cross-client without leaking proprietary commentary across clients" (commentary is separate, on the filesystem; the canonical layer's only multi-client concern is visibility, which this spec covers).
Per Client Privacy and the Path Off Local — privacy framing of the same architectural seam. This spec is the schema-side answer; that exploration is the privacy-side framing.
What lands first (v0.0.0.1)
Update the three writer scripts that exist (
surreal-write-persons.mjs,surreal-backfill-located-in.mjs,surreal-materialize-persons-locations.mjs,surreal-write-event.mjs) to stampclienton every observation they create. Read the slug fromprocess.env.SURREAL_CLIENT(default falls through toprocess.env.CLIENTthen aborts loudly if neither set).A small materializer script (
surreal-materialize-client-access.mjs) walks observations and refreshes theclient_accessarray on every canonical entity. Run after any write batch.Backfill the humain-vc cohort — one script, run once, stamps all pre-2026-06-15 observations with
client = "humain-vc"and refreshes the arrays.
fill_out_query, the Sparse Person Enrichment Surface verb that
hits the network, lands in the same slice that the surface lands — it
inherits the same client-stamping plumbing.
No client UI changes in this slice — operator never sees the client_access fields directly. They surface as filter predicates that make worklists correct, not as fields the operator edits.