Kind and facets
One entity, one kind, many roles: how Synap separates what a thing is from the roles it plays.
Not yet re-verified
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(aprofileKind = '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_facetsrows (each pointing at aprofileKind = 'role'profile). - What it's CONNECTED TO — its relations. Works at, assigned to, blocks. →
relations/ structuralentity_idproperties. 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
| Layer | Question it answers | Storage | Changes how often |
|---|---|---|---|
| Kind | What IS this? | profiles row with profileKind = 'kind', referenced by entities.profileId | Rarely — a kind change means the thing itself changed identity |
| Facet | What role is it playing, where? | entity_facets row → a profiles row with profileKind = 'role' | Often — attach/detach as relationships start and end |
| Property | What 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 |
| Relation | What's it linked to? | relations table (semantic) or a structural entity_id property | On demand, ad hoc |
| Lens (workspace) | Who can see this facet/property? | entity_facets.workspaceId (nullable — pod-wide when null) + the 3-layer property-def scope | Set 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 ONLYTwo things worth calling out:
- Soft delete only.
FacetRepository.detach()never hard-deletes a row — it setsdeletedAt. 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), withCOALESCE-sentinel UUIDs standing in for NULL in the two nullable columns — this is what makes "attachspeakertwice, once per event, viacontextEntityId" possible without colliding with "attachclientonce, 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 fromprofileKind: '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 ownprofileId/typeonto a target kind (e.g. every standalonecontactentity becomes apersonentity wearing acontact-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 wheredealandeventlive).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):
| Operation | Where | Notes |
|---|---|---|
| Attach a facet | attachFacet (tRPC) / POST /api/hub/entities/:entityId/facets | FacetRepository.attach() — the one write door. Validates profileKind === 'role' and applicableKinds unless skipValidation (trusted seed data only). |
| Update a facet | updateFacet (tRPC) / PATCH /api/hub/facets/:facetId | FacetRepository.update() — status/properties. |
| Detach a facet | detachFacet (tRPC) | FacetRepository.detach() — soft delete (deletedAt), never hard. |
| Resolve effective facets | getEffectiveFacets(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 facet | entities.list({ facetSlug }) | Short-circuits to empty if the slug doesn't resolve to a live role-profile. |
| Batch facet-slug lookup | loadFacetSlugsBatch(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
relationsrow points at anentities.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.
applicableKindslets one role-profile (e.g.sponsor) attach to bothpersonandcompany. 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
sponsorfacet 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 manualdeclareKind+ 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 viaattach_facetwhen the relationship actually starts (a deal wins → attachclient; a lead qualifies → attachprospect). 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.
Related
- Entity connections: relations and structural links (the third layer)
- Profile schemas: layered property scoping
