← Corpus / dididecks-ai / model

Auth surface — Identity, Session, Token, Organization, Membership, AuthEvent

The auth tables that gate every deck. Already a SQLite-shaped DB (astro:db on libSQL local, Turso remote) — so unlike the .md-on-disk models, this one is closer to translation-ready. Chroma is the most recent and authoritative schema. Calmstorm is an earlier-shape version. Humain doesn't have auth installed yet.

Path
models/Auth-Surface-Data-Model.md

Auth Surface · Data Model

What this model represents

Everything that gates a request to a private deck:

  • Who you are (Identity, OAuthAccount)
  • What session you’re in (Session, MintedToken)
  • What organization you belong to (Organization, Membership, FirmProfile)
  • Every gating decision the system has made on your behalf (AuthEvent — append-only audit log)

Unlike the .md-on-disk models, this is already a SQL-shaped database (astro:db on libSQL locally — auth.db — and Turso remotely). So “translation to a remote DB” for this model is much smaller — it’s mostly already a remote DB. The interesting question is whether to:

  1. Keep the per-client auth dbs (chroma has its own libSQL/Turso; humain will get its own; calmstorm has its own) — minimal change
  2. Consolidate into one shared auth DB with app_slug discriminator — more change, but one Identity row can attend multiple decks

Chroma’s schema already has the app_slug column on AuthEvent that supports option 2 — it’s an intentional preparation.

Where it lives

ClientSchema fileLocal DBRemote DB
calmstorm-decksdb/config.ts (older shape)auth.dbTurso (env vars in deploy)
chroma-decksdb/config.ts (current shape — authoritative)auth.dbTurso
humain-vc-decksabsent — auth not installedabsentabsent

The canonical schema (chroma — current)

Chroma’s db/config.ts defines these tables. All id columns are text (UUIDv7 strings); FKs are text references.

Identity

The canonical person record. One per email; lossless_id is a stable cross-system UUIDv7.

columns: {
  id:              text primaryKey   // UUIDv7
  lossless_id:     text unique       // cross-system UUIDv7 (also used by other Lossless apps)
  primary_email:   text unique
  label:           text optional     // display name override
  full_name:       text optional
  linkedin_url:    text optional
  handle:          text optional
  avatar_url:      text optional
  notes:           text optional
  created_at:      datetime default now()
  first_seen_at:   datetime optional
  last_seen_at:    datetime optional
}

OAuthAccount

GitHub (and future OAuth provider) identity linkage. Chroma supports OAuth via arctic.

columns: {
  id:                text primaryKey
  identity_id:       text references Identity.id
  provider:          text             // "github" | (future) "google", "linkedin"
  provider_subject:  text             // the provider's user ID
  provider_email:    text optional
  provider_username: text optional
  linked_at:         datetime default now()
  last_used_at:      datetime optional
}
indexes: {
  on [provider, provider_subject]:  unique
  on [identity_id]
}

MintedToken

The magic-link token system. Magic links are generated by the pnpm invite CLI; redeemed at /access/.

columns: {
  id:               text primaryKey  // the token itself (or hash of it)
  identity_id:      text optional references Identity.id  // null for "any-one-time-use" tokens
  role:             text              // viewer | admin | … (gates role on session creation)
  expires_at:       datetime optional
  max_uses:         int default 1
  uses_remaining:   int default 1
  minted_at:        datetime default now()
  minted_by:        text optional     // who minted (an Identity.id of an admin)
  revoked_at:       datetime optional
  notes:            text optional
}

Session

A live browser session. Created on magic-link redeem or passcode tier-3 fallback.

columns: {
  id:               text primaryKey  // the session cookie value (HMAC'd)
  identity_id:      text optional references Identity.id  // null for passcode-tier sessions (no identity attached)
  tier:             text             // "magic-link" | "oauth" | "passcode" | …
  role:             text             // viewer | admin | …
  shared_label:     text optional    // a human-readable label for passcode-tier shared sessions
  enrolled_at:      datetime default now()
  last_seen_at:     datetime optional
  revoked_at:       datetime optional
  ua_hash:          text optional    // hashed user-agent for tying session to browser
  ip_hash:          text optional    // hashed IP for tying session to network
  token_id:         text optional references MintedToken.id  // which token enrolled this session
}

Organization

Per the ai-labs CLAUDE.md “domain-as-id” convention: id is the canonical email domain.

columns: {
  id:               text primaryKey  // "lossless.group" | "trychroma.com" | "humain.vc" | "personal"
  slug:             text unique       // "lossless" | "chroma" | "humain" | "personal"
  name:             text              // display name
  created_at:       datetime default now()
}

Every deck seeds (at minimum) two organizations on first boot: lossless.group (operating team) + {client-domain} (the client this deck represents). Personal-email signups bucket as id = "personal".

FirmProfile

VC-firm-specific metadata for an Organization that IS a VC firm.

columns: {
  id:                text primaryKey references Organization.id
  firm_kind:         text optional    // "vc" | "operating-company" | "service-provider"
  portfolio_path:    text optional    // path in repo where this firm's portfolio lives (e.g. "data/investors/bloomberg-beta/portfolio")
  aum_tier:          text optional    // "small" | "mid" | "large"
  brand_assets_path: text optional
}

Membership

Identity-Organization join table. Roles are scoped per-org.

columns: {
  id:               text primaryKey
  identity_id:      text references Identity.id
  organization_id:  text references Organization.id
  role:             text             // "admin" | "viewer" | "owner" | …
  joined_at:        datetime default now()
  revoked_at:       datetime optional
}
indexes: {
  on [identity_id, organization_id]:  unique
  on [organization_id]
}

AuthEvent — append-only audit log

Every gating decision. Never updated, only appended.

columns: {
  id:                   text primaryKey
  at:                   datetime default now()
  app_slug:             text default "chroma-decks"   // ← supports cross-app consolidation
  outcome:              text             // "allow" | "deny" | "challenge" | "error"
  reason:               text optional    // "valid-session" | "expired-token" | "no-identity" | "wrong-passcode" | …
  tier:                 text optional    // session tier
  role:                 text optional    // session role
  identity_id:          text optional
  organization_id:      text optional
  session_id:           text optional
  token_id:             text optional
  shared_label:         text optional
  ip_hash:              text optional
  ua_hash:              text optional
  passcode_hash_prefix: text optional    // first N chars of hashed passcode attempt (for rate-limiting; never stores plaintext)
}
indexes: {
  on [at]
  on [identity_id]
  on [app_slug]
}

The calmstorm shape (older — what NOT to follow on new clients)

Calmstorm’s db/config.ts came first and has these differences:

  • No OAuthAccount table — calmstorm is magic-link + passcode only
  • No Organization / FirmProfile / Membership tables — no multi-org support; one client one tenant
  • Simpler Identity — has email instead of primary_email; has org as a text field instead of FK to Organization
  • AuthEvent.app_slug absent — calmstorm’s audit log doesn’t carry the app discriminator (it’s hardcoded to “calmstorm-decks” in code)

If consolidating into one shared auth DB, calmstorm rows would need to be migrated up: synthesize an Organization row from each calmstorm Identity’s org text field, then synthesize the Membership; backfill app_slug = "calmstorm-decks" on every AuthEvent.

Per-client variations

AspectCalmstormChromaHumain
Schema versionv1 (no orgs)v2 (orgs + OAuth)not installed
Identity primary key columnemailprimary_emailn/a
OAuth provider supportnoneGitHub via arcticn/a
Multi-org / Membershipnoyesn/a
lossless_id cross-systemnoyesn/a
AuthEvent.app_slugimplicitexplicit columnn/a
app_slug default valuen/a"chroma-decks"will be "humain-vc-decks"

When humain’s auth install plan executes (per client-sites/humain-vc-decks/context-v/plans/Install-Auth-Surface-from-Calmstorm-Pattern.md), it will use chroma’s schema verbatim with app_slug defaulted to "humain-vc-decks".

