Skip to content

drop additional domains a broader tracked domain already covers - #682

Open
jamiusaliu wants to merge 9 commits into
elmohq:mainfrom
jamiusaliu:fix/redundant-additional-domains-571
Open

jamiusaliu wants to merge 9 commits into
elmohq:mainfrom
jamiusaliu:fix/redundant-additional-domains-571

Conversation

@jamiusaliu

@jamiusaliu jamiusaliu commented Sep 1, 2026

Copy link
Copy Markdown

Fixes #571.

Matching is suffix-based (categorizeDomain, apps/web/src/lib/domain-categories.server.ts), so blog.acme.io is already counted as the brand's whenever acme.io is tracked. As the issue says, this is not serious, the entry just sits in the settings list doing nothing.

What changed

normalizeBrandUpdate now filters out an additional domain when something broader already covers it:

  • another entry in the same list, in either order, and through a chain (eu.blog.acme.io, blog.acme.io, acme.io collapses to acme.io)
  • the website, when the website is part of the same update

Dropping silently rather than erroring, to match how the function already treats exact duplicates. Redundant seemed closer to duplicate than to invalid.

Two things worth a second opinion

The label boundary. isCoveredBy anchors on ., so notacme.io and fake-acme.io survive next to acme.io. A bare endsWith would silently delete a genuinely different domain, which would be a worse bug than the one being fixed. There is a test for it.

The website is only consulted when it is in the same update. A partial edit that sends additionalDomains alone has no website to compare against, and this function deliberately never touches the database. The settings form always submits both, so the real path is covered. Happy to move the check into updateBrandFn where the stored brand is available if you would rather it hold for API callers too.

One thing I did not assume: an additional domain exactly equal to the website domain is also dropped, since categorizeDomain treats === and the suffix case identically. Say the word if you would rather only strict subdomains were removed.

Testing

Six tests added to apps/web/src/lib/__tests__/brand-settings.test.ts, written before the change. Four failed against main, the two boundary guards passed from the start. apps/web is at 353 passing, up from 347, with biome check and tsc --noEmit clean.

CLA signed in the first commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KxTY4DGQWJn6My6bRCoPeM


Open workspace in Conductor

@jamiusaliu
jamiusaliu requested a review from jrhizor as a code owner September 1, 2026 04:27
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

@jamiusaliu is attempting to deploy a commit to the Blue Whale Labs Team on Vercel.

A member of the Team first needs to authorize it.

jamiusaliu and others added 2 commits September 6, 2026 13:58
Adds my GitHub username to the signature registry, per CONTRIBUTING.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxTY4DGQWJn6My6bRCoPeM
Brand matching is suffix-based, so `categorizeDomain` already counts
`blog.acme.io` as the brand's whenever `acme.io` is tracked. Keeping both
is harmless but leaves an entry in the settings list that does nothing.

normalizeBrandUpdate now filters an additional domain out when another
additional domain, or the website in the same update, already covers it.
It reuses inDomainSet rather than testing the suffix itself, so this
answers "equals, or is a subdomain of" the same way the categorizer and
the citation snapshot do, and it inherits the label-boundary walk that
keeps `notacme.io` out of `acme.io`.

The website is only consulted when it is part of the same update. The
settings form always submits both, and this function deliberately never
reads the stored brand.

Fixes elmohq#571

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxTY4DGQWJn6My6bRCoPeM
@jamiusaliu
jamiusaliu force-pushed the fix/redundant-additional-domains-571 branch from 1728c19 to c660bc6 Compare September 8, 2026 14:30
@jamiusaliu

Copy link
Copy Markdown
Author

Rebased on main and updated for #690.

That PR replaced the endsWith check this was written against with inDomainSet, so the private helper here was a second copy of something you had just consolidated. It now calls inDomainSet instead, which means the "equals, or is a subdomain of" question is answered the same way as in categorizeDomain and the citation snapshot route, and the label-boundary walk that keeps notacme.io out of acme.io comes from your helper rather than from mine.

No change to behaviour or to the tests. apps/web is at 516 passing, tsc --noEmit and biome check clean, including the new complexity ceiling.

