← Corpus / augment-it / spec
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 (persons, organizations, events, locations on SurrealDB main/main) is cross-client by design — one row per real-world entity, deliberately shareable across reach-edu, humain-vc, and any future client. That sharing is the point. But the operator still needs to know, at a glance, *which clients have touched this entity*: who first surfaced them, who's edited them since, who currently has them visible in their workspace. The pattern: every observation carries a `client` field; every entity carries a materialized `client_access` array (sorted by first-touch time). Reads are filtered by `WHERE client_access CONTAINS '<current-workspace-slug>'`. Writes always stamp the current workspace into the observation. Three forms of 'touched' (entered, updated, currently available) all collapse into the observation log — no separate access table, no separate audit table.
- Path
- specs/Client-Tagging-on-Canonical-Writes.md
- Authors
- Michael Staton
- Augmented with
- Claude Code on Claude Opus 4.7 (1M context)
- Tags
- Spec · Augment-It · Canonical-Layer · Client-Visibility · Multi-Client · Workspaces · Provenance · FAIR
Client tagging on canonical writes
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 addition
Schema-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 cache
Same 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.