← Corpus / fullstack-vc / other
Pre-create Turso User Rows from External Rosters and Fuzzy-Bind to Public Participant Profiles
Build the Turso-side ingest + fuzzy-match layer that lets us pre-create User rows from external sources (Zoom exports, Luma RSVPs, conference registrations) with full email PII safely sequestered in the private database, then auto-bind those rows to OAuth sign-ins by email and offer high-confidence manual claims against public participants/*.md profiles. Closes the loop between 'we know they registered' and 'they have a face on the site.'
- Path
- tasks/Pre-create-and-Fuzzy-Bind-Users-from-External-Rosters.md
- Authors
- Michael Staton
- Augmented with
- Claude Code (Opus 4.7)
- Tags
- Identity-Model · Turso · User-Table · Pre-Creation · Zoom-Ingest · Fuzzy-Matching · OAuth-Binding · Participant-Profiles · Privacy-Boundary · Build-in-Public · Public-Private-Split
Pre-create Turso User Rows from External Rosters and Fuzzy-Bind to Public Participant Profiles
Status: Planned
Site: sites/fullstack-vc
Sibling task: [[Wire-Google-Workspace-OAuth-Provider]] — Google OAuth lowers the friction for the dominant pre-creation source (Workspace-registered Zoom attendees), making this task’s payoff much larger.
Predecessors: [[Auth-Identity-System-Worked-but-UX-Failed-Silent-Bounces]]
Related blueprints: [[Maintain-an-Interactive-Polling-System—v2]] §3 (identity as hard dependency for vote integrity), §3.1 (user-profile upgrade — flat-file profiles as enrichment, DB as source of truth)
1. The privacy boundary, and why this task exists
This is a build-in-public repo. Anything in src/content/participants/*.md is on the public internet the moment it’s committed. Today the participant markdown shape captures handle, name, firm, role, kauffman_class, headshot, github, linkedin, directory_profile_kauffman, joined_dojo, stacks — all things the person would happily show on a credibility card. It does not capture email, Zoom registration ID, or anything that came off a privately-shared roster.
That asymmetry is correct and we want to keep it. But it leaves us with a real workflow gap:
- We frequently receive Zoom registration exports, Luma RSVPs, and similar external rosters that contain emails for people we know are attending an upcoming Dojo session.
- Many of those people have an existing public profile in
participants/*.md(we pre-curated it for presenters, advisors, or prior attendees). Many don’t yet. - When such a person finally signs in via OAuth, the system today has no way to recognize “this is the same person we knew about” — it just creates a fresh
Userrow, and the public profile (if it exists) stays disconnected.
The fix splits along the privacy boundary the repo already enforces. PII lives in Turso. Public profiles stay in markdown. A single non-sensitive handle column on the User row bridges the two. External-roster ingest writes only to Turso. OAuth binds against Turso. Claim-against-public-profile is a separate, lower-confidence, human-confirmed action.
2. Current state
What works today
Usertable (Turso,db/config.ts) already has the right shape for multi-email PII:email: text— primaryemails: json—string[]of every email we’ve ever seen across providersgithub_handle,linkedin_sub— provider-specific stable IDsname,avatar,kauffman_class,firm,first_login_at,last_login_at
lib/oauth-roster.tshandlesemail+email_aliases[]matching againstkauffman_roster.jsonfor the Kauffman Fellows roster.resolveCanonicalUserId(commitc8b9597) makes vote attribution stable across providers.
What’s missing
- No
handlecolumn onUser. TodayUser.idis the lowercased roster email and is fine for vote attribution. There’s no field that says “this User corresponds toparticipants/tobyrush.md.” - No ingest path for external rosters. Zoom exports get processed by hand or by ad-hoc scripts (see
scripts/_reconcile-user-rows.mjsfor a reconciliation hint, but no first-class ingest). - No email-based pre-binding in the OAuth callback. A new sign-in always creates a fresh
Userrow if nogithub_handleorlinkedin_submatch exists, even if the session’s email matches a pre-createdUser.email/User.emails[]. - No fuzzy matcher. When the email doesn’t match anything pre-existing, we don’t surface “We think you might be Toby Rush — claim this profile?” candidates against the public markdown set.
- No admin triage surface. When the fuzzy match comes up empty or ambiguous, there’s nowhere for a host to manually bind a User row to a participant handle.
3. Design
3.1 The bridge: User.handle
Add one column to User:
// db/config.ts
handle: column.text({ optional: true }), // FK-by-convention to participants/<handle>.md
This is the only piece of information that traverses the private↔public boundary. It’s already a public string (it’s the URL slug for the participant page), so no privacy cost. Nullable because not every User will ever claim a public profile, and that’s fine — voting and identity work without it.
3.2 The external-roster ingest path
A new directory scripts/ingest/ with one script per source format:
ingest-zoom-csv.ts— read a Zoom registration CSV, normalize to{ name, email, firm?, registered_at }, write/mergeUserrows.ingest-luma-export.ts— same shape, different source CSV layout.ingest-rsvp-form.ts— for whatever future form ingest we use.
Each script:
- Parses the source file (header-aware; no positional column assumptions).
- For each row, lowercases the email and checks if a
Userrow exists with that email inemailoremails[]. - If yes: merge — add the row’s email to
emails[]if missing, fill inname/firmif blank, append apre_creation_eventaudit row (see §3.5). - If no: insert a new
Userrow withid: <lowercased-email>,email,emails: [email],name,firm, no provider subs, nohandle,last_provider: 'pre-created'(sentinel for “not yet OAuth’d”),first_login_at: null,created_at: now. - Print a per-row summary:
created/merged/skipped-duplicate.
Idempotent by design — safe to re-run on the same export file.
3.3 OAuth callback enhancement: email-based pre-bind
Today’s flow (paraphrased):
session arrives → lookup User by github_handle OR linkedin_sub
→ if found, update last_login_at and return
→ if not found, INSERT new User
New flow:
session arrives → lookup User by github_handle OR linkedin_sub
→ if found: same as today
→ if not found AND session.email is present:
lookup User by email = session.email
OR email in emails (JSON contains)
→ if found: this is a pre-created row. UPDATE it to attach the OAuth sub,
add session.email to emails[] (dedup), bump last_login_at.
Emit an AuthEvent { kind: 'pre_bind_email_match', from: 'pre-created' }.
→ if not found: INSERT new User as today.
This is the cheapest single change with the biggest payoff. A Zoom-imported user signs in once via any email-bearing provider (Google, LinkedIn) and the pre-creation immediately becomes a real, OAuth-linked identity — no duplicate row, no manual triage.
3.4 Fuzzy match service
New file src/lib/participant-match.ts:
export interface MatchCandidate {
handle: string; // points at participants/<handle>.md
confidence: number; // 0..1
reasons: string[]; // ['github_handle_exact', 'name_jw_0.94', 'firm_match']
}
export async function findCandidates(user: User): Promise<MatchCandidate[]>;
Confidence tiers (auto-bind threshold is >= 0.95, suggestion threshold is >= 0.70):
| Signal | Weight | Notes |
|---|---|---|
user.github_handle === participant.github | 0.50 | exact, lowercased |
| LinkedIn URL exact match | 0.45 | user.linkedin_sub resolved against participant.linkedin |
| Full name exact match (lowercased, trimmed, accents-normalized) | 0.35 | |
| Full name Jaro-Winkler ≥ 0.92 | 0.20 | typo / nickname tolerance |
Same kauffman_class | 0.15 | required if name fuzzes — prevents random “Mike” matches |
| Firm name exact (lowercased) | 0.15 | |
| Email domain matches a known firm domain on the participant | 0.10 | |
| First-initial-last-name match in display name | 0.08 | catches “M. Staton” → Michael Staton |
These add (capped at 1.0). The reasons[] array is what gets shown to the user in the claim UI so they understand why the system thinks they might be that person.
Implementation note: load all participants/*.md frontmatter once per request via Astro Content Collections, build an in-memory index keyed by lowercased github + name + firm. The collection is small (<100 files) — full scan is fine.
3.5 Audit trail
A new table UserBindingEvent (or extend the existing AuthEvent if it already has the right shape — verify) records:
{
user_id: string;
kind: 'pre_created' | 'pre_bind_email_match' | 'fuzzy_claim_offered'
| 'fuzzy_claim_accepted' | 'manual_bind' | 'unbind' | 'merge';
evidence: json; // { match_reasons: string[], confidence: number, source_file?: string }
actor: string; // 'system:zoom-ingest' | 'system:oauth-callback' | 'user:<id>' | 'host:<id>'
at: date;
}
Without this, any incident (“why did Lylan get bound to Marcos’s profile?”) is unrecoverable.
3.6 /me page enhancement: the claim card
When User.handle is null AND findCandidates(user) returns at least one candidate with confidence >= 0.70:
Render a claim card above the existing /me content:
We may have a profile that’s you.
[headshot] Toby Rush — Founder, Ideem · Class 23 Why we think so: matching GitHub handle, name match, same Kauffman class.
[ Claim this profile ] [ That’s not me ] [ Show me other matches ]
- “Claim this profile” → POST
/api/me/claim-participantwith{ handle }→ server verifies the candidate is still in the user’sfindCandidateslist (re-runs the match), writesUser.handle, emitsfuzzy_claim_acceptedevent, redirects to the now-bound profile. - “That’s not me” → POST
/api/me/reject-participantwith{ handle }→ records aUserBindingEvent { kind: 'fuzzy_claim_offered', evidence: { rejected: true, handle } }so the same suggestion doesn’t keep reappearing. - “Show me other matches” → expand to show the candidate list ordered by confidence.
3.7 Admin triage at /admin/unbound-users
For hosts to manually bind when fuzzy fails. List view:
| User | Provider | Top candidate (confidence) | Action | |
|---|---|---|---|---|
| Marcos Polanco | m@… | github | marcospolanco (0.97 — github_handle) | [ Bind ] |
| (unknown name) | foo@workspace.com | — | [ Create participant ] |
Admin can: (a) accept the top candidate, (b) pick a different candidate from the dropdown, (c) create a new participant markdown stub (triggers a GitHub App commit, see existing lib/github-commit.ts), (d) leave unbound.
This view is gated to host-role users only — wire via an existing role check or a hardcoded allowlist of github_handles in v0.0.1.
4. Out of scope (parked)
- Automatic public-profile creation from pre-created
Userrows. Even with high confidence, we don’t want to silentlygit commita public file on someone’s behalf. Profile creation stays a host-mediated action via the admin triage view. - Privacy-preserving zero-knowledge matching. A fancier system could hash emails client-side and match by hash so the server never sees the email. Out of scope — we already have the email in Turso for legitimate reasons (vote integrity, comms).
- Cross-session global identity. A future “Lossless ID” could span fullstack-vc, mpstaton-site, and other Astro-Knots sites. Out of scope here; the
handlefield is per-site for now. - GDPR-shaped deletion / export endpoints. Important and on the long-term roadmap. Not blocking this task.
5. Step-by-step implementation
- Schema: add
handle: column.text({ optional: true })toUserindb/config.ts.astro db push --remote. (~5 min.) UserBindingEventtable: add todb/config.ts. Push. (~10 min.)- Ingest script:
scripts/ingest/ingest-zoom-csv.ts. Take CSV path + dry-run flag. Idempotent merge logic per §3.2. (~45 min including a real Zoom export end-to-end test.) - OAuth callback enhancement: modify
src/pages/api/auth/{github,linkedin,google}/callback.ts(Google handler arrives via the sibling task). Add the email-based pre-bind lookup before falling through to “insert new User.” Emitpre_bind_email_matchevents. (~30 min.) - Fuzzy match service:
src/lib/participant-match.tsper §3.4. Unit tests for the weight math + Jaro-Winkler edges. (~1 hour.) - Claim API routes:
POST /api/me/claim-participant,POST /api/me/reject-participant. (~30 min.) /meclaim card UI: Svelte island for the candidate carousel (handles “show other matches”). (~1 hour.)- Admin triage view:
src/pages/admin/unbound-users.astro+ host-role gate. (~1 hour.) - Smoke test: Run a real Zoom export through the ingest, sign in as one of the included emails via Google, confirm the pre-created row gets bound rather than duplicated, confirm the claim card surfaces the correct participant.
Total cost estimate: ~5 hours of focused work. The fuzzy-match weight math is the only piece that benefits from real data calibration — first cohort of bind/reject events will tell us whether the thresholds need tuning.
6. Verification
- Ingest idempotency: run the same Zoom CSV twice → second run reports all rows as
mergedorskipped-duplicate, no duplicateUserrows. - Pre-bind on OAuth: pre-create a row for
<some-email>, sign in via Google with that email, confirm the OAuth sub binds to the existing row (not a new one). - Fuzzy-match thresholds: manually construct ten edge cases (typo, nickname, same name different class, same firm different person) and assert each lands in the right bucket (auto-bind / suggest / no-match).
- Claim flow: unbound user with a high-confidence candidate sees the claim card; clicking claim sets
User.handleand emits the audit event; refreshing /me no longer shows the card. - No-PII leak: grep
src/content/participants/*.mdforemail,phone,zoom, etc. and confirm nothing private got written to the public side as a side effect.
7. Open questions
- Should pre-created
User.iduse the email, or a synthetic ID? Today existingUser.idis the lowercased roster email. For pre-created users with no roster match, we either reuse the email or mint a UUID. Reusing the email keeps the existing convention but couples row identity to a (potentially changeable) email; UUIDs decouple at the cost of breaking the “id = email” invariant. Recommend: keepid = lowercased primary emailfor consistency. If a user later changes their primary email, theemails[]array captures the historical one and lookup still works. - Where does
participant_match.tsget its data — Astro Content Collections at request time, or a built index? Content Collections feel right; small dataset. If it gets slow (>50ms per call), switch to a built-timeparticipants-index.jsonregenerated on each deploy. - What’s the policy when fuzzy match offers a candidate and the user clicks “That’s not me” — do we ever re-offer that pair? No, unless an admin manually re-opens. Adds an
excluded_handles: string[]consideration to the matcher. - GitHub App commit on “create new participant from admin triage.” The infrastructure exists (
lib/github-commit.ts); the policy question is what minimal frontmatter we write. Recommend:handle,name,publish: falseas a draft so the host can fill in the rest before promoting. Stays out of the public site until publish flips.
8. References
- [[Wire-Google-Workspace-OAuth-Provider]] — sibling task; together they unlock the full “Zoom registers Workspace user → Google OAuth → pre-bind → claim public profile” loop.
- [[Auth-Identity-System-Worked-but-UX-Failed-Silent-Bounces]] — prior UX failure analysis; the claim card is exactly the kind of intentional post-login affordance that was missing.
- [[Maintain-an-Interactive-Polling-System—v2]] §3.1 — the “flat-file profiles as enrichment, DB as source of truth” framing this task operationalizes.
commit c8b9597— the canonical-id resolver. The email-based pre-bind logic in §3.3 is a small extension of the patterns this commit established.scripts/_reconcile-user-rows.mjs— the existing ad-hoc reconciliation hint; useful as a sketch of the kinds of inconsistencies the ingest script will encounter on real data.