Built in public · Changelog

How Supernova learned the job.

The product story, newest first: what actually became possible in each stretch, not a list of tweaks. Generated live from the engineering journal; the log is the changelog.

99

improvements shipped

9

days of building

11

shipped per day, avg

9
6 Jul
8
7 Jul
1
8 Jul
2
10 Jul
5
11 Jul
25
12 Jul
6
13 Jul
peak37
14 Jul
6
15 Jul

Product panorama

One deal, one continuous operating loop.

Six agents live in the product today, propose-only, a named human at every gate. Everything below ships; nothing is a mock.

01every desk, daily

Brief

The Daily Brief opens each cockpit: meetings with exact times, deals needing a touch, the one move.

02critic-checked

Draft

The Email Drafter investigates the thread through scoped tools, then a fresh-context critic verifies every claim.

038 dimensions

Qualify

Deal Check scores MEDDPICC with the evidence that earned each score, never vibes.

04pre-call

Prep

Meeting Prep briefs every call: who is in the room, where it stands, what to ask.

05with provenance

Track

The Promise Tracker extracts what each side committed to, source attached, resolved in place.

06propose-only

Audit

Every run costed and receipted: model, tokens, verdict, the audit trail behind every artifact.

15

Jul 2026

Wednesday

1 shipped

Data

Enrich closes the loop: the FIRST live Zoho write-back, plus company channels

Two upgrades the same day, from the user's asks ("a way to update Zoho with it"; "email phone linkedin needs to be figured out"):

Two upgrades the same day, from the user's asks ("a way to update Zoho with it"; "email phone linkedin needs to be figured out"):

  • Zoho write-back, live: the dossier's proposed field updates now carry an Update Zoho button. applyZohoUpdates (src/lib/enrichment/actions.ts) PUTs the fields to Zoho v8 (the first real Zoho write in the product — zohoPut had never been used), then updates our mirror, clears the applied proposals from the stored dossier, and audits ZOHO_FIELDS_UPDATED with full before/after. Field map is deliberate: leads get Industry / No_of_Employees / Website / City / State / Country; accounts add Phone + billing address fields. A lead's Email/Phone/LinkedIn are the person's — company research never writes them. Verified live on the ANSCER lead: 6 empty fields (industry, employees, website, city, state, country) filled in Zoho + mirror, audit row before all null / after all researched values, proposals section cleared itself.
  • Company channels in the dossier: company gained website / linkedin (company page URL, validated) / email / phone, rendered as actionable chips (site ↗, LinkedIn ↗, mailto:, tel:) under the identity row. Panel redesigned: ICP read as a banded row (emerald/amber/zinc wash by score), news as a mono-dated timeline, key people with initial avatars, updates as field-shaped diff rows ("empty → Bengaluru"). The model prompt now demands field-shaped values (city = the city name alone) and channels it actually found, never guessed.
  • Fix en route: drizzle expands a JS array param into N placeholders, so = any(${arr}::text[]) broke — string_to_array on a joined param instead.

14

Jul 2026

Tuesday

3 shipped

Data

Tech lead, from messy Zoho text to a resolved person on every deal

Zoho's Tech_Lead_Name_Involved is free text and a mess: first names, spelling variants (Radhakant / Radhakanth), multi-person strings ("Arka and Gorish"), and notes ("NA", "Through Prem,…

