Testing knowledge changes¶
Use this procedure to choose the smallest test environment that proves a change to knowledge ingestion, retrieval, or its backoffice surfaces. Local processes are the fastest feedback loop; a cloud preview is required when the code needs the cloud network path or isolated retrieval stores. Neither replaces the final diagnostic and retrieval evidence when stored knowledge can change.
Agents should use knowledge-ingestion-control-plane when starting or debugging
the local ingestion services and rose-knowledge-diagnostics when inspecting
RAG storage. Those skills add orchestration and safety checks; this page is the
shared procedure for developers and agents.
Choose the test path¶
| Change or question | First feedback loop | When cloud is required | Pre-merge proof |
|---|---|---|---|
| Pure function, contract, repository, or handler logic | Focused backend tests | Only if the changed boundary has no local execution path | Relevant suites from backend testing |
| Backoffice knowledge UI or Admin/Knowledge API route | Whole local stack against local or preview Supabase | Only for a cloud-only dependency or callback | Frontend checks plus the owning backend suites |
| Knowledge API/content-worker dispatch or callback behavior | Local stack on a preview branch (local dispatch runs tasks, the loader and recovery here); add tunnels only when Cloud Tasks delivery itself matters | Use a cloud preview when Cloud Tasks, scheduler recovery, IAM, or stable callback URLs are part of the claim | Operation state and logs, plus focused control-plane tests |
| Document-loader, chunking, embedding, storage adapter, or full ingestion path | Unit/integration suites | Use a cloud preview for any trial that runs the loader or writes retrieval storage. An adapter-only fix may stop at units when the verification checklist requires no rebuild | Knowledge verification checklist |
| Firecrawl scrape options or the page-cleaner prompt | Live before/after on sample pages | Not needed: the scrape and clean run from a laptop | Before/after evidence on about 30 pages from about 10 clients |
| Stored corpus integrity, tenant isolation, cleanup, or deletion safety | Read-only diagnose-tenant baseline |
Use the environment whose stores are in scope; use preview storage for a write trial | Comparable before/after diagnostics with coverage and findings |
| “Does the chatbot use the changed knowledge?” | Passage capture and rose-chat against the same target as the trial |
Use the preview search API when the candidate corpus lives in preview retrieval storage | Retrieved passages, sources, and a traced chat result |
Use more than one row when the change crosses boundaries. For example, a new document-loader handler needs focused tests, a cloud-preview job trial, matching before/after diagnostics, and retrieval/chat evidence. A UI-only display change does not need a loader run.
Choose the database before starting services¶
IX_ENVIRONMENT=test does not select an isolated Supabase database. Test,
staging, and production services share the production Supabase project unless
.env.local overrides it.
Choose one target and make every local reader and writer agree:
| Target | Setup | Use it for | Boundary |
|---|---|---|---|
| Local Supabase | From supabase/, run just dev |
Fast UI/API/handler iteration with synthetic data | Laptop-only Postgres; no isolated MongoDB, Neo4j, Redis, or LightRAG |
| Hosted preview Supabase | From the repository root, run just target isolated (wraps ./bootstrap.py --branch; it first pushes the git branch to origin, publishing it before any PR) |
Branch migrations, hosted callbacks, local services that need an isolated Postgres target, and local onboarding into a branch corpus | Isolated Postgres plus a branch-scoped Mongo database and tenant prefix on the shared test retrieval stores |
| Shared production Supabase | No override; the host is drtzxyuvppalvgczwhne.supabase.co |
Read-only inspection or an explicitly authorized production operation | test and staging labels do not isolate its rows |
Both local Supabase and ./bootstrap.py --branch write the effective credentials
to backend/.env.local and frontend/.env.local. The branch flow also writes
MONGO_DATABASE and IX_TENANT_PREFIX, so rose-tenant onboard --execution local
and rose-chat read and write only that branch's corpus. Start or restart services only
after choosing the target; do not edit those files by hand. Verify the host that
the launcher prints before using any mutation flow.
For the opt-in v2 flow, run ROSE_PREVIEW_V2_ENABLED=1 ./bootstrap.py --branch.
New previews share Mongo database rose_preview_test, common collections and
indexes, and tenant prefix pv2_<Supabase project ref>__ across Mongo and Neo4j.
The prefix must match the actual Supabase URL. Existing previews retain their
legacy namespaces; no corpus is moved automatically. See
bounded cleanup and rollout gates
before enabling automated retirement. Missing indexes or a busy writer defer
cleanup rather than scanning shared data or deleting the preview anyway.
From the repository root, just target prints the current target on one line:
| Mode | Meaning |
|---|---|
ISOLATED |
Preview branch, with its branch ref, Mongo database and tenant prefix |
LOCAL |
Loopback Supabase from supabase/ just dev |
SHARED |
Production Supabase: local writes hit production |
BROKEN |
Isolated mode was requested but the wiring is incomplete; local knowledge writes refuse |
just target isolated also writes the gitignored .rose-target marker. While it
exists, every CLI and the local stack behave as if ROSE_REQUIRE_ISOLATED_TARGET=1
were set: if the branch wiring disappears (an unwire, a teardown, a concurrent
session), local writes refuse instead of falling back to production. Remote CLI
mutations refuse too whenever the Knowledge API they call writes the shared project.
just target shared refuses while the local stack or a local loader is still
running, then asks for confirmation, purges the preview corpus
(rose-tenant purge-preview --yes), unwires the branch (or stops local
Supabase) and removes the marker. rose-merge-branch runs
just target shared --yes in its pre-flight.
For local Supabase, use the repository recipe rather than a bare
supabase start:
The recipe starts Supabase and runs gen-env; without that second step, the
application services continue loading .env.test and can silently reach shared
production Supabase. Local knowledge services also need Application Default
Credentials for GCP-backed providers and OIDC:
Apptest works on production data¶
Apptest (--env test) and production share the production Supabase project, so
an apptest onboarding writes real production rows. Onboard real clients on
production. Production's automatic source sync skips a workspace first loaded in
another environment (its corpus_environment), so a client onboarded on apptest
is not retrained by production behind your back.
For anything that must not touch production rows, use a preview branch
(just target isolated, or just cloud-preview for cloud-only paths) and copy
the real clients you need into it:
# supabase/ — SOURCE_DB_URL = production, TARGET_DB_URL = the preview branch
# (session pooler URLs); a production target is refused.
just copy-clients acme.com,beta.io --conversations 10 --days 30
The copy keeps production ids and storage keys and replaces only those clients on re-run. It never copies secrets, ingestion runtime or backups, and includes visitor PII from the copied conversations.
Retrain an existing production client in test¶
To rebuild a live client's knowledge in test and then promote it, hold the client so production's automatic sync waits, and copy test to production when done: follow Retrain a live client in test, then promote it.
Choose a test client and workspace shape¶
Use the fictional TestFeatures client for local or preview backoffice/API
smoke tests. Its migration-created root workspace is registered at
testfeatures.com/, and local Supabase auto-login gives the admin access to it.
Confirm the fixture before testing:
select w.id, w.config_key, r.host, r.path_prefix, r.enabled
from public.workspaces w
join public.workspace_roots r on r.workspace_id = w.id
where r.host = 'testfeatures.com'
order by r.path_prefix;
Choose the workspace shape that matches the change:
| Question | Fixture |
|---|---|
| Does the local backoffice/Admin/Knowledge API path work for an ordinary workspace? | Select the existing TestFeatures workspace at / |
| Does host/path resolution keep sibling workspaces isolated? | Run just test-knowledge-ingestion-control-plane-migration from supabase/; its disposable routing.example fixture creates /fr, /uk, and /fr/pricing workspaces and rolls them back |
| Does workspace-keyed configuration preserve the host sibling? | Run supabase/tests/ix5071_client_configs_workspace_key.test.sql against isolated local/preview Postgres; it creates a host workspace plus /fr and rolls back |
| Does an interactive UI flow specifically require a path workspace? | Create the sibling under testfeatures.com only in local/preview Supabase, record its ID, and remove it by resetting/tearing down that isolated target |
For the focused workspace-key test, run from the repository root:
psql postgresql://postgres:postgres@127.0.0.1:54322/postgres \
-v ON_ERROR_STOP=1 \
-f supabase/tests/ix5071_client_configs_workspace_key.test.sql
Run the workspace non-regression checks
when the implementation changes workspace/root selection, routing, or
workspace_id ownership. Keep the host workspace and path sibling distinct in
assertions; seeing rows for testfeatures.com alone does not prove that the
selected workspace owns them.
TestFeatures currently has no seeded rows in config.knowledge_faqs. It proves
workspace selection and control-plane routing, not retrieval quality. Do not add
a curated FAQ merely to make the smoke test pass: use the rolled-back SQL/unit
fixtures for ownership behavior and an authorized preview corpus for actual
loader, retrieval, and chat evidence.
Start the local application stack¶
For backoffice → Admin API → Knowledge API → content-worker development, run
from backend/:
The command starts and supervises:
| Process | Address |
|---|---|
| Client backoffice | http://localhost:3002 |
| Admin API | http://localhost:8080 |
| Knowledge API | http://localhost:8081 |
| Content worker | http://localhost:8082 |
| Search API | A free port, printed at startup; the backoffice playground (VITE_IX_API_URL) uses it |
It refuses to reuse occupied ports, prints the effective target (just target), and
records each owned PID, working directory, port, and log path under
.context/dev-knowledge-stack/. Ctrl-C stops only the process groups started
by that invocation. The local Supabase env generated by just dev configures
backoffice auto-login as admin@admin.com / admin. It refuses to start on a
BROKEN target, or on the shared project when the marker or
ROSE_REQUIRE_ISOLATED_TARGET requires isolation.
To point the widget demo at the same search API, run the command the stack
prints from frontend/widget/ (just dev http://localhost:<search port>).
The equivalent commands, each in its own terminal, are:
# backend/
export KNOWLEDGE_API_URL=http://localhost:8081
export KNOWLEDGE_API_OIDC_AUDIENCE=http://localhost:8081
export KNOWLEDGE_API_IMPERSONATE_SA="$(gcloud run services describe admin-api-test --region=europe-west9 --project=inboundx --format='value(spec.template.spec.serviceAccountName)')"
just dev-admin test
Use the separate commands when one service needs a custom port, tunnel URL, or OIDC audience. Follow the local control-plane reference for those arguments and authentication requirements.
Before any local write, verify the environment of the processes that actually
hold the backend ports. Each command must print a local API URL containing
127.0.0.1:54321, not the shared Supabase host:
for port in 8080 8081 8082; do
pid="$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t)"
ps eww -p "$pid" | tr ' ' '\n' | grep '^SUPABASE_URL='
done
Then check process health without creating knowledge state:
curl --fail http://localhost:8080/health
curl --fail http://localhost:8081/ready
curl --fail http://localhost:8082/ready
Open the backoffice knowledge page and confirm its authenticated operations request returns 200 before testing a form or handler. A 502 from the Admin API usually means the local Knowledge API URL was supplied without the matching OIDC audience or Admin API impersonation identity; the aggregate launcher sets all three.
Run a full ingestion locally on a preview branch¶
On an ISOLATED target the stack turns on local dispatch
(KNOWLEDGE_LOCAL_DISPATCH=1 on the Knowledge API) and exports
ROSE_REQUIRE_ISOLATED_TARGET=1 for every process. A client onboarded from the
local backoffice then runs discovery → page selection → scrape → clean → train on
the laptop, and the playground and widget demo answer from the branch corpus:
- Content work items are posted straight to the local worker at
/v1/tasks/{stage}, with the Cloud Tasks body, the deterministicX-CloudTasks-TaskNameand an OIDC token minted by impersonatingknowledge-tasks-test. Failed deliveries are retried like a Cloud Tasks queue. - The loader runs as a child process (
run_document_loader.py) with the same overrides as the Cloud Run job plus the branch isolation keys. Its pid is the execution handle. The local API accepts its callbacks asknowledge-tasks-test. - A recovery loop runs
dispatch_recoveryevery 30 s:retry_wait, undispatched claims, dead loader processes and lost handles after a--reloadrestart.
The API refuses to start local dispatch unless IX_IS_LOCAL is set, Supabase is
not the shared project, and the target resolves to the branch namespace. The Admin
API's test knowledge-write refusal applies only to the shared project, so the
local backoffice can onboard into a branch. No Cloud
Tasks or Cloud Run executions are created. Your account needs
roles/iam.serviceAccountTokenCreator on knowledge-tasks-test; run
just setup-knowledge-control-plane-smoke-access test from backend/ once.
A fresh preview only registers the seeded fake hosts (acme.com, testfeatures.com, …),
so Add website with a real site fails with source root outside registered workspace
roots. Register the real site first: in the local backoffice, Super Admin → Edit
clients → New client with its domain (this writes the preview database only), switch
the workspace picker to it, then add the website. Keep the page selection to about
50–150 pages so the fetch and prepare steps finish in minutes.
Retrieval logs show the branch-prefixed tenant (<slug>__<storage key>). Neo4j
and Redis isolation is logical (tenant prefix on the shared test instances),
like the cloud preview. The widget demo also needs an environment-aware
get_widget_config (IX-5161) to resolve a branch-only client.
On a SHARED or LOCAL target the stack keeps cloud dispatch: Cloud Tasks
cannot deliver to localhost, so a real run needs authenticated HTTPS tunnels or
a cloud preview. GCS, Firecrawl and model providers remain remote in every mode.
On a LOCAL or ISOLATED target only, you can check what an action queues
(counts, progress) without fetching anything: start the Knowledge API with
KNOWLEDGE_DISCOVERY_QUEUE, KNOWLEDGE_SCRAPE_QUEUE, KNOWLEDGE_CLEAN_QUEUE and
KNOWLEDGE_DOCUMENT_LOADER_JOB_NAME set to a name that does not exist. Dispatch
fails with NotFound and items stay pending in that database. Never do this on
SHARED: the deployed recovery scheduler reads the same rows and re-dispatches
them through the real queues.
Run a full ingestion on a loopback stack¶
Without a preview branch, local dispatch also accepts a fully loopback stack:
Supabase on port 54321 (the only local port accepted) plus local MongoDB,
Neo4j and Redis. To keep the shared inboundx-2 volume untouched, copy
supabase/ under .context/<name>/supabase/ as supabase/AGENTS.md describes, keep
the standard ports, change only project_id, and supabase start --workdir
.context/<name>; SUPABASE_WORKDIR=../.context/<name> just gen-env (from
supabase/) writes the overrides for that copy. Run mongodb/mongodb-atlas-local
(Community mongod lacks vector search), neo4j:5-community and
redis/redis-stack-server bound to 127.0.0.1, and declare MONGO_URI,
MONGO_DATABASE, NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD, NEO4J_DATABASE
and REDIS_URL in the generated backend/.env.local after gen-env (rerunning
gen-env rewrites the file and drops them). Start the Knowledge API with
KNOWLEDGE_LOCAL_DISPATCH=1. If your account cannot mint tokens for
knowledge-worker-test, export KNOWLEDGE_CONTENT_WORKER_SERVICE_ACCOUNT_EMAIL
set to knowledge-tasks-test@inboundx.iam.gserviceaccount.com for both the API and
the worker, plus KNOWLEDGE_API_IMPERSONATE_SA with the same value for the worker.
Remove the containers and the generated env files when done.
Drive a rescan review without the backoffice¶
With the stack running on an ISOLATED target, call the local Knowledge API as the
Admin API identity (SA from gcloud run services describe admin-api-test … serviceAccountName):
TOKEN=$(gcloud auth print-identity-token --impersonate-service-account="$SA" \
--audiences=http://localhost:8081 --include-email)
POST /v1/website-rescan-reviewswith{"workspace_id", "idempotency_key"}starts the scan.- Poll
GET /v1/website-rescan-reviews/current?workspace_id=…untilreview.statusisready. POST /v1/website-rescan-reviews/<id>:applywithselected_page_ids([]refreshes included pages only) and a freshidempotency_key, then followGET /v1/operations/<id>.
A 404 on an unknown operation id proves the token is accepted before any write.
Use a cloud preview for ingestion and retrieval writes¶
From backend/, deploy the branch's ingestion job (previews are job-only — a
shared -test service is never redeployed):
Verify the exact callback URLs, OIDC audiences, queues, and preview job name used by the operation: the preview job does not automatically reroute the shared test scheduler or queues. If those connections are not preview-specific, the run proves the job in isolation, not end-to-end orchestration.
Deploy and run the preview document-loader once, passing the tenant for this run:
TENANT_ID is required; LIMIT bounds the legacy source pass; add
IGNORE_TIMESTAMPS=true to force a reload. Before trusting the result, confirm
both resolved tenant IDs and the database in the execution logs. Legacy previews
use <id>__<tenant> in <id>_test; v2 uses pv2_<project ref>__<tenant> in
rose_preview_test. A bare tenant ID or rose_test means the run touched shared
test data. Then read the final
Document update completed counts. See the cloud-preview procedure
for teardown and isolation details.
Legacy previews use a branch-derived Mongo database and tenant prefix. V2 previews
use the isolated Supabase project ref for their tenant prefix in shared
rose_preview_test collections; local and cloud wiring agree. Both namespace Mongo,
Neo4j and the loader's Redis state without shifting live test traffic.
just cloud-preview ensures the current branch's Supabase preview and rewrites
the generated .env.local files to that branch. Local services started after
that point therefore see the same Supabase rows as the preview. This is useful
for a local UI or Admin API while inspecting a preview operation.
Do not assume that this makes the local processes part of the preview runtime. Legacy cloud jobs may receive a different Mongo database and tenant prefix through their secret; v2 uses the same retrieval wiring as the local stack. Do not split one operation across local and preview API/worker/loader processes unless the control-plane tunnel guide has made every callback URL, OIDC audience, queue, and database target agree. For retrieval and loader proof, call the preview service or job itself.
When finished, remove the preview corpus first, while .env.local still points at
the branch (from backend/; drop --yes to see counts only):
A purge keeps the branch's Supabase rows. If you re-import afterwards, rose-tenant onboard --from
can report completed with nothing ingested, so check that MongoDB has chunks. If it has none,
rebuild with rose-document-loader update-docs-tenant <site> --env test --scope managed.
Then tear down locally created v2 preview resources:
For legacy previews, follow explicit legacy retirement instead: automatic deletion remains refused after a manual purge.
Teardown deletes the Supabase branch but leaves .env.local pointing at it.
From the repository root, point local services back at the shared project:
Clean up local state¶
From the repository root, return to the shared target:
It purges a preview corpus, unwires the branch or runs just stop for local
Supabase (which removes the generated entries from the backend and frontend
.env.local files), and removes the isolation marker. Delete the Supabase branch
itself with python3.12 scripts/supabase_branch.py delete <git branch>.
Compare scrape options or cleaner prompts¶
Scrape each sample page live (maxAge: 0) with the old and new options, then clean both
with the production prompt (PageCleanerService._system_prompt / _user_prompt). Both
steps are non-deterministic, so read a difference only against its noise:
- Re-clean the same raw file 3–5 times first. Run-to-run churn of 20–60 changed lines on a long page is normal; judge facts by presence counts, not diff size.
- Scrape each variant 3 times. Firecrawl's
onlyCleanContentand lazy-loaded images change the raw text between runs. - To compare with production, read the current snapshot's
processed_markdown_refandraw_markdown_reffromknowledge.document_snapshots. Check a "lost" fact against that raw file: an older cleaner sometimes invented sections the page never had.
Apply the merge gate¶
The runtime choice answers “where can this code execute?” The knowledge verification checklist answers “what evidence is required before merge?” Follow its relevant stages:
- Run the combined owning unit suites and any guarded FAQ integrations.
- Capture a read-only
diagnose-tenantbaseline when storage can change. - Run an authorized ingestion trial in the selected isolated target only when the change needs one.
- Capture matching after-state diagnostics and raw retrieved passages.
- Run a traced
rose-chatquestion against the same retrieval environment when answer behavior is part of the claim. - Report target, commit/image/execution, commands and exits, coverage, findings, retrieved sources, remaining unknowns, and preview teardown.
A successful service health check proves startup. A successful loader execution proves completion. Neither proves corpus integrity, tenant isolation, retrieval relevance, or answer quality; retain the corresponding diagnostic and retrieval evidence.