Skip to content

Workspace non-regression checks

Run this before and after any change that touches the identity seam: how a host, an alias or a path becomes a workspace, and how the backoffice, the widget and the chat runtime use that workspace. Typical triggers: public.sites / site_roots and their alias views, get_chat_workspace, get_widget_config, has_site_access, the backoffice workspace selector (WorkspaceContext, services/workspaces.ts), workspace_id columns, and the site_domain keys they replace.

It answers one question per kind of workspace: does it still reach the right agent, configuration, knowledge and numbers? See the glossary for the terms and Workspace routing for the runtime.

Everything here is read-only. Never save a form, create a FAQ or upload a document to exercise a path: the database is shared with production.

The three cases

Case Fixture Shape
L — legacy host abtasty.com One workspace, one root, no alias. explicit_routing_enabled off.
A — alias hosts lucca-software.com One workspace, five roots: canonical plus lucca-software.ch, .de, .es, lucca.fr. Flag off. Spare: skello.io + skello.es.
N — path-rooted workspace Cactus Inbound — How it works Canonical root www.cactusinbound.com/how-it-works. Shares its host with "Main site" (cactusinbound.com, alias www.cactusinbound.com). Flag on. Corpus environment production.

A failure on L or A is a regression for a paying client: stop the merge. A failure on N blocks a routing pilot only. Fixtures were last confirmed on 2026-09-18; re-confirm them with the fixture check when a result looks wrong.

Layer 1 — every client at once (SQL, seconds)

Two fixtures prove little about the other thousand workspaces. These invariants cover all of them. Run each statement in the Supabase SQL editor (or the Supabase MCP, one statement per call). Every count except the totals must be 0.

The backoffice passes selectedDomain to every hostname-keyed reader. It is the registered domain of the workspace's canonical root host. For a workspace that owns its whole host it must equal the workspace's config_key, which is what the selector passed before workspaces existed. If that holds, every analytics RPC and every configuration read receives the same input as before, so their results cannot have changed.

select count(*) filter (where r.path_prefix = '/')                                as bare_host_workspaces,
       count(*) filter (where r.path_prefix = '/' and
             coalesce(d.canonical_domain, d.domain, r.host) <> s.config_key)      as key_changed,
       count(*) filter (where d.domain is null)                                   as root_host_not_registered
from public.sites s
join public.site_roots r on r.site_id = s.id and r.is_canonical
left join public.domains d on d.domain = r.host;

Every grant must still lead to a workspace, and every workspace must have exactly one canonical root:

select (select count(*) from public.backoffice_user_domains)                       as grants,
       (select count(*) from public.backoffice_user_domains bud
         where not exists (select 1 from public.site_roots r
                            where r.host = bud.site_domain and r.is_canonical))    as grants_without_workspace,
       (select count(*) from public.sites s
         where (select count(*) from public.site_roots r
                 where r.site_id = s.id and r.is_canonical) <> 1)                  as workspaces_without_one_canonical_root;

Once workspace_id exists on a table (IX-4524), no resolvable row may be unkeyed, and the key must agree with the host. Repeat per re-keyed table:

select count(*) filter (where t.workspace_id is null and r.site_id is not null)   as resolvable_but_unkeyed,
       count(*) filter (where t.workspace_id is not null and r.site_id is not null
                          and t.workspace_id <> r.site_id
                          and not exists (select 1 from public.site_roots o
                                           where o.site_id = t.workspace_id and o.host = t.site_domain)) as keyed_to_another_host
from config.knowledge_faqs t
left join public.site_roots r on r.host = t.site_domain and r.path_prefix = '/';

Layer 2 — the routing function itself (SQL, seconds)

public.get_chat_workspace(host, path, environment) is the function the chat runtime calls on every turn. It is STABLE and returns NULL for a host that does not use explicit routing, which sends the runtime down the legacy, hostname-keyed path.

Its EXECUTE right belongs to postgres and service_role. On production it was also granted by hand, on 2026-09-18, to supabase_read_only_user, the role the Supabase MCP connects as, so an agent can run this layer. That role already reads every table the function reads, so the grant exposes nothing new. It lives outside the migrations: a migration that drops and recreates the function loses it. If the MCP answers permission denied for function get_chat_workspace, run the query in the Supabase SQL editor, or have an operator re-issue grant execute on function public.get_chat_workspace(text, text, text) to supabase_read_only_user;.

select t.label, t.host, t.path,
       x.r -> 'route' ->> 'config_key'     as config_key,
       x.r -> 'route' ->> 'matched_prefix' as matched_prefix,
       x.r -> 'route' ->> 'storage_key'    as storage_key