Zoho's Tech_Lead_Name_Involved is free text and a mess: first names, spelling variants (Radhakant / Radhakanth), multi-person strings ("Arka and Gorish"), and notes ("NA", "Through Prem, hence no tech lead"). 205 deals tagged, 71 distinct values, only 14 linking to an app user. Fixed it as a resolution layer, not a blind rewrite:

  • `tech_lead_map` (new table + Drizzle def): one row per distinct raw value, mapping it to a clean_name (the canonical spelling we write back to Zoho) and, where the person is in the system, a user_id. Seeded by scripts/tech-lead/resolve.mjs, which auto-resolved 36 names / 143 of 205 deals (70%) by first-name match (TECH_LEAD/DEV preferred, spelling variants folded).
  • `/admin/tech-leads` review surface: the residue (unmatched names, multi-person, notes) is a human's call, so the page lists every value by status with a one-click suggestion, a teammate picker, a "just a name" option, "no tech lead", and an inline add-a-teammate flow for people not yet in the system (Arka = 24 deals, Divyansh, Sudhanshu…). Every resolution is audited (TECH_LEAD_RESOLVED) and re-arms the Zoho write-back.
  • Shown everywhere a deal is (user: "super super important"): one TECH_LEAD_VALUE SQL expression resolves the display name (clean when mapped, raw until reviewed, null when cleared), threaded through the deal hub (clickable to the teammate), pipeline kanban cards, the Desk slipping list + deal hover-card, the Accounts table top-deal, the Inbox deal threads, the Follow-ups list, account + contact hubs, the team page, and the deal peek pane, all via the shared DEAL_SELECT / TechLeadChip / deal-row components.
  • Write-back of the cleaned names to the Zoho source is next (write scope now granted); the map is the reviewed outbox.
Data

