Id RFC 013 invite chain
RFC-013 — Invite Chain & Trust Score
- *tatus:*Draft
- *ate:*2026
0411 - *uthor:*Koder Team
- *epends on:*RFC
001, RFC002, RFC006, RFC012
Summary
Defines the inviteonly signup machinery of Koder ID: how invites are issued, redeemed, tracked, and used to compute a peruser trust score. The invite chain is a firstclass data structure (not a log) that records the complete lineage of every identity admitted during Phases 0 and 1 of the rollout defined in RFC012. It is the foundation for anti-abuse, reputation, and provenance queries throughout the Koder ecosystem.
Goals
- *ate signups*during Phases 0 and 1 so only invited users can create identities
- *rack full lineage*— for every identity, we can answer "who invited this person, and who invited that inviter, all the way back to a Koder staff member or a direct-signup root"
- *erive trust score*from the lineage, to drive anti-abuse decisions, UI ranking, and onboarding experience
- *upport invite rate limits*per user and per lineage, to contain abuse radius if a single user is compromised
- *reserve audit trail*— invite events are immutable and queryable for compliance and forensics
- *ork at scale*— the invite tables must scale to ≥10⁸ identities without per-query lineage walks becoming a bottleneck
Non-Goals
- Replacing OAuth2/OIDC authentication flows — invites are orthogonal to authentication (RFC
003, RFC004) - Handling post
Phase2 signups (those do not use invites) - Making invites transferable between users (an invite token is bound to its issuer)
- Implementing social referral programs for marketing (invites are a trust mechanism, not a growth hack — even if they look similar)
Motivation
RFC012 adopts a singledomain strategy (@koder.dev for all individuals) that relies on invite-only gating during Phases 0 and 1 to solve two problems that would otherwise be blockers:
- *nti-abuse on day 1*— a fully open signup for
@koder.devon day 1 would attract mass registration from spammers, poisoning the handle namespace and the sending reputation of the domain. - *sername allocation chaos*— first
come firstserved without gating would allocate the best handles to bots within hours of launch.
Invite-only gating resolves both: we admit users at a controlled rate, we know who each user was vouched for by, and we can trim entire subtrees of the lineage if a single root turns out to be compromised. This is the same mechanism Gmail used from 2004 to 2007.
The invite chain is the data structure that makes this machinery work — it is the record of *ho vouched for whom*over the entire rollout, kept in a form that allows fast lineage queries and trust-score derivation.
Design
Core concepts
- *nvite*— a one-time token issued by an inviter, redeemable by exactly one invitee. After redemption, the invite is "spent" and cannot be reused.
- *nviter*— the identity that issued the invite. Must exist and be in good standing at the time of issuance.
- *nvitee*— the identity that redeems the invite. Created as part of the redemption flow.
- *hain edge*— the persistent record
(inviter_id, invitee_id, issued_at, redeemed_at)that remains after the invite is spent. This is the normalized form of the lineage. - *oot*— an identity with no inviter. In Phase 0, all roots are Koder staff provisioned directly by the Koder ID admin. In Phase 2, roots are direct-signup users.
- *rust score*— a non
negative integer derived from the lineage, used by antiabuse and ranking systems.
Invariants
- *t most one inviter per identity*— the
invite_chain_edgestable has a unique constraint oninvitee_id. An identity either has one inviter or zero (root). - *nvites are one-time*— once redeemed, the invite row transitions to
status = 'redeemed'and cannot be reused. - *hain edges are immutable*— once written, a row in
invite_chain_edgesis never updated or deleted. Revocation events are recorded as separate rows ininvite_revocations, not as mutations of the edge. - *o cycles*— trivially true because each identity has at most one inviter, making the chain a forest (set of trees rooted at staff or direct-signup users).
- *nviter must exist and be active at time of issuance*— a suspended user cannot issue invites. If an inviter is suspended after issuing an invite but before it is redeemed, the invite is automatically revoked (see below).
- *nvite quotas are enforced*— the Identity Service refuses to issue an invite if the inviter has exceeded their per
user or perlineage quota.
Data model
All invite-related tables live in the koder_id_identity namespace alongside the users table.
-- An outstanding invite. Lives here until redeemed or revoked.
CREATE TABLE invites (
id TEXT PRIMARY KEY, -- ULID
tenant_id TEXT NOT NULL, -- Koder ID system tenant
inviter_id TEXT NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE, -- Argon2id hash of the token (never store raw)
intended_email TEXT, -- optional hint (not enforced)
message TEXT, -- optional personal note from inviter
status TEXT NOT NULL DEFAULT 'open',-- open, redeemed, revoked, expired
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL, -- default 30 days from issuance
redeemed_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
INDEX (inviter_id, status),
INDEX (token_hash)
);
-- The persistent edge of the invite chain. One row per identity
-- that was admitted via invite. Immutable after write.
CREATE TABLE invite_chain_edges (
invitee_id TEXT PRIMARY KEY REFERENCES users(id),
inviter_id TEXT NOT NULL REFERENCES users(id),
invite_id TEXT NOT NULL REFERENCES invites(id),
depth INTEGER NOT NULL, -- distance from nearest staff root
issued_at TIMESTAMPTZ NOT NULL,
redeemed_at TIMESTAMPTZ NOT NULL,
INDEX (inviter_id), -- fast "who did I invite"
INDEX (depth) -- fast trust-tier queries
);
-- Revocation events. Used for lineage auditing and trust score decay.
CREATE TABLE invite_revocations (
id TEXT PRIMARY KEY,
invitee_id TEXT NOT NULL REFERENCES users(id),
reason TEXT NOT NULL, -- abuse, fraud, policy, inviter_compromised, etc
reason_detail TEXT,
revoked_by TEXT NOT NULL REFERENCES users(id),
revoked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cascade BOOLEAN NOT NULL DEFAULT FALSE -- if true, descendants are also affected
);
-- Per-user invite quotas. Updated on issuance and on trust score changes.
CREATE TABLE invite_quotas (
user_id TEXT PRIMARY KEY REFERENCES users(id),
total_allowed INTEGER NOT NULL, -- lifetime cap
total_issued INTEGER NOT NULL DEFAULT 0,
total_redeemed INTEGER NOT NULL DEFAULT 0,
period_allowed INTEGER NOT NULL, -- rolling 30-day cap
period_issued INTEGER NOT NULL DEFAULT 0,
period_reset_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);The depth column in invite_chain_edges is computed at insertion time (it is the inviter's depth + 1). Staff roots have depth 0. This denormalization makes trust-tier queries O(1) instead of requiring a recursive lineage walk. Updates to depth only happen if an ancestor is revoked with cascade=true, in which case a batch job recomputes affected subtrees.
Trust score
The trust score is a non-negative integer derived from the lineage. It is stored on users.trust_score and recomputed on any event that affects the lineage (new invite redeemed, revocation, role change, abuse signal).
*ormula (v1, to be calibrated empirically):*
base_score(user) =
if user is staff: 1000
else if user is direct-signup root: 100 (only exists in Phase 2)
else:
max(0, inviter.base_score - 50 * depth)
adjustments:
+ 20 for each other user this user has successfully invited
(capped at +200 — rewards quality inviting, but not farming)
+ 100 if user holds the `verified` badge (KYC)
+ 50 if user holds the `developer` badge
- all for each active abuse signal (spam report, fraud flag, chargeback)
- 500 if any descendant has been revoked for abuse (contagion penalty)
trust_score(user) = max(0, base_score + sum(adjustments))Trust score determines:
- *nvite quota*— number of invites the user can issue (RFC-012 Phase 1 default: 10 for staff, 3 for external users with score ≥300, 0 below)
- *andle allocation tier*— users above a threshold can claim short handles (see
policies/username-allocation.kmd) - *utbound email rate limit*— higher score = more outgoing email per day before throttling
- *orkspace creation eligibility*— must have score ≥200 to create a workspace
- *tore publish eligibility*— must have score ≥300 to publish an app on Koder Hub
Trust scores are recomputed:
- *mmediately*when a user issues or redeems an invite
- *mmediately*when a user is revoked, suspended, or flagged for abuse
- *ightly*for all users as a batch, to pick up drift from scheduled events (expiry, quota period reset, adjustments from recent activity)
Invite issuance flow
- Authenticated user
alice(inviter) callsPOST /v1/inviteswith optionalintended_emailandmessage - Identity Service checks:
- Alice exists and is in
status = 'active' - Alice's quota has room (
period_issued < period_allowedANDtotal_issued < total_allowed) - Alice's trust score is above the issuance threshold (default 100)
- Alice exists and is in
- Generate a cryptographically random 256-bit token, hash it with Argon2id
- Insert
invitesrow withstatus = 'open',expires_at = now() + 30 days - Increment
invite_quotas.total_issuedandperiod_issuedfor alice - Return the raw token to alice *nce*— never stored, never retrievable
- Alice shares the invite URL (
https://id.koder.dev/invite/<token>) with the invitee outofband
Invite redemption flow
- Unauthenticated user visits the invite URL
- Identity Service hashes the provided token, looks up
invitesbytoken_hash - Validates: row exists,
status = 'open',expires_at > now(), inviter still active - Renders signup form (handle selection, password/passkey, display name, optional avatar)
- On submit:
a. Validates handle against
policies/username-allocation.kmd(reservation list, format rules) b. Createsusersrow with the chosen handle and<handle>@koder.devemail c. Createsinvite_chain_edgesrow withinvitee_id,inviter_id,invite_id, anddepth = inviter.depth + 1d. Updates the invite:status = 'redeemed',redeemed_at = now()e. Incrementsinvite_quotas.total_redeemedfor the inviter f. Issuesinvited-by-staffbadge ifinviter.role = staffg. Computes initial trust score for the new user h. Triggers welcome email via Koder Mail - Returns authenticated session to the new user
Invite revocation
Revocation exists in two flavors:
*elfrevocation (inviterinitiated):*DELETE /v1/invites/:id by the original inviter, before redemption. Sets status = 'revoked', revoked_at = now(). Does not affect any existing chain edges (there are none yet).
*dmininitiated revocation:*any Koder ID admin can revoke an alreadyredeemed invite by calling POST /v1/invites/:id/revoke with a reason. This creates a row in invite_revocations rather than mutating the invite_chain_edges row (which remains immutable). The revocation:
- Records the reason and actor
- If
cascade = false, only the direct invitee is affected (their trust score drops, they may be suspended) - If
cascade = true, a background job walks the subtree rooted atinvitee_idand re-evaluates every descendant:- Each descendant's trust score is recomputed
- Any descendant below the active-account threshold is suspended pending review
- The chain edges remain intact — we do not rewrite history, we annotate it
Cascade revocation is the primary abuse response tool: if a Koder staff member's account is compromised and used to issue 50 invites, revoking with cascade kills those 50 invitees and everyone they invited transitively.
Depth and root semantics
- A user with no
invite_chain_edgesrow is a *oot* Roots are either staff (Phase 0) or direct-signup users (Phase 2). - A user with an
invite_chain_edgesrow hasdepth = inviter.depth + 1. - The shortest
pathto-staff depth is stored denormalized for O(1) lookup. If a user has multiple paths to staff (impossible under current invariants — each user has at most one inviter — but permitted by a future relaxation), this field always stores the minimum. - Staff have
depth = 0. A user invited by staff hasdepth = 1. Depth is used both for trust score and for UI badges ("second-degree Koder connection").
API surface
# Invite management
POST /v1/invites # issue new invite (authenticated)
GET /v1/invites # list my outstanding invites (authenticated)
GET /v1/invites/:id # get details (authenticated, inviter only)
DELETE /v1/invites/:id # self-revoke unredeemed invite (authenticated, inviter only)
# Invite redemption (unauthenticated)
GET /v1/invites/by-token/:token # validate token and return invitation details
POST /v1/invites/by-token/:token/redeem # redeem and create user
# Lineage queries
GET /v1/identities/:id/ancestors # walk up the chain (authenticated, staff only in most cases)
GET /v1/identities/:id/descendants # walk down the chain (authenticated, staff only)
GET /v1/identities/:id/trust-score # read-only (authenticated, self or staff)
# Admin
POST /v1/invites/:id/revoke # admin-initiated revocation (authenticated, staff only)
POST /v1/invites/admin/recompute-scores # trigger batch recomputation (authenticated, staff only)
GET /v1/invites/admin/report # abuse report (outliers, high-issue-rate users, etc)Quota defaults
Quotas are per-user and live in invite_quotas. Defaults (configurable via admin API):
| Role / Score tier | Total lifetime cap | Rolling 30-day cap |
|---|---|---|
staff |
1000 | 50 |
| External, trust ≥500 | 100 | 20 |
| External, trust 300–499 | 30 | 10 |
| External, trust 100–299 | 10 | 3 |
| External, trust <100 | 0 | 0 |
When a user's trust score crosses a tier boundary, their quota is adjusted at the next nightly recomputation (or immediately if the change is due to abuse signal).
Anti-abuse considerations
- *ate limiting per-user*— enforced by
invite_quotas.period_allowed - *ate limiting per-lineage*— if subtree rooted at user X has issued >1000 invites in the last 7 days, X's own quota drops to 0 temporarily pending review
- *bnormal redemption patterns*— if a single inviter has 10 invites redeemed from the same IP block within 1 hour, flag for review
- *oken brute
force protection*— tokens are 256 bits, hashed with Argon2id; ratelimited at gateway - *mail harvesting via
intended_email*— this field is hintonly, never crossreferenced with users or external databases; leaked by design only to the inviter - *rust score reversibility*— abuse signals drop scores fast (immediate); recovery is slow (nightly recomputation, no "forgiveness" by default). Users can appeal to staff.
- *ompromised staff account*— the worst case. Mitigated by: short session TTL for staff, mandatory passkey, admin audit alerts on burst-issuance, cascade revocation tool
Performance considerations
- *ineage walks*—
depthdenormalization makes trust-tier queries O(1). Full ancestor/descendant walks use the indexes oninviter_idandinvitee_idand are bounded by tree depth (expected ≤10 in practice, cap enforced at 100). - *ightly recomputation*— runs as a KDB batch job over the entire
userstable, partitioned bytenant_id(always the system tenant for Koder ID). Expected to process 10⁸ identities in ≤30 minutes on the target KDB cluster. - *hain edge inserts*— single row per redemption, with the depth computed inline from the inviter — no recursive queries on the write path.
- *evocation cascade*— runs async via a background job; the UI shows "cascade in progress" on the admin panel. Expected to process subtrees of ≤10⁴ descendants in seconds.
Privacy considerations
- *ineage is not public*— a user cannot see who invited them without explicit consent from the inviter (or via staff query during abuse investigation). The invite URL itself carries no inviter identity — the invitee only learns the inviter's handle after redemption, and only if the inviter allowed it in the invite settings.
- *rust score is private*— a user's trust score is visible only to themselves and to Koder staff, never to other users. UI elements that depend on the score (e.g., "can publish on Store") display as eligible/ineligible without revealing the number.
- *ggregate lineage stats*— the per-lineage metrics used for abuse detection are computed over hashed user IDs and never expose individual identities in reports
- *ight to erasure*— when a user deletes their account, the
invite_chain_edgesrow is preserved (with theinvitee_idstill pointing to the softdeleted user row), because the lineage is necessary for trustscore integrity of the descendants. The user's handle is preserved according topolicies/username-allocation.kmd.
Alternatives considered
| Alternative | Rejected because |
|---|---|
| No invite gating; open signup with strong anti-abuse from day 1 | Day |
| Invites are transferable (one person can resell or gift an invite) | Breaks the vouching semantics; turns invites into a marketplace |
| Each user has unlimited invites | No backpressure; one compromised account can mass-register thousands |
| Lineage stored as recursive pointer chain walked per query | Doesn't scale; trust tier queries become expensive above 10⁶ users |
| Trust score is a float | Hard to reason about, hard to test, hard to explain in support tickets |
| Revocation mutates chain edges in place | Breaks immutability invariant; complicates audit and forensics |
Decisions — hyperscalealigned (202604-11)
The items below are now *inding decisions* not open questions.
Invites carry optional structured reason
An invites row gains two optional columns: reason_code (enum: colleague, friend, project, community, other) and reason_detail (free text, ≤500 chars). Both are shown to the invitee during redemption as context. Useful for three things:
- *rust calibration*— invites with
reason_code = colleaguebetween two trust-high users get a small trust bonus; invites markedotherwith no detail get nothing - *buse pattern detection*— spammers tend to leave reason empty or copy-paste;
reason_detailis a signal in the abuse pipeline - *X*— the invitee sees "Rodrigo invited you because you work together on the Kruze project" instead of a cold form. Reduces drop-off.
Reason is optional (no UX friction if the inviter doesn't want to fill it).
Public verified invite proofs (opt-in)
An inviter can mark an invite as public_verifiable = true at issuance. After redemption, the pair (inviter_handle, invitee_handle, invite_id, redeemed_at) becomes queryable via a public endpoint (GET /v1/invites/proof/:invite_id) and is displayed as a verified badge on the invitee's profile: "Invited to Koder by @rodrigo". This creates a reputation graph where users can show who vouched for them publicly — useful for community building (open-source maintainers, mentors, etc.).
Default is false — public proofs are opt-in only, because most invites are private social graph.
Multiinvite resolution — firstredeem-wins
If a user is given multiple valid invites before they sign up, *he first one they redeem wins* All others are automatically marked status = 'expired' at redemption time (not at the nominal expires_at of each invite). The invite_chain_edges row records only the winning invite. Other inviters do *ot*get their quota refunded — issuance consumes a slot regardless of whether the invite was the one used. This prevents "spray and waste" farming where a user distributes many invites expecting some to be unused.
Reparenting is forbidden
The lineage is immutable. If an inviter is compromised or revoked, the system response is:
- *evocation*on the compromised user (
invites/:id/revokewithcascade=trueif warranted) - *rust-score recomputation*on affected descendants
- *uspension*of descendants that fall below active-account threshold
- *ppeal path*for descendants who were legitimate but got caught in the cascade
Reparenting would allow the system to "launder" a bad inviter by moving their invitees to a clean parent. That breaks the integrity of the trust graph and creates an abuse vector (staff can clean up their own mistakes without penalty). *o reparenting, ever.*
Quota defaults (binding)
These are the default values at service startup. They can be tuned per-environment via admin API, but the shape is frozen.
*er-user quotas:*
| Role / Score tier | Total lifetime cap | Rolling 30-day cap |
|---|---|---|
staff |
1000 | 50 |
| External, trust ≥800 | 200 | 30 |
| External, trust 500–799 | 100 | 20 |
| External, trust 300–499 | 30 | 10 |
| External, trust 100–299 | 10 | 3 |
| External, trust <100 | 0 | 0 |
*ystem-wide global rate cap (new for hyperscale):*
The total number of invites issued across the entire Koder ID system is hard-capped at:
- Phase 0: 1 000 total (closed alpha)
- Phase 1 initial: 10 000 / 24h
- Phase 1 steady: 100 000 / 24h (enabled after 30 days of no abuse incidents)
- Phase 2 opening: no cap (public signup, invites become optional trust signal)
This global cap is a circuit breaker that prevents a coordinated compromise of many inviter accounts from saturating the signup pipeline before the abuse team can react. It is enforced at the API Gateway layer by a global Redis counter with a 24h sliding window.
*er-lineage cap (hyperscale addition):*
A subtree rooted at any single user cannot produce more than *00 descendants per 24h*across the entire subtree. If the cap is hit, new invite redemptions in that subtree are throttled until the next window. This prevents a single compromised high-trust user from exponentially seeding a spam cluster before cascade revocation kicks in.
Sharding and recomputation at hyperscale
Trust score recomputation is the heaviest workload in this system. Decisions:
- *ightly job*partitioned by
tenant_id(always the Koder ID system tenant for individuals) and sharded further by a hash ofuser_id(256 shards). Each shard is processed independently and in parallel on the KDB compute layer. - *vent-triggered incremental updates*on the hot path (new invite issued, invite redeemed, abuse flag set) touch only the affected user and the subtree within a bounded radius (depth+3). This keeps the cost of individual events
O(subtree_size at depth+3), typically ≤1000 rows. - *ascade recomputation*(after a
cascade=truerevocation) runs as a dedicated background job per subtree, independent of the nightly job. It writes progress to acascade_jobstable visible in the admin panel. - *rust score schema*— stored on
users.trust_score(int16range clamped to [0, 10000]), also mirrored onread-amplification on the hot path.invite_quotas.period_allowedfor zero
Invite expiration default
- *efault
expires_at = issued_at + 30 days* - Inviter can override at issuance time with a shorter window (minimum 1 hour for out
ofband handoffs) or longer (maximum 90 days) - After expiration, the invite row transitions to
status = 'expired'via a nightly sweep job — expired invites are *ot*purged for 1 year (audit trail), then soft-deleted
Cascade revocation behavior
The cascade job walks the subtree rooted at invitee_id and re-evaluates each descendant. Behavior per descendant:
- *epth ≤ 2 from the revoked node:*automatically suspended, pending admin review
- *epth 3–5:*trust score recomputed; if it falls below 100, suspended; otherwise flagged for review
- *epth > 5:*trust score recomputed only; no automatic suspension
- *taff descendants:*never auto-suspended — even in cascade, staff require a human admin to confirm
Cascade jobs are auditable and reversible within 14 days: an admin can "un-cascade" a revocation and restore affected descendants if the original decision was wrong. After 14 days, the decision is permanent.
Remaining open questions
Items that require empirical calibration during Phase 0/1.
- *xact per-lineage cap numeric value*— 100/24h is the initial default but may need tuning once we see real subtree growth patterns
- *utbound reputation pool thresholds*— which trust score gets which IP pool in Koder Mail. Decided by the Koder Mail team based on real warmup data.
- *lobal rate cap progression schedule*— the 30-day window between Phase 1 initial and steady is a guess; may need shortening if no incidents or extension if we see early abuse
Dependencies on other RFCs
- *FC
002*— adds the inviterelated tables to the data model - *FC-003*— the redemption flow plugs into the standard auth flow
- *FC-006*— the identity service owns the invite endpoints
- *FC-012*— defines the rollout phases (012) that this machinery gates
Changelog
- 2026
0411 — Initial draft