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.

Before editing a live path workspace's routes

Adding, removing or moving a route of a path workspace with its own corpus (site:<id>) marks that corpus absent (invalidate_site_corpus_on_root_change). Routed turns then fail closed, so its live pages go silent until the corpus is ready again. Edit in this order:

  1. List the client's enabled routes, then switch its Path routing off (Edit clients). Its pages return to the legacy bot and the database disables every path route of the client. Setting one route's display Off is not the same thing: with routing still on, that route serves nothing.
  2. Edit the routes, then wait for the corpus to be ready (the knowledge retrains rebuild it; re-scan the website source if it stays absent).
  3. Set the main path route's display On first. That turns routing back on; right after, switch On every other route from step 1's list and the new routes. A route left off serves nothing once routing is on, and a new route switched On first turns routing on while the main route is still off.
  4. Run Layer 2 with rows for every route from step 1 and every new host and path.

The three cases

Case Fixture Shape
L — legacy host abtasty.com One workspace, one root, no alias. explicit_routing_enabled off.
A — alias hosts skello.io One workspace, two roots: canonical plus skello.es. Flag off. Lucca (lucca-software.com) has since opted into explicit routing: not a fixture for this case.
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',        'skello.io',             '/'),
  (3, 'A  alias',            'skello.es',             '/precios'),
  (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 cannot; the Playground can (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, skello, cactus L: one entry. A: one "Skello" entry, the alias is not an entry, the trigger shows +1. 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. Only Analytics → Workspaces splits them, with one row each
B6 With N selected, open Config Studio › Knowledge › FAQ, Documents, Website and Pricing; switch Main site → N → Main site Before database activation, knowledge screens remain guarded. After workspace_knowledge_ready() returns true, they open and reload for the selected workspace (IX-5070). They must never show or edit "Main site" knowledge under N's name. In-flight responses and unsaved forms from the previous workspace must not appear after switching
B6a With N selected, open Evaluations, Integrations and ChatGPT These screens remain guarded: a notice appears, and "Open Main site" switches and opens it
B6d With N selected, open Playground and ask one product question; switch Main site → N → Main site N's widget config loads and N answers from its own knowledge (IX-5069); the conversation restarts only when switching between two workspaces on the same host. Main site answers as before
B6e Select a path workspace whose root is disabled, or whose client is flag-off (e.g. sunday UK, sundayapp.com/en-gb), or a workspace with its own ready corpus on a domain still disabled (e.g. lucca-software.ch), and ask one question in Playground messages.resolved_workspace ->> 'config_key' is that workspace, never its host's or the legacy tenant (IX-5252, IX-5386)
B6b With N selected, open Brand & tone, Website Agent › Calls-to-action and Settings › Identity They open (configuration is keyed by workspace, IX-5071) and show what N inherits from "Main site" plus N's own overrides. Do not save. Settings › Team stays open (account-level)
B6c On Calls-to-action, switch Main site → N → Main site The form reloads each time; no value typed for one workspace survives into the other
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 runs on a trusted origin (localhost). The chat API skips path resolution for it and answers as the workspace that owns the whole host. It therefore proves L and A, and for N only that the host's main agent is undisturbed. The backoffice Playground names N's root URL instead, so B6d covers N (IX-5069).

# 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=skello.es&lang=es (alias) Same branding, configuration and knowledge as ?domain=skello.io: an alias is not a separate agent. Spanish UI copy (the generic demo page defaults to English without lang=es)
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.