Corporate-domain email matcher (#13): the whole company thread, not one contact

A user question — "why am I not seeing the latest emails on this deal?" — turned out to be two things. First, a diagnosis that cleared the air: nothing was stale. Both mirrors are current…

A user question — "why am I not seeing the latest emails on this deal?" — turned out to be two things. First, a diagnosis that cleared the air: nothing was stale. Both mirrors are current (Zoho to 07-13/14, Graph email to today), and on the TruBoard Cleantech deal Jul 10 genuinely IS the newest email — confirmed by scanning the whole mirror for any TruBoard mail after it (zero). Anisha's mailbox is synced (anisha@antino.io.io not .com; her login/CRM identity vs her mailbox differ, bridged by the mapping).

But the diagnosis surfaced the real limitation: the deal's email thread matched only its mapped Zoho contacts — here a single contact, S. Kadhirvel — while the person actually driving the thread, Sohail Usmani (@truboardpartners.com), isn't a Zoho contact at all. His mail showed only incidentally (he cc'd Kadhirvel); drop that cc and it'd vanish. The whole TruBoard account has a team in Zoho (Srickant, Vipul, Rakesh…), none attached to this deal.

The fix (#13): match a deal's emails by the company domain, not just its contacts. src/lib/graph/match.ts — a reusable matcher: corporateDomains() (derive domains from the deal + account contacts, dropping a personal/internal exclusion list — gmail/yahoo/… and antino.com/antino.io/goldenflitch.com, so we never over-match free providers or internal mail) + matchedMessageIds(emails, domains) (a UNION of four branches: from-email, from-domain, recipient-email, recipient-domain). Wired into getDealTimeline (the thread — full 4-way match) and getDealInbox (the left-pane ranking — exact-email + from-domain only). TruBoard now surfaces 10 of Sohail's emails directly, plus the rest of the account's conversation.

The performance was the hard part (the whole reason it's worth logging): the natural shapes are traps. A top-level OR … EXISTS predicate → 8,305 ms (seq-scans all 170k messages). IN (select … from cte)1,078 ms (a CTE defeats the functional index — Postgres hash-semi-joins with a full seq scan; confirmed via EXPLAIN). The fix: pass emails/domains as array parameters and use = any($arr) in a UNION of index-backed branches29 ms. Added one index (idx_gmr_domain_lower on lower(split_part(email,'@',2)) for the recipient side) to scripts/perf/add-indexes.mjs. The inbox ranking stayed lean (exact-email + from-domain, recipient-domain dropped — it cost +480ms for barely any ranking change): 162 ms → 54 ms. Lesson banked: never match email sets against a CTE with `IN`/`EXISTS`; materialize to an array and `= any` the functional index. Second gotcha (caught in the browser after the first commit): ` sql${jsArray}::text[] renders as a **row constructor** ($1,$2,…)::text[] — a cast error — because drizzle expands a JS array into a paren-list. Build the literal explicitly: sqlarray[${sql.join(vals.map(v => sql${v}), sql, )}]::text[] array[$1,$2,…]::text[]. Verified via PgDialect().sqlToQuery + a live getDealTimeline run, not just a raw-pg` scratch (which had hidden the bug — raw pg accepts a single array param, drizzle doesn't render one).

Data

Recording page v4: chat transcript, deadline chips, "not in your CRM" pulse + Add to CRM

Four upgrades on top of the tabbed rebuild, plus three new design principles:

Four upgrades on top of the tabbed rebuild, plus three new design principles:

  • Chat transcript — buyer left (white cards) / seller right (clay wash), full-width, with the buyer/seller labels anchoring each side. The join is proshort_recording_parties.speaker_name → affiliation. You can read the negotiation's rhythm at a glance.
  • Parser coverage — action items without an "(Org)" ("Mahak - Share the framework… - No deadline specified") now parse: owner matched to the call's external parties for the right avatar tint, and the trailing deadline becomes a chip (amber when real, muted for "no deadline"). Checked: there is NO literal markdown (**/##) anywhere in the 548 recordings — the "raw text" smell was unparsed structure, not un-rendered markdown.
  • "Not in your CRM" pulse + actiongetRecordingParties now carries crmKind (contact | lead | null by email). An external participant who exists nowhere in Zoho gets an attention Pulse + an Add to CRM button → proposeContact server action logs CONTACT_ADD_PROPOSED (PREVIEW) with the full proposed record to the audit spine; button flips to "✓ proposed" + toast. Captured externals show a Lead/Contact chip.
  • Tabs look like tabs — the line variant: bold active label + clay underline.

DESIGN.md principles 13–15 (from this session's gaps): 13 Every action is acknowledged (toast naming what happened + what happens next, or the control flips to its done-state — never silence); 14 The text color scale (ink → body /85 → meta muted → mono eyebrow; tabular-nums; one clay number per surface; never bold whole sentences); 15 Parse structure out of strings (owner/title/deadline embedded in a string is structure wearing a costume — split and typeset it, never dump the bullet).

13

Jul 2026

Monday

2 shipped

Data

#17: Proshort tables in the Drizzle schema (db:push no longer drops them)

src/db/proshort-schema.ts already mirrored the 5 Proshort tables — but it was never registered in drizzle.config.ts, so drizzle-kit didn't read it and db:push still generated DROP…

src/db/proshort-schema.ts already mirrored the 5 Proshort tables — but it was never registered in `drizzle.config.ts`, so drizzle-kit didn't read it and db:push still generated DROP statements for proshort_* (525 recordings + 22k insights = data loss). Fix: added it to the config's schema array, plus the one missing index def (proshort_parties_email_idx on lower(email)) so the schema matches the live DB exactly (columns, PKs, indexes all verified against information_schema). Did not run push to "prove" it — that's the hazard; the fix is that drizzle-kit now knows the tables. Updated the memory (never-drizzle-push → "cautious": the drop hazard is resolved, but push still applies all diffs at once, so prefer targeted ALTER TABLE) + DATA-REALITY.md.

Data

Breadcrumbs show entity names, not zoho PKs

The breadcrumb rendered the raw id for [id] routes (e.g. AE / Deals / 4350976000054900540) because AppBreadcrumbs is a client component with no way to resolve a name. Fixed with a tiny…

The breadcrumb rendered the raw id for [id] routes (e.g. AE / Deals / 4350976000054900540) because AppBreadcrumbs is a client component with no way to resolve a name. Fixed with a tiny context (src/components/breadcrumb-title.tsx): BreadcrumbTitleProvider wraps the shell content, and each detail page renders an invisible <BreadcrumbTitle name={entity.name} /> that registers its name via effect; AppBreadcrumbs reads it and swaps the final crumb's label. Wired into all 9 detail pages (deal, account, contact, teammate, email, recording, meeting-event, AE + SDR lead). Now it reads AE / Deals / Defect Detection on Steel Sheets.

12

Jul 2026

Sunday

4 shipped

Data

Zoho coverage: audit + field fill (Next_Step, Mobile, Reports_To…)

Audited whether the Zoho mirror pulls everything (a subagent, task #15). Finding: coverage is selective by construction — src/lib/zoho/manifest.json is a hand-picked field list, no raw…

Audited whether the Zoho mirror pulls everything (a subagent, task #15). Finding: coverage is selective by constructionsrc/lib/zoho/manifest.json is a hand-picked field list, no raw overflow (deliberate "fully-typed 1:1 mirror"). The forecasting spine is dense (probability/forecast/amount/close/stage all ~100%), but several workflow fields are structurally absent. Filled the real gaps (task #16): added zoho_deals.next_step + expected_revenue, zoho_contacts.reports_to + department, zoho_leads.mobile to the schema + manifest; the self-healing importer populates them on the next sync. Discovered a live hazard: drizzle-kit push wants to DROP the Proshort tables (525 recordings + 22k insights — they live outside the Drizzle schema, created by scripts/proshort/). The no-TTY guard blocked it; applied the columns via surgical ALTER TABLE ... ADD COLUMN IF NOT EXISTS instead. Recorded the never-push rule (memory + docs/DATA-REALITY.md, ported from antino3 with live 2026-07-12 fill rates — most account attributes are <2% filled, converted_s is 0%). Remaining #16: mirror CRM Attachments + deal line items (bigger builds); the raw jsonb idea is deferred — it contradicts the deliberate no-jsonb schema convention (flagged for a decision). Follow-up task #17: add Proshort tables to the Drizzle schema so push is safe.

Data

Lead timeline: Zoho tasks (who_id is a dead link for leads)

The lead timeline queried tasks by who_id=leadId — but a DB check shows Zoho task who_id points at contacts (539,625) and never leads (0), so that block returned nothing on every lead.…

The lead timeline queried tasks by who_id=leadId — but a DB check shows Zoho task who_id points at contacts (539,625) and never leads (0), so that block returned nothing on every lead. Leads are linked to their follow-ups by the subject convention instead ("Followup N with Lead {FirstName} from {Company}"), the same match getLeadTasks uses. Rewired getLeadTimeline's task block to match by the lead's owner + company + first name across all statuses (so the timeline shows the full done+pending chain, not just open). Added ownerId to LeadFull/getLead to scope it to the lead's actual owner (works for read-only cross-rep views too). Verified as Himanshu on Ishika's Collinson lead (read-only): "Followup 1/2 with Lead Saurabh from Collinson" now render in the timeline, while the owner-scoped "Open follow-ups" rail stays empty (correctly — they're not his to action). Both SDR and AE lead pages inherit this.

Data

Deal timeline: real-calendar meetings (the missing category)

The deal 360 was missing meetings. Grounded it in the data: for Himanshu's Tata Steel deal, zoho_events (CRM events, what_id) = 0, but the real Outlook calendar (graph_events) has meetings…

The deal 360 was missing meetings. Grounded it in the data: for Himanshu's Tata Steel deal, zoho_events (CRM events, what_id) = 0, but the real Outlook calendar (graph_events) has meetings with the buying committee — the deal timeline only queried zoho_events, so they never showed. (Org-wide only 197 CRM events link via what_id at all; the real meeting record lives in graph_events.) Added a graph_events block to getDealTimeline, matched to the same buying-committee email set the email block uses (primary contact + contact-roles), deduped across mailboxes. Now the deal timeline is genuinely unified — CRM tasks/notes/calls + committee emails + real-calendar meetings + Proshort calls. Verified as Himanshu on Tata Steel: "TATA Steel <> Antino" meetings render (teal calendar icon, "· with the buying committee") interleaved with the MOM email, follow-up tasks, and the Proshort discovery call. Subtitle updated to "CRM + email + meetings + calls, unified". (Lead timeline already carried all three — tasks via who_id, meetings via graph_events, emails exact/domain.)

Data

Follow-up chains, unified calls (Proshort), user-scoped timeline

Follow-ups grouped into chains. DB study confirmed the shape: 763 leads carry a "Followup 1 → Followup 2" chain (1636/1639 tasks resolve to a lead). So /sdr/tasks now groups by lead — one…

  • Follow-ups grouped into chains. DB study confirmed the shape: 763 leads carry a "Followup 1 → Followup 2" chain (1636/1639 tasks resolve to a lead). So /sdr/tasks now groups by lead — one card per lead with its chain and a Clear chain action — instead of a flat list. Ordered by lead recency (getTasks now returns leadRecency, cap lifted 300→2000 so the full 1,639 shows across 875 leads).
  • Proshort integration (conversation intelligence) — the call layer. New proshort_* staging tables (scripts/proshort/create-tables.mjs, typed in src/db/proshort-schema.ts) + backfill (scripts/proshort/backfill.mjs, v3 list→detail, throttled). Recorded calls (recap + overview: pain points, action items, risks, competitors, decision process) now surface as call items in the lead timeline. See docs/PROSHORT.md. Key in .env (rotate it).
  • User-scoped timeline (privacy). getLeadTimeline(lead, userEmail) now scopes meetings to the signed-in SDR's own mailbox and Proshort calls to recordings they were a party on — a user sees only their meetings/calls, never everyone's. Emails + meetings + calls interleave by date; the Inbox/lead labels count each.
  • Threaded user.email through the timeline callers (inbox, lead detail) and the Writer draft path.

11

Jul 2026

Saturday

1 shipped

Data

First metrics dashboards on live Zoho data (docs catch-up)

Shipped inside a7fe7fe alongside the pool-retry work, documented here: /admin/command (company-wide command board: ₹-pipeline/lead/deal stat tiles, pipeline by stage, lead-status breakdown,…

  • Shipped inside a7fe7fe alongside the pool-retry work, documented here: `/admin/command` (company-wide command board: ₹-pipeline/lead/deal stat tiles, pipeline by stage, lead-status breakdown, 12-week activity, top owners) and `/ae/team` (the same lens scoped to the signed-in AE's reporting subtree with per-member performance).
  • Foundations: src/lib/metrics/queries.ts (SQL over the zoho_* staging layer joined to our org tree) + format.ts (₹ INR + count formatting), and dashboard primitives StatTile / ChartCard / CategoryBarChart / WeeklyActivityChart — all charts on the entity color tokens, fixed order, per the color language.

10

Jul 2026

Friday

1 shipped

Data

Microsoft Graph pipeline: multi-tenant email + calendar + attachment bytes

New graph_ staging layer (src/db/graph-schema.ts) — fully-typed, jsonb-free mirror of M365 email + calendar. Tenant-aware: every table keyed by an org discriminator (ANTINO | GOLDENFLITH),…

  • *New `graph_ staging layer** (src/db/graph-schema.ts) — fully-typed, jsonb-free mirror of M365 email + calendar. **Tenant-aware**: every table keyed by an org discriminator (ANTINO | GOLDENFLITH), because Antino and Goldenflith are separate Microsoft tenants with separate app registrations. Creds per-org env vars; graph_tenants` mirrors them (AES-encrypted secret) for a future admin UI.
  • Attachment bytes live in Postgres (graph_message_attachments.content bytea) — the explicit requirement. Inline path for ≤3 MB, /$value fetch for larger; verified byte-perfect (a 25 MB PDF stored intact, %PDF%%EOF). One heavy mailbox ≈ 900 MB of attachments.
  • Engine (src/lib/graph/importer.ts + client.ts, CLI scripts/graph/run.ts): client-credentials auth per tenant; full pull (delta-primed) + every-15-min delta via stored deltaLink; messages (body, recipients, threading), events (attendees), attachments. Hardened against transient Azure Postgres/network errors — per-mailbox retry-then-skip, pool keepAlive + timeouts, so one blip can't kill a multi-hour pull. Added a targeted mailbox <emails> command for re-syncing specific inboxes.
  • Access model: User.Read.All lets us enumerate the directory, but mail read is gated by an Exchange application access policy scoped to the sales team per tenant. Inaccessible + hard-excluded (CEO/leadership: vinay@, ceo@goldenflitch.com) mailboxes are marked and skipped, never fatal.
  • Antino: app access policy grants the sales team. 13 sales mailboxes pulling (Anisha, Nitish + the 10 SDR/closers, plus Harshad/Ishika/Priyadarshini once added to the policy mid-session). Vedant excluded (disabled account); Dhruv Raj + Abhishek Rana turned out to be Goldenflith, not Antino (their org-chart placeholder @antino.com emails were wrong).
  • Goldenflith: separate tenant; seeded from its own directory (53 goldenflitch.com mailboxes), access policy gates to the sales set. Pulling now.
  • 15-min delta cron added to src/instrumentation.ts alongside the hourly Zoho sync — per-tenant, skips an org until its FULL pull completes, guards overlaps.
  • Creds for both tenants in .env (gitignored).

08

Jul 2026

Wednesday

1 shipped

Data

Zoho pipeline: full CRM in Postgres, Integrations page, hourly sync, webhook

All Zoho data is in our DB — 866k+ rows across a fully-typed, jsonb-free staging layer (zoho_): Tasks 587,297 · Contacts 140,499 · Accounts 50,904 · Notes 47,874 · Calls 19,384 · Leads…

  • All Zoho data is in our DB — 866k+ rows across a fully-typed, jsonb-free staging layer (zoho_*): Tasks 587,297 · Contacts 140,499 · Accounts 50,904 · Notes 47,874 · Calls 19,384 · Leads 18,610 · Deals 1,660 · Events 891 + campaigns/products/vendors, plus related lists (stage history 3,642 · contact roles 1,615 · campaign members 559) and zoho_users (70) with the zoho_user_mappings bridge to our users (email/local-part/name matching; catches anisha@antino.comanisha@antino.io).
  • Schema is generated from Zoho's own field metadata (scripts/zoho/generate-staging-schema.tssrc/db/zoho-schema.ts): one typed column per field, bigint lookup ids (indexed), multiselects as child tables. No hard FKs on staging by design (Zoho doesn't enforce integrity; truncate-reload + out-of-order sync) — orphan tallies instead (currently 0 dangling refs); the future domain layer gets hard FKs.
  • Engine (src/lib/zoho/importer.ts, ultracode-built + live-tested): Bulk Read v8 full imports (200k-row CSV pages, chunked inserts, self-healing drop-and-retry for bulk-unsupported fields), hourly incremental sync (per-module Modified_Time checkpoints via COQL, deleted-records tracking, Notes REST walk), targeted webhook sync, Zoho watch registration. Usable from server actions, instrumentation, and CLI (scripts/zoho/run.ts full|sync|users|related|module <Name>).
  • `/admin/integrations` — connection status, per-module batch table, Import everything / Sync now / Enable push (audited: ZOHO_IMPORT_TRIGGERED/ZOHO_SYNC_TRIGGERED/ZOHO_PUSH_ENABLED; admins notified), auto-refresh while running; in the admin sidebar.
  • Hourly cron in src/instrumentation.ts (activates on server boot; skips until a FULL import exists; renews the Zoho watch when APP_PUBLIC_URL is set). Webhook /api/zoho/webhook (token-checked; proven live — a pushed vendor id round-tripped through targeted sync).
  • Verified: all 11 module counts match live Zoho exactly; lead-status distribution reproduces the audit (77.3% Open); the "Anisha's active leads" question answers identically from pure SQL (Open 1,272 / Working 2: MILIND + Srishti, stale since March) — and SQL surfaced a status (Disqualified, 3 leads) the per-picklist API counting had missed.
  • Zoho credentials + webhook token live in .env (gitignored).

07

Jul 2026

Tuesday

1 shipped

Data

Zoho CRM current-state audit (docs/ZOHO-CRM.md)

Mapped the live Zoho CRM (read-only) and wrote it up in docs/ZOHO-CRM.md, linked from the docs index: module inventory + record counts, the full entity-relationship graph (lookups + related…

  • Mapped the live Zoho CRM (read-only) and wrote it up in docs/ZOHO-CRM.md, linked from the docs index: module inventory + record counts, the full entity-relationship graph (lookups + related lists), the Tasks linkage analysis, and the fault lines vs. SALES-PROCESS.md.
  • Entity relationships: hub-and-spoke around Accounts (company) + Contacts (people); Leads convert into Account/Contact/Deal; Deals hang off Account + Contact (+ Contact Roles M:M); Activities (Tasks/Calls/Meetings) attach polymorphically via Who_Id/What_Id. The live graph is only Leads → Accounts/Contacts → Deals + Activities — the quote-to-cash chain, Cases, Price Books, and the custom Legal Contract Management module are 0 records (empty scaffolding).
  • Volumes: Contacts 140.5k, Tasks 587k, Accounts 50.9k, Calls 19.4k, Leads 18.5k, Deals 1.66k, Meetings 891; Quotes/Sales Orders/Invoices/Purchase Orders/Price Books/Cases/Legal Contract Management all 0.
  • Tasks (587k) link mostly to leads/contacts, not deals; the convention shifted over time (older to Contacts 76%, newer to Leads 83%; Accounts steady ~13%; Deals 4-10%). Confirms activity lives at the top of the funnel, not on opportunities.
  • Docs-only change; no code touched.

06

Jul 2026

Monday

1 shipped

Data

Auth, user import, admin user management

Schema: users gains status (user_status: ACTIVE | SUSPENDED), employee_code (unique), designation, department, password_hash. New audit_events table — the audit spine is live (recordAudit()…

  • Schema: users gains status (user_status: ACTIVE | SUSPENDED), employee_code (unique), designation, department, password_hash. New audit_events table — the audit spine is live (recordAudit() in src/lib/audit.ts, secrets redacted).
  • Import: scripts/import-users.ts loads 226 employees from the designation sheet (scripts/data/designation-data.json) + seed admin (admin@glue.tools). All share the dev password dev-test-pw-1 (bcrypt). Roles mapped heuristically: IT leadership/leads → TECH_LEAD, IT rest → DEV, sales leadership/managers → AE, sales juniors → SDR, other departments → DEV (open question). Result: 1 ADMIN, 26 TECH_LEAD, 9 AE, 6 SDR, 185 DEV. Idempotent; every row audited.
  • Auth: email + password sign-in (/sign-in), jose-signed JWT session cookie (7d) with userId + role. DAL (src/lib/auth/dal.ts): verifySession / getCurrentUser / requireRole. Suspended users blocked at sign-in and DAL.
  • URL convention locked in: /<role-name>/<page-name>. src/proxy.ts (Next 16 renamed middleware → proxy) optimistically gates role areas and bounces wrong-role visitors to their own home; authoritative checks stay in layouts/actions.
  • User management: /admin/users — search/filter 227 people, inline role change, manager assignment (self/cycle-safe), suspend/reactivate. Self-role-change and self-suspend blocked. Every mutation audited + revalidatePath.
  • Role home stubs for all five roles so every sign-in has a landing surface.
  • Verified end-to-end against the dev server: sign-in (admin → /admin, tech lead → /tech-lead, bad password rejected), wrong-role redirect, 227 rows SSR'd on /admin/users, audit counts (227 USER_CREATED, USER_SIGNED_IN on each sign-in).