Kompass RFC 001 identity and org unification
KompassRFC001 — Identity and Organization Unification
- *tatus:*Draft
- *ate:*2026
0411 - *uthor:*Koder Team
- *epends on:*
id-v2-RFC-002-data-model.md - *locks:*all
erp-RFC-NNNdocuments,kompass-RFC-002onward - *cope:*
platform/id,platform/kompass, legacy org chart app (~/dev/organograma/),Crescer/c-corp/modules/seguranca
Summary
Today, four independent systems answer the question "who is a person and which organizations do they belong to," and they disagree with each other:
- *platform/id
(Koder ID v2)** definesuserswithtenant_id` as partition key. One human = N user records across tenants. - *platform/kompass
** definesMemberwithOrgID uuid.UUID` on every row. A user belonging to N orgs = N member records. - *he legacy org chart app (
~/dev/organograma/) OrgML*models a person as a single entity with a multicompany badge (class, and PJ/CLT as a first-class flag. It has the richest domain model but lives outside the monorepo.[CKV]= Crescer + Koder + Vivver), vacancies as first - *Crescerc-corpmodulesseguranca
** re-implements its own IAM (usuario,perfil,permissao`) with Google auth and domain-restricted login. It is running in production for CrescerVivver.
These four cannot all be right. This RFC picks *ne*identity model, describes the migration, absorbs OrgML as the canonical DSL for org structure import/export, and retires the other three implementations.
This is *hase 0*of the ERP track per erp-RFC-000. No downstream RFC is allowed to define new entities that reference identity until this RFC lands.
Motivation
The concrete failure case
Rodrigo Pereira de Mendonça is director of Crescer, Koder, and Vivver simultaneously. He has one CPF, one e-mail he uses for work, one password, one MFA device, one face, one legal signature. He manages across the three companies every day — approving bids for Crescer, signing hires for Vivver, deploying infrastructure for Koder.
Under the current model, representing this person requires:
- *hree rows in
platform/idusers*— one per tenant (tenant_id = crescer,tenant_id = koder,tenant_id = vivver). Each row has its ownid, its own password hash column, its own session history, its own MFA enrollment. When he changes his password, it changes in one tenant and diverges from the other two. - *hree rows in
platform/kompassmembers*— one per org. Each row has its owndisplay_name,email,phone,photo_url, and optionaluser_idlink back to one of the three ID users. Updating his phone number means updating three rows. - *hree sessions to switch between*— no single sign-on across his three tenants in a way that lets him say "show me all my tasks across all my orgs." Switching tenants is actually switching identities.
- *ne row in
~/dev/organograma/OrgML.md*—@ Rodrigo Pereira de Mendonça [CKV]. This is the source of truth in practice, because it is the one Rodrigo edits by hand onorganograma.koder.dev. The other three models drift from it.
Why OrgML has the right model and the others don't
OrgML was written after everything else, under pressure from the actual org chart Rodrigo had to maintain. It made concessions the earlier systems did not:
| Concept | OrgML | Koder ID v2 | Kompass | c-corp seguranca |
|---|---|---|---|---|
Person is global (one row per human) |
✅ implicit | ❌ tenant-partitioned | ❌ per-org Member |
❌ per-org usuario |
| Person belongs to multiple tenants | ✅ [CKV] badge |
❌ | ❌ | ❌ |
| Vacancies are first-class | ✅ !@, ! with xN |
— | ❌ | — |
| Employment type (CLT/PJ) | ✅ {pj} |
— | ❌ | — |
| Location per member | ✅ (Cidade - UF) |
❌ user-level only | ❌ member |
— |
| Role inline | ✅ *Cargo* |
— | partial (separate role entity) |
✅ |
| Hierarchy (units containing subunits) | ✅ numeric levels | — | ✅ | — |
| Color/metadata per unit | ✅ {#hex} |
— | ❌ | — |
OrgML is missing: strong identifiers (no IDs, names are keys), auth (no password/credential), authorization (no permission model), machine-readability beyond a custom parser.
The other three are missing: multi-tenant person, vacancy, employment type, location.
The answer is neither "adopt OrgML wholesale" nor "fix Kompass to match OrgML within its current schema." The answer is to *plit the problem* identity goes to platform/id as a global Person, org structure goes to platform/kompass as Membership, and OrgML becomes the canonical text format for importing/exporting the structure.
Proposed model
Entities
erDiagram
Person ||--o{ Membership : "belongs via"
Tenant ||--o{ Membership : "contains"
Tenant ||--o{ Unit : owns
Unit ||--o{ Unit : "parent of"
Unit ||--o{ Membership : "assigned to"
Role ||--o{ Membership : "granted to"
Unit ||--o{ Vacancy : "has open"
Role ||--o{ Vacancy : "for role"
Person ||--o{ Credential : authenticates
Person ||--o{ Email : has
Person ||--o{ Phone : has
Person {
ulid id PK
string canonical_name
string preferred_name
string locale
string timezone
jsonb metadata
}
Tenant {
ulid id PK
string slug
string display_name
string country
jsonb metadata
}
Membership {
ulid id PK
ulid person_id FK
ulid tenant_id FK
ulid unit_id FK
ulid role_id FK
string employment_type "clt|pj|intern|apprentice|volunteer|contract"
string location_city
string location_state
string location_country
timestamp started_at
timestamp ended_at
jsonb metadata
}
Unit {
ulid id PK
ulid tenant_id FK
ulid parent_id FK
string name
string color_hex
int level
jsonb metadata
}
Role {
ulid id PK
ulid tenant_id FK
string name
string category
jsonb permissions
}
Vacancy {
ulid id PK
ulid tenant_id FK
ulid unit_id FK
ulid role_id FK
string kind "owner|member"
int quantity
string employment_type
string location_city
string location_state
timestamp opened_at
timestamp filled_at
jsonb metadata
}
Credential {
ulid id PK
ulid person_id FK
string kind "password|webauthn|totp|oidc_subject|api_key"
bytes secret
timestamp expires_at
}
Email {
ulid id PK
ulid person_id FK
string address
bool verified
bool primary
}
Phone {
ulid id PK
ulid person_id FK
string e164
bool verified
bool primary
}Invariants enforced by this model
- *Person
is tenant-less.** It has notenant_id` column, never. It is the global identity of a human. - *Credential
,Email,Phonebelong toPerson`.*One password, one primary email, one MFA device for the human — not three. - *Membership` is the only tenant
scoped table that references a person.*Everything tenantspecific about a person lives in Membership: which unit, which role, CLT or PJ, where they work from, when they joined, when they left. Multiple memberships per person are normal; that is the whole point. - *Vacancy` is first-class.*It is not a flag on Membership. It has its own lifecycle (opened → filled → closed), its own approval chain, its own reporting.
- *Unit
has a recursiveparentid.** Unlimited depth. OrgML's1[DIRETORIA] → 2[Gerência] → 3[Departamento] → 4[Subseção]translates to rows with level 1..4 and aparentid` chain. - *Role` is tenant-scoped.*"Diretor" in Crescer is not the same Role as "Diretor" in Vivver, even if they share a name. The model could in principle support a global role catalog, but it does not — tenant isolation of role definitions matters for permissions and compliance.
- *EmploymentType
is an enum, not free-form text.**clt,pj,intern,apprentice,volunteer,contract`. Extensible by adding enum values in a later migration; never by free-form.
Authentication and session
Authentication produces a Personscoped session. That session can then be "activated" against one or more tenant pointer that the user can change at any time without re-authenticating. Logout ends the session; it does not end "some of the tenants."Tenants — the session carries a current
This matches how every SaaS with multi-tenant membership does it: Slack (one login, pick a workspace), GitHub (one login, pick an org), Notion (one login, pick a workspace). Koder was the odd one out.
Migration from the current Koder ID v2 schema
Current platform/id/v2 has users(id, tenant_id, username, email, …) with UNIQUE (tenant_id, username) and UNIQUE (tenant_id, email). Migration:
- *ntroduce
personstable*with the global shape above. - *or each
(email, email_verified=true)pair across the existinguserstable* insert onepersonsrow if one does not already exist for that e-mail. For verified duplicates, collapse into a single person (assume same human). For unverified duplicates, flag for manual resolution — do not silently merge. - *ewrite
usersintomemberships*by translatingtenant_id, username, … → memberships(person_id, tenant_id, …). Everything the oldusersrow carried that was tenantspecific (e.g. last login from this tenant, pertenant role assignments) moves tomembershipsor a sibling table. Everything that was identity-global (primary email, password hash) moves topersonscredentialsemails. - *redentials are deduplicated.*If three rows in the old
usersshared the same password hash AND the same verified primary email, they were the same human — move onecredentialsrow topersonsand drop the others. - *auditlog
entries are preserved verbatim** with botholduseridand the new(personid, tenant_id)` coordinate. Never rewrite history.
The migration is destructive in the sense that three old users collapse into one Person, but it is not destructive for audit or billing — the migration records the mapping so that any old identifier can be resolved to the new coordinates.
Migration from the current Kompass schema
Current platform/kompass/backend/internal/models/member.go has Member { OrgID uuid.UUID; UserID *uuid.UUID; FirstName, LastName, DisplayName, Email, Phone, Address, City, …} — a wide denormalized row with every personal detail duplicated per-org.
Migration:
- *or each
Memberrow* look up thePersoncorresponding to thatMember.UserID(via the Koder ID v2 migration above). IfUserIDis null, create a newPersonfrom the member's name/email. - *opy the member's
FirstName, LastName, DisplayName, Email, Phone, …into thePerson*— but only if the Person is new or the Member row is newer than what the Person currently has. Later rows win; ties broken byupdated_at. - *reate a
Membershiprow*with(person_id, tenant_id=OrgID, unit_id, role_id, employment_type, location_city, location_state, started_at=JoinDate, ended_at=ExpiresAt). - *rop the denormalized fields*from
Member→Membership. Keep only the coordinate fields (person, tenant, unit, role, lifecycle). - *rgML import*becomes a one
shot: parseworld org structure as maintained by Rodrigo.~/dev/organograma/OrgML.md, upsertPersonrows by name, upsertMembershiprows by(person, tenant)coordinate, upsertVacancyrows for!@/!entries. This run seeds the three real tenants (Crescer, Koder, Vivver) with the real
Migration from c-corp/seguranca
c-corp has its own usuario, perfil, permissao tables with Google OAuth domain restriction. Migration:
- *very
usuariorow becomes aPerson*if one does not already exist by canonical email. - *very
perfilbecomes aRole*in the appropriate Kompass tenant (Crescer or Vivver depending on domain). - *very
permissaois mapped to Kompass's permission model*— or, if the mapping is ambiguous, flagged for manual review and parked in a temporarylegacy_permissionstable keyed by(membership_id, legacy_code). - *
corp stops usingcorp frontend adds a Koder ID login screen as the only auth path.modules/segurancaand starts using Koder ID OIDC*viaplatform/id. c - *he
modules/segurancacode is deleted*from ccorp once the last user has migrated. Softdeprecation period: 60 days after the Koder ID integration ships.
OrgML as canonical DSL
OrgML becomes a first-class format in the stack:
- *pec lives at
meta/docs/stack/specs/orgml.md* ported from~/dev/organograma/SPEC.md. Keep the semantic additions that the RFC model introduces (EmploymentType enum values, Vacancy lifecycle, multi-tenant membership via company tags). - *o parser at
platform/kompass/internal/orgml/*— port the JS parser from~/dev/organograma/index.htmlinto Go. Expose as a library plus a CLI atplatform/kompass/cmd/kompass-orgml/withimport,export,validate,diffsubcommands. - *art parser at
apps/mosaic/lib/orgml/*— second port, for client-side rendering when Mosaic wants to let users paste OrgML directly. - *he org chart app (
organograma.koder.dev) becomes a thin client*that reads/writes OrgML via Kompass's API. The standaloneapi.py+OrgML.mdpair on the k.lin filesystem is retired; the file becomes a view of the Kompass data, generated on demand and synced back on POST. This preserves the "edit a textarea, hit save, see the chart" workflow that Rodrigo likes, while making Kompass the source of truth. - *mport/export round-trip is tested.*A Kompass tenant exports to OrgML, imports back, and the resulting state is identical modulo field ordering. This is the acceptance test that the OrgML spec and the Kompass model agree with each other.
OrgML extensions added by this RFC (beyond the current spec):
- *ate fields*— optional
{start: 2024-03-01}{end: 2026-12-31}on members, for employment history tracking. - *redential hints*— optional
{email: nome@crescer.net}when a member's email does not match their name for deterministic e-mail generation. - *ole reference*— optional
*Cargo*today is freeform. The extension allows `*ref:roleslug*` for referencing a Role by slug instead of inlining the label.
These extensions are backward-compatible: the current OrgML.md parses without them, the Kompass model accepts them when present.
API surface
New endpoints in platform/id:
GET /v1/persons/{id}
GET /v1/persons?email=...
POST /v1/persons
PATCH /v1/persons/{id}
GET /v1/persons/{id}/memberships → proxies to Kompass
GET /v1/persons/{id}/credentials
POST /v1/persons/{id}/credentials
DELETE /v1/persons/{id}/credentials/{cid}
GET /v1/persons/{id}/emails
POST /v1/persons/{id}/emails
GET /v1/persons/{id}/phones
POST /v1/persons/{id}/phones
POST /v1/auth/login → returns session keyed to person, not tenant
POST /v1/auth/tenant/activate → sets current tenant in session
GET /v1/auth/me → returns person + list of tenantsNew endpoints in platform/kompass:
GET /v1/tenants/{id}/memberships
POST /v1/tenants/{id}/memberships
PATCH /v1/tenants/{id}/memberships/{mid}
DELETE /v1/tenants/{id}/memberships/{mid}
GET /v1/tenants/{id}/units → tree, optional ?depth=N
POST /v1/tenants/{id}/units
GET /v1/tenants/{id}/vacancies
POST /v1/tenants/{id}/vacancies
PATCH /v1/tenants/{id}/vacancies/{vid} → mark filled/closed
POST /v1/tenants/{id}/orgml/import → bulk upsert from OrgML text
GET /v1/tenants/{id}/orgml/export → full tenant as OrgML text
POST /v1/tenants/{id}/orgml/validate → parse without applying
GET /v1/tenants/{id}/orgml/diff → compare uploaded OrgML to current stateExisting endpoints change meaning but not shape — /v1/users/{id} becomes a compatibility shim that resolves (tenant, user) to (person, membership) for 180 days, then is deleted.
Compatibility and retirement plan
gantt
title Identity unification rollout
dateFormat YYYY-MM-DD
section Schema
Introduce Person/Membership tables :a1, 2026-04-15, 14d
Backfill from id-v2 users :a2, after a1, 10d
Backfill from kompass members :a3, after a2, 10d
Backfill from c-corp seguranca :a4, after a3, 10d
Backfill from OrgML :a5, after a4, 5d
section API
Ship new /v1/persons and membership APIs :b1, after a1, 21d
Ship compatibility shim for old /v1/users :b2, after b1, 5d
section Clients
Mosaic uses new APIs :c1, after b1, 14d
Org chart app becomes thin client :c2, after b1, 10d
c-corp uses Koder ID OIDC :c3, after b1, 21d
section Retirement
Delete kompass Member denormalized fields :d1, after c1, 7d
Delete c-corp seguranca module :d2, after c3, 7d
Delete id-v2 users compat shim :d3, 2026-10-15, 1dFreeze period: starting when this RFC merges, no new code is allowed to reference users.tenant_id or Member.OrgID for identity purposes. New code uses persons and memberships. The old columns exist for read compatibility until the shim is deleted.
Permissions and authorization
This RFC does *ot*redesign authorization. It reuses whatever permission model platform/id and platform/kompass currently ship, with one structural change:
*ermissions are evaluated against Membership, not Person.*When Rodrigo asks to approve a bid in Crescer, the authorization check runs against Membership(person=rodrigo, tenant=crescer) — his roles in Crescer, not his roles in Koder or Vivver. This is what lets him be director of three companies without leaking cross-tenant permissions.
Session tokens carry (person_id, active_tenant_id, active_membership_id). Switching active tenant reissues the session with a new active_tenant_id and active_membership_id; the person_id never changes until logout.
Security considerations
- *ccount takeover blast radius increases.*If Rodrigo's password is compromised, the attacker gains access to all three tenants, not one. Mitigation: mandatory MFA for persons with memberships in more than one tenant, enforced at login.
- *ross
tenant data leakage risk in joins.*Any query that joinstenant scope. Kompass's query layer adds a helpermembershipsmust filter byactive_tenant_idor explicitly acknowledge crosswithTenant(ctx, tenantID)that stampsWHERE tenant_id = ?and refuses queries without a stamp. Cross-tenant reporting queries usecrossTenant(ctx, personID)instead — explicit and auditable. - *udit log granularity.*Audit log entries now carry
(person_id, tenant_id, membership_id)instead of justuser_id. Migration back-fills theperson_idcolumn; older rows have onlyuser_idand are looked up via the compat shim. - *GPD — right to be forgotten.*A deletion request targets a
Person, cascading toCredentials,Emails,Phones, andMemberships. Audit log entries are scrubbed to remove PII but retained for compliance. Cross-tenant deletion is atomic: either all three memberships of Rodrigo are deleted along with his Person, or none. - *ulti
tenant membership is consentbased.*Adding aMembershiprequires explicit consent from the Person if the Person already exists. A Tenant admin cannot silently attach a new org to someone else's identity.
Risks
- *ata quality in backfill.*If the existing
usersrows have lowquality duplicates (typos in emails, mismatched usernames), the Person collapse step mismerges humans. Mitigation: run the collapse in dryrun mode first, produce a manualreview list, resolve by hand for CrescerVivverKoder (the only three tenants with real data today). - *rgML spec drift.*If Kompass adds fields the OrgML parser does not know about, exports become lossy. Mitigation: spec and parser versioning — OrgML headers carry
# orgml-version: 2and the parser rejects unknown fields in strict mode, warns in lenient mode. - *
corp runtime during migration.*ccorp is in production for Crescer/Vivver. Replacing its auth backend while running carries risk. Mitigation: dual-path auth for 30 days — Google OAuth and Koder ID both work, feature flag decides which UI is shown. Users are migrated gradually. - *rg chart app regression.*The thin
client rewrite of the org chart app (byorganograma.koder.dev) must preserve the editing UX exactly. Mitigation: sideside deploy of the old and new versions for 14 days on different subdomains; cut over only after explicit signoff. - *ompass tests break.*Every Kompass test that references
Member.OrgIDmust be rewritten. Mitigation: automated refactor pass that replacesMember{OrgID: x}literals withMembership{TenantID: x, PersonID: y}and emits a manualreview list for tests where the rewrite is nonobvious. - *onger sessions = stale permissions.*Since sessions span tenants, a permission change in one tenant does not invalidate the session. Mitigation: push permission-change events to a session invalidation channel; sessions refresh their permission cache on every sensitive action.
Open questions
- *hould
Person.canonical_namebe mutable?*Yes, for legal name changes and gender transitions. The RFC proposes allowing mutation with audit logging. Alternative: immutable canonical with a mutablepreferred_name. Decision pending. - *hould
EmailandPhonebe separate tables or embedded inPersonas arrays?*This RFC proposes separate tables for verification tracking and indexability. Alternative: aJSONBarray on Person. Decision pending. - *ow does Membership model acting roles (someone filling in for another for two weeks)?*This RFC does not cover it. A follow
up `kompassRFC-002can introduceMembershipAssignment` or similar. - *ow are dependents (children of members, for example in a health plan context) modeled?*Out of scope for this RFC. Current Kompass has
FamilyLink; keep it, revisit in a future RFC. - *oes Koder ID still need a
tenant_idon theuserstable for legacy reasons?*No — the table itself is replaced bypersons+memberships. The old table is dropped at the end of the compat window.
Non-goals
- This RFC does not redesign authorization or permissions beyond the structural point that permissions attach to
Membership. - This RFC does not introduce a global role catalog. Roles remain tenant-scoped.
- This RFC does not decide whether the org chart app (
organograma.koder.dev) is kept or retired beyond requiring it to be a thin client of Kompass; the final call is a product decision. - This RFC does not specify the UI for tenant switching in Mosaic or other apps.
- This RFC does not extend to federated identity (SAML, external IdPs). That is a separate RFC in the
id-v2-RFC-NNNtrack.
Appendix A — Worked example with Rodrigo
Before
platform/id/v2 users table:
id=01HZABC…, tenant_id=crescer, username=rodrigo, email=rodrigo@crescer.net, password_hash=A
id=01HZDEF…, tenant_id=koder, username=rodrigo, email=rodrigo@koder.dev, password_hash=B
id=01HZGHI…, tenant_id=vivver, username=rodrigo, email=rodrigo@vivver.com.br,password_hash=C
platform/kompass members:
id=M1, org_id=crescer, user_id=01HZABC…, first_name=Rodrigo, last_name=Mendonça, email=rodrigo@crescer.net, phone=…, photo_url=…
id=M2, org_id=koder, user_id=01HZDEF…, first_name=Rodrigo, last_name=Mendonça, email=rodrigo@koder.dev, phone=…, photo_url=…
id=M3, org_id=vivver, user_id=01HZGHI…, first_name=Rodrigo, last_name=Mendonça, email=rodrigo@vivver.com.br,phone=…, photo_url=…
~/dev/organograma/OrgML.md:
1[DIRETORIA]{#ef4444}
@ Rodrigo Pereira de Mendonça [CKV]Three separate password hashes, three separate photo URLs, three separate phone numbers, three separate session histories. The OrgML is the only place that tells the truth.
After
persons:
id=01J00001, canonical_name=Rodrigo Pereira de Mendonça, preferred_name=Rodrigo
emails:
id=E1, person_id=01J00001, address=rodrigo@crescer.net, verified=true, primary=false
id=E2, person_id=01J00001, address=rodrigo@koder.dev, verified=true, primary=true
id=E3, person_id=01J00001, address=rodrigo@vivver.com.br, verified=true, primary=false
credentials:
id=C1, person_id=01J00001, kind=password, secret=hashed
memberships:
id=MEM1, person_id=01J00001, tenant_id=crescer, unit_id=crescer-diretoria, role_id=diretor, employment_type=clt, started_at=…
id=MEM2, person_id=01J00001, tenant_id=koder, unit_id=koder-diretoria, role_id=diretor, employment_type=clt, started_at=…
id=MEM3, person_id=01J00001, tenant_id=vivver, unit_id=vivver-diretoria, role_id=diretor, employment_type=clt, started_at=…One person, three memberships, one password hash, one session that he activates against whichever tenant he is currently working in.
Appendix B — OrgML → Kompass mapping reference
OrgML syntax → Kompass entity
──────────────────────────────────────────────────────────────────
C = Crescer #10b981 → Tenant{slug=crescer, display_name=Crescer, metadata.brand_color=#10b981}
1[DIRETORIA]{#ef4444} → Unit{level=1, name=DIRETORIA, color_hex=#ef4444, parent_id=null}
2[Gerência Administrativa]{#06b6d4} → Unit{level=2, parent_id=DIRETORIA, name=Gerência Administrativa, color_hex=#06b6d4}
@ Rodrigo Pereira de Mendonça [CKV] → Membership{person=Rodrigo, tenant=Crescer|Koder|Vivver (one row each), role=<unit's owner role>}
- Gabriel Vilete de Souza [V] → Membership{person=Gabriel Vilete de Souza, tenant=Vivver}
@ Carlos Henrique [V] *Coordenador* → Membership{person=Carlos Henrique, tenant=Vivver, role.name=Coordenador}
- Angelo Victor [V] (João Pessoa - PB) → Membership{person=…, tenant=Vivver, location_city=João Pessoa, location_state=PB}
- Cristiano Araújo [C] {pj} → Membership{person=…, tenant=Crescer, employment_type=pj}
!@ Coordenador [V] → Vacancy{tenant=Vivver, unit=<current>, role=Coordenador, kind=owner, quantity=1}
! Técnico de Campo [V] {pj} x3 → Vacancy{tenant=Vivver, unit=<current>, role=Técnico de Campo, kind=member, quantity=3, employment_type=pj}Round-trip invariant: importing OrgML then exporting produces the same OrgML modulo field ordering and whitespace.
Appendix C — Exit criteria checklist
- [ ]
persons,memberships,credentials,emails,phones,units,roles,vacanciestables exist with the schema above - [ ] Koder ID v2 RFC-002 has been amended to reference this RFC
- [ ] Migration scripts for id
v2corpusers, Kompassmembers, cusuario/perfil/permissao, and OrgML are checked in and dry-run tested against CrescerKoderVivver data - [ ] New `v1persons