Skip to main content

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 NULL in 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/:id with a new spaceId. Moving a page that has children is rejected with 409 Conflict (to avoid silent subtree moves that lose per-page audit context).

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 spaceId is omitted from a POST /api/pages request.

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 with 400 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.

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

MethodPathAuth gateDescription
GET/api/spacestenant memberList all non-deleted spaces for the caller's tenant
POST/api/spaceswiki:spaces:adminCreate a space
GET/api/spaces/:spaceIdtenant memberFetch one space (includes pageCount)
PATCH/api/spaces/:spaceIdwiki:spaces:adminUpdate name, description, or slug
DELETE/api/spaces/:spaceIdwiki:spaces:adminSoft-delete an empty space (409 if it has pages)
POST/api/spaces/:spaceId/decommissionwiki:spaces:adminHuman-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:

  1. Cascade-archives every live page in the space — the reserved index and log pages and maintenance-schema pages included.
  2. Sweeps every link touching those pages.
  3. Flags external referrers: every live page outside the space that linked into it gets an open lost_space_reference lint finding so the dangling reference surfaces for review rather than rotting silently.
  4. Soft-deletes the space and emits a compliance audit entry plus the wiki.space.deleted event (Project Tracker drops the dead id from knowledgeBaseSpaceIds[] 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:

ToolDescription
list_spacesList all spaces in the caller's tenant
get_spaceFetch one space by UUID (includes visible page count)
create_spaceCreate a space (wiki:spaces:admin required)
update_spaceRename or re-describe a space (wiki:spaces:admin required)
delete_spaceSoft-delete an empty space (wiki:spaces:admin required)
decommission_spaceBulk 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 on create_page and update_page that sets which space a page lives in. Forwarded to the POST /api/pages or PATCH /api/pages/:id request body.
  • spaceSlug (slug string) — a resolution context on tools that accept a slug-path page reference. Passed to the resolveWikiPageId helper so it builds the correct GET /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 on search_pages and list_child_pages to 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:

EventWhen emittedKey payload fields
wiki.space.createdSpace is createdspaceId, tenantId, name, slug
wiki.space.deletedSpace 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.

See also