Skip to content

spec: domain column rationalization (Stage 3, #4159)#4215

Merged
bokelley merged 2 commits into
mainfrom
bokelley/4159-domain-rationalization-design
May 8, 2026
Merged

spec: domain column rationalization (Stage 3, #4159)#4215
bokelley merged 2 commits into
mainfrom
bokelley/4159-domain-rationalization-design

Conversation

@bokelley

@bokelley bokelley commented May 8, 2026

Copy link
Copy Markdown
Contributor

Summary

A spec / design doc for the third and final stage of the domain-cleanup work that started with the Media.net escalation #321. Stage 1 (auto-populate, #4157) and Stage 2 (member self-service, #4179) shipped. Stage 3 is the schema-level rationalization.

Recommendation: Option B — collapse member_profiles.primary_brand_domain into organization_domains.is_primary. The ~5 real DBA/holding-co cases migrate to AAO's existing parent/child org-hierarchy model.

Anchored on data

Survey of prod (155 profiles):

  • 53 (34%) match: brand_primary == membership_primary
  • 64 (41%) NULL primary_brand_domain
  • 38 (25%) divergent — categorized in the spec:
    • ~10 cosmetic (www.foo.com vs foo.com) → fixed by canonicalization during migration
    • ~14 personal-tier members where membership-primary is just absent → insert org_domains row with source='manual'
    • ~5 real DBA/holding-co cases → migrate to parent/child orgs
    • ~3 junk → ignore

So ~85% is bugs or trivially resolvable. The conceptual "two primaries" distinction is mostly unintentional drift, not real product requirement.

What this PR is and isn't

  • Is: Specs/design doc + survey results + migration plan + consumer rewrite list (24 source files referencing primary_brand_domain).
  • Is not: Any actual code change. Implementation begins after Brian's review of the 5 real-divergence cases.

Decision needed

The 5 real-divergence cases. Spec lists each. For each: do we (a) move them to parent/child orgs, or (b) just pick one domain as primary?

🤖 Generated with Claude Code

bokelley and others added 2 commits May 8, 2026 06:55
Option B recommendation: collapse member_profiles.primary_brand_domain
into organization_domains.is_primary. Surveys current divergence (38 of
155 profiles) and categorizes each. Most are bugs or trivially
resolvable; ~5 real DBA/holding-co cases would migrate to AAO's existing
parent/child org-hierarchy model.

Migration plan: stage 0 audit + canonicalization, stage 1 dual-read with
resolver, stage 2 drop column, stage 3 single canonical writer.

Open for review on issue #4159 — wants Brian's steer on the ~5 real
divergence cases before implementation begins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brian signed off on Option B 2026-05-08. Updates:
- Status: Decided.
- Auto-link safety check: findPayingOrgForDomain walks any verified
  organization_domains row, not just is_primary. Migration safe as long
  as old primaries stay as verified non-primary rows.
- Per-case dispositions for the 6 non-trivial divergence cases. None
  need the parent/child hierarchy model.
- Sequencing: Stage 0 preconditions, Stage 1 resolver, Stage 2 drop
  column, Stage 3 canonical writer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley
bokelley merged commit fec4b19 into main May 8, 2026
13 checks passed
@bokelley
bokelley deleted the bokelley/4159-domain-rationalization-design branch May 8, 2026 11:10
bokelley added a commit that referenced this pull request May 8, 2026
* feat(scripts): Stage 0 domain-cleanup script (#4159)

Two phases for the precondition work before #4159's Stage 1 resolver:

- canonicalize-www: strip www. prefix from primary_brand_domain values
  where the apex form is already in organization_domains for the same
  org. ~10 such cases per the 2026-05-08 survey.
- per-case-fixes: hand-tuned for the 6 non-trivial divergence cases
  (DanAds, iPROM, Transfon, Mission Media, Triton, Mangrove). Each
  guards on expected before-state and aborts on drift, so re-runs after
  manual changes don't stomp.

Dry-run default; --apply to write. Both phases are independently
runnable. Per-case writes BEGIN/COMMIT inside FOR UPDATE on the org
row to serialize against concurrent WorkOS webhooks.

Spec: specs/domain-column-rationalization.md (merged in #4215).

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

* address expert review on Stage 0 script

- Post-write invariant assertion: throw + ROLLBACK if not exactly one
  is_primary=true row remains. Prevents silent email_domain=NULL
  corruption if a future case demotes without promoting. (code-reviewer)
- Wire up expected_organization_domains_before guard that was declared
  but never read. Each per-case fix now asserts the rows it depends on
  match before-state. (code-reviewer)
- Reapply path: when re-run after partial application, run the
  org_domains writes idempotently to converge drift, but skip the
  brand_primary update (already at after-state). Was silent-skip before;
  drift in org_domains-only would have been missed. (code-reviewer)
- requires_external_proof flag on Mission Media. The DBA assertion
  enables auto-link for @winstarinteractive.com — script refuses to
  --apply without --allow-external-proof. (security-reviewer)
- --only=Name1,Name2 filter for staged rollout per case. (security-
  reviewer)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 9, 2026
* feat(scripts): Stage 0.3 — insert-missing-rows phase (#4159)

Adds the third Stage 0 phase. For each member_profiles row where
primary_brand_domain is set but no matching organization_domains row
exists for the same org, inserts as source='manual', verified=true,
is_primary=true.

Trust model: these domains were claimed via the brand-claim verify flow
(DNS publication of brand.json) — different proof from WorkOS DNS, but
the script asserts them as verified-equivalent for the purpose of
auto-link routing. Pre-Stage-0 these orgs already routed via the
member_profile field; this just makes the trust explicit on the row.

Cross-org collisions (some other org owns the domain) are surfaced as
warnings + exit code 2, not silently stomped. Five collision-shaped
tests cover the candidate-discovery and ON CONFLICT DO NOTHING paths.

Spec: specs/domain-column-rationalization.md (merged in #4215).

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

* address expert review on Stage 0.3 script

Security (must-fix):
- Filter candidates through assertClaimableBrandDomain before insert.
  Stale primary_brand_domain values can include junk (Mangrove had
  linkedin.com pre-fix). Without the filter, we'd elevate those to
  verified+is_primary, enabling auto-link from those domains. Mangrove-
  shaped rows now get classified as non-claimable and surfaced in the
  exit-code-2 tally for manual reset.

Code review (nice-to-haves):
- Track race-lost as its own counter and exit code 2 on any race loss.
- LOWER() on insert + LOWER() on email_domain mirror — symmetry with
  the LOWER-based candidate match.
- Drop unused is_personal column from candidate query.
- New test asserts the demote-rollback path: pre-seed an is_primary=true
  row, simulate ON CONFLICT race-loss, assert the existing primary
  survives.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 9, 2026
* feat(server): Stage 1 brand-domain resolver (#4159)

Introduces the single read surface for an org's brand-identity primary
domain. Reads organization_domains.is_primary=true first (the canonical
Stage 1 source after Stage 0's backfill), falls back to
member_profiles.primary_brand_domain for any org Stage 0 missed. Logs
a warn on every fallback so we can spot remaining drift before Stage 2
drops the column.

Two surfaces:
- getBrandPrimaryDomain(orgId): single-org lookup
- getBrandPrimaryDomainsForOrgs(orgIds): batched, for list views

No call site changes here. Subsequent PRs migrate the ~14 read sites
identified in the spec.

Spec: specs/domain-column-rationalization.md (#4215).

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

* address expert review on Stage 1 resolver

Code-reviewer:
- Refactor test setup so beforeAll/afterAll runs once per file (was once
  per describe — duplicate runMigrations + double pool init).
- Aggregate batch fallback warns into one log line per call instead of
  one-per-row. Keeps signal usable on large batches.
- Add tests for verified=false is_primary=true (documents that the
  resolver doesn't filter on verified — verified is enforced at write
  time) and multiple-is_primary=true rows (documents the don't-crash
  contract on data anomalies).
- Tighten doc comment: missing orgs are absent from the result map,
  not present with undefined values.

Security-reviewer:
- Add explicit AUTHORIZATION note to the file's doc comment.
- Replace LIMIT 1 (without ORDER BY) with full-result + multi-primary
  detection. logger.error if the Stage 0 "exactly one" invariant is
  broken; still return a valid primary so callers don't crash on the
  anomaly. Same shape applied to the batch variant.

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

---------

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