SynapSynapDocs
Concepts

Kind and facets

One entity, one kind, many roles: how Synap separates what a thing is from the roles it plays.

For BuildersOpen-source podSynap Cloudbeta

Not yet re-verified

This page predates the current docs review and has not been checked against the code yet. Commands, names and counts may be out of date. Prefer the pages marked as reviewed.

One real-world thing is one entity, forever. What it's being used as, in a given context, is a facet — not a new entity.

The one-line frame

Every entity answers three separate questions, and Synap keeps them in three separate places:

  • What it IS — its kind. A person, a company, a task. Fixed at creation, rarely changes. → entities.profileId (a profileKind = 'kind' profile).
  • What it's BEING USED AS, where — its facets. Client here, investor there, speaker at this one event. Additive, workspace-lensed, can come and go. → entity_facets rows (each pointing at a profileKind = 'role' profile).
  • What it's CONNECTED TO — its relations. Works at, assigned to, blocks. → relations / structural entity_id properties. See Entity connections.

Kind + Facets is about the first two. Getting the split wrong is what produced the historical bug this page documents: modeling "this person is now a client" by creating a second entity (a client record) that shadows the person, instead of attaching a role to the one entity that already exists.

Why this exists

Before Kind + Facets, "client," "partner," "prospect," "sponsor," "contact" and similar relationship states were each their own entity profile. A person who was a lead, then a client, then also a conference speaker accumulated multiple disconnected entity rows — no shared identity, duplicate data, and no way to answer "show me everything about this person" without a manual join a human had to think to do. The AI capture path made this worse: every time a message implied a new role, the path of least resistance was create_entity again.

Kind + Facets fixes this at the storage layer: one entity per real-world thing, and every role it plays is an additive attachment, not a fork.

The 5-layer assignment table

LayerQuestion it answersStorageChanges how often
KindWhat IS this?profiles row with profileKind = 'kind', referenced by entities.profileIdRarely — a kind change means the thing itself changed identity
FacetWhat role is it playing, where?entity_facets row → a profiles row with profileKind = 'role'Often — attach/detach as relationships start and end
PropertyWhat are its field values?entity_property_index / properties JSONB, on the entity for kind-level fields, on the facet for role-specific fields (e.g. crmStatus lives on the client facet, not the person)Continuously
RelationWhat's it linked to?relations table (semantic) or a structural entity_id propertyOn demand, ad hoc
Lens (workspace)Who can see this facet/property?entity_facets.workspaceId (nullable — pod-wide when null) + the 3-layer property-def scopeSet at attach/create time

The thingness ladder — and the promotion rule

Not every recurring "role" stays a facet forever. The test: does it accrue its own independent life — its own timeline, its own relations to other things, its own state machine that outlives any single parent? If yes, it earns kind-hood.

The canonical precedent is deal. A deal isn't just "a person in the won state" — it's a relationship-THING with its own buyer × seller × stage lifecycle, its own close date, its own linked contacts and documents. deal was kept as a primary kind (a keep entry in the conversion manifest), never folded into a facet, precisely because it has that independent life. event is the same case — a time-bound container, not a role someone plays.

Contrast with client / partner / prospect / sponsor / competitor: these describe a stance a person or company has, with no independent existence outside that person or company. They were converted to facets (convertToFacet entries in the manifest). A facet is inherently subordinate — detach it, and the parent entity is untouched; delete the parent, and the facet cascades away with it (entityId references entities.id with onDelete: 'cascade').

Rule of thumb: if you can imagine the "role" outliving or being shared across multiple parents with its own graph of relations, it's probably a kind. If it's inseparable from one specific entity and just toggles that entity's stance, it's a facet.

Storage model

profiles
  profileKind: 'kind' | 'role'         -- what this profile TYPE is
  applicableKinds: text[]              -- role profiles only: which kinds can wear this facet

entity_facets
  entityId        -> entities.id       -- the parent (cascade on delete)
  profileId       -> profiles.id       -- the attached role-profile
  userId                                -- owner floor for pod-wide facets
  workspaceId     nullable              -- null = pod-wide, set = workspace-lensed
  contextEntityId nullable              -- disambiguator (e.g. "speaker" at two different events)
  status                                -- e.g. active / paused / churned
  properties      jsonb                 -- role-specific field values
  deletedAt       nullable              -- SOFT DELETE ONLY

