Skip to content

fix(webhooks): source email_domain from is_primary, not org.domains[0]#4459

Merged
bokelley merged 1 commit into
mainfrom
bokelley/email-domain-from-is-primary
May 12, 2026
Merged

fix(webhooks): source email_domain from is_primary, not org.domains[0]#4459
bokelley merged 1 commit into
mainfrom
bokelley/email-domain-from-is-primary

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

Closes a Scope3-specific drift identified during the #4448 reconciliation: their organizations.email_domain had been set to www.scope3.com while organization_domains.is_primary=true was on scope3.com. Cause: the WorkOS organization.updated webhook handler (syncOrganizationDomains) sourced email_domain from org.domains[0], and WorkOS's array order is not stable. Scope3 had [www.scope3.com (failed), scope3.com (verified)] in WorkOS, so every webhook fire overwrote email_domain to the failed www variant.

upsertWorkosDomain already preserves is_primary across conflicts (so our table's choice survives webhook re-fires) — this change reads email_domain from that same canonical source. Falls back to org.domains[0] only for the initial-sync case (cold start, no is_primary row exists yet).

Downstream impact of the bug

Lookups like services/brand-enrichment.ts:772 (SELECT … FROM organizations WHERE email_domain = $1) were missing Scope3's org row entirely — brand enrichment ran as though scope3.com had no associated org.

Included

  • server/src/routes/workos-webhooks.tssyncOrganizationDomains reads email_domain from organization_domains.is_primary=true. Export added so the integration test can call it directly (mirrors the existing upsertOrganizationDomain export pattern).
  • server/src/scripts/sync-email-domain-from-is-primary.ts — backfill script for any other org that drifted pre-fix. Dry-run by default; --apply to write.
  • server/tests/integration/workos-sync-email-domain.test.ts — pins the new behavior. Three cases: (1) non-primary first in WorkOS array stays at the canonical primary, (2) initial cold-sync falls back to org.domains[0], (3) personal orgs are not touched.

Applied on prod

Scope3 backfill applied via fly ssh 2026-05-12 11:55 UTC:

before: {"workos_organization_id":"org_01KC84E45378RJDDARFGR4E2WD","name":"Scope3","email_domain":"www.scope3.com"}
canonical (is_primary=true): scope3.com
would set email_domain: www.scope3.com → scope3.com
after: scope3.com

The webhook fix in this PR prevents re-drift on the next WorkOS organization.updated for Scope3.

Test plan

  • Typecheck clean
  • CI integration test passes
  • Re-run audit: no remaining email_domain drift across non-personal orgs

🤖 Generated with Claude Code

WorkOS's `org.domains` array order is not stable. Orgs with a verified
root + a `failed` www variant can have WorkOS list the www variant
first, which overwrote `organizations.email_domain` on every
`organization.updated` webhook fire even though our table's
`organization_domains.is_primary=true` was already pointing at the
correct root.

Scope3 hit this in prod (escalation #334 follow-up):

  WorkOS:
    [{domain: 'www.scope3.com', state: 'failed'},
     {domain: 'scope3.com',     state: 'verified'}]

  Our DB:
    organization_domains.is_primary=true → scope3.com
    organizations.email_domain          → www.scope3.com  (drifted)

`upsertWorkosDomain` already preserves `is_primary` across conflicts, so
our table's choice survives. Reading `email_domain` from the same
canonical source (`organization_domains.is_primary=true`) keeps the two
in lockstep. Fallback to `org.domains[0]` covers the initial-sync case
(cold start, no is_primary row yet); in normal operation the
`upsertWorkosDomain` loop sets `i===0` as primary, so the SELECT finds
a row.

Downstream impact: lookups like `brand-enrichment.ts`'s
`WHERE email_domain = $1` were missing Scope3's org row entirely.

Adds:
  - `server/src/scripts/sync-email-domain-from-is-primary.ts` — backfill
    pre-fix drift. Applied on prod 2026-05-12 11:55 UTC for Scope3.
  - Integration test pinning the new behavior (non-primary first in
    WorkOS array stays at the canonical is_primary value).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley

Copy link
Copy Markdown
Contributor Author

Reviewed. Ready to ship.

Correctness verified:

  • The new SELECT runs inside the same transaction (client), AFTER the upsert loop, so cold-sync hits the just-inserted is_primary=true row from i===0. Traced workos-webhooks.ts:562-571 (upsert) → :607-614 (select).
  • upsertWorkosDomain does not update is_primary on ON CONFLICT (organization-domains-db.ts:210-214) — so re-fires preserve the canonical row. The fix is structurally sound.
  • Removed-domain edge case (DELETE at :574-583 nukes the primary row): SELECT returns nothing → fallback to org.domains[0]. That's identical to pre-fix behavior; not a regression.

Backfill script: WHERE clause is correct. is_personal = false enforces the personal-org carve-out, LOWER(COALESCE(o.email_domain, '')) handles NULL + case drift. Dry-run default + --apply follows the established script pattern.

Test: Three cases pin the right behavior — non-primary-first preserves primary, cold sync uses domains[0], personal orgs untouched.

Nit (non-blocking): workos-webhooks.ts:620-622 — the else if branch assigns primaryDomain but it's never read after that for personal orgs. Dead code; drop the branch or just leave it as null.

Nit (non-blocking): workos-sync-email-domain.test.ts cleanup deletes from brands WHERE domain LIKE 'wkos-sync-email-%.test' but no test inserts brand rows. Defensive but unused.

Export of syncOrganizationDomains is fine — matches the upsertOrganizationDomain export pattern, only the new test re-imports it.

*/

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { initializeDatabase, closeDatabase, getPool } from '../../src/db/client.js';
@bokelley bokelley merged commit a4bb6fd into main May 12, 2026
13 checks passed
@bokelley bokelley deleted the bokelley/email-domain-from-is-primary branch May 12, 2026 11:54
bokelley added a commit that referenced this pull request May 12, 2026
…_type (#4464)

Every saved or serialized agent now carries an authorization_type
discriminator plus matching selector. Copy/Download block when any agent
lacks scope (catches registry-picker stubs and untouched v2 imports) or
when the contact section has fields filled but no name.

Refs Russell Stringham's #4459 thread question.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 15, 2026
The original script flipped any `organizations.email_domain` that didn't
match `organization_domains.is_primary=true`. Audit on prod revealed
three distinct classes inside that broad definition:

  1. `null`       — never had a webhook fire / missed migration 468.
                    Strictly safe to fill from canonical.
  2. `www_drift`  — Scope3 class. Webhook patched in #4459; opt-in via
                    `--include-www-drift` if another org regresses.
  3. `mismatched` — subsidiary/M&A (LinkedIn↔Microsoft,
                    EdDomus↔quattroruote.it, Omnicom↔omc.com, Teal↔teal.works,
                    Cora AI↔noverift.ai). NOT a drift bug. These are
                    structural modeling questions for `brands.house_domain`
                    + `brand_domain_aliases` and possibly separate org
                    records, not for an automated overwrite.

Default behavior now applies only class (1). Class (3) is reported and
never written. Class (2) requires explicit `--include-www-drift`.

Applied on prod 2026-05-12 12:02 UTC: 7 null cases backfilled (Diageo,
digital opua GmbH x2, harvingupta, Spectrum Reach, TVIQ, Willem LLC).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant