Skip to content

ADR: Client-Scoped Sites, Knowledge, and Routing

Status

Accepted

Date

2026-07-16

Terminology note

IX-4926 renamed this client-facing container to workspace. This ADR retains site where it records the accepted physical schema (public.sites, site_id) and the historical decision that produced it. In current backoffice copy and frontend selection code, each selectable workspace maps to one of those site rows; site otherwise means the website placement served through its roots.

Context

Rose currently uses a domain as several identities at once: configuration target, site identity, and LightRAG tenant key. This works when a client has one website, one site configuration, and one knowledge corpus. It becomes limiting when a client operates several markets that share most content but differ on facts such as pricing, legal terms, product availability, or positioning.

Separate hostnames can be registered as separate domains today, which gives them separate configurations and retrieval tenants. Canonical domain aliases take the opposite approach and intentionally share both configuration and knowledge. Neither model covers all required cases:

  • a client may want locale sites on both separate hostnames and paths such as example.com/fr/
  • locale sites should share common behavior without copying a complete configuration
  • shared documents and FAQs should remain available in every market
  • market-specific knowledge must not leak between locales
  • a visitor may navigate between locale routes during an active conversation

The current LightRAG integration isolates data by tenant, but does not apply locale filters throughout vector and graph traversal. Storing several locales in one graph would therefore not provide a reliable isolation boundary. Locale-specific relationships between the same entities can also conflict with one another.

This ADR separates site behavior, site knowledge, and website placement. Two words carry precise and different meanings throughout:

  • A site is the client-scoped container: one configuration target, one knowledge corpus, one or more roots. It is what the backoffice selects and what everything else is scoped by. This is the entity the ADR introduces.
  • An agent is a surface that works on a site — the Website Agent, the Content Agent, and later others. Agents live inside a site and read its knowledge. Neither word means an internal LangGraph node or a response-agent prompt.

Only the Website Agent consumes retrieval today, so this ADR describes retrieval, routing, and knowledge for a site as that agent uses them. Because the corpus belongs to the site rather than to one agent, a future retrieval-consuming agent such as the Nurturing Agent reads the same corpus by virtue of being on the same site. It needs no new entity, no type column, and no reference between corpora.

Decision

1. Model the site as the client-scoped identity

A client may create several sites, including one per market locale. A site defines:

  • its market locale, such as fr-fr or en-gb
  • its enabled skills and behavioral configuration
  • its own knowledge corpus, addressed by an opaque storage key

The site, not the domain, is the long-term identity. Its identifier is a client-scoped slug, so roots may move without changing configuration, knowledge, or history. bot_key is the current name for this identity: resolve_bot_key(site_name) already maps a requested domain to a canonical bot, which is one site per client today.

Market locale and response language are separate concepts. The market locale selects regional behavior and facts; the response language may still follow the visitor or channel language.

Site configuration uses composition: the client's existing client-level configuration is the base, and each site stores only its overrides. This keeps shared behavior in one place while allowing locale-specific prompts, skills, presentation, and policy.

2. Give each site its own knowledge

Knowledge belongs to the site, not to a domain. Each site owns one retrieval boundary — its documents, FAQs, vectors, and graph — addressed by an opaque storage key. There is no separate knowledge entity to create, bind, or keep in sync.

Content that applies to several markets is authored once and indexed into every site that needs it:

  • a document or a config.knowledge_faqs row remains a single client-owned record
  • that record carries the set of sites it is indexed into, defaulting to every site of its client
  • ingestion fans it out, and editing it re-indexes every site holding it

The invariant is duplicate the index, never the source. Copies exist only in vector and graph storage, which is rebuildable. An operator never maintains two versions of the same document or FAQ, so common content cannot drift between markets.

Each site's corpus is an independent retrieval and graph boundary. Entity and relationship data never cross it during ingestion, so a market fact cannot reach a sibling market by any query path. A complete localized corpus therefore duplicates pages that are common across markets; this duplication is accepted in exchange for reliable graph isolation.

3. Route website locations to sites

A website route maps an exact hostname plus a normalized path prefix — a root URL — to one site.

A site that has roots has exactly one canonical root. That root is the site's user-visible label and the default ingest scope for its corpus. A site may also have any number of alias roots that resolve to the same site with no separate configuration:

example.fr/*         canonical
example.com/fr/*     alias
example.be/fr/*      alias

Site identity remains the site, not its root URL. Roots may be added, renamed, or migrated without changing the site, its corpus, or its conversation and analytics history. A root URL is a placement, not a key. A site may also have no root at all, which is how a future non-website surface would be placed.

Routing follows one deterministic contract:

  1. Resolve the website boundary from authoritative request metadata as defined by the Website Boundaries ADR.
  2. Match the exact requested hostname within that boundary.
  3. Select the route with the longest matching path prefix.
  4. Use the hostname's required default route when no more-specific prefix matches.

Prefix matching is segment-boundary safe: a prefix matches when the requested path equals it or continues with /, so /uk never claims /ukraine. Hostnames are compared case-insensitively and paths case-sensitively, per RFC 3986, which makes /FR and /fr distinct roots.

Every configured hostname must have a default site route. Overlapping routes that would produce an ambiguous result are invalid configuration and must be rejected when written.

The backend resolves the route on every chat turn from trusted host and path data. A client-supplied site identifier is not authoritative. If navigation selects another site, the next turn uses the new site while preserving conversation history. The transition is recorded so behavior and retrieval can be audited per turn.

4. Retrieve from exactly one corpus

A turn retrieves from the corpus of its resolved site and from nothing else. Market-specific and market-agnostic content already sit together there, so there is no second query, no cross-corpus merge, and no precedence rule between shared and market evidence. Retrieved context retains its source attribution so downstream nodes and observability can explain the answer.

A locale site cannot reach a sibling locale, because the sibling's storage key is never named in its request. Isolation is a property of the resolution path, not a filter applied to results, so there is no locale filter to configure and none to get wrong.

Retrieval resolves an ordered list of storage keys that has exactly one entry. Should duplicating common content across sites become expensive for a specific client, a shared corpus can be added as a second entry with combined reranking, without changing site identity, routing, or knowledge ownership.

A website route is enabled only after its site's corpus is ready. An unready corpus keeps the route disabled; it does not degrade to partial knowledge.

5. Preserve existing clients through a legacy mapping

Existing clients conceptually migrate to one legacy site owning its existing corpus. Current host and alias behavior remains unchanged until locale sites and routes are explicitly configured:

  • existing standalone domains retain their independent behavior
  • existing canonical aliases continue to route to the same legacy site and knowledge, becoming additional roots at / on that site rather than a domain-to-domain relationship
  • existing LightRAG tenant data is reused rather than relabeled as a locale
  • a new locale route is activated only after its site's corpus has been indexed

Storage-key format and cache invalidation remain unspecified here. The physical schema, the data backfill, and the rollout sequence are specified below.

6. Physical schema

The identity model above is realised by two tables and one capability flag.

public.sites is the site: id UUID primary key, client_id, config_key TEXT UNIQUE, name, status (draft | live | disabled), and corpus_status (absent | indexing | ready | failed).

config_key is the stable configuration handle that config.client_configs keys on. It is assigned once and never derived from a root, which is exactly what lets a root move without changing configuration, knowledge, or history. A site whose canonical root is a bare host at / takes config_key = host, so every client that exists today keeps its current key and no configuration row moves; only a path-rooted site generates a slug. There is exactly one way to create each kind: registering a domain brings its site into being, and a path-rooted site is created explicitly.

public.site_roots is the placement: site_id, host, path_prefix (default /), is_canonical, and enabled (default false).

config.client_configs.domain deliberately keeps referencing public.domains. Repointing it at public.sites(config_key) looked free — every canonical domain becomes a site with an identical key — but production disproves the premise it rested on: some alias domains hold configuration rows of their own. Those rows are never read, because configuration resolution follows canonical_domain, but they exist, an alias never becomes a site, and deleting customer rows from a migration is not an option. Nothing in the identity phase needs the repoint: a site at a bare host is keyed by a registered domain and configures exactly as before, and only a path-rooted site cannot hold configuration — which cannot be routed to until the runtime phase regardless. The repoint belongs to the re-key phase, where those stale rows are resolved deliberately rather than as a side effect.

public.domains keeps its own job — hostname registration and the tenant boundary, per the Website Boundaries ADR — and a root's host must be a registered domain of the same client.

While canonical_domain still drives resolution, roots at / describe that graph rather than drive it. Reconciliation therefore belongs in the database, as one idempotent function behind both the initial backfill and a trigger on public.domains, so every writer — backoffice, backend, CLI, migration — keeps roots true. Roots authored by hand cannot drift from the behaviour they claim to describe.

Invariants belong in the database

Section 3 makes overlapping routes invalid configuration that is rejected when written. That is a constraint, not a warning. A backoffice surface may explain an invariant; it never stands in for one.

Invariant Mechanism
Host lowercased; path prefix normalized to a leading /, no trailing /, no //, no whitespace normalize trigger and check constraint
No two roots on one hostname and path prefix unique constraint
At most one canonical root per site partial unique index
A site with at least one root has exactly one canonical root deferrable constraint trigger
A hostname with any enabled root has an enabled root at / deferrable constraint trigger
A root's host is a registered domain of the same client trigger
Enabling a root requires the client capability, a live site, and a ready corpus trigger
A site leaving live or ready, or a client losing the capability, disables its roots in the same transaction triggers on both tables
Site creation is atomic security-definer function
A domain's site and its root at / always match canonical_domain idempotent reconciliation function, called by the backfill and by a trigger on public.domains

Two consequences of section 3 that are easy to get wrong:

  • Rootless sites are legal, so "exactly one canonical root" is conditional on having roots. A unique index can forbid duplicates but never absence, which is why the conditional half needs its own constraint trigger rather than an index.
  • The readiness gate must be bidirectional. A trigger that only refuses to enable a root leaves a site that later fails still serving. Losing readiness disables the affected roots in the same transaction.

The capability flag

public.clients.explicit_routing_enabled defaults to false. While it is false, no path-prefixed root can be enabled; flipping it back disables any that are and returns the client to legacy resolution, with no deploy. It is both the per-client opt-in and the rollback lever.

Roots at / are deliberately outside that gate. They describe hostname-level routing, which is what every client already has, and gating them would mean shipping a schema whose backfill contradicts production on day one. The gate exists to stop the new capability — routing by path — going live before the runtime phase can honour it, and a root at / is not that capability.

It is what allows the schema to ship before runtime resolution exists. Configuration routed by path while retrieval still resolves by hostname would answer one market from another market's corpus — a wrong answer that looks like a correct one. The flag makes that state unreachable rather than merely unlikely.

7. Phase mapping

Each delivery phase is one merge. Every merge must leave the trunk green and production behaviour unchanged, and a behaviour change is only permitted behind a flag that already exists in the database when the code that reads it lands.

Phase Delivers Gate before the next
Identity The schema above, the backfill, the invariants, and a staff surface for reading and authoring roots Existing clients' alias graphs read correctly as roots; widget configuration resolution is byte-identical
Ingestion Site-scoped crawl seeds and the corpus_status lifecycle A new site reaches ready from its own roots; no legacy corpus is touched
Runtime Host and path prefix to site per chat turn, storage key from the site, path-aware widget configuration, and the market-locale skill condition With the capability off, resolution is identical to today
Re-key Backoffice and analytics move from source hostname to site identity Expand, switch reads, then contract — three merges, not one
Pilot One client with the capability on The validation scenarios below, with evidence

Ingestion precedes runtime because nothing can be routed to a corpus that does not exist. Re-key depends only on identity, and is what lets a path-rooted site become selectable in the backoffice; until it lands, such a site is staff-visible only.

Model at a glance

Ownership is a single tree. Each site owns its knowledge; documents and FAQs belong to the client and are indexed into the sites that need them.

Client: acme
├── Base site configuration          shared behavior, defined once
├── DOCUMENTS AND FAQS                authored once, client-owned
│       every record lists the sites it is indexed into
│       default: every site of the client
└── SITES
    ├── site:fr      locale=fr-FR
    │   ├── overrides:  skills, prompts, CTAs, presentation
    │   ├── knowledge:  own corpus — common pages + FR prices, legal, availability
    │   │               own documents + FAQs + vectors + graph
    │   │               independent ingestion and retrieval boundary
    │   ├── agents:     Website Agent, Content Agent, … — all read this corpus
    │   └── roots:      example.fr/*            canonical
    │                   example.com/fr/*        alias
    │                   example.be/fr/*         alias
    ├── site:uk      locale=en-GB
    │   ├── knowledge:  own corpus — common pages + UK prices, legal, availability
    │   └── roots:      example.com/uk/*        canonical
    └── site:de      locale=de-DE   draft, corpus not indexed yet
        └── roots:      example.de/*            canonical, disabled

Knowledge is still the heavy part; it now hangs under the site that owns it instead of beside it. A market is one site, one corpus, and one or more roots — three attributes of one thing rather than three entities to keep in sync. Agents are surfaces on a site, not owners of anything, which is why adding one costs nothing structurally.

Runtime resolution

visitor at https://example.com/fr/pricing
 website boundary          host trust, per the Website Boundaries ADR
 exact host  example.com
 longest root prefix  /fr/  ─────► site:fr
 site's corpus ──► retrieve( storage key of site:fr )
        │              one call, one rerank, nothing to merge
        │              the UK and DE storage keys are never named
 answer citing the fr source URL

Readiness gate:

site:fr corpus ready?
   yes ──► its roots serve traffic
   no  ──► its roots stay disabled     never a partial or cross-market answer

Two invariants are encoded here: common content is authored once and duplicated only in the index; and a locale site never names a sibling locale's storage key.

Implementation Direction

Reuse the existing locale-aware skill filter

The current skill system already has a useful enforcement mechanism. Client skills are loaded from one canonical client library, and locale variants may use URL-prefix, URL-regex, or conversation-language conditions. The registry evaluates those conditions before skills are shown to the selector LLM, after the LLM returns a selection, and when always_on skills are injected. This prevents the selector from choosing a locale sibling that was not eligible.

The implementation will retain that three-stage filter but change the preferred market signal:

  • ResolvedSiteContext.market_locale becomes an explicit skill condition value.
  • market_locale compares canonical, full BCP 47 tags exactly, preserving distinctions such as en-GB and en-US.
  • A missing or invalid runtime market locale fails closed for a market-gated skill.
  • conversation_language remains the reply language and does not select market policy or knowledge. Its current normalization intentionally collapses regional variants and a visitor may converse in another language.
  • URL conditions remain supported for legacy sites and genuinely page-specific behavior, but stop being the long-term market identity.
  • Global and canonical client skill libraries remain shared. Locale sites do not receive copied skill directories.
  • When market behavior only adds to a shared skill, a conditioned client skill should use the existing augments mechanism instead of copying the full shared instructions.

For example, a migrated UK regulatory skill uses:

conditions:
  - market_locale: en-GB

A market-gated skill then depends on the resolved site rather than on a page URL, which is what keeps it correct when several roots select the same site.

Deliver incrementally

flowchart LR P1[Identity and legacy resolver] --> P2[Site-scoped ingestion and fan-out] P2 --> P3[Site configuration and locale skills] P3 --> P4[Website routing, backoffice, and pilot]

Each phase has a compatibility exit gate. Existing clients continue through the legacy domain resolver and existing storage key until explicit routes are enabled. The pilot must prove skill isolation, retrieval isolation, route precedence, readiness gating, site transitions, and rollback before domain coupling is retired.

Relationship to Existing ADRs

Unified Configuration System

This ADR extends the unified configuration model with client-level base configuration and site-level overrides. It does not replace typed configuration definitions or their validation.

Website Boundaries and Host Routing

Website boundaries remain the source of truth for host trust, cookie scope, analytics identity, permissions, and origin validation. Site route selection occurs after that boundary is resolved and adds path-level placement. A site route is not a security boundary.

Subdomain Tenant Split via Explicit Domain Registration

Explicit domain registration remains valid existing behavior and a supported transitional mechanism. This ADR changes the long-term identity: domains route to sites instead of being the site and knowledge identity themselves.

Document Storage, DB Schema, and Conflict Model

The existing document lifecycle, provenance, snapshots, and conflict model remain applicable. A future implementation will record which sites a document is indexed into while keeping one authored record per source page: duplicate the index, never the source. Source-domain provenance and tenant isolation are unchanged.

Consequences

Positive

  • Supports locale sites on either separate hostnames or paths.
  • Makes market isolation structural: a sibling market's storage key is never named, so no locale filter has to be configured and none can be got wrong.
  • Keeps retrieval at one call and one rerank per turn, unchanged from current cost.
  • Keeps every document and FAQ a single authored record, so common content cannot drift between markets.
  • Gives sites, knowledge, and website placement independent reasons to change.
  • Provides a stable model for future brands, business units, or product-specific sites without encoding them as domains.

Negative

  • Introduces new product concepts and relationships that require schema, API, ingestion, backoffice, and migration work.
  • Content common to several markets is indexed once per site, so vector and graph storage grow with market count. At graph tier each additional site also repeats LLM entity extraction over the common corpus.
  • Editing one common document re-indexes every site holding it, so ingestion cost scales with market count rather than with the size of the edit.
  • Site route management needs validation, readiness checks, and operator-facing tooling.
  • Switching sites within one conversation requires per-turn attribution and careful context handling.
  • Existing domain-keyed configuration and persistence cannot move to the new identities in one atomic change.

Neutral

  • A client may continue using one site and one corpus indefinitely.
  • Documents and FAQs default to every site of their client; restricting one to a subset is explicit.
  • Website routing is one way to select a site. A future non-website agent would select its site directly, without a root.
  • A shared corpus with multi-corpus retrieval remains reachable later through the ordered storage-key list, as a per-client decision rather than an architectural one.
  • Agents other than the Website Agent are out of scope. Because knowledge belongs to the site, adding one requires no new entity: it reads the corpus of the site it is placed on.
  • This ADR defines architecture and behavior, not the implementation schema or rollout schedule.

Validation Scenarios

The implementation design following this ADR must satisfy these scenarios:

  1. example.fr/* and example.com/fr/* select the same French site.
  2. /uk/pricing selects the UK site instead of the hostname's default site.
  3. An uploaded document with no explicit site assignment is retrievable from every site of its client.
  4. Navigating from /fr/* to /uk/* switches sites without losing conversation history.
  5. A UK site whose corpus is not ready keeps its routes disabled, and no request ever names the French storage key.
  6. An existing client continues as one legacy site using its existing retrieval data until explicitly migrated.

Alternatives Considered

1. Register and configure an independent domain per locale

This works with the current tenant model for separate hostnames, but duplicates complete configuration, allows locale sites to drift, and cannot represent path-based locales.

2. Add locale namespaces inside the current domain tenant

This is a smaller tactical change, but it keeps domain, site, routing, and knowledge identity coupled, so a path locale still cannot select its own knowledge.

3. Store locale tags in one LightRAG graph

The current retrieval path does not enforce locale filtering throughout vector lookup and graph traversal. Relationships between the same entities may also differ by market, making one graph an unsafe isolation boundary.

4. Keep market-agnostic knowledge in a shared corpus searched alongside the site's own

Giving a site both a shared corpus and its own avoids indexing common content more than once. It also adds a second retrieval call, a cross-corpus merge, a rerank over mixed evidence, and a precedence rule for conflicting shared and market facts — on every turn, for every locale client, permanently. That online cost is paid to avoid a duplication cost this ADR already accepts for common pages inside a localized corpus, and duplication is offline, rebuildable, and proportional to market count rather than to traffic.

Decision 4 keeps this option reachable through its ordered storage-key list. It should be taken for a specific client whose common corpus is large, whose market count is high, and whose sites run at graph tier, where re-indexing common content repeats LLM entity extraction per market.

5. Model knowledge as its own entity that sites reference

A separate knowledge entity would only be necessary if one corpus had to serve several sites. Nothing requires that: a site owns exactly one corpus, and every agent placed on that site — the Website Agent today, others later — reads it by being there. The entity would be a table, a foreign key, a readiness field, and a backoffice screen maintained to express a relationship that is always one-to-one.

This is the main argument for naming the scope a site rather than an agent. Had the corpus belonged to the Website Agent, a second agent needing the same knowledge would have forced either a shared entity or a reference between agents. Owning it at the site removes the question.

6. Keep shared knowledge and locale overrides in one graph

This reduces storage, but requires graph-level precedence and conflict rules that the current retrieval model cannot guarantee. A wrong regional pricing or legal fact is a more serious failure than duplicated storage.