from (values
  (1, 'L  legacy host',      'abtasty.com',           '/pricing'),
  (2, 'A  canonical',        'lucca-software.com',    '/'),
  (3, 'A  alias',            'lucca.fr',              '/tarifs'),
  (4, 'N  path root',        'www.cactusinbound.com', '/how-it-works'),
  (5, 'N  under the path',   'www.cactusinbound.com', '/how-it-works/step-2'),
  (6, 'N  segment boundary', 'www.cactusinbound.com', '/how-it-works-not'),
  (7, 'N  sibling at /',     'www.cactusinbound.com', '/'),
  (8, 'N  bare host',        'cactusinbound.com',     '/how-it-works')
) as t(n, label, host, path)
cross join lateral (select public.get_chat_workspace(t.host, t.path, 'production') as r) x
order by t.n;
# Expected
1, 2, 3 NULL in every column: legacy clients are not routed. A non-null here means a paying client silently changed resolution.
4, 5 config_key = www-cactusinbound-com-how-it-works, matched_prefix = /how-it-works, storage_key = site:<uuid>
6 config_key = cactusinbound.com, matched_prefix = /: /how-it-works-not is not under /how-it-works
7 config_key = cactusinbound.com, matched_prefix = /
8 config_key = cactusinbound.com: the path root is placed on www., not on the bare host

Last run on production on 2026-09-18: all eight rows as expected.

Rows 4 to 8 need 'production': the "How it works" corpus lives in the production environment, and the resolver fails closed for any other. This layer is what tests a path-rooted workspace. The demo page and the Playground cannot (see below).

Layer 3 — what needs eyes (browser, about five minutes)

Setup: just dev-client-backoffice from frontend/ (check who holds port 3002 first; the login session is per origin) and, for the demo page, just dev from frontend/widget/. rose-browse drives both. Sign in as staff.

Backoffice

# Do Expected
B1 Open the selector, search abtasty, lucca, cactus L: one entry. A: one "Lucca" entry, the four aliases are not entries, the trigger shows +4. N: "Cactus Inbound — How it works" with www.cactusinbound.com/how-it-works on its own line, next to "Main site", "Framer test", "Landing page"
B2 Select each; read the URL L and A: ?domain=<registered domain>, no workspace=. N: ?domain=cactusinbound.com&workspace=<id>
B3 Open /?domain=cactusinbound.com in a new tab "Main site", never "How it works": a hostname means the workspace that owns the whole host
B4 With N selected, reload Still N
B5 With N selected, open Home and Conversations Same numbers as "Main site": workspaces of one website share analytics by design
B6 With N selected, open Config Studio, ChatGPT and Settings › Identity Until configuration and knowledge are keyed by workspace (IX-5071, IX-5070): a notice, and "Open Main site" switches and opens it. Settings › Team stays open (account-level). It must never show or edit "Main site" data under N's name
B7 With L then A selected, open Config Studio › Knowledge › FAQ and Brand & tone Open normally, rows present. Do not save
B8 Switch L → A → N → L with the console open No error; the switch overlay clears each time

Demo page

The demo page and the backoffice Playground run on a trusted origin (localhost, userose.ai). The chat API skips path resolution for them and answers as the workspace that owns the whole host. They therefore prove L and A, and for N only that the host's main agent is undisturbed. IX-5069 lifts this by letting a trusted tool name a location.

# Open Expected
D1 http://localhost:8083/demo.html?domain=abtasty.com Widget renders with AB Tasty branding; one product question gets a grounded answer; console clean
D2 …?domain=lucca.fr (alias) Same branding, configuration and knowledge as ?domain=lucca-software.com: an alias is not a separate agent
D3 …?domain=cactusinbound.com "Main site" answers. It must not answer from "How it works" knowledge

One real turn on the path (production, optional)

On https://www.cactusinbound.com/how-it-works, send one message that starts with a marker such as smoke-2026-09-18, then confirm what was recorded:

select c.site_domain, c.created_at,
       (select m.resolved_workspace ->> 'config_key' from public.messages m
         where m.conversation_id = c.id and m.resolved_workspace is not null limit 1) as runtime_workspace
from public.conversations c
where c.created_at > now() - interval '1 hour'
  and exists (select 1 from public.messages m
               where m.conversation_id = c.id and m.content ilike 'smoke-2026-09-18%');

runtime_workspace must be www-cactusinbound-com-how-it-works. Once conversations.workspace_id exists it must name the same workspace: the runtime's answer wins over the host rule.

Fixture check

select s.config_key, s.status, s.corpus_status, s.corpus_environment, c.explicit_routing_enabled,
       string_agg(r.host || case when r.path_prefix = '/' then '' else r.path_prefix end
                  || case when r.is_canonical then '*' else '' end
                  || case when r.enabled then '' else ' (off)' end, ', '
                  order by r.is_canonical desc, r.host) as roots
from public.sites s
join public.clients c on c.id = s.client_id
join public.site_roots r on r.site_id = s.id
where s.config_key in ('abtasty.com', 'lucca-software.com', 'skello.io',
                       'cactusinbound.com', 'www-cactusinbound-com-how-it-works')
group by s.config_key, s.status, s.corpus_status, s.corpus_environment, c.explicit_routing_enabled
order by 1;

Not covered

Ingestion and rescans (knowledge change verification), answer quality (rose-eval), form and CTA tracking, GEO publishing, HubSpot. This procedure guards only the path from host, alias and path to workspace.