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

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, under context.guardSite: "space.kb_write_state" — filed before the verdict is read 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.
  • 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.

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) shows every space's default in its own column, as the same classification chip the tree, the page header and search hits use — for every space, at every clearance, because the value is readable by anyone who can see the space (below).

A picker replaces the chip only where it could actually change something: the caller must clear the tier already stored, and some other tier must be on offer. An administrator whose clearance leaves them nothing to pick sees the chip alone, with the reason stated above the table — a control that opens, offers the value already selected and closes is not an affordance. Tiers you cannot assign are listed there too, by cause: above your clearance, not assignable in this release, or held down because the space is a knowledge base.

Changing it is confirmed in a dialog naming the space and both tiers before anything is written, since it decides what every page created there afterwards inherits. The create dialog carries the same control, 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.

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

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 can create up to the spoke cap, 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.

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). /wiki/admin/spaces carries the marker in two places: each row's Space cell shows a Knowledge base badge — its own independently labelled badge, never merged with Default — and the row's action menu offers Mark as knowledge base / Remove knowledge-base marker to a wiki:spaces:admin holder. A caller without that permission is not offered the item 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.

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.

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. 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 is not on this surface — it is a tenant-wide, one-way operation and still needs the API or an agent tool.

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.

    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 ways out are marking the space or deleting/decommissioning it.

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.

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 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, filter with ?search=, or resolve one with ?slug=
POST/api/spaceswiki:spaces:adminCreate a space (optionally at a defaultClassification the caller clears)
GET/api/spaces/:spaceIdtenant memberFetch one space (includes pageCount)
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)
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.

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.

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). 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); a refusal rolls the delete back, so the buffered entry is written 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.

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.

Every decommission that reaches the cascade writes an audited read, whatever it finds (PLT-1308). 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; a refusal rolls the decommission back, so the buffered entry is written 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
get_spaceFetch one space by UUID (includes 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​