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 and an explicit parentId (use null to 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 with 409 Conflict (to avoid silent subtree moves that lose per-page audit context).

Trashing a page that has children​

DELETE /api/pages/:id refuses a page that still has live child pages you can see, with 409 Conflict and details.reason: "page_has_child_pages". It does not cascade. Move the children elsewhere or trash them first, then trash the parent. A child that is already in the trash does not block the delete.

Restoring keeps the tree consistent. A page whose parent is still in the trash is restored to the root of its space, not under the trashed parent. Restore the parent first if you want the child to go back beneath it.

Only children you can see are counted. A child classified above your clearance does not block the delete, so the 409 never tells you that a hidden page exists. Such a child stays live under the trashed parent. So do the children of pages trashed before this rule existed, of pages removed by an ingest-batch revert, and of a retracted source page. Any code that reads parent_id must therefore still expect a parent that is in the trash.

Asking whether a page can be moved​

GET /api/pages/:id/space-move answers { canChangeSpace, reason } for the page it names, so a client can disable its space control up front instead of offering a move the save will refuse. reason is null when the move is allowed, and otherwise one of:

reasonMeaning
has_childrenThe page has child pages, so the 409 above applies.
no_alternate_spaceThe tenant has no live space other than this page's own, so there is nowhere to move it.
not_permittedThe caller does not hold wiki:page:write.

Two properties are deliberate. The route is gated on the write grant, because the child check reads through a SECURITY DEFINER predicate that sees children regardless of classification: a caller who may write can already elicit the same answer from the 409, and a caller who may not is told not_permitted on a 200 without that predicate ever being consulted. And no_alternate_space is resolved before the child check, mirroring the server's own order — with nowhere to move to, no PATCH can reach the child rule, so reporting has_children there would disclose clearance-hidden children through a path the write side could not.

The answer is a snapshot, not a subscription: a child created or removed after the read is not reflected until the client asks again. The PATCH remains the authority either way.

In the wiki editor, this is what disables the Space field with an explanation instead of letting the user select a destination, lose the parent that a space change clears, and only then meet the conflict.

Asking whether a write into a space would be refused​

GET /api/spaces/:spaceId/kb-write-state answers { data: { state } }, where state is allowed or refused: would an ordinary write into this space be refused right now by knowledge-base write enforcement? A page uses it to present a knowledge-base page as read-only only where the server would actually refuse the save, rather than on the knowledge-base marker alone — which is stricter than the endpoint on a tenant that has not been reconciled, on a space whose provenance can no longer be proved, and while the operator kill-switch is set.

Caller and spaceAnswer
a space the caller cannot see404
a space without the knowledge-base markerallowed, for every caller
a marked space, caller without wiki:page:writerefused, derived from the marker alone
a marked space, caller holding wiki:page:writethe write guard's own decision

Two properties are deliberate. The real decision reaches only a caller who may write pages. It depends on the tenant's reconciliation verdict and on a provenance check that runs through a SECURITY DEFINER function over rows the caller's clearance may hide; a caller who may write can already elicit the same answer from the save itself, and a caller who may not is answered from the public marker without either being read. The privileged branch is audited (constitution §5) with the same wiki.space.kb_provenance_check_applied_outside_clearance_scope entry the write guard files — written by the provenance check itself before the verdict is read, under context.check: "wiki.kb_space_enforceable_inventory", and carrying no outcome, so the entry says neither what was found nor whether the tenant is reconciled.

The answer is a snapshot, not a promise: the write guard re-decides inside the writing transaction, so a client must not word it as a server guarantee. The route is not cached for the same reason.

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 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.
  • At most 120 characters, whether supplied or auto-derived. A name whose derived slug would be longer is rejected with 400 Bad Request on the slug field rather than truncated; supply an explicit slug instead. The derived slug can be longer than the name: İ (U+0130) lower-cases to two characters, so it becomes two slug characters. Spaces created before this rule may carry a longer slug. They stay addressable and are not truncated, and the ?slug= list filter accepts up to 512 characters to keep them resolvable.
  • 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.

The served-scope receipt​

Both page reads — GET /api/pages/:id and GET /api/pages/by-path/... — return, beside the page, the scope the server served it under (PLT-905):

{
"data": { "id": "…", "title": "…" },
"meta": {
"servedScope": {
"userId": "…",
"activeOrgIds": ["<org id>", "<tenant id>"],
"clearance": "SECRET"
}
}
}

userId is the session subject. activeOrgIds holds the organisation the tenant wrapper resolved for the request, spelled both as its organisation id and as its paired tenant id, since an x-active-org cookie may hold either. It also includes "" when the request named no organisation. clearance is the tier the RLS session ran at. The field is additive: a caller that reads only data is unaffected. The wiki UI records a Recent-menu visit only when this receipt names the principal it is rendering for, so a read served to someone else during an organisation, clearance or sign-in switch is never filed under the incoming principal.

The space reads return the same receipt (PLT-1630): GET /api/spaces beside its other meta fields, and GET /api/spaces/:spaceId as meta.servedScope. The space switcher reads the list, and any space the active-space cookie names past its first page, before /api/auth/me has answered. When the receipt names someone other than the principal the session then resolves to — another tab signed in as a different account in between — the switcher discards that answer and reads the list again for the principal now in force, instead of showing the previous account's spaces. An ordinary cold load, whose receipt names the arriving principal, still reads the list once.

Default classification​

A space carries a default classification (default_classification on wiki.spaces, PLT-595) — the tier a page created in that space will inherit when its author names none. It exists so a space that groups sensitive material protects it by default, instead of relying on every author to remember a tier.

During a wiki deploy the value can read back as UNCLASSIFIED briefly. The field is published by every instance that has this release, but an outgoing instance's response omits it, and a client tolerates that by reading UNCLASSIFIED rather than failing the whole space list. The stored value is unaffected, and every write is gated on the value the server has stored — so a stale read cannot be used to lower a tier.

The inheritance is live (PLT-596). A page created with no explicit classification is born at the tier its space stores. The tier is resolved and authorised inside the transaction that creates the page, before the row is written, so an author whose clearance is below the space default gets a 403 that names the space and the tier — and no page, no revision, no audit entry and no event. Row-Level Security still refuses such a write underneath, but it is the backstop, not what you meet.

It is never retroactive. Changing a space's default will decide what the next page inherits and re-tiers nothing already stored. No page's own classification is read or written when the default changes, and no page is re-evaluated afterwards. A space whose default moves from UNCLASSIFIED to SECRET still holds exactly the pages it held, at exactly the tiers they had.

A space is UNCLASSIFIED unless someone says otherwise: every space that existed before the column did, and every space created without naming the field, holds UNCLASSIFIED. A space can be created at a higher tier — the field is accepted on create precisely so that a space is never created at the wrong tier and patched afterwards — provided the creator clears that tier.

Where you see it and change it​

The spaces admin screen (/wiki/admin/spaces) lists every space as one row (PLT-2045). Each row shows the space's default access level, by its plain name, beside its page count — in columns when the list is wide enough, on a facts line under the name when it is not. The value is shown for every space, at every clearance, because it is readable by anyone who can see the space (below).

Selecting a row opens the space's settings. The default access level is a choice there only where it could actually change something: the caller must clear the tier already stored, and some other tier must be on offer. Otherwise the settings show the level and the reason instead of a control. Tiers you cannot assign are not offered; one sentence says which and why: above your clearance, not assignable in this release, or held down because the space is a knowledge base.

Changing it takes one Change button, under a sentence naming the space and the new level and saying that pages already in the space keep theirs. The New space dialog carries the same control under More options, so a space is created at its intended tier rather than created UNCLASSIFIED and patched — see the note on create above.

Readable by everyone, changeable by few​

The stored value is returned to every caller who can already see the space — GET /api/spaces has no admin gate and space RLS checks tenant identity only, so listing spaces already tells every tenant member that the space exists. The tier is a policy attribute of that container, not page content: it discloses no page, and spaces have no membership model of their own.

Hiding it from under-cleared readers was considered and rejected. A projection that dropped the field only when it is raised would make absence itself the signal — a worse oracle than the value — and it would leave an under-cleared admin looking at a blank editor with nothing to explain it.

Only changing it is gated, and the gate has two halves:

  • The caller must clear the tier they are setting — the same rule page authoring has always applied to a page's own classification.
  • The caller must also clear the tier already stored. Without this the open read becomes a write-down hole: an UNCLASSIFIED admin can see a SECRET default perfectly well, and could otherwise lower it to UNCLASSIFIED and silently re-tier every page created there afterwards.

Both are 403 Forbidden. The check runs against the row under its lock, so two admins changing the same space cannot each validate against a value the other has already replaced.

Every 403 the space policy answers carries a machine-readable error.details.reason, so a client can tell the causes apart without reading the message (PLT-1115):

reasonCause
missing_permissionThe caller does not hold the spaces-admin permission.
desired_above_clearanceThe tier being set, on a create or a change, is above the caller.
stored_above_clearanceThe tier already stored is above the caller, so it cannot move.

The stored check runs first, so a caller below both tiers reads stored_above_clearance. To a client that offered the change because the tier it showed was one the caller clears, that reason means the client is out of date — another admin raised the tier, a rolling deploy read it back as UNCLASSIFIED, or the caller's clearance dropped on the server before the client's copy of it did — and the remedy is to reload. The spaces admin says exactly that; it names both possibilities only for a 403 that carries no reason it recognises. The message beside the reason is prose for people, and clients must not switch on it.

The 403s that protect the tenant's Default space — refusing to mark it as a knowledge base, delete it or decommission it — carry a reason of their own, default_space (PLT-1849). It is about the space rather than the caller, so it is not one of the policy reasons above. The spaces admin answers each typed 403 with its one cause: on the marker and delete paths default_space and missing_permission, in the create dialog missing_permission and desired_above_clearance, and on rename missing_permission. A 403 with no reason the page recognises, or one the request could not have produced, keeps the earlier wording, which on the marker and delete paths names both causes.

Supplying the field is what counts as trying to write it — a PATCH carrying defaultClassification is gated even when the value equals the one stored. A client editing a name or a description omits the field rather than echoing it back.

Knowledge-base spaces stay UNCLASSIFIED​

A knowledge-base space must hold UNCLASSIFIED, because the KB operates at UNCLASSIFIED and refuses to ingest anything above it. The invariant is enforced in both directions, and both answer 409 Conflict:

  • raising the default on a space that is already marked as a knowledge base is refused;
  • marking a space whose default is above UNCLASSIFIED is refused.

Neither operation silently fixes the other — lowering a default is a classification change and gets its own audited action, rather than happening as a side effect of marking a space. Declassify first, then mark.

Auditing​

Creating a space emits a critical audit entry (action: CREATE) at every tier, including the UNCLASSIFIED default: creating a space now sets a security control, and an audit rule that skipped "the default" would stop being an invariant the moment the default became configurable — which is exactly what this field makes it.

A PATCH that moves the tier emits a critical entry under its own action, wiki.space.default_reclassified, carrying the before and after tiers, so a re-tiering can be selected on the action alone. Every other PATCH — a rename, a description, an ingest-policy change, or one that merely repeats the stored tier — stays a routine UPDATE entry. Either way exactly one entry is written.

Both entries name the fields the PATCH changed in changed_fields — name, slug, description, ingestPolicy, defaultClassification — and nothing more: no values are copied, so a description or an ingest policy never reaches the audit table, and the re-tiering entry's changes still holds only the tier pair. The list is diffed from the space row read under its lock against the row the write stored, so a field that repeats its stored value is absent, and a PATCH that changed nothing records an empty list rather than NULL. Entries written before PLT-1653 have changed_fields NULL on the routine UPDATE, and ['defaultClassification'] alone on a re-tiering.

Both critical entries carry a classification of their own, inherited from the tier they describe and never below the SIEM floor: a creation is filed at the tier the space was created at, and a re-tiering at the higher end of the transition. Filing a downgrade under its destination would classify the record of lowering a SECRET default as UNCLASSIFIED — the one direction that must not become less visible than what it describes.

What still sets a page's own classification​

This field is the default, not an override. A page created with an explicit classification uses that value, and page-level classification rules are unchanged.

An author refused by an inherited tier has a remedy, and the error names it. Because the field is a default rather than a ceiling, an author below it may still create a page at or below their own clearance by naming the tier explicitly. The refusal says so, and it names the space and the stored tier — both of which that author can already read, since the default is container policy every space reader sees.

Two refusals, two messages. Naming a tier above your clearance is refused as a page classification; naming none and inheriting one above your clearance is refused as a space default. They are deliberately not the same sentence: telling an author who named no tier that "your classification is too high" describes a request they did not make.

The tenant Default space carries no exemption. A page created with no spaceId lands there and inherits that space's own stored default like any other.

A concurrent change to the default cannot slip in between. The create reads the space row under a share lock, which the clearance-gated update takes exclusively — so either the page commits at the tier it read and the change waits, or the change lands first and the page inherits the new tier. A page can never commit at a default that had already been superseded.

The generated index, log and spoke pages are pinned​

The machine-generated navigation pages a space carries — the index hub, the log, and the per-topic index_spoke pages — are created at UNCLASSIFIED explicitly, and go on being created there in a space whose default is raised. They do not inherit a raised default, and this is not a convenience: index maintenance and ingest run pinned to UNCLASSIFIED clearance so they cannot render a classified page's title into a page that lower-cleared readers see, which means a page-INSERT at a higher tier would be refused by RLS and the rebuild would fail outright rather than merely produce a differently-tiered page.

The same holds for the pages an ingest writes — the source, its summary, and any fan-out pages.

When a page they quote is raised​

Being pinned means these pages only ever quote UNCLASSIFIED pages when they are written. When a quoted page is later raised above UNCLASSIFIED, the same PATCH withdraws it:

  • The pages that an ingest of the raised page wrote are raised to its new tier: the source and source_summary pages, any fan-out page the ingest created, and any existing page a fan-out update rewrote. Every earlier revision of theirs is hidden with them, because a revision is readable only while its page is. For a rewritten page that includes its history from before the ingest. Lowering such a page again makes the ingest's revision readable again, because a revision cannot be edited, so a declassification of it also discloses that revision. A rewritten page is raised even if a later edit or a revert has since replaced its body, because the ingest's revision still holds the text. If one of those pages was itself ingested again, its own copies are raised too, and so on.
  • The index of every affected space that has one is rebuilt at the UNCLASSIFIED pin, so the hub and spokes stop naming the page. A space with no index gains none.
  • Each affected space's log has its ingest headings rewritten to ingest | a source. New ingest lines are written that way from the start. The log is readable by everyone, so a heading that named the source would outlive the source's tier.

Only the pages the wiki maintains, which are agent-owned and UNCLASSIFIED, are rewritten. A human-authored index or log page is left as it is, and it never causes the raise to fail. A space whose hub is human-authored or classified cannot be rebuilt, so the spokes the wiki still maintains there are emptied in place instead, exactly as a rebuild empties the spoke of a topic that vanished.

A batch revert re-renders the index and log it restored in the same way, so it cannot bring back a withdrawn title.

When a page leaves a space​

A topic's label is the title of its source page only while that page is in the space being indexed. So moving a page to another space, or putting it in the trash, rebuilds the index of the space it left in the same request (PLT-2129), together with the index of every space holding a page whose derived_from chain reaches it, trashed links included. Spaces with a page classified above you are found only as far as you can see, as with every read. The rebuild touches only the hub and the spokes: the log keeps its headings. The space the page moves into is not rebuilt, and nor is anything by a restore, a batch revert, a retraction or a space decommission; those rebuild their own navigation or wait for the next rebuild.

The raise writes only live pages, so a page in the trash at the time keeps its tier. It does walk through such a page (PLT-1588): a live page that a later ingest wrote from a trashed copy is still re-tiered, and the space each copy is in, trashed or live, gets the same navigation repair as above. So does every space holding a page whose derived_from chain reaches a page the walk passed through, through trashed pages and links too (PLT-1715): a topic label names its source page while that page is in the space, and before PLT-2129 a move or a trash did not rebuild the space it left. The walk stops at a page classified above you, as every read does. Restoring the trashed page repairs its own tier: a page an ingest wrote, whether the ingest created it or a fan-out update rewrote it, is raised on restore to the highest tier among every source page it was written from, and every source page those were written from in turn, and files its own wiki.page.reclassified entry. If the restoring user cannot read any one of those source pages, because it is in the trash or classified above them, the restore answers 403 and the page stays in the trash; restore the source first, or restore as a user cleared for it. A page several ingests rewrote can therefore need a user cleared for all of their sources. A trashed index, index_spoke or log page cannot be restored at all. A raise is also never refused because a source_summary page's derived_from link points at a trashed page: an update that only raises a page's classification skips that check.

The revision history of the hub, spokes and log (PLT-1569). Each revision the wiki writes to those pages records which pages it quotes, written by the renderer that produced it rather than found by matching text. The history you are shown hides a revision while any page it quotes is one you cannot see, re-checked on every read, so a revision that named a page reappears if the page is lowered again. A revision whose quotes are unknown is withheld from every reader: those written before this was recorded, those written without it, and any body derived from one of them. A hub or spoke becomes readable again at its next rebuild; a log that existed before keeps withholding its later revisions too, since its body keeps those older entries. The same holds for any log once a page one of its entries names is raised or trashed: every later revision still carries that entry, so it stays hidden from the readers who cannot see that page. The revisions themselves are not changed, a batch revert still reaches all of them, and the current body of every one of these pages is unaffected.

Each page re-tiered this way files its own wiki.page.reclassified critical entry. The raise's own entry lists them under changes.withdrawnCopies, and lists the rebuilt spaces under changes.rebuiltSpaceIds.

Auditing the generated pages​

The rule above (a creation is critical at every tier) reaches these pages too:

WriteEntry
The index hub or the log page is createdone critical entry, action: CREATE, against that page, recording classification: { before: undefined, after: 'UNCLASSIFIED' }
The hub's body is rewritten, the hub is re-rooted, or a log entry is appendedone routine UPDATE entry against that page
A rebuild creates index spokesone critical entry, action: space_index.spokes_created, against the space, naming every created spoke id
A rebuild moves a pre-existing spoke under the hubone routine UPDATE entry per moved spoke

Two consequences worth knowing before you query the audit log.

A created spoke is not addressable by resourceId. A rebuild of a large space creates one spoke per topic, thousands of them, and it runs inside one transaction with a bounded budget; a critical entry per spoke would put that many outbox writes inside it. So the spokes are recorded as one entry naming their ids, and a reader looks a spoke up in that entry's id list rather than by filtering on the page.

The event contract is unchanged. These pages continue to publish wiki.page.updated on both the create and the overwrite path, never wiki.page.created, so a consumer goes on treating the index and the log as a single mutable page. The create/update distinction is in the audit row only. Spokes publish no page event at all — that silence is what keeps them out of knowledge-base candidacy — though consumers of audit.entry.created do see the created spoke ids, which is what carries the record.

Knowledge-base spaces​

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.

Every new organisation gets one (PLT-2107). Creating an organisation — from the organisation switcher, through partner provisioning, or with Directory's tenant create — gives its tenant a Knowledge base space (slug knowledge-base, UNCLASSIFIED), marked, with a reconciliation verdict of clean. The wiki owns the whole act in one idempotent operation, POST /api/spaces/kb-provisioning (wiki:spaces:admin; MCP provision_knowledge_base), which Project Tracker calls for the organisations it creates and which the wiki runs itself on directory.tenant.created. Run on a tenant that already has its knowledge base it writes nothing (already_provisioned); it never adopts an existing unmarked knowledge-base space (slug_conflict) and never touches a tenant whose reconciliation is blocked — both need a spaces admin. Creating another organisation inside an existing tenant does not run it.

What the marker changes in the sidebar (PLT-1252). The wiki sidebar's Knowledge base group — Curated pages (/knowledge), Wiki Review (/review, with its open-findings badge) and Usage insights (/insights) — renders only while the active space carries isKnowledgeBase: true. In any other space, and while the active space is still resolving, the sidebar shows Home and Pages plus the Archive and Trash footer. The desktop rail and the mobile drawer follow the same rule. Only the navigation is gated: all three routes still load by URL, and Curated pages stays on the Home page in every space. A tenant with no marked space therefore shows the group nowhere. This amends ADR-038 Decision 7.

Setting it from the UI (PLT-1063, PLT-2045). /wiki/admin/spaces carries the marker in two places: each row shows a Knowledge base badge beside the space's name — its own independently labelled badge, never merged with Default — and the space's settings offer a Knowledge base switch to a wiki:spaces:admin holder. A caller without that permission is not offered the switch at all; the collection read reports the answer as meta.canAdminSpaces on GET /api/spaces, computed by the same permission check the write path enforces with. That bit governs display only — the server gate is unchanged, and a caller who reaches the endpoint anyway still gets a 403.

The same bit withholds every other administrative control on the page (PLT-1107): without it there is no New space button and the rows do not open the settings — Rename, Delete and the switch go with them — and the list itself stays readable. Delete is offered only where it can succeed; otherwise the settings say why (the space still holds pages, it is the Default space, or it is a knowledge base). The controls are omitted rather than disabled, and the button appears only once a list read has reported the permission.

Both directions are confirmed, and the confirmations differ because the operations do. The mark names the freeze below and says ordinary writes may be restricted — accurate rather than cautious, since enforcement additionally depends on the tenant's reconciliation verdict and on the space's provenance. The unmark is the destructive one and names both of its tenant-wide effects: the reconciliation verdict resets to pending, and the slug fallback closes permanently.

The request carries the slug the administrator's row was rendered from, as the CAS token the endpoint requires — so a rename underneath the surface is refused with a stated error rather than landing the marker on a renamed row. After a successful change the acting tab refreshes on its own: both the ingest affordances and the space list re-read, so the switcher's knowledge-base indicator and /wiki/knowledge stop showing the old answer without a page reload. Your other open wiki tabs follow: each one's ingest-capability check notices the change and re-reads that tab's space list in the background, so the indicator and /wiki/knowledge update there too. A mark made over the API or MCP reaches your open tabs the same way, at the next capability check — about every 30 seconds while a tab is in view, and less often after failed checks. A tab you are not looking at catches up when you return to it, and a tab acting as a different organisation is not affected.

The wiki reads the space list once per page load and shares it between the sidebar and every view that lists spaces (/wiki/knowledge, /wiki/pages, search, review, insights and the editor's space field). Creating, renaming or deleting a space refreshes it in the acting tab the same way. Apart from knowledge-base marks (above), a change made in another tab, or by another user or agent, appears when a view that lists spaces next opens — which re-reads the list in the background — or on a page reload; moving between pages that list no spaces reads nothing.

Running the reconciliation scan has its own panel below the table — see Running the scan from the admin screen.

Setting the marker is 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.

The rule governs transitions. A migration, a test fixture or a privileged operator seed may create a new space already marked, provenance included — there is no prior state to protect, and the integration fixtures and migration 035 already do exactly that. What none of them may do is convert an existing unmarked space: that is a transition, and transitions belong to the audited admin action alone.

So an ingest into an unmarked space is refused rather than marking it (PLT-515). POST /api/spaces/:spaceId/ingest answers 409 Conflict with a message naming the admin action — set the marker with PUT /api/spaces/:spaceId/kb-marker, or run POST /api/spaces/kb-reconciliation. It is a 409 and not a 403 on purpose: the caller's authorization passed, and what is wrong is the destination's state.

The destination test is the same one the coverage resolver applies: the marker, or the default hinted slug knowledge-base while that tenant's one-way fallback latch is still open. So during a tenant's migration window an unmarked knowledge-base space is still accepted, until its first reconciliation closes the latch. Admitting it does not mark it.

The hint is the default slug, not the deployment's configured one. COORDINATOR_KB_SPACE_SLUG reaches wiki only on a coordinator event, as described above — the ingest path receives no such value and has nothing to pass, so it resolves against the default hint. A deployment whose knowledge base carries a different slug therefore gets no transitional fallback and must have that space marked explicitly, which is what the admin input is for. The tenant's Default space is never a valid target, because it can never be marked.

Two paths deliberately keep working in an unmarked space: reverting an ingest batch and retracting a source. Both act on history that predates the marker — a batch may legitimately target Default or any other mixed-content space — and gating them would strand that content instead of cleaning it up.

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; clear the marker with PUT /api/spaces/:spaceId/kb-marker first.

A tenant has at most one marked knowledge-base space (ADR-038). Both writers refuse a second one, in the two different ways their callers need. The audited marker input answers 409 Conflict, naming every space already marked and telling the administrator to clear all of them first. The reconciliation scan does not raise: it skips the auto-mark and finishes, because an exception there would roll back the verdict and the conflict inventory the scan exists to write, leaving the administrator a refusal and no report. A tenant that already carries several is reported as blocked, with every marked space listed under the multiple_marked_spaces conflict reason. Resolution is always an explicit wiki:spaces:admin unmark: the system never picks which marker to keep. Unmarking moves, archives and deletes no page, but the demoted space leaves the knowledge-base coverage set, so its pages become re-nominatable.

The database enforces it too. A unique partial index on wiki.spaces (tenant_id), over live marked spaces only, refuses a second marker whoever writes it — including direct SQL, migrations and operator sessions that the writers' guards do not reach. The guards stay: they are what answers 409 Conflict with the incumbents and the remedy, where the index alone would answer a raw constraint violation. The index shipped one release after the guards, because migrations apply before code deploys. Its migration fails, changing nothing, in any environment where a tenant still carries two live markers; the remedy is the same audited unmark, then re-running it.

Coverage resolution is still set-shaped, and still declines to guess a destination when several spaces are marked. That branch is unreachable wherever the index is present, and is kept as a safeguard until the index is proven present in every environment.

Project Tracker's knowledgeBaseSpaceIds[] is a different thing that shares a name, and is unaffected: that array holds references to any space visible to the caller, validated without ever inspecting is_knowledge_base, per initiative and per project rather than per tenant. It is a read scope, not the tenant's ingest destination.

Coverage checks span every marked space, so a page already covered by one knowledge base is not offered for ingestion into another.

The resolver names a single add-to-KB target, 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 a target at all, since the wiki never learns that slug. With several marked spaces and no slug match there is no target, because picking one the caller never named would be a guess.

The two affordances consume that target differently, and both are gated on a destination the ingest route will accept. The per-page "add this page to the KB" control targets the resolver's target exactly, and renders nothing when there is none. The global "Add to knowledge base" action in the navbar prefers the active space when the active space is itself a valid destination — every space in the coverage set is one, including a marked space that is not the hinted one — and otherwise retargets to the resolver's target, naming it in the dialog so a global action never writes somewhere the reader was not told about. When neither exists it does not render. Before this, that action ingested into the active space unconditionally and was gated only on wiki:page:write, so in an ordinary space it offered a dialog whose submit was certain to fail the destination gate above.

GET /api/ingest/capability is what carries this to the client: { canIngest, kbDestinations: [{ id, name }], kbTargetSpaceId, kbGeneration }. kbDestinations is the coverage set, which is why it can name a space whose is_knowledge_base marker is false — a space still on the transitional slug fallback below is a valid destination, so the client cannot derive this from the marker alone.

kbGeneration is an opaque digest of the tenant's KB configuration as that answer was resolved. It exists because marking happens out-of-band — over the REST marker endpoint, over MCP, or through the reconciliation scan — so nothing in the wiki UI knows a knowledge base has appeared or moved. The client polls this endpoint while its tab is visible and treats a changed digest as "re-resolve everything that depended on this answer"; without it a probe taken on mount served the whole session, and a knowledge base created afterwards left the affordance hidden with no failure to explain its absence.

Every change that could move the resolver's verdict moves it: a space entering or leaving the live candidate set, a marker set or cleared, a rename that moves a space onto or off the configured hint slug, or the slug_fallback_allowed latch closing. The converse does not hold, deliberately — the latch is always digested, so closing it refreshes every client even for a tenant whose only knowledge base is a marked, non-hinted space and whose resolution is therefore unchanged. That is one conservative refresh, never a missed one. It deliberately does not change on a space's name, description, ingest policy or classification — those cannot move the verdict, and moving the digest for them would make every cosmetic edit cost every open tab a re-probe. Treat it as opaque: compare it for equality, never parse it, so its inputs can widen without breaking a client.

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

Running the reconciliation scan​

POST /api/spaces/kb-reconciliation (wiki:spaces:admin) reconciles the calling tenant in one audited transaction. It does three things:

  1. Backfills the space carrying the default knowledge-base slug — but only if it can prove ownership. The proof is immutable creator provenance: every page in the space, live or still restorable from the trash, must have a creation revision (revision_num = 1) stamped with an ingest batch. If a single page fails that test the space is recorded as a conflict instead, because marking it would make human-authored content read-only once KB write enforcement activates.

    The check deliberately does not use isAgentOwned — that field is set by the caller and a human-authored page can carry it — and it reads through a privileged database function, because trashed and above-clearance pages are invisible to an ordinary read and those are exactly the pages that must not be missed.

    A space with no pages at all is not marked. "Every page was ingest-created" is vacuously true of an empty space, which is not evidence of anything; mark it explicitly instead.

  2. Records every other ambiguous space as a conflict, without marking it. Any live space that a historical ingest batch targeted is inventoried. Ownership is never inferred from a batch: ingesting needs only page-write permission plus an approver, so a batch proves that someone wrote there, not that the space is a knowledge base.

    Only a batch every reader of the inventory may see counts: one whose classification ceiling is UNCLASSIFIED. The stored inventory is read by any spaces admin whatever their clearance, so listing a space because of a batch above that would tell an under-cleared admin the batch exists. A space that only higher-ceiling batches targeted is therefore not listed for its ingest history, whoever runs the scan. An inventory stored before this rule (PLT-1595) can still carry such an entry until the scan is re-run.

    The Default space is excluded from this. Every pre-spaces page was migrated into it and most tenants have ingested into it, so treating it as ambiguous would block nearly everyone while naming nothing anyone could act on — and its structural identity already answers the question.

  3. Stores the verdict and closes the latch. clean when nothing was inventoried, blocked otherwise.

The scan is idempotent: re-running it over an unchanged tenant produces the same verdict, so it is also how you confirm a remediation worked.

Clearing a conflict​

Reconciliation never resolves a conflict on its own. A conflict says "the role of this space is unresolved", and only a human can resolve it.

If the space really is a knowledge base, register it with PUT /api/spaces/:spaceId/kb-marker and re-run the scan. That works for every conflict.

If it is not, what to do depends on why it was listed, and the two are not interchangeable:

  • The space carrying the knowledge-base slug was listed because its content could not be proven ingest-created. Move the hand-written pages out — including anything sitting in the trash, which still counts — and re-run the scan; it re-reads the pages, so the conflict clears.
  • A space a historical ingest targeted was listed because ingesting there says nothing about the space's role. Moving its content out does not clear it: the listing comes from the ingest history, which is a permanent record and does not change when pages do. The only remedies are marking the space or deleting/decommissioning it. (The listing also stops if every UNCLASSIFIED batch that targeted the space has its ceiling raised, which happens when a page it covers is reclassified upward — a side effect, not a remedy.)

GET /api/spaces/kb-reconciliation returns the stored verdict and the conflict inventory. It is admin-gated and audited, because the inventory is derived from a privileged scan.

When a stored verdict is stale (PLT-816)​

The response carries status beside the stored verdict: the same three values, or stale when a write the scan reads has committed since it ran. The wiki keeps two monotonic provenance generations, advanced at commit by database triggers in the writing transaction itself, so a write that rolls back advances nothing:

  • per tenant, for the structure the scan reads — a space created, deleted or renamed, its Default flag or knowledge-base marker changed, an ingest batch recorded (or its target or ceiling changed) in a space that is neither the Default space nor a knowledge base;
  • per space, for the pages in it — a page created, deleted, moved, trashed, restored or retracted, or its first revision written.

The scan records the tenant generation and, when it inventoried the unmarked knowledge-base space, that space's. A verdict is stale when either has moved on. A page written in any other space does not make it stale, because the scan never reads that space's pages; nor does editing a page's title or body, or ingesting into a marked knowledge base.

Two things follow. Every verdict scanned before this release reads Stale until the scan runs again: nothing compared it, so nothing may call it current. And the stored verdict is what write enforcement reads, unchanged: a stale clean still enforces, because enforcement re-reads each marked space's provenance on every write rather than trusting the verdict.

Staleness is a single bit with no page, space, count or time attached, and it is computed over every page whatever its classification — coarser than what the stored conflict list already tells the same wiki:spaces:admin reader.

Running the scan from the admin screen​

/wiki/admin/spaces shows a Knowledge base card above the spaces list to a wiki:spaces:admin holder (PLT-1108, PLT-2045); other members do not see it, and it issues no read for them. It shows:

  • one health line for the stored verdict — Not set up when no space is a knowledge base, Not checked yet (also after a knowledge base was turned off, which resets the verdict), Ready, N spaces need attention, or Changed since the last check when something the scan reads has changed since it ran (PLT-816) — with when it was checked. Changed since the last check keeps the conflict list on screen as that scan found it; check again to bring it up to date. Ready is still a snapshot: it means no tracked write since the scan, not that a scan now would agree — a trashed page ageing out of the restore window changes the answer without any write;
  • the spaces that need attention, by name, each with its reason in plain words. Nothing page-derived is shown until you ask: Show evidence on a space issues the separate, audited read below (PLT-1628) and lists, in plain words, the finer reasons and the pages, added sources or other marked spaces you can see. It is read only on that click — never when the card loads or refreshes — and Hide evidence discards it, so showing it again is a new read. When you can see none of it the card says so, with no count, and every answer notes that anything above your clearance leaves no trace. A space that has left the list since the card was read is reported as such, and the card's status is read again.

Check now runs the scan. While the slug fallback may still be open — the first check — it asks for confirmation first, saying the check closes it permanently, for every knowledge-base hint in the tenant, and that a deployment whose COORDINATOR_KB_SPACE_SLUG is not knowledge-base must turn Knowledge base on for that space first — the wiki never learns the configured slug, so the scan cannot mark it. Once the fallback is closed, Check now runs straight away: a re-run closes nothing further, though it can still mark the knowledge-base space if the tenant has no knowledge base.

After a check, the card shows the verdict it stored, the list re-reads (a space the scan marked gains its badge), and the knowledge views and the space switcher refresh in the same tab. A failure never echoes the server's message. A refusal the wiki answered (400, 401, 403, 404, 409, 422) changed nothing; any other failure — a dropped connection, a timeout, a gateway or proxy error — may have come after the scan committed, or while it was still running, so the card says its status may predate that check and offers a refresh.

Knowledge-base sources and Add to knowledge base​

Below the list, Knowledge-base sources lists every source added to the knowledge base (PLT-2045), newest first, searchable by name, with Undo on each row. It offers every space, knowledge bases first: a space whose Knowledge base was turned off keeps its sources, and their Undo stays reachable, but only a knowledge base offers Add source.

Add to knowledge base — from this section, the shell's top bar, a page, or a review-inbox approval — takes two steps: the source (pasted text or a .md / .txt file, with an optional name), then a summary drafted by AI that you can edit, plus the page address and one-line summary under Advanced. A caller that already has a page or a draft starts at step two. The add ends with a toast offering Open and Undo; Undo is refused after a switch of organisation or user, so it never reverts another principal's add.

Every status read is the audited GET above, so each admin visit to the screen records one.

Seeing why a space is in conflict​

The stored inventory says only that a space is in conflict. To see why, GET /api/spaces/:spaceId/kb-conflict-evidence (wiki:spaces:admin) reads the evidence behind one stored conflict — computed now, under your own clearance, never from the privileged scan. It answers 404 for a space that is not in the stored inventory.

Stored conflictEvidence you get
unprovable_provenance on the knowledge-base slugThe pages whose ingest provenance you cannot see proved, live and in the trash (restorable only, as the trash listing shows them): no_ingest_batch when revision 1 is visible and carries no ingest batch, revision1_unavailable when you cannot see revision 1 — which may still carry a batch above your clearance, so such a page is not necessarily what blocks the space
unprovable_provenance on any other spaceThe ingest batches that targeted it, where you clear the batch's ceiling (id, createdAt, status, sourceRef)
multiple_marked_spacesThe other spaces currently carrying the marker

Three things to know when reading it:

  • evidence: null means you can see none of it — not that none exists. You get the stored reason and no count, because a count of pages you cannot see is itself a disclosure. An empty list beside a non-empty one means the same: nothing of that kind is visible to you.
  • derivedReasons refines the stored reason only from what you can see: pages_without_ingest_provenance, historical_ingest_target or multiple_marked_spaces, and otherwise the stored reason alone. Two admins at different clearances can get different answers for the same conflict.
  • A conflict on a space deleted since the scan has no evidence. Re-run the scan.

Each read is audited as wiki.kb.conflict_evidence_read, naming what was shown, at the highest classification it disclosed. The trash half of the read also files its own entry, wiki.kb.conflict_evidence_trash_read_outside_rls_scope, on every call.

Registering a knowledge-base space by hand​

PUT /api/spaces/:spaceId/kb-marker takes { "isKnowledgeBase": true|false, "expectedSlug": "<the space's current slug>" }.

expectedSlug is required and is checked against the stored row: the write is refused with 409 Conflict if the space was renamed or deleted in the meantime. Without it a stale admin screen could mark a space that has since become something else, and the configured KB slug would quietly stop resolving.

The Default space cannot be marked — 403 Forbidden. Marking it would make a tenant's whole legacy corpus read-only and break trash restore, which re-homes into it. Unmarking is always allowed.

Two ordering rules matter:

  • A deployment that sets COORDINATOR_KB_SPACE_SLUG must register its space here BEFORE running the first scan. The scan only ever backfills the default slug, but the latch it closes is per-tenant — so scanning first would retire the fallback for a space nothing had marked, and KB writes would stop with no error raised anywhere.
  • Unmarking resets the verdict to pending so a re-scan can run, but it does not re-open the fallback latch. A database trigger enforces that independently of the API.

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.

The permission is tenant-wide and knows nothing about clearance, which is why changing a space's default classification carries its own clearance check on top of it.

Roles that satisfy the admin check: any name in auth-core's ADMIN_EQUIVALENT_ROLES, wiki:spaces:*, or wiki:spaces:admin.

The admin screen follows the active organisation (PLT-1627). /wiki/admin/spaces is rebuilt whenever the signed-in person or the active organisation changes, including a switch made from the header without a reload. The previous organisation's rows and any open dialog are discarded, and the admin controls and the reconciliation panel are shown or withheld from the new organisation's own meta.canAdminSpaces. A write that finishes after the switch reports nothing into the new organisation's screen. If another tab moved the organisation and this one has not caught up, a write started here is not sent and the screen refreshes instead.

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 the tenant's non-deleted spaces, filter with ?search=, resolve one by ?slug= / ?activeSpaceId=
POST/api/spaceswiki:spaces:adminCreate a space (optionally at a defaultClassification the caller clears)
GET/api/spaces/:spaceIdtenant memberFetch one space (includes pageCount unless ?includePageCount=false)
PATCH/api/spaces/:spaceIdwiki:spaces:adminUpdate name, description, slug, ingest policy, or default classification
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
POST/api/spaces/kb-reconciliationwiki:spaces:adminRun the KB reconciliation scan for the tenant
GET/api/spaces/kb-reconciliationwiki:spaces:adminRead the verdict and the conflict inventory (audited)
GET/api/spaces/:spaceId/kb-conflict-evidencewiki:spaces:adminRead the evidence behind one stored conflict, under your own clearance (audited)
PUT/api/spaces/:spaceId/kb-markerwiki:spaces:adminSet or clear the knowledge-base marker

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.

A caller that only needs the space itself — its name, slug or flags — can skip the count with GET /api/spaces/:spaceId?includePageCount=false (PLT-786). The response is the same space without the pageCount field, and the server does not count the space's pages to produce it. includePageCount=true is the default spelled out; any other value is a 400. The wiki's own page views read the space this way, since they only display its name.

A caller that needs the count should ask for it with ?includePageCount=true rather than rely on the default (PLT-1672). The default is planned to become the response without pageCount, once every deployed consumer asks explicitly. The admin spaces table and the MCP get_space tool already do.

Sample-scenario spaces​

A sample scenario is the example project the "Get started with an example" prompt is designed to install across Project Tracker and the Wiki (PLT-1552; the Project Tracker orchestrator and prompt are PT-1167 and PT-1166). The Wiki's share of it is one space and four linked pages, managed through /api/sample-scenarios/:scenarioId. It is meant to be called by Project Tracker with the user's forwarded credentials, and every verb requires wiki:spaces:admin:

MethodBody / queryAnswer (data)
GET?version=<n>absent, installed, partial or conflict, plus the anchor day and the fence
POST{ scenarioId, version, anchorDate, expectedFence }installed or already_installed, with every resource's id and the new fence
DELETE{ scenarioId, version, expectedFence }removed or not_installed, with the new fence
  • Idempotent. Resources take deterministic ids, so a second install finds the first one's rows and writes nothing.
  • A removal is final for its ids. It goes through the decommission cascade, which soft-deletes rows. Soft-deleted rows keep their ids and cannot be restored under them, so a reinstall writes the next generation: the same content under ids derived from <key>.gen-<n>. The install answer names the ids actually used.
  • conflict means the space's slug (example-checkout-service) is taken by a space that is not the sample, or by a Default-space root page. The install answers 409 and writes nothing.
  • partial means a user trashed or moved a sample page. Installing again replaces the space with the next generation. If a user also added pages of their own, the state is conflict instead, and only an explicit removal takes them.
  • Fenced (PLT-1566). Every install and removal names, as expectedFence, the fence its caller last observed — from the GET, or from its own install's answer. The Wiki applies it only while that is still the current fence, and advances the fence in the same transaction, whatever the outcome — already_installed and not_installed included. A request that arrives after any other install or removal therefore writes nothing and answers 409 with details.reason: "stale_fence", however late it commits — even after Project Tracker gave up waiting for it. Read the state again and retry. The fence lives in wiki.sample_scenario_fences, which only these routes write. The application role cannot delete from it; only privileged test and fixture cleanup removes rows.
  • Audit. An install that writes files one wiki.sample_scenario.installed entry, and a removal that takes a space files one wiki.sample_scenario.removed entry. already_installed and not_installed write no entry; the fence advance alone is not audited. The events are the ordinary wiki.space.created and wiki.space.deleted.

Specs: .ai/specs/SPEC-plt-1554-wiki-sample-loader.md, .ai/specs/SPEC-plt-1566-sample-scenario-fence.md.

Resolving one space by slug​

GET /api/spaces?slug=<slug> resolves a single space in one request, so a ?space=<slug> deep link does not have to page the collection to find its target. The filter is an exact match — never a prefix, a suffix, or a case-insensitive one — and answers with the ordinary collection envelope holding either one space or none.

A slug you cannot see and a slug that does not exist are the same answer: 200 with an empty data array. Another tenant's space, a soft-deleted space and a slug that never existed are indistinguishable in both status and body, so the endpoint cannot be used to probe which spaces exist elsewhere. (Timing equivalence is not claimed — an RLS predicate may do more work for an existing hidden row than an index miss does for an absent slug.)

A value that is not a slug at all — containing a space, an underscore, or an upper-case letter — is a 400, not an empty result. Every stored slug is lowercase kebab-case, so such a value could never match, and answering 400 says the URL is malformed rather than presenting a corrupted deep link as a lookup that merely found nothing. A bare ?slug= is a cleared filter, not a malformed one: it returns the ordinary unfiltered list.

offset is ignored when slug is supplied — the result is one row or none — so a deep link that carries the offset of the listing page it was opened from still resolves.

Searching spaces by name​

GET /api/spaces?search=<term> narrows the listing to the spaces whose name or slug contains the term, case-insensitively (PLT-920). It is the browse mechanism the wiki's space pickers use to reach a space past the first page of the collection, where ?slug= resolves one exactly. Everything else about the listing is unchanged: the same tenant scope and soft-delete exclusion, the same ordering (default space first, then by name), and the same limit / offset window, so a client can ask for one row more than it shows to learn whether more spaces match.

  • The match is literal: %, _ and \ in a term are ordinary characters, not wildcards, so ?search=% finds a space whose name contains a percent sign rather than every space.
  • The term is trimmed, and a term that is empty after trimming — a bare ?search= — is a cleared filter, not a malformed one: it returns the ordinary unfiltered list. A term longer than 200 characters is a 400.
  • slug takes precedence: with both supplied, search is ignored, as offset is.
  • ?knowledgeBase=true narrows the listing — with or without a term — to the knowledge-base spaces (PLT-1210). Only true is accepted; any other value is a 400, and a bare ?knowledgeBase= is a cleared filter. slug takes precedence over it too.

In the app, the search field appears inside the space menus (sidebar switcher, the editor's space field, the Insights space control, and the Space filter on the pages list and on search results) only when the first page of spaces is incomplete; a tenant whose spaces all fit on one page sees the plain list. The /wiki/knowledge view's space filter searches knowledge-base spaces only, so typing never offers a space outside the knowledge base.

Resolving the active space beside the page​

GET /api/spaces?activeSpaceId=<uuid> answers one named space beside the listing, as a top-level activeSpace field next to data and meta (PLT-798). It lets a client name the space its reader is working in with the same request that loads the list, even when that space sits past the first page of the collection.

  • activeSpace is never injected into data, nor removed from it. data is exactly the requested window and meta keeps describing it, so a client that asks for one row more than it shows still learns honestly whether more spaces exist.
  • It is resolved under the caller's tenant, from the same database snapshot as the page. An id that does not exist, a soft-deleted space and another tenant's space all answer activeSpace: null, with identical bodies.
  • It is a lookup, not a filter: ?slug=, ?search= and ?knowledgeBase= narrow data only, and activeSpace is answered whatever the window holds — including when the same space also appears in data.
  • Without the parameter the response carries no activeSpace key at all, so null always means "asked, and not readable". A bare ?activeSpaceId= is the same as leaving it out; a value that is not UUID-shaped is a 400.
  • The server does not read the browser's active-space cookie: the client names the id it wants resolved.

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.

Every delete that reaches the emptiness check writes an audited read, whatever it finds (PLT-1307, PLT-1474). The check counts live pages of every classification — including pages above your clearance — so that a space holding only pages you cannot see is refused rather than deleted. Because that read goes past your clearance, every call that reaches it files one wiki.space.soft_delete_emptiness_check_applied_outside_clearance_scope entry, naming the space, at the space's default classification, never below RESTRICTED (SECRET when the space id no longer names a live space). It carries no count or status, and it is written identically whether the space was deleted (204) or refused (409, or 404 when a concurrent delete won). The database function writes the entry itself, before the check, and publishes it as an audit.entry.created event, so it is recorded however the function is called. Since PLT-1551 the function has one signature, which names the actor type and takes the entry id: the older 2-argument form, kept for one release for the build a deploy replaced, filed every entry as USER and is dropped (migration 20261020114740). A refusal rolls the delete back, so the same entry is written again in a fresh transaction afterwards. The 409 itself is unchanged and still says the space may contain pages outside your visibility: the space stays listed after it, so no other answer would hide that it survived. A request refused before the check — the Default space, a marked knowledge-base space, a missing permission, an unknown space — files none.

A delete resolves the space's own findings (PLT-1570). When the space is deleted (204), every open lint finding recorded against it — on the space itself, or on one of its pages already in the trash — is closed as resolved by wiki-space-delete, in the same transaction, so none is left open in a space nobody can reach. The review queue shows it as an automatic closure, not a reviewer decision, and a closed finding cannot be reopened. The delete's DELETE audit entry records how many were closed, resolvedFindingCount, and is classified at the highest tier among the space and those findings, hidden ones included. A refused delete (409, 404) closes nothing. If another operation holds one of those findings at that moment, the delete does not wait: it answers 409 with details.reason: "findings_busy", changes nothing, and can be retried as is. Since migration 20261103121230 the audited function is wiki.soft_delete_empty_space, which returns the count with its tier split. wiki.soft_delete_space, kept with its text answer as a delegate to it for one release so the build a deploy replaced could still delete spaces, is dropped since PLT-2098 (migration 20261106081237): wiki.soft_delete_empty_space is the only signature.

No open finding is written into a deleted space (PLT-1571). Both a delete and a decommission hold the space while they close its findings, and a finding being recorded into the same space at that moment waits for them. Since migration 20261104094510 the database then re-checks the space and refuses the finding once it is gone, so nothing is left open in a space that has just been closed: POST /api/spaces/:spaceId/lint/findings answers 404, as it does for a space that was already deleted. An operation that files findings into other spaces — a source retraction, or a decommission flagging external referrers — fails as a whole if one of those spaces is deleted at the same moment, and a retry no longer reaches the deleted space.

A finding recorded on a page no longer deadlocks against a decommission of that page's space (PLT-2103). A decommission takes the space and then its pages; recording a finding on one of those pages used to take the page first and the space second, so the two could each wait on the other and the database aborted one of them. Since migration 20261104131401 every finding write on a page takes the page's space first. A finding recorded while a decommission is running either lands first and is then closed by it, or waits and is refused with 404 once the space is gone. In the one remaining case — the page is moved to another space at the very moment the finding is written, and that space is being deleted or edited — the write is refused at once with 409 and details.reason: "space_busy" rather than waiting; nothing was written, and retrying succeeds once the other change has finished.

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. Resolves the space's own findings (PLT-700): every open lint finding recorded against the space — on one of its pages or on the space itself — is closed as resolved by wiki-space-decommission, so none is left open in a space nobody can reach. The review queue shows it as an automatic closure, not a reviewer decision. A closed finding cannot be reopened; a page later restored out of the trash gets fresh findings from the next lint pass in its new space. The space.decommissioned audit entry records how many were closed.
  5. 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.

The counts name only what you may read (PLT-1457). The cascade reaches pages, links and referrers above your clearance — a hidden referrer still gets its finding, a link to a hidden page is still swept — but trashedPageCount, deletedLinkCount and externalFindingCount count only the trashed pages, links and flagged referrers your clearance admits, with nothing in place of the rest. A link counts when your clearance admits both of its endpoints, whichever direction it runs. pageCount and humanPageCount need no scoping: the clearance guard has already refused any space with a live page above you. The space.decommissioned audit entry keeps the full counts and is classified at the highest tier among the space and everything it counts — the archived live pages included, which may sit above the space's own tier when your clearance does — so a reader cleared only for the space may not see it.

Every decommission that reaches the cascade writes an audited read, whatever it finds (PLT-1308, PLT-1456). Flagging external referrers and sweeping links reads pages and links past your clearance, so that a referrer you cannot see still gets its finding and no link is left orphaned. So every call that reaches the cascade files one wiki.space.decommission_check_applied_outside_clearance_scope entry, naming the space, at the space's default classification, never below RESTRICTED (SECRET when the space id names no live space). It carries no count, status or reason, and it is written identically whether the decommission succeeds or is refused with a 404, 403 or 409. The database function writes the entry itself and publishes it as an audit.entry.created event, so it is recorded however the function is called. Since PLT-1559 the function has one signature, which names the actor type and takes the entry id: the older 6-argument form, kept for one release for the build a deploy replaced, filed every entry as USER and is dropped (migration 20261105170450). A refusal rolls the decommission back, so the same entry is written again in a fresh transaction afterwards. A request refused before the cascade — a marked knowledge-base space, a missing approvedBy, a missing permission — files none.

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, marking knowledge-base spaces [KB]; knowledgeBaseOnly: true lists only those
get_spaceFetch one space by UUID (includes whether it is a knowledge base, the default classification and the visible page count)
create_spaceCreate a space, optionally at a defaultClassification (wiki:spaces:admin required)
update_spaceRename, re-describe, or re-tier a space's default classification (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​