Id RFC 013 invite chain

RFC-013 — Invite Chain & Trust Score

  • *tatus:*Draft
  • *ate:*20260411
  • *uthor:*Koder Team
  • *epends on:*RFC001, 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

  1. *ate signups*during Phases 0 and 1 so only invited users can create identities
  2. *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"
  3. *erive trust score*from the lineage, to drive anti-abuse decisions, UI ranking, and onboarding experience
  4. *upport invite rate limits*per user and per lineage, to contain abuse radius if a single user is compromised
  5. *reserve audit trail*— invite events are immutable and queryable for compliance and forensics
  6. *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 (RFC003, RFC004)
  • Handling postPhase2 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:

  1. *nti-abuse on day 1*— a fully open signup for @koder.dev on day 1 would attract mass registration from spammers, poisoning the handle namespace and the sending reputation of the domain.
  2. *sername allocation chaos*— firstcome 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 nonnegative integer derived from the lineage, used by antiabuse and ranking systems.

Invariants

  1. *t most one inviter per identity*— the invite_chain_edges table has a unique constraint on invitee_id. An identity either has one inviter or zero (root).
  2. *nvites are one-time*— once redeemed, the invite row transitions to status = 'redeemed' and cannot be reused.
  3. *hain edges are immutable*— once written, a row in invite_chain_edges is never updated or deleted. Revocation events are recorded as separate rows in invite_revocations, not as mutations of the edge.
  4. *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).
  5. *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).
  6. *nvite quotas are enforced*— the Identity Service refuses to issue an invite if the inviter has exceeded their peruser 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

  1. Authenticated user alice (inviter) calls POST /v1/invites with optional intended_email and message
  2. Identity Service checks:
    • Alice exists and is in status = 'active'
    • Alice's quota has room (period_issued < period_allowed AND total_issued < total_allowed)
    • Alice's trust score is above the issuance threshold (default 100)
  3. Generate a cryptographically random 256-bit token, hash it with Argon2id
  4. Insert invites row with status = 'open', expires_at = now() + 30 days
  5. Increment invite_quotas.total_issued and period_issued for alice
  6. Return the raw token to alice *nce*— never stored, never retrievable
  7. Alice shares the invite URL (https://id.koder.dev/invite/<token>) with the invitee outofband

Invite redemption flow

  1. Unauthenticated user visits the invite URL
  2. Identity Service hashes the provided token, looks up invites by token_hash
  3. Validates: row exists, status = 'open', expires_at > now(), inviter still active
  4. Renders signup form (handle selection, password/passkey, display name, optional avatar)
  5. On submit:

    a. Validates handle against policies/username-allocation.kmd (reservation list, format rules) b. Creates users row with the chosen handle and <handle>@koder.dev email c. Creates invite_chain_edges row with invitee_id, inviter_id, invite_id, and depth = inviter.depth + 1 d. Updates the invite: status = 'redeemed', redeemed_at = now() e. Increments invite_quotas.total_redeemed for the inviter f. Issues invited-by-staff badge if inviter.role = staff g. Computes initial trust score for the new user h. Triggers welcome email via Koder Mail

  6. 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:

  1. Records the reason and actor
  2. If cascade = false, only the direct invitee is affected (their trust score drops, they may be suspended)
  3. If cascade = true, a background job walks the subtree rooted at invitee_id and 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_edges row is a *oot* Roots are either staff (Phase 0) or direct-signup users (Phase 2).
  • A user with an invite_chain_edges row has depth = inviter.depth + 1.
  • The shortestpathto-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 has depth = 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

  1. *ate limiting per-user*— enforced by invite_quotas.period_allowed
  2. *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
  3. *bnormal redemption patterns*— if a single inviter has 10 invites redeemed from the same IP block within 1 hour, flag for review
  4. *oken bruteforce protection*— tokens are 256 bits, hashed with Argon2id; ratelimited at gateway
  5. *mail harvesting via intended_email*— this field is hintonly, never crossreferenced with users or external databases; leaked by design only to the inviter
  6. *rust score reversibility*— abuse signals drop scores fast (immediate); recovery is slow (nightly recomputation, no "forgiveness" by default). Users can appeal to staff.
  7. *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

  1. *ineage walks*— depth denormalization makes trust-tier queries O(1). Full ancestor/descendant walks use the indexes on inviter_id and invitee_id and are bounded by tree depth (expected ≤10 in practice, cap enforced at 100).
  2. *ightly recomputation*— runs as a KDB batch job over the entire users table, partitioned by tenant_id (always the system tenant for Koder ID). Expected to process 10⁸ identities in ≤30 minutes on the target KDB cluster.
  3. *hain edge inserts*— single row per redemption, with the depth computed inline from the inviter — no recursive queries on the write path.
  4. *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

  1. *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.
  2. *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.
  3. *ggregate lineage stats*— the per-lineage metrics used for abuse detection are computed over hashed user IDs and never expose individual identities in reports
  4. *ight to erasure*— when a user deletes their account, the invite_chain_edges row is preserved (with the invitee_id still pointing to the softdeleted user row), because the lineage is necessary for trustscore integrity of the descendants. The user's handle is preserved according to policies/username-allocation.kmd.

Alternatives considered

Alternative Rejected because
No invite gating; open signup with strong anti-abuse from day 1 Day1 antiabuse stack is expensive and error-prone without prior calibration
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:

  1. *rust calibration*— invites with reason_code = colleague between two trust-high users get a small trust bonus; invites marked other with no detail get nothing
  2. *buse pattern detection*— spammers tend to leave reason empty or copy-paste; reason_detail is a signal in the abuse pipeline
  3. *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:

  1. *evocation*on the compromised user (invites/:id/revoke with cascade=true if warranted)
  2. *rust-score recomputation*on affected descendants
  3. *uspension*of descendants that fall below active-account threshold
  4. *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 of user_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=true revocation) runs as a dedicated background job per subtree, independent of the nightly job. It writes progress to a cascade_jobs table visible in the admin panel.
  • *rust score schema*— stored on users.trust_score (int16range clamped to [0, 10000]), also mirrored on invite_quotas.period_allowed for zeroread-amplification on the hot path.

Invite expiration default

  • *efault expires_at = issued_at + 30 days*
  • Inviter can override at issuance time with a shorter window (minimum 1 hour for outofband 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.

  1. *xact per-lineage cap numeric value*— 100/24h is the initial default but may need tuning once we see real subtree growth patterns
  2. *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.
  3. *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

  • *FC002*— 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

  • 20260411 — Initial draft

Source: ../home/koder/dev/koder/meta/docs/stack/rfcs/id-RFC-013-invite-chain.md