Two things worth calling out:

  • Soft delete only. FacetRepository.detach() never hard-deletes a row — it sets deletedAt. A partial unique index (entity_facets_entity_profile_ctx_ws_uniq) covers only live rows, so re-attaching the same facet after a detach is a normal, idempotent flow (a churned client who comes back doesn't need special-casing).
  • Uniqueness is (entityId, profileId, contextEntityId, workspaceId), with COALESCE-sentinel UUIDs standing in for NULL in the two nullable columns — this is what makes "attach speaker twice, once per event, via contextEntityId" possible without colliding with "attach client once, pod-wide."

Identity resolution — why profileSlug::value was the bug

Before this model, there were six independent "is this the same subject?" implementations scattered across entity-resolution, capture within-batch collapse, capture's persisted-dedup JS scan, entity-upsert cross-source matching, and import graph-merge. They disagreed on the one question that mattered most — does an email address auto-merge? — and one of them re-implemented a SQL join as a fetch-5000-rows-and-filter-in-JS scan.

IdentityResolutionService (packages/database/src/services/identity-resolution-service.ts) is the single replacement, with a frozen policy:

  • Strong atoms — email, phone, url (LinkedIn/website), handle, external-id — are globally unique per subject. A match on any of them auto-resolves: link, don't create. Tracked in entity_identity_signals, one row per (type, value) pair.
  • Weak atoms — name (+ aliases[]) — are not unique. They surface candidates/suggestions only, scoped to the caller's visible rows and to the entity's kind. They never auto-merge on their own.

This is why resolve-identity-first matters for facets specifically: attaching a client facet to the wrong person entity because two people share a name is a worse failure than asking the user to disambiguate. The strong path exists so that "this is clearly the same person" (same email) resolves without asking, and everything else asks.

The conversion story: the manifest is the record

Migrating a profile from a primary kind into an attachable role is not a hand-written SQL migration. It's a declarative entry in packages/database/src/conversions/manifest.ts, interpreted by runConversions() (conversions/engine.ts) against the live database. Each op carries a stable opKey, recorded in a _conversions ledger so re-running the manifest is idempotent — an already-applied op is skipped.

The manifest is a versioned, ordered list of ConversionOps:

  • declareKind — assert a profile stays a primary kind (usually a no-op; records intent).
  • seedKindProfile — create a new kind profile if missing.
  • convertToFacet — the core op: flips a profile from profileKind: 'kind' to 'role', and for every live entity currently on that profile: (a) attaches a facet row carrying the mapped properties/status, then (b) re-points the entity's own profileId/type onto a target kind (e.g. every standalone contact entity becomes a person entity wearing a contact-shaped facet — never duplicated, never deleted).
  • mergeInto — fold multiple kinds into one (e.g. note + capture → item).
  • keep — explicit "stays a kind" with a reasoning note (this is where deal and event live).
  • extractNonEntity — pull something out of the entity model entirely.

Ops are append-only and immutable once shipped: a later change appends new opKeys rather than editing old ones, so the manifest doubles as an audit trail of every kind/facet decision made.

API surface

Backend (packages/api/src/routers/entities.ts, Hub Protocol REST under /api/hub/entities/:entityId/facets):

OperationWhereNotes
Attach a facetattachFacet (tRPC) / POST /api/hub/entities/:entityId/facetsFacetRepository.attach() — the one write door. Validates profileKind === 'role' and applicableKinds unless skipValidation (trusted seed data only).
Update a facetupdateFacet (tRPC) / PATCH /api/hub/facets/:facetIdFacetRepository.update() — status/properties.
Detach a facetdetachFacet (tRPC)FacetRepository.detach() — soft delete (deletedAt), never hard.
Resolve effective facetsgetEffectiveFacets(db, entityId, { userId, workspaceId }) (facet-resolution-service.ts)Joins each live facet with its role-profile and that profile's workspace-lensed effective properties. Powers entity.get's facets list.
Filter entities by facetentities.list({ facetSlug })Short-circuits to empty if the slug doesn't resolve to a live role-profile.
Batch facet-slug lookuploadFacetSlugsBatch(db, entityIds)Unfiltered lens, used by search indexing and the graph service — the one shared implementation of a join that used to be hand-rolled twice.

All reads share one visibility predicate: facetVisibilityConditions() (utils/facet-visibility.ts) — workspace-scoped facets are visible to the workspace's members, pod-wide (null-workspace) facets carry an owner floor. The access-layer VisibilityRule registered for entityFacets in packages/api/src/access/registry.ts mirrors this predicate; the two are kept in sync by convention, not by shared code, so changing one requires changing the other.

Intelligence Service side: the attach_facet tool is how agents attach a role instead of creating a second entity — governed the same way entity creation is (may return status: 'proposed', awaiting review, which is success-pending-review, not an error). update_facet is its counterpart for status transitions.

Open questions

  • Relation targeting a facet, not just an entity. Today every relations row points at an entities.id. There's no way to say "this note is about Alice in her capacity as a client" as opposed to Alice generally — relations always resolve to the parent entity. Facet-scoped relation context is under design.
  • Cross-kind facet reuse. applicableKinds lets one role-profile (e.g. sponsor) attach to both person and company. The UX for browsing facets across kinds (a single "all my clients, whether person or company" view) isn't built yet — entities.list({ facetSlug }) returns entities of any kind wearing that facet, but no dedicated view composes it.
  • When does a facet get promoted to a kind after the fact? The manifest handles the reverse direction (kind → facet) as a first-class op. Promoting a facet back into a kind (a sponsor facet that starts accumulating its own deal pipeline and should become its own thing) doesn't have a symmetric op yet — it would today be a manual declareKind + data migration.

Worked example — CRM

A CRM workspace models relationship state entirely as facets on person and company identity entities, with deal staying a kind:

  • person / company — pod-wide identity kinds. Carry no sales state directly.
  • client / partner / prospect — role-profiles. Attached via attach_facet when the relationship actually starts (a deal wins → attach client; a lead qualifies → attach prospect). Status (active, paused, churned) lives on the facet, never on the identity entity.
  • deal — stays a kind. Its own pipeline stage, value, and close date; links to the person/company via relations, not by being a facet of them.

When a deal transitions to won, the agent checks the person's existing facets first (via get_entity's facets list): no client facet yet → attach_facet; an existing client/prospect/paused facet → update_facet to transition its status instead of re-attaching. This is what makes renewals (a new deal referencing a person who already wears a client facet), multi-stakeholder deals, and clean churn tracking (detach the facet, the identity remains) all fall out of the same primitive instead of needing special-case logic. The synap agent skill instructs agents to use attach_facet rather than create_entity for this.


On this page