What’s load-bearing

  • Identity.lossless_id — the only cross-system stable ID. If consolidating to one DB, this is the FK target.
  • Organization.id = domain — the load-bearing convention (per ai-labs CLAUDE.md). lossless.group, trychroma.com, humain.vc, calmstormvc.com. Personal-email signups bucket as "personal".
  • AuthEvent.app_slug — required for multi-app consolidation. New AuthEvent inserts MUST carry this column.
  • Session.identity_id optional — passcode-tier sessions don’t have an identity. The DB cannot enforce non-null here.
  • MintedToken.id IS the token — or the hash of it. Compromised DB = compromised tokens. Storage at rest needs to match the security tier.

Translation to a remote DB

Option A — keep per-client Turso databases (minimal change)

This is the current state. Each deployed client has its own Turso database. No schema changes; just verify each client’s db/config.ts is current with the chroma schema as it evolves.

Pros: zero-change. Per-deck isolation. Compromise of one client doesn’t expose others. Cons: no cross-deck identity. Reader has to re-authenticate per deck. No central admin surface.

Option B — consolidate to one Postgres / one Turso with app_slug discrimination

Migrate all clients’ auth tables to one shared instance. Every row carries app_slug (chroma already does on AuthEvent; add to other tables for consistency).

// Same tables as chroma's astro:db schema, with these additions:

model Identity {
  // … all chroma fields …
  // No app_slug — identities cross apps (the whole point).
}

model Session {
  // … all chroma fields …
  app_slug   String   // NEW: "chroma-decks" | "humain-vc-decks" | "calmstorm-decks"
  @@index([app_slug, identity_id])
}

model MintedToken {
  // … all chroma fields …
  app_slug   String   // NEW
  @@index([app_slug])
}

model Membership {
  // … all chroma fields …
  // App-scoping is via Organization (the client's domain Organization),
  // not via an app_slug column. Membership semantics: this identity is a
  // member of this org, which the app surface knows is "their" org.
}

model AuthEvent {
  // … all chroma fields including app_slug …
}

Pros: one Linda Avey, one Aneil Mallavarapu. SSO across decks. Central admin. Cons: larger blast radius on compromise. Migration cost (especially calmstorm’s older shape).

Recommendation

Start with option A (per-client Turso). Defer option B until there’s a concrete need (e.g., a reviewer needs to authenticate across humain + chroma in one session). The schemas are mostly compatible — migration is feasible later.

Open questions for the collaborator

  1. Are you trying to design ONE auth DB or per-deck DBs? The chroma schema is ready for either. Per-deck is simpler today; one shared is the destination if cross-deck SSO becomes a requirement.

  2. Postgres or libSQL/Turso? Chroma + calmstorm use libSQL (because astro:db speaks libSQL natively). If switching to Postgres, the schema translates directly (texttext or varchar, datetimetimestamptz). Recommendation: stay on libSQL/Turso for new clients; Turso scales fine to the deck-OS use case and astro:db’s developer ergonomics matter.

  3. Where do new Identity rows come from? Today: magic-link redeem creates an Identity. Future: OAuth callback. Possible: passcode-tier sessions never create an Identity (anonymous reviewer). Recommendation: keep the existing flow.

  4. Organization.id = domain convention — strict or relaxed? It’s strict in chroma (every Identity gets an Organization row keyed by their email domain). If consolidating, do we honor it across apps? Recommendation: yes — it’s the load-bearing convention; cross-app identity makes more sense when Identity.primary_email and Membership.organization_id already agree on domain bucketing.

  5. PII at rest: Identity.primary_email, Identity.linkedin_url, Identity.avatar_url, Session.ip_hash, Session.ua_hash — what’s the storage discipline? Chroma stores hashes for IP/UA; emails in plaintext (justifiable for invite/audit but worth surfacing for compliance review). Recommendation: revisit when the first big-firm LP onboards.

See also

  • client-sites/chroma-decks/db/config.ts — current authoritative schema (read this first)
  • client-sites/calmstorm-decks/db/config.ts — older shape (read for migration context)
  • client-sites/humain-vc-decks/context-v/plans/Install-Auth-Surface-from-Calmstorm-Pattern.md — the porting plan
  • ../../context-v/explorations/Shared-Auth-for-Applied-AI-Labs.md (in ai-labs/) — parent-level exploration of the cross-app identity story
  • ai-labs CLAUDE.md § “Organization naming convention — domain-as-id”