Setting up a new tenant's knowledge base
How to stand up an isolated agent-native knowledge base (the
knowledge-base wiki space) for a new team — one that agents read index-first
and that compounds from their work, fully isolated from other tenants by
Row-Level Security.
For what the KB is and how it operates, read the agent-native knowledge base concept page first; for the read path agents use, see KB retrieval.
The model: a team that needs its own KB = its own tenant
- Tenant = the RLS isolation boundary. A team that needs an isolated KB gets its own tenant.
- Organisation = a sub-entity within a tenant (ADR-005). Orgs in one tenant share the tenant's RLS scope, so they share one KB.
- Therefore: isolated KB ⇒ separate tenant, not just a separate org.
Prerequisite — verify RLS is actually enforced (do this first)
The entire multi-tenant isolation rests on the app connecting to Postgres as a
non-BYPASSRLS role. If the runtime connects as a superuser / BYPASSRLS
role, all RLS is inert — one tenant could read another's data, and per-tenant
KB isolation is illusory.
Before onboarding any team whose KB must be isolated, confirm the runtime connects as a non-bypass role. If it does not, fix that first. Everything below assumes RLS is genuinely enforced.
Setup steps
1. Provision the tenant + org (Directory)
Create the tenant (types: AGENCY / SUPPLIER / PROGRAMME_OFFICE /
PLATFORM_OPERATOR), at least one organisation, and an admin user. This is the
RLS boundary every later step inherits.
2. Create the knowledge-base space (in the new tenant)
Use exactly the slug knowledge-base. That well-known slug is what wires the
coordinator read path and consult file-back to the space. Mark it agent-owned and
give it a description (mirror the platform KB space). One KB space per tenant.
3. Give agents tenant-scoped access
Access is tenant-scoped either way: the credential carries tenant_id, so
every MCP / pt CLI call RLS-scopes to this tenant — a credential for Team A
can never read Team B's KB.
Developers and interactive agents — pt login (no key to mint):
export PT_BASE_URL=https://constellation.planetb2b.com/projects
npx pt login
Then, for Claude Code, register the MCP server once per machine from the
main checkout (claude mcp add writes Claude Code's config only):
claude mcp add constellation -s user \
-e PT_BASE_URL=https://constellation.planetb2b.com/projects \
-e WIKI_BASE_URL=https://constellation.planetb2b.com/wiki \
-- npx -y tsx "$(pwd)/tools/mcp-server/src/index.ts"
Claude Desktop (claude_desktop_config.json), Codex, Cursor, Aider, and hosted
(Streamable HTTP) clients each have their own registration — see the
MCP server setup for the canonical per-runtime detail.
CLI / stdio / direct-API CI or headless only — where browser consent is impossible — provision the
integration's SERVICE principal and have an authorised operator issue its fixed-
90-day key through POST /api/auth/api-keys/service. Store the one-time response
as PT_AUTH_TOKEN; it takes precedence over the pt login store, so never
leave one set on a developer machine. The employee Settings page self-issues for
the signed-in human and is not a CI path. A hosted HTTP connector uses connector
OAuth after OAUTH_ENFORCE_AUD=true; it rejects both employee and SERVICE
Directory keys because their audience is not MCP_RESOURCE_ID. See CI credentials and expiry.
4. Wire the environment
| Variable | Where | Why |
|---|---|---|
WIKI_BASE_URL | each developer's MCP registration (claude mcp add … -e WIKI_BASE_URL=…) / the host's own config | Registers the direct wiki MCP tools (search_pages, get_page, ingest, lint, …). Does not gate query_knowledge_base — that tool is always registered against the PT client and reads the wiki through PT's WIKI_ZONE_URL. |
WIKI_ZONE_URL | the PT / coordinator runtime | The backend read path for query_knowledge_base and the coordinator KB reader; the wiki-spaces proxy fails closed if it is unset (returns WIKI_BACKEND_NOT_CONFIGURED, never a silent empty). |
CRON_SECRET + DISPATCHER_DATABASE_URL | the wiki runtime (for file-back) | The outbox dispatch-events cron must run for consult file-back to deliver. Use a dispatcher-role DSN. |
KB_READS_EMITTER_SECRET | both the PT runtime and the wiki runtime — same value | Authenticates KB usage-read recording (wiki.kb_reads). Two-sided — see the callout below. Without it the KB works normally but records nothing. |
KB_READS_HASH_SALT | the wiki runtime | Keys the HMAC question hash. Also gates recording: while it is unset, above-UNCLASSIFIED reads are not recorded at all (only UNCLASSIFIED ones are), so recording can be half-dead even with the emitter secret correct. |
KB_USAGE_MAX_WINDOW_ROWS | the wiki runtime (optional) | Ceiling on how many wiki.kb_reads rows the kb_usage report will aggregate in one window (default 15,000). Above it the report refuses the window with an explicit error naming the row count and a smaller window to retry — it never runs the expensive aggregation and never returns a bare 502 (PLT-755). Raising it does not add capacity — the transaction budget is fixed, so a higher ceiling only admits windows that may then time out, which is the opaque failure the ceiling exists to replace. The default is NOT currently calibrated to production, and knowing that is the point of this sentence. It was sized at roughly half the transaction budget on timings measured against a local database (PLT-755 spec § Measurement); PLT-1321 then measured the deployed path and found capacity between 1,226 and 2,439 rows — roughly 6x below the ceiling — so the breaker never fired and every overrun took the slower residual-timeout path instead. PLT-1321 made the report's two citation facets ~3x cheaper, which moves capacity but not to a figure anyone has measured yet; re-deriving both this ceiling and a possible companion limit on array volume is a post-deploy step on that ticket. Until it lands, treat the default as an upper bound that may not bind. If a legitimate window is being refused, the answers are to scope the report to one space, make the report cheaper, or raise the budget — raise this only with a measurement showing the larger window actually completes. An unset or invalid value falls back to the default rather than disabling the ceiling. |
KB_READS_EMITTER_SECRET is two-sided — one side is the same as neither (INF-295)KB usage recording spans two separate deployments: project-tracker sends the
secret in the x-kb-reads-emitter header, and the wiki validates it. They must
carry the same value, and all three failure modes are equally silent:
| Misconfiguration | What happens |
|---|---|
| Unset on PT | emitKbUsageRead returns early after one console warning per process lifetime — recording is off for every read on the deployment |
| Unset on the wiki | Every emit gets a 503 KB_READS_EMITTER_NOT_CONFIGURED |
| Set to different values | Every emit gets a 403 — and the PT side swallows it |
The emit is fire-and-forget and post-response (deliberately — a KB read must
never fail or slow because telemetry failed), so none of these reach a
caller. In every case no new rows reach wiki.kb_reads, and what
kb_usage shows depends only on what was recorded before recording stopped:
- Never configured — the report is all-zero, indistinguishable from "no traffic". That is exactly how the 2026-07-20 incident went unnoticed from release until a human traced the code.
- Configured, then broken (secret removed, rotated on one side only, or the
two sides drifted apart) — rows already inside the 1–90 day report window keep
being counted, so the report stays non-zero and looks healthy while
recording is completely dead. It goes quiet only gradually, as the last good
rows age out of the window. A non-zero
kb_usagereport is therefore not evidence that recording is currently working — the counts may be entirely historical.
After setting it, redeploy both projects with a genuine full build — a plain Vercel redeploy can miss newly-added env vars (PLT-361). Then verify that the count moves — a moving count is the only check that distinguishes live recording from stale rows:
Poll for the increase — do not read the count once. The usage emit is
scheduled through Next.js after(), so query-kb returns to you before the
wiki write happens. Reading kb-usage immediately races the emitter and can show
an unchanged count on a perfectly healthy deployment — a false alarm that sends
you looking for a config bug that isn't there.
npx pt kb-usage --days 1 # note the count
npx pt query-kb "verification probe"
# then poll, up to ~30s, until it rises
for i in $(seq 1 10); do
sleep 3
npx pt kb-usage --days 1
done
(The MCP equivalents are kb_usage(days: 1) and query_knowledge_base(...).)
Treat it as failed only if the count never rises across the whole poll window.
Run the probe as an UNCLASSIFIED caller, or set KB_READS_HASH_SALT on the
wiki first — above-UNCLASSIFIED reads are not recorded while that salt is unset,
so the count would stay flat for a reason unrelated to the emitter secret.
None of this is detected automatically yet. Making the unset-secret cases detectable — a wiki-side lint finding, a PT-side check, and a recording-health banner on the report — is rolling out under INF-295 and all three are still pending; until they ship, the two-sided secret has to be checked by hand. The mismatch case stays undetectable even after they land, because neither side can see the other's value; closing it needs a wiki-side rejected-emit counter, tracked as a follow-up.
5. Set the coordinator profile + initiative (PT)
The coordinator is initiative-scoped. Create the team's initiative and its
coordinator_profile (the conventions the coordinator reasons with). The
platform coordinator service is generic — per-team behaviour lives in this
profile.
6. The per-repo convention layer (in the team's repo)
The platform is generic; convention lives in two places — the per-initiative
coordinator_profile (step 5) and the team's repo:
- MCP access — each developer runs
npx pt loginonce (OAuth; no token to distribute) and, for Claude Code, registers the server user-scoped withPT_BASE_URLandWIKI_BASE_URLviaclaude mcp add(Claude Desktop, Codex, and other runtimes use their own config).query_knowledge_baseis always registered and reads the wiki via PT's server-sideWIKI_ZONE_URL, so omitting your localWIKI_BASE_URLdoes not stop it working — what goes missing are the direct wiki tools (get_page,search_pages, …). A SERVICE-principalPT_AUTH_TOKEN(CLI/stdio/direct-API CI/headless) or aPT_AUTH_COOKIE(local dev against a logged-in browser / mock-auth session) both take precedence over the OAuth store, so a leftover one silently shadows the login. Employee self-issuance is not an automation setup path. - The
consult-the-kbskill — so agents query the KB before implementing and cite what they used. .claude/agents/pt-coordinator.md— copied in, with their initiative ID.
7. Seed the KB (recommended)
A fresh KB is empty. Seed the team's durable canonical knowledge (their
constitution / ADRs / conventions) via ingest_source (human-approved, with
approvedBy), in curated waves — not a raw dump. ADRs and locked decisions
are the best seed material; volatile documents create curation churn. See the
knowledge-base concept page
for what makes a good ingestion candidate.
Verify it works
- Read path: a live consult on the team's initiative returns citations referencing seeded KB pages.
- File-back loop: a successful UNCLASSIFIED consult files back a
synthesis-consult-*page (andevents.deliveriesgrows — proving the dispatcher delivers). Choose the test question with care, because file-back is gated: ask something durable ("why is X built this way", "what is the convention for Y") that the answer can cite a seeded page for. A planning or status question ("what should I work on next") is classifiedephemeral, and an uncited answer fails the quality gate — neither writes a page, and neither means the setup is broken. A near-duplicate of an existing synthesis is skipped as well. (Cycle-close retrospectives are the one exception — they file back uncited; see Knowledge base.) - Index health: the space
indexis a small hub-and-spoke, under the coordinator reader cap. - Isolation: a token for this tenant cannot see another tenant's KB (the RLS check from the prerequisite, now proven end to end).
Keep it healthy (curation cadence)
- Daily mechanical lint — automated cron: orphan / broken-crossref / provenance-drift.
- Per-release LLM judgement pass + nomination-queue processing: stale-claim / contradiction findings + ingesting newly-nominated pages.
- See Wiki lint & curation and the
wiki-curatorsubagent.
What is not shared across tenants (yet)
Each tenant's KB is fully isolated. Common knowledge — a shared constitution, platform ADRs, cross-team conventions — is duplicated per tenant today; there is no shared/federated KB. A cross-tenant / platform shared KB is a future enhancement, not a current capability.
Deployment models
| Model | Trade-off |
|---|---|
| (a) Additional tenant, shared instance | Fastest. The team is a tenant with its own KB. Works today — modulo the RLS prerequisite above. |
| (b) Separate Constellation deployment | Heavier, full infra isolation. SaaS / Dedicated-Cloud / On-Prem tiers; each deployment gets its own KB. See Deployment. |