Wiki spaces
A space is the top-level organisational container for wiki pages. Think of it as a named folder — every page belongs to exactly one space, and child pages must live in the same space as their parent.
Spaces were introduced in PLT-192 to give other modules (e.g. Project Tracker initiatives) a stable, UUID-anchored reference point without coupling the wiki to PT's own entity model.
The container invariant
Spaces are true containers, not labels or filters:
- A page without a space is not a valid domain object (
space_id NOT NULLin the schema). - Creating a child page in a different space from its parent is rejected with
400 Bad Request. - Moving a single (leaf) page to a different space is allowed via
PATCH /api/pages/:idwith a newspaceIdand an explicitparentId(usenullto move it to the destination root). Requiring both fields prevents an omitted, clearance-hidden physical parent from being looked up or disclosed during the move. Moving a page that has children is rejected with409 Conflict(to avoid silent subtree moves that lose per-page audit context).
Viewer-relative hierarchy
A page can remain visible after its parent is reclassified above the caller's clearance. Public page responses treat that child as a viewer root: parentId is null, root listings include it, and filtering by the hidden parent's UUID behaves like filtering by a missing UUID. The stored relationship remains intact for trusted policy and cycle checks.
Routine updates must omit an untouched projected parentId; sending null is an explicit move to root. Cross-space moves always require explicit parent intent so the API never needs to reveal or round-trip a hidden physical parent.
Default space
Every tenant has exactly one Default space (identified by is_default = true in the database). Its slug is permanently "default" and cannot be renamed. The Default space:
- Is created lazily on the first page creation for a tenant (via
getOrCreateDefaultSpace— idempotent, race-safe). - Cannot be deleted (attempts return
403 Forbidden). - Is the fallback target when
spaceIdis omitted from aPOST /api/pagesrequest.
The Default space's immutable slug is what makes the by-path/default/<page-path> URL shape and the MCP resolveWikiPageId helper's default resolution safe and stable.
Slug rules
Space slugs are lowercase kebab-case (^[a-z0-9][a-z0-9-]*$):
- Auto-derived from the space name on creation if a slug is not explicitly supplied.
- Unique per tenant (partial unique index on
(tenant_id, slug) WHERE deleted_at IS NULL). - The slug
"default"is exclusively reserved for the Default space. Attempting to create or rename a non-Default space to the slug"default"is rejected with400 Bad Request. - The Default space's slug cannot be changed after creation.
Shared namespace with Default-space root pages
The by-path URL GET /api/pages/by-path/<first-segment>/... resolves <first-segment> as a space slug first. If no matching space is found, it falls back to the Default space and treats all segments as a page-slug path. To keep this lookup unambiguous, the wiki service enforces at write time that the same slug string can never be simultaneously a live space slug and a root page slug in the Default space.
Listing and resolving pages by path
The canonical URL shape for the by-path API is:
GET /api/pages/by-path/<space-slug>/<page-path...>
For example: /api/pages/by-path/runbooks/incident-response/sev-1
Legacy bookmarks using the old /api/pages/by-path/<page-path> form (no space prefix) continue to work via the Default-space fallback — all pages that existed before PLT-192 were migrated to the Default space.
Knowledge-base spaces
PLT-513 ships the storage and the resolver described below. The audited
wiki:spaces:admin action that actually sets or removes the marker arrives with
PLT-514, along with the reconciliation scan that backfills existing tenants.
Until then no space is marked, every tenant reads as never-reconciled, and KB resolution behaves exactly as it did before: a space carrying the configured slug is treated as the knowledge base. The rules below describe the model this section documents and the guards already enforced — not an admin workflow you can run today.
A space can be marked as a knowledge-base space. The marker is a durable column on
wiki.spaces (is_knowledge_base, PLT-513), and it is what every KB consumer reads —
the coordinator file-back workflow, the ingestion-candidacy dedup, and the per-page KB
status.
Why a column rather than the knowledge-base slug. Project Tracker chooses the KB
slug per deployment through COORDINATOR_KB_SPACE_SLUG. That value lives in PT's process
environment and reaches the wiki only transiently, on a coordinator.consult.synthesized
event — the wiki can neither enumerate nor verify it. So a space named knowledge-base
may not be the knowledge base, and the knowledge base may not be named knowledge-base.
Slug comparison was never a predicate.
The marker is surfaced on every space read as isKnowledgeBase. Its provenance (who
marked it, when) is deliberately not part of the API response.
Setting the marker will be a wiki:spaces:admin action, audited in its own transaction —
never a side effect of ingest. Ingesting a source needs only wiki:page:write plus a
non-empty approvedBy, so marking-as-a-side-effect would let an ordinary page writer turn
any space into a knowledge-base space. It is already not settable through
POST /api/spaces or PATCH /api/spaces/:spaceId, and the schema refuses a marker that
carries no record of who set it and when.
A marked space is frozen. It cannot be deleted, decommissioned, or have its slug
renamed until the marker is explicitly removed; all three would break KB writes silently,
because the resolver obeys the configured slug and never substitutes a different marked
space. Name and description stay editable. The attempt returns 409 Conflict naming the
required unmark — which, until PLT-514 lands, means such a space cannot be torn down at
all. That is inert today, since nothing is marked.
A tenant may have more than one knowledge-base space — Project Tracker already models
KB membership as knowledgeBaseSpaceIds[] per initiative and project.
Coverage checks span every marked space, so a page already covered by one knowledge base is not offered for ingestion into another.
The "add this page to the KB" affordance targets a single space, chosen in this order:
the space the configured slug resolves to; failing that, the tenant's sole marked space
— which is not a guess, and is the only way a deployment whose COORDINATOR_KB_SPACE_SLUG
differs from the default gets an affordance at all, since the wiki never learns that slug.
With several marked spaces and no slug match the affordance is hidden rather than
picking one the caller never named.
Note the asymmetry with the file-back path: when the coordinator files an answer it names a slug, and resolution for that path obeys the name exactly and never substitutes a different marked space — otherwise the page would be written into one space and looked up in another.
Per-tenant reconciliation state
wiki.kb_reconciliation_state holds one row per tenant recording whether its KB spaces
have been reconciled: a pending | clean | blocked verdict, a conflict inventory for
remediation, and a one-way slug_fallback_allowed latch.
Until a tenant is reconciled the latch stays open and a space carrying the configured slug still resolves as the knowledge base, so existing tenants are unaffected — which is every tenant today, because the scan that writes the first verdict ships with PLT-514. The first reconciliation closes the latch — and so does removing a marker, so demoting a space cannot quietly re-enable the old slug-based lookup for it.
The conflict inventory records a space and a single fail-closed reason code, and deliberately no page counts, no page identity, and no reason derived from page content: it comes from a privileged scan that sees pages above the reader's clearance and pages in the trash, so even a reason like "holds a human-authored page" would tell an under-cleared space admin something about what that scan saw.
Admin permission gate
Space-level admin operations — create, rename, and delete a space — require the wiki:spaces:admin permission. Any authenticated tenant member can list and read spaces.
Roles that satisfy the admin check: admin, wiki:spaces:*, wiki:spaces:admin.
The permission string should be registered in the Directory permission catalog so tenant administrators can grant or revoke it through the RBAC admin UI (tracked as DIR-65). JWT role-matching works today without the catalog registration.
API endpoints
| Method | Path | Auth gate | Description |
|---|---|---|---|
GET | /api/spaces | tenant member | List all non-deleted spaces for the caller's tenant |
POST | /api/spaces | wiki:spaces:admin | Create a space |
GET | /api/spaces/:spaceId | tenant member | Fetch one space (includes pageCount) |
PATCH | /api/spaces/:spaceId | wiki:spaces:admin | Update name, description, or slug |
DELETE | /api/spaces/:spaceId | wiki:spaces:admin | Soft-delete an empty space (409 if it has pages) |
POST | /api/spaces/:spaceId/decommission | wiki:spaces:admin | Human-approved bulk teardown of a populated space |
GET /api/spaces/:spaceId returns a pageCount field that reflects only the pages visible to the caller under their current clearance. This count is for display purposes in the admin UI. The server's internal delete guard uses an all-classifications count and never exposes the exact number to prevent leaking the existence of classified pages.
Deleting vs decommissioning a space
DELETE is the empty-space path: it soft-deletes a space that holds no live pages and returns 409 Conflict if any remain. For an agent-populated space — where deleting the pages one by one is impractical — the escape hatch is POST /api/spaces/:spaceId/decommission (PLT-205), a single audited transaction that:
- Cascade-archives every live page in the space — the reserved
indexandlogpages and maintenance-schema pages included. - Sweeps every link touching those pages.
- Flags external referrers: every live page outside the space that linked into it gets an open
lost_space_referencelint finding so the dangling reference surfaces for review rather than rotting silently. - Soft-deletes the space and emits a compliance audit entry plus the
wiki.space.deletedevent (Project Tracker drops the dead id fromknowledgeBaseSpaceIds[]automatically).
Request body: reason (recorded in the audit entry) and approvedBy (the human who authorised the teardown) are both required — there is no autonomous decommission path. Pass includeHumanAuthored: true to include live human-authored pages in the cascade; without it the call is rejected with 409 Conflict when any exist.
Archive semantics are non-destructive. Cascade-archived pages keep their content and revision history and stay in the normal 30-day trash window — an individual restore re-homes the page into the Default space. Decommission is not a content-destruction tool; to expunge derived content, retract the source first.
Guards: 403 Forbidden for the Default space; 409 Conflict if the space holds pages above the caller's clearance, or human-authored pages without the includeHumanAuthored override. The response reports pageCount, humanPageCount, trashedPageCount, deletedLinkCount, and externalFindingCount.
MCP tools
The constellation MCP server exposes six space tools when WIKI_BASE_URL is configured:
| Tool | Description |
|---|---|
list_spaces | List all spaces in the caller's tenant |
get_space | Fetch one space by UUID (includes visible page count) |
create_space | Create a space (wiki:spaces:admin required) |
update_space | Rename or re-describe a space (wiki:spaces:admin required) |
delete_space | Soft-delete an empty space (wiki:spaces:admin required) |
decommission_space | Bulk teardown of a populated space — human-confirmed, wiki:spaces:admin required (details) |
spaceId vs spaceSlug in page tools
The page tools distinguish two separate space-reference roles:
spaceId(UUID) — a payload field oncreate_pageandupdate_pagethat sets which space a page lives in. Forwarded to thePOST /api/pagesorPATCH /api/pages/:idrequest body.spaceSlug(slug string) — a resolution context on tools that accept a slug-path page reference. Passed to theresolveWikiPageIdhelper so it builds the correctGET /api/pages/by-path/<spaceSlug>/<path>URL. Defaults to"default". UUID refs are space-independent and ignore this parameter.spaceIds(array of UUIDs) — a filter onsearch_pagesandlist_child_pagesto restrict results to the given spaces.
For cross-space page references, supply a page UUID — slug-path resolution is single-space only.
Domain events
Two domain events are published for space lifecycle changes:
| Event | When emitted | Key payload fields |
|---|---|---|
wiki.space.created | Space is created | spaceId, tenantId, name, slug |
wiki.space.deleted | Space is soft-deleted (delete or decommission) | spaceId, tenantId, deletedAt, slug?, pageCount? |
Both the empty-space DELETE path and the decommission cascade emit wiki.space.deleted. The slug? and pageCount? fields are PLT-205 additive-optional widenings — the decommission path includes pageCount (the number of live pages cascade-archived), but historical outbox rows carry only the original three fields, so subscribers must not require them. See the domain events reference for full payload shapes and source links.