jrhizor and others added 7 commits September 10, 2026 23:57
The issue asks for the entry not to be allowed, not for it to disappear on
save. normalizeBrandUpdate now returns an error naming what already covers it
("blog.acme.com (already covered by acme.com)"), the same way it reports an
invalid domain, and the settings form runs the identical check as the chip is
added so the user finds out while typing.

The rule moves to @workspace/lib/domains alongside inDomainSet, which it was
already built on, so the onboarding pipeline can reach it too. findRedundantDomains
returns each flagged domain mapped to the one covering it, which is what lets
the messages name a domain; dropRedundantDomains is the same rule for the paths
that correct their input rather than reject it.

updateBrandFn now reads the brand's stored website and passes it in, so an
update that edits domains without resending the website is judged against the
website the brand actually has. addDomainToBrandFn does its own check against
the brand's website and existing list before appending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The settings form was the only path that checked. Five others write
additionalDomains, and they already carried a weaker version of the same rule —
dropping an exact match of the website — which is what generalizes.

createBrand and updateBrand cover the onboarding wizard and the admin
/api/v1/brands POST and PATCH. splitDomainsForStorage is where the API's one
flat list becomes a website plus the rest, so it is also where the rest stop
being worth storing. analyze.ts is where these most often originate: the model
routinely answers with a site's own subdomains next to the site.

These correct their input rather than error. Nobody typed the model's answer,
and the API's flat list carries no field to point at. The wizard's domain input
gets the settings form's check so the user still sees it at entry time.

Competitor filtering keeps seeing every domain the model called the brand's,
including the ones dropped, so a competitor never gets through on a subdomain
that was just collapsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Redundant additional domains get discouraged where they are typed and nowhere
else. Every server-side change comes back out: normalizeBrandUpdate, the
brands API, onboarding, and the analyzer all store exactly what they are
given again.

Enforcing it server-side made a small fix expensive. Brands already hold
redundant entries, and rejecting on save meant a rename could be blocked by a
domain the user never touched, while backfilling them away is not something we
want to do. Reporting may also want to break these out one day rather than
treat them as interchangeable, and the API is the wrong place to carry a
contract this implicit.

What is left is redundantDomainReason, next to the inDomainSet it reads from,
and the settings form and wizard calling it as the chip is added. The settings
website field becomes controlled so the check sees what is typed rather than
what was last saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model routinely answers with a site's own subdomains next to the site, so
the wizard pre-filled chips the domain input would now reject. The schema
description says what "already covered" means rather than leaving it implied by
"do not include the primary website".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
analyze.ts has no concept of how domains are matched downstream, so explaining
it there asked the model to reason about a rule it cannot see. "Omit the primary
website and any subdomain of the primary website" is the same instruction
without the premise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the two validateDomain preambles and the test asides, and cut the
redundantDomainReason and story docs to the parts a reader cannot get from the
code: that the return is a message fragment, that nothing enforces this on
stored domains, and which combobox the story reaches for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no story for this route, so the domain input could only be exercised
through the wizard. Renders the route component directly, the way the LLM
settings story does, with the brand from the use-brands mock and the
organization from the router mock's loader data.

Covers both branches of the message: a subdomain of the website, and the
website itself. An exact repeat of a domain already in the list never reaches
validation — TagsInput drops it as a duplicate first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jamiusaliu

Copy link
Copy Markdown
Author

Thanks for taking this the rest of the way. Rejecting the domain at the input is a better read of #571 than what I sent: the issue says these "shouldn't allow" subdomains, and a silent drop at save time answered a different question. Naming the covering domain in the message is the part I would not have thought to add.

One thing you may not have from your side: #724 landed on domain-categories.ts after you branched, so I ran ad6beff both on its own and merged with current main. It merges cleanly and is green either way. apps/web 516 on the branch and 528 merged, packages/lib 532, tsc --noEmit clean in apps/web, biome check clean repo-wide.

I did not run E2E, the build, or the new stories. I have also left the branch alone rather than rebase it, since you are working in there. Say the word if a rebase or anything else would help.

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.

additional domains shouldn't allow subdomains that match the same domain

2 participants