Subjects & identities
Real organizations rarely have one canonical user id across every system. The same
person might authenticate to one app via an SSO email claim and to another via a
phone-based login, with nothing forcing the two to visibly agree. CNS resolves all of
that fragmentation down to one stable, opaque subject_id per tenant — the id
every event's actor/target, every policy, and every authz decision is keyed on.
Two layers, deliberately separate
adapters/identity.Identity— the adapter for one specific IdP (SSO-JWT, Supabase Auth, Firebase Auth, …). ItsVerify(token)checks a Bearer token against that IdP and returns claims in whatever shape that IdP uses; itsResolvecall hands those claims to the layer below.core/identitymap.Mapper— tenant-agnostic stitching policy: given a narrow, adapter-independentClaimSet{TenantID, Email, Phone, Raw}, decide whichsubject_idthis is. Core never imports a specific identity adapter — it only knows this narrow claim shape.
Stitching
The default implementation (StitchingMapper) resolves claims by email or phone
against a store. When a ClaimSet carries one identifier the store already knows
and one it doesn't, it links ("stitches") the new identifier onto the existing
subject — this is how, for example, a user's email-based SSO session today and a
phone-based login tomorrow converge on the same subject_id instead of fracturing
their activity timeline into two people. If a claim set instead asserts two
identifiers that are already each bound to a different existing subject, that's
a genuine conflict — the mapper rejects it (ErrIdentityConflict) rather than
silently merging two people into one.
PII stays out of the hot path
Raw email/phone live only in the identity-mapping store. They are never copied
into event rows — the event store (long-retention, queried constantly) only ever
carries the opaque subject_id. This is a deliberate boundary, not an incidental
one: it's what lets the event store be broadly queryable without also being a PII
store.
What Phase 1 is (and isn't)
Phase 1 is a thin adapter off JWT claims — one cached lookup at ingestion time, not a general-purpose identity service. There's no identity UI, no manual merge/split tooling beyond what the mapper does automatically, and no cross-tenant identity resolution (identity is always resolved within one tenant).
Authenticating via a non-native IdP
The identity adapter (ssojwt) is IdP-shape-agnostic by design — issuer, audience,
signing-key source (static HMAC or JWKS), and claim names are all configurable. A
tenant doesn't have to use the platform operator's own SSO; see
Wiring a non-native IdP for a worked example using
Supabase Auth, and Authentication for the token mechanics.