kompass-RFC-002 — Attachable identifiers & per-org identity policy
Extends kompass-RFC-001 (Person/Membership unification, Phase 0) with two capabilities the unified model implies but did not yet make first-class: (1) **attachable verified identifiers** — identity is the immutable `Person`/`koder_user_id` anchor, and email/handle/passkey/external-email become rows in a `person_identifiers` table attached to it, each carrying a verification method, an assurance level, and a purpose; the single-domain thesis of RFC-012 is preserved as a *policy default*, not as the DB CHECK constraint it is today; and (2) **per-org identity policy** — the org owner configures membership prerequisite, external-representation authority, and a data-custody/offboarding regime, exposed as named "org type" presets (corporate / collective / DAO) over three orthogonal axes (authentication, membership, representation). Mechanism is separated from policy: the data model carries the superset; per-Stack and per-org policy decide what is exposed. Uniqueness/auth invariants are pinned to the kdb `strong:` tier (single-primary), reads served from local followers. Resolves the vocabulary drift across RFC-002/012/017/019 + kompass-RFC-001 by adopting Org→Workspace→Project + Person/Membership as canonical.
Status: Draft — the §7 design decisions are resolved (owner, 2026-06-25, via /k-arch + /k-go); implementation remains gated only by kompass-RFC-001 Phase 0. This RFC amends/extends
kompass-RFC-001and is sequenced after its Phase 0 (kompass-RFC-001 forbids downstream RFCs from defining identity entities until its Person/Membership substrate lands; this RFC adds the identifier and policy layers on top of that substrate, not a competing identity model).
1. Summary
kompass-RFC-001 already chose the right substrate: a global, tenant-less Person (the immutable koder_user_id anchor) with Credential, Email, Phone belonging to the Person, and Membership as the only tenant-scoped table linking a Person to an org. This RFC does two things that substrate implies but did not make first-class:
- Attachable verified identifiers. Promote email/handle (and external
email, passkey-as-login-hint, future phone) into a single
person_identifierstable attached to thePersonanchor. Each identifier carries a verification method, an assurance level, a purpose, and an optional tenant context. Global uniqueness is enforced only among verified identifiers. The RFC-012 single-domain thesis (<handle>@koder.devis the only individual email) is preserved as a policy default, replacing theusers_individual_email_domainCHECK constraint that hardcodes it in the DB today (RFC-002).
- Per-org identity policy. The org owner configures, independently:
- whether org membership requires an org-issued email,
- which identifier is authoritative for representing the org externally,
- the data-custody / offboarding regime (who keeps emails, documents,
contacts when a member leaves).
These are exposed as named "org type" presets — corporate, collective, DAO — over three orthogonal axes (authentication, membership, representation), so an org owner picks an archetype instead of wiring N knobs, while power users can still set knobs individually.
The governing principle is mechanism vs policy separation (Meta First, Quality > Speed): the data model carries the superset; Stack-global policy and per-org policy decide what is exposed and when. At launch the global policy can be exactly RFC-012 (only @koder.dev anchors individuals), shipping the brand thesis unchanged, while the substrate never forecloses the consumer / DAO paths.
2. Motivation
2.1 The substrate exists but two layers are missing
kompass-RFC-001 unifies four divergent systems (Koder ID users(tenant_id,…), Kompass Member(OrgID,…), legacy OrgML, Crescer c-corp IAM) into Person/Membership. But:
- Identity today is column-bound, not identifier-bound. RFC-002 stores
email/handleas columns onusers, withCHECK (kind <> 'individual' OR email = handle || '@koder.dev'). That is the single-domain policy welded into the schema. A person with a corporate email, a personal Gmail, and a passkey cannot be modeled as one anchor with three identifiers — they become threeusersrows (the very failure kompass-RFC-001 sets out to kill). (erasure-flow.kmdcascades a productionuser_identitiestable, but that one holds social-provider links, not attachable identifiers — see the naming reconciliation in §9.)
- Org identity policy is not expressible. Real orgs differ: a company wants
corporate email mandatory and work-product custody on offboarding; a collective wants members to keep personal identity; a DAO wants self-sovereign identity with no central seizure. The current model has no place to record this choice, so custody outcomes become accidental (they depend on which mailbox a member happened to use).
2.2 The custody insight (why org type matters)
Data custody follows the identity a resource was written under, and that is fixed at write time by the tenant scope (koder_user_id + workspace_id per multi-tenancy/contract.kmd):
- A resource written under the org context (
workspace_id= org workspace)is org-owned → on offboarding the org retains it, the member loses access.
- A resource written under the personal context (
workspace_idNULL) isuser-owned → it leaves with the member.
So if an admin links a member's personal email as their work identity, the emails live in the personal mailbox and the org loses them on termination; if a corporate email is used, the org keeps them. This is not a bug to fix — it is the correct behavior, and different org archetypes want opposite defaults. The org owner must therefore choose the regime, and the choice must drive the write-time scope, not be a passive label.
2.3 Why the anchor must outlive the org email
On offboarding the org reclaims its domain identifier (joao@empresa.com is the org's domain), but the koder_user_id anchor is the person's and survives — they keep their passkey, handle, and personal identifiers. If identity were the corporate email (the naive model), firing someone would destroy the person. Decoupling anchor ≠ org-email is what makes offboarding possible without annihilating identity. This validates the Person anchor.
3. Design
3.1 The anchor (adopt kompass-RFC-001 verbatim)
Identity is the global, tenant-less Person with immutable koder_user_id. Credential (passwordpasskeyTOTP) is separate. The Person participates in 0..N orgs via Membership (M:N) and may own 0..N orgs (an ownership is a Membership/org_member row with role = owner). Email count, org count, and login method are all independent of the anchor.
3.2 Attachable identifiers — person_identifiers
CREATE TABLE person_identifiers (
id BIGINT PRIMARY KEY,
koder_user_id BIGINT NOT NULL REFERENCES koder_id.persons(id), -- the anchor
kind TEXT NOT NULL, -- 'handle' | 'email' (passkey/phone are separate tables)
value TEXT NOT NULL, -- 'rodrigo' | 'rodrigo@gmail.com' | 'rodrigo@crescer.net'
value_canonical TEXT NOT NULL, -- lowercased + normalized (gmail dots, +tags) — dedup key
verify_method TEXT, -- 'koder_owned' | 'org_domain' | 'email_confirm' | 'admin_provisioned'
verified_at BIGINT, -- NULL = pending; no trust until set
proofing_level TEXT NOT NULL DEFAULT 'none', -- identity-proofing tier of THIS identifier (≈NIST IAL): none|ial1|ial2|ial3; neutral, gov.br Bronze/Prata/Ouro etc. mapped at the jurisdiction boundary (§3.7)
purpose TEXT[] NOT NULL DEFAULT '{login}', -- subset of {login, recovery, representation}
tenant_ctx BIGINT REFERENCES koder_id.organizations(id), -- NULL = personal; else org that governs it
is_primary BOOLEAN NOT NULL DEFAULT FALSE, -- exactly one per (person) — display/notification default
created_unix BIGINT NOT NULL,
CONSTRAINT one_primary EXCLUDE (koder_user_id WITH =) WHERE (is_primary)
);
-- Global uniqueness ONLY among verified rows: a verified identifier belongs to one anchor, worldwide.
CREATE UNIQUE INDEX ux_person_identifiers_verified
ON person_identifiers (kind, value_canonical) WHERE verified_at IS NOT NULL;Notes:
- Unverified rows do not reserve the namespace (or reserve weakly with TTL),
so a pending row cannot lock out another person's email.
handleuniqueness continues to be backed by the append-onlyhandle_registry(RFC-002) —person_identifiersmirrors it for resolution; the registry remains the allocation authority.- Single-domain becomes policy. The RFC-002
users_individual_email_domainCHECK is dropped; the invariant "individuals anchor only on@koder.dev" moves to the Stack-global identity policy (§3.5 default A). The schema permits external email; policy decides whether it may anchor.
3.3 Login resolution as a uniform lookup
login-resolution.kmd R1–R9 are re-expressed as a single lookup over person_identifiers, with no special cases:
- Normalize input →
value_canonical. - If bare (no
@): try<input>@<tenant.primary_domain>askind='email'(this is R1/R3; disabled when the tenant has no primary domain — R8).
- Lookup verified
kind='email'. - Fallback: lookup
kind='handle'on the raw value (this is R4). - Hit → return
koder_user_id; miss → timing-safe failure (R6).
Alias domains (R9) are simply additional kind='email' rows pointing at the same anchor. The external-email literal of R2 is a row with verify_method='email_confirm'. No behavior of the ratified contract changes; it becomes a table lookup instead of three code paths.
3.4 Per-org identity policy — three orthogonal axes
The org owner configures three independent axes. Conflating them is the central design error to avoid (D1 — right abstraction):
| Axis | What it controls | Field on organizations (or org_policy) |
|---|---|---|
| Authentication (AAL) | How a member proves they are themselves to Koder ID (passkeypasswordMFA). The org may require a minimum; it can NEVER replace auth with "org-domain email." | min_auth_assurance (authenticator strength, ≈NIST AAL: aal1aal2aal3) |
| Membership prerequisite | Whether joining the org requires an org-issued email identifier. | membership_requires_org_email (bool) |
| Representation authority | Which identifier may represent the org externally (SSO subject, verifiable credential), in addition to the member's auth credential. | external_representation (org_domain_only / any_verified) |
min_auth_assurancenever weakens auth. "Validation requires an org-domain identifier" means the org-domain identifier is required *for representation, layered *on top of*passkey/password — not instead of them. Passkey remains the strongest authenticator; an org policy may raise the bar, never lower it.
3.5 "Org type" presets (Meta First — one model, named bundles)
Rather than N org types as N code paths, "org type" is a named preset over the axes above plus the custody regime (§3.6) and the default write scope:
| Preset | membership_requires_org_email |
default write scope | external_representation |
Offboarding custody | Substrate |
|---|---|---|---|---|---|
| corporate | true | org context | org_domain_only |
org retains all org-scoped data; member access revoked; org-domain identifier reclaimed | workspace_id/org_id scope |
| collective | false | personal or shared | any_verified |
personal data leaves with the member; shared data persists by grant | personal tenant + optional workspace |
| dao | false (self-sovereign anchor + passkey/keypair) | personal + grant | any_verified |
nothing is seized; data leaves with the member | tribe_member cross-tenant overlay (tribus-RFC-001) |
The preset is the default; an org owner may override individual axes. The DAO archetype already has its substrate: the tribe_member overlay in multi-tenancy/contract.kmd is membership decoupled from home tenant, grant-based, with anonymous anyone_with_link grants (koder_user_id IS NULL) — i.e. no central admin that can confiscate a member's data.
3.6 Data custody & offboarding (first-class lifecycle)
Custody is fixed at write time by scope (§2.2). Therefore the org-type preset must drive the write-time context a member acts under (the representation axis is the enforcement point), not merely label the org. A clear default + an explicit context signal in the UI is required so members do not put personal data in an org-custody context by accident (the Google Workspace model: admin owns everything in the workspace; offboarding export is admin-controlled).
The offboarding flow composes with already-ratified primitives:
erasure-flow.kmd: membership deletion leaves the workspace intact; aworkspace owner cannot self-erase without transferring ownership (no orphans). Offboarding extends this with the custody split (org-scoped stays, personal-scoped leaves) and org-domain identifier reclamation.
- Email outbound is physics, not policy. Sending authenticated mail "as the
org" is domain-bound by DKIMSPFDMARC — you cannot emit org-authenticated mail from a personal domain. The representation toggle therefore governs the other external assertions (federated SSO subject, verifiable membership credential); for email it is a law of the protocol, stated so the spec does not pretend it is a free choice.
3.7 Representation & delegation (compose with token-exchange)
External representation builds on the implemented token-exchange.kmd (RFC 8693): the issued token copies tenant_id verbatim (R4), records the actor chain in act (R6), and only downscopes (R7/R12). The representation-authority policy (§3.4) layers on top: it decides which identifier is authoritative for the org (the org-domain email as SSO subject / credential subject), while token-exchange carries tenant + actor. Per-org representation happens through tenant scope, not a per-org actor identity — consistent with the spec.
Assurance is two-dimensional (resolved §7.4) — do not collapse it into one scale:
- Identity proofing (≈NIST IAL) — how well-proven is this identifier —
lives on the identifier (
person_identifiers.proofing_level, §3.2). - Authenticator strength (≈NIST AAL) — how strong was the login — lives on
the org login policy (
min_auth_assurance, §3.4) and is asserted per-session via OIDCacr/amr.
The core enums are neutral (ial* / aal*). Jurisdiction vocabularies — gov.br BronzePrataOuro (Stack target per #098 gov-br parity), eIDAS substantial/high, NIST — are boundary adapters: a gov-BR surface maps proofing_level×AAL to a selo, never the reverse. Adding a jurisdiction is a mapping, not an enum migration (D1 + D9).
3.8 Domain-claim collision rule (consent, never silent absorption)
When an org verifies a domain (RFC-019), the flow scans person_identifiers for verified, personal (tenant_ctx IS NULL) rows on that domain and, for each match, creates a domain_claim pending the Person's consent (reusing the consent.kmd fail-closed model):
- Person accepts → the row's
tenant_ctxflips to the org (org now governsthat identifier); membership may be offered.
- Person declines → the identifier loses its verified status for that
Person (resolved §7.2). Rationale: the org now controls the domain's DNS/MX, so it — not the Person — controls who receives mail at that address; keeping it trusted would be a takeover vector (the org could later re-verify it). A grace window + notification lets the Person move their primary/recovery to another channel first (the §3.9 invariant guarantees one exists). Going forward the address resolves under the org's tenant context, not the Person's. Mirrors real life: you lose
you@company.comas your identity when you leave. - The
koder_user_idanchor is immutable in all cases; the org gainsgovernance over the domain, never over the person.
gmail.com/outlook.comare unverifiable by design → never trigger this.
3.9 Recovery channel — not "≥1 email"
No spec requires "≥1 email." The schema requires ≥1 resolvable identifier (a handle suffices). The robust invariant is every account must have ≥1 verified recovery channel — email (koder.dev or external), phone, or recovery codes:
@koder.devaccounts get it for free (the handle is a mailbox byconstruction).
- B2C/external accounts use the verified external email.
- A pure passkey-only account must register an explicit fallback at signup.
This decouples identity from "having an email" while guaranteeing recoverability — what the "≥1 email" assumption was really protecting.
3.10 Storage substrate (kdb) — the one design rule
Per kdb-RFC-001008009 and the consistency analysis:
- Single region: SSI + pessimistic locks make the global uniqueness index
(§3.2) sound today — two concurrent inserts of the same verified email, one deterministically loses.
- Multi-region (kdb-RFC-008, shipped + homologated 2026-06-14): uniqueness
writes route automatically to the tenant's primary region,
strong:tier (single-primary). Reads of identifiers/membership are served from the local follower within the koder-id lag budget (≤200 ms), orstrongwhen immediate consistency is required. No extra code beyond RFC-008 wiring. - Active-active (kdb-RFC-009, draft, unimplemented): the
strong:/relational:tiers remain single-primary.
Design rule (normative): identity uniqueness and auth invariants live in the kdb
strong:tier (single-primary, Raft quorum) and MUST NOT migrate tolww:/ active-active. Identifier and membership reads MAY be served from local followers (bounded staleness). kdb is not a blocker for identity at launch scale.
4. Vocabulary reconciliation (resolve the drift)
Four artifacts use different words for the same concepts. This RFC adopts the ratified RFC-017 hierarchy + kompass-RFC-001 Person/Membership as canonical and retires the rest:
| Concept | Canonical (this RFC) | Retired aliases |
|---|---|---|
| Immutable identity anchor | Person / koder_user_id |
RFC-002 users(tenant_id,…) (3-rows-per-human); RFC-012 "Individual" |
| Identity/billing boundary | Organization (org_id) |
RFC-002 v1 tenants; RFC-012 "Workspace-as-top" |
| Governance/metered bucket | Workspace (workspace_id) |
RFC-002 subtenants |
| Delivery/resource boundary | Project (project_id, RFC-017 §6) |
— |
| Person↔org link | Membership | RFC-002 users.tenant_id; Kompass Member |
| Attached loginrecoveryrepresentation value | Identifier (person_identifiers) |
users.email / users.handle columns |
"Tenant" remains only as the RLS scope word (app.current_tenant_id resolves to koder_user_id+workspace_id), never as an identity entity.
5. Security considerations
- Account-takeover blast radius grows with multi-org Persons (kompass-RFC-001
§Security): mandatory MFA / passkey-first for any Person with ≥2 memberships.
- External-anchor ≠ external IdP. Anchoring on
rodrigo@gmail.comdoes NOTfederate to Google — Koder ID stays the sole IdP (
oauth-client-external- providers.kmd §R1); the external email is an identifier/recovery channel, the authenticator is a Koder-held passkey/password. - Anti-abuse preserved. The invite-chain (RFC-013) — trust score, immutable
lineage, cascade revocation — is the asset that makes open/external signup safe; the corporatecollectiveDAO presets do not weaken it. At launch the global policy = single-domain + invite-only, so no day-1 anti-abuse debt.
- Custody by accident is the main hazard: enforce write-time context +
explicit UI signal; default-deny on representation (fail-closed, mirroring
consent.kmdR6).
6. Migration path
Sequenced after kompass-RFC-001 Phase 0 (Person/Membership tables exist):
- Create
person_identifiers; backfill fromusers.email/users.handle(one rowper existing verified value),
verify_method='koder_owned'for@koder.dev,verify_method='org_domain'for workspace-domain emails. - Drop the
users_individual_email_domainCHECK; install the Stack-globalidentity policy (default A = single-domain) as the replacement gate.
- Add
org_policyfields (membership_requires_org_email,external_representation,min_auth_assurance,org_type); default every existing org to the corporate preset (current Crescer/Vivver behavior). - Wire
domain_claiminto the RFC-019 domain-verification flow. - Implement the offboarding custody split on top of
erasure-flow.kmd. - Expose org-type presets in the admin UI.
Each step is independently shippable behind the kompass-RFC-001 compat shim.
7. Resolved decisions (2026-06-25)
Resolved via a /k-arch deliberation + owner /k-go (2026-06-25), grounded in architecture-quality.kmd (D1D3D6D7D9) + stack-principles.kmd (#1 Meta First, #2 Quality > Speed). Common thread: model the complete/general form in the substrate, stage/adapt via policy and boundary — in each case the "complete" state is already reachable without the extra machinery.
- Launch global policy → A. Individuals anchor only on
@koder.dev;external email is allowed solely as recovery/representation, never as an anchor at launch. Preserves the invite-chain anti-abuse asset (RFC-013) and the brand thesis (RFC-012). Because the substrate already carries the superset, enabling external anchors later is a policy flip (config), not a migration — taken when a real B2C product and a proven anti-abuse stack both exist (two-way door, D9).
- Decline semantics → lose verification (anchor survives). A declined domain
claim strips the identifier's verified status; the org controls the mailbox, so trusting it would be a takeover vector (D7). The anchor is untouched and the §3.9 recovery invariant prevents lockout. Implemented in §3.8.
org_typeextensibility → three fixed presets + per-axis override.Per-axis override already makes every state reachable, so custom named presets would add machinery without new capability (Meta First × YAGNI — the rare case where the simpler option is also the more complete one). Revisit a preset registry only when ≥3 orgs demonstrably need named custom bundles (Meta First's own ≥3 threshold).
- *ssurance → neutral internal model + jurisdiction mapping at the boundary,
and it is two-dimensional.*Identity-proofing (≈NIST IAL) lives on the identifier (
proofing_level, §3.2); authenticator strength (≈NIST AAL) lives on the org login policy (min_auth_assurance, §3.4). Core enums neutral (ial*aal*); gov.br BronzePrata/Ouro, eIDAS, NIST are boundary adapters (§3.7). This refines the original singleassurancefield into two orthogonal axes (D1 + D9).
No residual open questions block implementation; remaining work is gated only by kompass-RFC-001 Phase 0 (§6, §8).
8. Relationship to kompass-RFC-001
This RFC is the identifier + policy layer of the unification; kompass-RFC-001 is the substrate (PersonMembershipOrg structure + OrgML). It consumes, does not contradict, that model: person_identifiers attaches to its Person; the org-type presets parameterize its Membership/Org; the custody regime composes with its employment_type (CLT/PJ) and first-class Vacancy. It must land after kompass-RFC-001 Phase 0 and may be merged into the kompass track as its RFC-002 (the slot kompass-RFC-001 reserves).
9. Production reconciliation (2026-06-25, discovered during ID-253)
Exploration of the live services/foundation/id/engine (kdb-next storage, migrations 1–70) surfaced three deltas from this RFC's original assumptions — recorded so implementation matches reality:
- Table renamed
user_identities→person_identifiers. A productionuser_identitiestable already exists (auth service, migration #4) holding social-provider links (OAuth/SAML:provider,provider_user_id,profile_json). The attachable-identifier table of this RFC is therefore namedperson_identifiersto avoid the collision. All references above updated. - No email-domain CHECK exists in code. The
users_individual_email_domainCHECK described in id-RFC-002 was never implemented; single-domain is enforced at the service layer (if at all). §6 step 2 "drop the CHECK" is a no-op — it becomes "install the Stack-global identity policy gate" only.
- The org hierarchy is already live.
organizations,subtenants(=workspaces),
projects,org_members,org_domains,credentialsalready exist (RFC-017 EXPAND+MIGRATE done, CONTRACT active). Phase 0 (ID-253/ KOMPASS-001) reuses these; it does not recreate them. The genuinely new id-side tables arepersons,person_identifiers,phones(+ a nullableperson_idcolumn on the existingcredentials).