Skip to main content

Wiki module

Self-hosted specifications and knowledge with an AI-traversable dependency graph. Lets defence and corporate tenants author, link, and search documentation entirely on tenant-owned infrastructure — no third-party SaaS.

  • Source: apps/wiki/
  • Schema: wiki
  • Project Tracker prefix: PLT-* (delivered under the Shared Platform project)
  • Hosting: sub-zone in the multi-zone topology — served at /wiki, rewritten by the Directory shell via WIKI_ZONE_URL. Preview/local deployments serve standalone at /.

Purpose

Wiki is where a tenant's knowledge lives: specifications, decision records, assessments, and runbooks, organised into per-team spaces and connected by typed links instead of piling up as loose documents. Because it targets regulated tenants, every page carries a classification (UNCLASSIFIEDSECRET) that the database itself enforces — and it runs entirely on tenant-owned infrastructure, with no third-party SaaS involved.

Key capabilities

  • Spaces with hierarchical markdown pages, revisions, and attachments
  • Typed links between pages (depends_on, supersedes, references, implements) forming a browsable dependency graph
  • Per-page classification enforced by row-level security, not by convention
  • Full-text search scoped to what the reader is cleared to see

Who uses it

  • Delivery teams keeping project knowledge next to the work
  • Defence and corporate tenants that must self-host their documentation
  • Security reviewers, via the audit trail the covered mutations write — see Classification auditing for the coverage and its one deliberate exception

For product context, see How the pieces fit together. The rest of this page is the technical reference.

Capabilities

  • Spaces — top-level organisational containers. Every page belongs to exactly one space; child pages must be in the same space as their parent. A per-tenant Default space is created lazily on first page creation. See Spaces for full details.

  • Markdown pages with YAML-style frontmatter, organised in a parent/slug hierarchy within a space.

  • Typed links between pages (depends_on, supersedes, references, implements) forming a dependency graph.

  • Full-text search, tenant- and classification-scoped via row-level security; filterable by space (spaceIds), by classification tier (classifications) and by page owner (ownerUserIds). Each filter is a comma-separated list, ORed within itself and ANDed against the others, and is applied in SQL before the relevance cutoff so a match is never lost to the result limit before the filter runs. A filter can only narrow what the reader's clearance already permits — asking for a tier above it returns nothing rather than an error. The owner filter keys on the page's current owner, which is reassignable, and is deliberately not an "author" filter.

  • The sidebar lists every section of the wiki. The left rail carries a pinned Home and Pages pair, a Knowledge base group (Curated pages, Wiki Review, Usage insights), and an Archive and Trash footer below the page tree; below the lg breakpoint the same list is inside the navbar's navigation drawer, so no viewport is short a destination. The row for the page being read is marked as current — by aria-current, not by colour alone — and stays marked on routes beneath it. Only the group heading names the subsystem; each row names its own contents. The rows read Knowledge and KB dashboard until the 2026-09-03 walkthrough found the group unreadable — a group headed Knowledge base over a Knowledge row and a KB dashboard row is three near-synonyms for three genuinely different destinations, and the labels spent themselves on what the heading had already said. Each row that opens a page which names itself now carries that name — Home is the exception, and always was, since the home greets rather than names. Wiki Review carries the number of open findings, read from the same tenant-wide aggregate the review queue's own counts come from, so neither surface computes a total of its own; while that read is unsettled, and after it fails, the row shows no number at all rather than a 0 that would claim an empty queue, and a queue that really is empty reports 0. One gap remains, and it is being closed separately: retrying a failed count from the review page updates that page but not this badge, which stays blank until the sidebar is remounted or a finding is resolved. Narrowing the rail leaves the same destinations as icons: the label and, for the review row, the count in words ("Wiki Review, 12 open findings") move into the link's accessible name, and no badge, dot or tint carries the count visually — the space switcher and the page tree, which have no icon-only form, are hidden instead of abbreviated. Archive and Trash live only in that footer and no longer in the top bar: they are different lists — archived is a live-page status while Trash holds soft-deleted rows — that answer the same question, so they sit together and each has exactly one control. Managing spaces stays in the space menu.

  • Navbar search leads with title matches — the search panel in the wiki navbar shows a Pages by title group of up to seven pages whose title contains what the reader typed, above the full-text results rather than in place of them. The two groups answer different questions and are derived from different reads: the title group is a title ILIKE lookup over GET /api/pages scoped with searchIn=title, while the full-text group is the ts_rank search above and keeps its full result cap. The scoping is what makes the group correct rather than merely tidy: the route's default predicate also matches the slug, and the limit is applied by the database to whichever predicate is in force — so asking for the default and discarding the slug-only rows afterwards would apply the cap to a set the group does not display, and enough recent slug-only matches would fill the window and leave the group empty above title matches that never left the database (PLT-908). Slugs are hyphenated where titles have spaces, so a hyphenated fragment reaches that state without anything unusual in the data. The browser does not re-check the titles it gets back: JS and Postgres fold case differently — lower('İSTANBUL') is istanbul under the database collation, while JS toLowerCase() produces an i followed by a combining dot — so a client-side re-check would discard titles the database legitimately matched, and at one match that empties the group it was meant to protect. A page that matches both ways is listed under both, because removing it from the content group would hide a genuine content match behind a title one. The title group exists as its own read rather than as a re-labelling of the full-text rows because the search index ranks one combined title-and-body vector and applies its cutoff in the database — so an exact title match on a short page can be dropped before any row reaches the browser, and no partition of those rows could recover it. A title lookup that fails or is slow degrades to no title group; it never delays or hides the content results.

  • The search results page shows what a hit IS, not just that it matched. /wiki/search renders each hit as a card carrying four signals: its classification tier, whether the page carries the agent marking, its knowledge-base state (in the KB, or nominated as a candidate) and whether it has open review findings. The tier comes from the same row that produced the match; the other three arrive for the whole result window in one batched read rather than one request per hit. The three filters above the results — space, classification and owner — are the same facets the API takes, and they narrow the ranked results only: the title group beside them answers a different question and is deliberately left unfiltered. Filter state is not carried in the URL and does not survive changing the query, which is treated as a new search rather than a narrowed one.

    Offering to create the page is a claim that it does not exist, so it waits for both reads. When the ranked read comes back empty the page offers a Create “…” link — but only once the title lookup has also settled with nothing. While that read is loading or has failed there is no button, because an unread is not a no and the cost of being wrong is a duplicate page. And when the title lookup DID match, the empty state says the content did not match rather than “no matches”, since the matching titles are listed directly above it. The two reads answer different questions — a substring lookup and a full-text one — so a title match with no ranked hit is ordinary rather than a contradiction.

    Relevance is shown as a position and a share, never as a probability. Postgres' ts_rank has no calibrated scale — its values on this corpus sit an order of magnitude below 1 — so rendering rank × 100 as a percentage would print a single-digit "match" beside the best answer to a query. Each hit therefore carries its ordinal (Rank #1) and, as text, its score as a share of the top hit in the same result set (62% of top match). Both figures are comparable within one query only, and the wording says so; when no hit in a set scores above zero, the ordinal is shown and no share is.

    Two things the page deliberately does not show yet, because the data is not on the wire: the owner's name on each hit (the owner lookup resolves a name to an id, not the reverse — the filter works, the per-hit line waits on PLT-881), and a highlighted excerpt or last-updated figure (PLT-1004). Neither is stubbed in the interim.

  • Pages list filtering and sortingGET /api/pages filters by space (spaceIds), parent, page type, slug, status, agent-ownership, tag and a search substring, and additionally by classification tier (classifications), page owner (ownerUserIds) and excluded page type (excludePageTypes). search matches the title or the slug by default; searchIn=title narrows it to the title alone, so a caller that only wants title matches spends its limit on them rather than filtering a window it has already lost. An unrecognised searchIn is a 400, never a silent fallback to the default. The classification and owner facets are comma-separated lists, ORed within themselves and ANDed against everything else, capped at 5 and 10 entries respectively. excludePageTypes is described separately below: it is neither ORed within itself nor capped at either of those numbers. Ordering is requested with sortBy (updatedAt, the default, or title) and sortOrder (asc / desc); results always carry a stable secondary sort, so the order is a deterministic total order and paging over an unchanged result set cannot skip or repeat a row — pages inserted or re-sorted between two requests still can, as with any offset pagination. The stable tiebreak does not follow the direction, which means the two directions are exact reversals only up to ties. Every filter and the sort are applied in SQL before the result window, so the rows and the total in meta always describe the same set — filtering an already-paginated page would silently drop matches. Filters can only narrow what the reader's clearance and tenant already permit. As with search, the owner facet keys on the page's current, reassignable owner and is deliberately not an "author" filter. There is no filter on the derived knowledge-base state: applying it before the window means deriving it over the whole filtered set, which cannot be bounded within the request budget on a large tenant, so it is deferred until it can be.

    excludePageTypes (PLT-1096) removes the pages the wiki maintains about itself — the space index hub (index), its per-topic spokes (index_spoke) and the space log (log). It is a comma-separated list, capped at 3 entries — the size of that set — and admits only those three values: it exists to hide platform-maintained navigation, not as a general negative page-type search, so any other value is a 400. Unlike the other list facets it also rejects an empty token — ?excludePageTypes=index,,log is a 400 rather than a repaired list — because an empty collection means "no filter" to the query builder, so a repaired value would silently return the unfiltered list the caller was trying to narrow. A bare ?excludePageTypes= still means absent. Like every other filter it is applied in SQL before the window, so the rows and the total describe the same set: a caller asking for 20 rows gets 20 real pages rather than whatever survives trimming a window that navigation pages had already filled.

  • The /pages browse surface is deep-linkable (PLT-568) — every filter it offers round-trips through the query string, so a filtered view is a link that can be bookmarked, shared and walked back with the browser's Back button. The grammar is ?space=<space uuid>&classification=<tier>&owner=<user uuid>&status=<page status>&search=<text>&sort=<field>:<asc|desc>&showReserved=true; each key is optional and the sort is written only when it differs from the default (updatedAt:desc), so a bare /pages and an explicitly-default one are the same link rather than two spellings of one view. For every narrowing facet an absent key means "do not filter on it"; showReserved is the one exception and is described below, since absent there means the reserved-page exclusion is on. Values are validated on arrival: a space or owner that is not UUID-shaped, and a classification outside the five tiers, are dropped rather than forwarded — a dropped facet narrows nothing and shows itself, where forwarding an unparsed value would answer 400 on a URL the reader cannot tell is malformed. An over-long search is clamped to the length the route accepts rather than dropped, because a truncated search is still a real one, and surrounding whitespace on it is trimmed — the one place a value is normalised rather than carried verbatim, so ?search=%20foo%20 renders and requests foo and a search of nothing but whitespace is no search at all. Query keys the surface does not own are carried through untouched by a filter change and left alone by its Clear. There is no kb key: the derived knowledge-base state has no list filter for the reason given above, so the URL cannot ask for one either.

    showReserved is the one key that WIDENS rather than narrows (PLT-1096): the list hides the wiki's own navigation pages by default, and ?showReserved=true puts them back. Only the exact value true does so — every other spelling reads as the default, which is the safe direction. It is a boolean rather than the page-type list it resolves to, so a link shared today still means "show the navigation pages" if a fourth reserved type is ever added, instead of silently continuing to hide only the three that existed when it was written. A visible Show navigation pages control drives it, which is also how a reader learns anything is being hidden at all. It is deliberately absent from the active-filter count — the badge counts filters that narrow, and a reader who asked to see more rows should not be told they have one filter applied. That has a visible consequence worth stating: Clear removes this key along with the rest when it is on screen, but Clear is itself only offered when some narrowing filter is set, so a link that sets nothing but showReserved shows no Clear at all. Unchecking the control is the way back from that state, which is why the control is visible rather than the key being an undocumented one. A space named by a link the reader can no longer resolve — one past the tenant's space-list window, or one they may not see — still filters, and the control shows its id rather than claiming the list is unfiltered.

  • /knowledge is the same list under a knowledge-base scope (PLT-569) — the knowledge-management view renders the identical browse surface with two differences, both of which are defaults rather than a separate page. It scopes its request to the spaces the tenant has marked as knowledge bases, so a bare /knowledge asks for those spaces rather than for every one; and it shows the page's editorial Status where the browse list shows the derived KB state, because that derived state answers whether a page is covered by the knowledge base — a question about source pages, which reads as noise when asked of a page that is already curated inside it. Everything else — the filter grammar above, the facets, the sort, paging — is unchanged, and ?space=<uuid> overrides the scope with one space. Clearing that key returns to the knowledge-base scope rather than widening to every space, so this surface deliberately cannot express a tenant-wide query; /pages is that surface. A tenant with no marked space is told its knowledge base is not configured and no page list is requested at all, which is kept distinct from a failure to read the space list — an outage is reported as one rather than as a fact about the tenant. The view reads only the first page of a tenant's spaces, and it never states more than that read supports: where it found no marked space but may not have seen every space it says it could not tell rather than that none exists, and where it found some but may not have found them all it says the list may be short. Only a reader whose spaces were listed in full is told, as a fact, that the tenant has no knowledge base. There is no knowledge-base-state filter here either, for the same reason /pages has none. It also hides the wiki's own navigation pages by default, and carries the same showReserved opt-in — a deliberate choice rather than an inherited one (PLT-1096): the marked knowledge-base spaces are exactly where the index hub, its spokes and the log live, so this is the surface where a rebuild most easily fills an updatedAt-ordered window with maintenance. An administrator who wants to inspect them turns them on.

  • Attachments with short-lived signed download URLs (storage provider never disclosed).

  • Per-page classification (UNCLASSIFIEDSECRET) enforced in RLS, not by frontmatter convention.

  • Mutations through the page, link and attachment APIs write a universal-audit-log row and publish a wiki.* domain event (payloads never carry the markdown body, preserving tenant/classification isolation). Two paths differ deliberately and are described under Classification auditing: an ingest batch records itself through audit.entry.created rather than a per-page wiki.* event, and index spokes written by a space-index rebuild emit neither.

The classification ladder, and why the wiki reads five tiers but writes four

The platform ladder is UNCLASSIFIED < INTERNAL < RESTRICTED < CONFIDENTIAL < SECRET. In the wiki, INTERNAL arrives across two releases, and in the release between them the module deliberately reads a tier it cannot write:

ReadsWrites
Before4 tiers4 tiers
Release N (PLT-502)5 tiers — API responses, list/search filters, page signals, the editor, the event validators4 tiers — the wiki.classification DOMAIN and every input schema still reject INTERNAL
Release N+1 (PLT-977)5 tiers5 tiers

The asymmetry is the safety property, not a transitional wart. During a deploy two builds serve concurrently. A release that both produced and read a new tier would let the newer build create a page the older one cannot parse; widening every reader first removes that window by construction. At the end of release N every serving build accepts INTERNAL and none can produce it, so whichever build handles the first such page in N+1 already parses it. The reasoning and its accepted residuals are in ADR-032.

Two consequences visible in the API and the UI during release N:

  • A classifications filter naming INTERNAL is accepted and simply matches nothing, rather than erroring — no page can hold the tier yet.
  • The editor renders a page's stored tier even when it is not writable, and keeps it selectable so an accidental change stays reversible; what it never does is offer a non-writable tier to a page that does not already hold it. An attachment upload from such a page is refused rather than stamped at a lower tier, because the upload endpoint would otherwise default it to UNCLASSIFIED and store the file below its own page.

Operators of Dedicated Cloud and On-Prem deployments must install release N before release N+1 — see Upgrade prerequisite.

Classification auditing

The writes listed below are audit-critical: the entry is written in the same transaction as the write and published to the transactional outbox as audit.entry.created, so the event is durably recorded and delivered even if the process dies after the commit. That guarantee covers publication, not consumption — SIEM forwarding is optional and, when configured, drops entries below its classification threshold (see the note on filing tiers below). Routine edits keep the cheaper channel, which writes the row but publishes no event.

The guarantee is scoped to writes that go through the page and attachment tools — the public APIs and the in-process callers that use them, such as the coordinator's automated file-back — plus the pages an ingest batch touches. It is not an HTTP-only boundary. It is not universal: index spokes written by a space-index rebuild get no audit entry at all — see the exception at the end of this section.

WriteAudit actionChannel
Create a pageCREATEaudit-critical
Change a page's classificationwiki.page.reclassifiedaudit-critical
Set, change or clear a page's KB nomination policywiki.page.nomination_policy_changedaudit-critical
Any other page editUPDATEroutine
Upload an attachmentCREATEaudit-critical
Soft-delete a pageDELETEroutine

Five things worth knowing if you consume these entries:

  • A reclassification is queryable on action alone. It does not share UPDATE with ordinary edits, so a SIEM rule needs no JSON inspection. A single PATCH that changes the tier and the body produces exactly one wiki.page.reclassified entry, not two.
  • The entry records both ends of the move. changes carries { classification: { before, after } } — the platform's normalised diff pair, with before absent on a creation. On a reclassifying PATCH, changed_fields carries the full list of fields the write touched and is deliberately wider than the keys of changes: page bodies and frontmatter are never copied into the audit table, so a classified page's content cannot leak through its own audit trail. Creations do not carry that list — they pass no explicit changed_fields, so it is derived from changes and reads ['classification'] even though the write also set the slug, body, filename or MIME type. That also means changed_fields is a row-level signal; the outbox event carries changes only.
  • Creation is audited at every tier, including the default. A rule that exempted UNCLASSIFIED would stop holding the moment the effective default becomes configurable per space.
  • One PATCH can produce more than one entry. A write that moves the classification and the nomination policy emits one wiki.page.reclassified and one wiki.page.nomination_policy_changed, and no routine UPDATE. A policy write whose resolution crosses a clearance-hidden ancestor adds a wiki.nomination_policy.resolved_from_hidden_source entry on top — the audited READ described at the end of this section, which is a separate event on a separate action. Count per action, never per request. These are two independently material control changes, and each carries its own action precisely so a rule can select one without inspecting JSON — folding the policy into the reclassification entry would hide it from exactly that rule. Group by correlation_id and resource_id to recover the single request. The nomination entry is filed at no lower than RESTRICTED, raised to the page's own tier when that is higher (both tiers on a reclassifying PATCH, higher end wins) — a floor on the entry, not a claim about the page, so the SIEM forwarder's default threshold cannot drop it.
  • A reclassification is filed under the higher of its two tiers, not its destination. Downstream delivery is threshold-filtered on that field — the SIEM forwarder defaults to dropping anything below RESTRICTED — so filing SECRET → UNCLASSIFIED under UNCLASSIFIED would silence the single highest-risk operation in the module. changes still records the real destination. It also drives the severity a CEF-configured forwarder emits — CEF carries no changes and no literal classification, so for those tenants the tier survives only as that number.

The exception. Index spokes get no audit entry when the space-index rebuild writes them. The line falls on who writes, not on which operation: everything the rebuild does to a spoke — create it, update its body, empty it — is unaudited, while writes through the ordinary page API are audited normally, a reclassification included. Spokes are derived navigation pages, regenerated from content pages that already carry their own entries, and the rebuild never supplies a classification and runs pinned to UNCLASSIFIED — so a spoke is born there and the rebuild can never move it. A spoke reclassified above UNCLASSIFIED through the API becomes invisible to the pinned rebuild, which then fails with a 409 rather than silently forking the index; that behaviour is deliberate and owned by PLT-702. (Spokes also publish no wiki.page.* event, which is what keeps them out of knowledge-base candidacy and out of a rebuild loop. That is a separate mechanism from the audit gap, and it does not explain it.)

Three audited READS (PLT-609). A folder-run ingest that traverses a clearance-hidden ancestor files wiki.ingest.governance_walk_crossed_hidden_ancestor, at the same RESTRICTED floor and with the same redaction — the visible source page, never the withheld ancestor. It is a separate action from the nomination entry below, and the separation is a correctness requirement rather than taxonomy: that entry's contract is that it carries the resolved value (exclude / nominate), and the governance walk has none. It runs before the policy is resolved, and on the truncated, chain-unstable and unreadable-anchor branches the policy is never resolved at all. Folding the two together meant recording inherit — false whenever the hidden ancestor supplies a real policy, and not even a member of the other action's vocabulary. The two are genuinely two reads: the walk reads parent_id, classification and deleted_at to establish membership and to take locks; the resolution reads the policy. Both can cross the boundary, both are recorded, and each records what it actually learned. A third action, wiki.ingest.governance_lock_anchor_unreadable, records the primitive's other privileged read: re-evaluating the ANCHOR's own readability after the ordered lock statement returns, outside RLS, on a row that may be hidden by then. Separate from the walk entry because the walk crossed no ancestor in that case, and filed on the whole unreadable-anchor branch rather than only when the row is above clearance — an entry that appeared only for a hidden anchor, and not for one deleted or absent, would let a reader tell those apart from the trail alone. The walk entry is filed before any disposition, so a skipped source and a committed one record the crossing identically — an entry written on one branch only would make the audit trail itself the oracle the redaction exists to close.

The nomination READ. Every entry above records a write; wiki.nomination_policy.resolved_from_hidden_source records a read, and is listed separately because that difference matters to anyone writing a rule over these entries. Resolving a page's inherited KB nomination policy walks past ancestors the caller cannot see, so when the answer comes from one of them the caller learns a policy decided by a row they cannot read. That resolution writes an audit-critical entry carrying the resolved value (exclude / nominate) and, deliberately, not the ancestor that supplied it — the audit trail is readable below the clearance of the row it describes, so naming the source there would undo the redaction the resolver exists to apply. A resolution whose source the caller can see writes nothing: that is an ordinary read they could have performed themselves.

Two things follow from how the resolver withholds a source, and both are deliberate:

  • The entry does not claim a clearance crossing. A source is withheld either because it is above the caller's clearance or because it is soft-deleted, and the resolver returns the same null for both — a column separating them would answer "does a hidden ancestor exist?", which is the disclosure the redaction exists to prevent. So the action names what is provable. Over-reporting is the safe direction here; separating the two cases properly is tracked as PLT-768.
  • It is filed at RESTRICTED. Not a claim about the page — a floor on the entry, so the SIEM forwarder (which drops anything below RESTRICTED) actually delivers it. An entry written to the table and never delivered would defeat the external monitoring it exists for.

Entry volume tracks the number of withheld-source resolutions, so a bulk candidacy sweep across a subtree under one hidden excluded folder legitimately emits one entry per page.

Pages written by an ingest batch are covered by that batch's own ingest.batch.committed entry, which names the source, summary, fan-out, index and log pages it touched — the same created-or-updated sense as page_count below. After a space's first ingest the index and log pages are updated rather than created, and a fan-out entry may target an existing page, so the entry is a touch-set record and does not distinguish the two dispositions.

Page fields

Beyond the title, slug, body, frontmatter, and classification, a page carries two machine-facing fields used by the agent walking skeleton (PLT-194):

  • summary (string | null) — a machine-readable 1-3 sentence tl;dr triage signal. It lets a consumer (MCP list output, a per-space index page) decide whether a page is relevant without pulling the full body. NULL is valid — human-authored pages typically leave it unset. Accepted on both create and update.
  • is_agent_owned (boolean, default false) — distinguishes agent-owned pages from human-authored ones, so a later automated pass knows which pages are safe to overwrite. Accepted on create only and surfaced read-only; its write-gate enforcement is deferred. Human-authored pages keep the false default.

Creating a page from the browser (PLT-566)

The API's defaults and the create form's behaviour are deliberately not the same thing, and three differences are worth knowing because they are the form's rules rather than the route's.

  • A classification must be chosen; the form supplies no default. classification is optional on POST /api/pages, but the browser refuses to submit without one and says so on the field. A create surface has no stored value to honour, so a default there is not a fallback — it is the whole answer, and it would file every browser-authored page at the bottom tier whatever it contains. The tier is picked from the same five-segment control the editor uses, offering only the tiers the author's clearance permits.
  • The form always sends ownerUserId, as an explicit null when no owner is chosen. This is the one place the distinction above matters to a reader: an omitted key means "the acting user", so a create form offering a "No owner" choice has to send null or it would silently make the author the owner of every page.
  • A space marked as a knowledge base is not a direct create target. It is still listed in the space menu — with the reason it cannot be chosen, and a pointer to approved ingest — rather than hidden, because an absent row answers no question. This mirrors the server rule rather than adding one: browser creation goes through the ordinary createPage path, which refuses a marked space with a 403 carrying details.reason = "kb_page_create_forbidden". The rule follows the space's marker, so it also applies when a tenant's Default space is itself marked.
  • The parent is picked from a search, not typed as an id (PLT-1054). The form offers the same parent picker the editor does — a space-scoped search with a placement preview — so the set it offers is the set that can actually be saved; PageService.assertParentInSpace refuses a cross-space parent as a 400 that cannot say which input was wrong. Changing the space therefore clears the parent. With the virtual Default row selected the picker is disabled and the page is created at the top level: that row deliberately carries no space id — the Default space may not exist yet, and the server materialises it on save — so there is nothing to scope a search by and no sibling set to count, and the form says so rather than offering a choice it could not honour. To file a page under an existing Default-space page, create it and then set the parent in the editor, where the space is a real id.

How a page's status is displayed (PLT-531)

status is one of draft, in_review, approved, archived, and every surface that shows it resolves its appearance through one lookup — pageStatusVisual in apps/wiki/src/lib/page-status-display.ts — rather than choosing a tone locally. The lookup returns a label, a design-system Badge variant and the label's own colour; a value outside the four resolves to null, and the surface then shows no status chip rather than inventing a tone for something it cannot interpret.

Two properties of that mapping are load-bearing rather than cosmetic, and a new surface must not undo them:

  • The label is always real text. The tone never carries the meaning on its own, so the chip survives greyscale, colour-vision deficiency and a screen reader unchanged.
  • approved is blue (info), not green. The design implies a green settled state, and the green was measured at 3.03:1 in the light theme against the 4.5:1 WCAG AA requires. The success tone is the only status tone with no --color-*-ink correction in the token set, so it cannot be fixed where it is used; info has one, and measures 5.70:1 light / 6.10:1 dark. When --color-success-ink is added, approved can return to green — that is the only reason to change this mapping.

The neighbouring classification chip follows the same shape through classificationVisual, and the two vocabularies are deliberately kept apart: classification says who may read the page, status says where it sits in the authoring flow.

Page owner (ownerUserId)

owner_user_id is guarded by the trg_pages_validate_owner trigger, which accepts an owner who either holds an ACTIVE membership in the page's tenant or whose home tenant is the page's tenant. The second branch is what keeps system and agent owners — the curator-lint cron actor, agent-owned pages — working when they hold no membership row at all. The first is what lets a multi-org member own a page in a non-home org they legitimately belong to; requiring home-tenant equality alone used to 500 that case (PLT-308).

Since PLT-516 the same rule is evaluated in PageService before the write, so a POST /api/pages or PATCH /api/pages/:id naming an owner outside the tenant returns a 400 naming the field, never a raw 500. The trigger remains the actual boundary — the preflight can be overtaken by a membership revoked between the check and the write, and that trigger failure is translated to the same 400. The error does not distinguish "not a member of this tenant" from "not a user at all".

Three shapes deliberately skip the check: an omitted ownerUserId on create (the owner defaults to the acting user, whose membership the auth boundary already resolved in this tenant) and an explicit null (which clears the owner and the trigger accepts without a lookup). A PATCH that only echoes the page's current owner and changes nothing else is still a no-op returning 200 — it never reaches the trigger, so it is not preflighted either.

Reserved pages have no owner (PLT-1094)

The index hub, every index_spoke and the log are created and maintained by SpaceIndexService rather than authored by a person — so since PLT-1094 they are created with an explicit null owner and have no human steward at all. (A person can still edit one; what the platform can say is that the index will normally overwrite it, not who typed the bytes currently there — see the chip below.) Before that they inherited the acting user, because PageRepository.create falls back to the actor whenever ownerUserId is absent and the reserved-page funnel passed none: every machine-written navigation page was owned by whoever happened to trigger the ingest or the index rebuild that created it, and the wiki home rendered that person's name and avatar on it.

An explicit null is not the same as an omitted key, and that distinction is the whole fix — the omitted key is what inherits the actor, and the explicit null is the only value that says "no steward". The override lives at the single createReservedPage funnel, so all three reserved writers are covered and a fourth added later cannot reintroduce the defect by forgetting a key.

The invariant is durable, not initial. Every reserved maintenance write forces the null too, not only the create. The editor offers its owner picker for every page and PATCH /api/pages/:id accepts ownerUserId, so a person can assign themselves to a reserved page after it was created — or after the repair below has cleared it — and the index would go on overwriting the body while the page named them as its steward and the chip disappeared. The next rebuild now takes the page back. It does not refuse the assignment: the PATCH still succeeds, and the owner survives until that rebuild. Refusing outright changes what the update API accepts and is tracked as PLT-1112.

Taking a page back ends a stewardship a person stated, so it is audited through auditCritical() rather than the routine channel the ordinary body rewrite uses — the same sink the repair script uses for the same mutation. The spokes a rebuild took back are recorded as one entry naming all of them, for the reason spoke creations are: a rebuild runs inside a transaction whose budget already warns at 60%, and per-spoke critical entries would put that many serial outbox writes inside it. In steady state no owner is cleared and nothing is written. The entry records the transition, assigned → unassigned, and not the id: owner_user_id matches none of the audit layer's redaction patterns, so an id placed there would be published on the audit.entry.created event as well as stored.

The consequence is that reserved pages leave every owner-filtered result — the home's "For you" tab, the ownerUserIds facet on /pages, and the owner facet on /wiki/search. (Since PLT-1096 the home feed and both browse lists exclude them outright, by page type, so the For-you tab no longer relies on the owner filter to be rid of them — but the reasoning below is why they are unowned, which is a separate fact and still the one the owner facet and /wiki/search act on.) That is correct rather than a disappearance: those surfaces answer "who stewards this", and nobody stewards a page the platform normally rewrites on its next rebuild. The For-you tab's empty state says so in as many words.

The page header says so. A reserved page that is agent-owned and unowned carries a System-maintained chip on the page-read view, whose tooltip states the consequence: the index rewrites these pages, so an edit made there is normally overwritten.

The hedge is deliberate. resolveReservedTarget additionally requires the canonical slug<space-slug>-index / <space-slug>-log — and refuses a renamed page with a 409 telling you to rename it back. The chip cannot see that condition, because the canonical slug derives from the space's slug and the page-read surface does not carry it, so a renamed index or log is still labelled while never being rewritten. The repair script below does apply the canonical-slug condition, since there it is one line of SQL and the stakes are a destructive write. It is drawn beside the agent marker rather than instead of it — the marker reports the is_agent_owned marking and claims nothing further, while this reports the maintenance regime, which needs a reserved page type and an absent owner as well.

The chip deliberately does not say "generated automatically", and the reason is that the schema cannot back that claim. page_revisions.actor_type records the initiating principal, not the composer: POST /api/spaces/:id/index/rebuild supplies no explicit actor type, so a person pressing "rebuild index" in a browser stamps USER on the hub and every spoke — that endpoint writes those two; the log is appended by the separate appendLogEntry, which takes its actor from its own caller the same way — even though SpaceIndexService composed every byte. Nothing else in the schema identifies index composition either — editSummary is caller input, ingest_batch_id answers a different question, and trusted_kb_operation is stamped only from a validated KB capability the public rebuild does not supply. So the chip claims the regime, which resolveReservedTarget enforces, rather than the authorship, which nothing proves. An immutable per-revision origin is tracked as PLT-1104.

One consequence is worth knowing: a reserved page that still carries a human owner is not labelled, because the third conjunct is the absent owner. That is deliberate — such a page is one the repair below has not reached, and a chip claiming the platform maintains it while the home feed renders a person's name beside it would be the two surfaces contradicting each other.

Repairing pages created before the fix. npm run db:repair-reserved-page-owners -w @constellation/wiki -- --tenant <uuid> clears the owner on the rows that are both a reserved page type and agent-owned, one tenant per invocation, emitting an auditCritical() entry per cleared page in the same transaction as its UPDATE and proving convergence with a second pass that must clear nothing. It is a post-deploy step, not a migration: migrations apply before the code deploys, so a migration-time repair would run past the reserved pages the outgoing build kept creating with an actor owner. Like the folder-run repair it takes WIKI_REPAIR_TX_TIMEOUT_MS, requires an RLS-bypassing role (a clearance-gated caller would match no classified page and converge clean while the misattribution remained), and has no estate-wide mode.

A broken audit chain aborts the pass rather than stranding it. chainAuditEntry computes its hash in a per-call savepoint, so a serialization failure there does not abort the surrounding transaction — the entry is written with NULL hashes and the run would otherwise commit. Concurrent audit traffic for the same tenant is enough to cause that under REPEATABLE READ (PLT-953). The repair therefore reads its own entries back before committing and refuses the pass if any is unchained: nothing is cleared, and the pages are still repairable when you re-run in a quiet window. The one entry outside that guarantee is the scan record, written in its own transaction beforehand — if the break is there it stays committed and unchained, recording a read that did happen.

Two side effects are priced rather than suppressed. Each repaired row takes a version bump — correct, since clearing an owner is exactly the metadata write version is defined to count, though it will 409 an editor holding that page's previous version — and a fresh updated_at, which drives the "recently updated" ordering. Until PLT-1096 excludes reserved page types from the home and browse lists, every repaired page jumps to the top of them once, so prefer running the repair after that ships. Suppressing the triggers was rejected: migration 046 does it with table-wide ALTER TABLE … DISABLE TRIGGER, which is right for a one-shot whole-table backfill in a deploy window and wrong for a live repair, where it would stop stamping every concurrent session's writes too.

Listing the eligible owners (PLT-519)

GET /api/users/lookup answers the other half of the question: not "is this user a valid owner" but "who are they". It backs the owner picker, and it enumerates exactly the set the trigger above would accept — an ACTIVE member of the acting tenant, or a user homed in it.

ParamTypeDefaultNotes
qstringabsentAbsent or blank browses rather than erroring. Max 200 chars, matched against name and display name, prefix matches ranked first.
limitint10Bounded 1–25, and re-bounded inside the database function — LIMIT NULL is unbounded in PostgreSQL, so the bound is not left to the caller.

Where the rule lives. The eligibility predicate is identity.lookup_owner_candidates, a Directory-owned SECURITY DEFINER function (PLT-518) that refuses unless the session's app.tenant_id is set and equals its argument. The wiki reaches it through the shared findOwnerCandidates helper in @constellation-platform/auth-next, which Directory's own GET /api/v1/users/lookup uses too — so the two surfaces cannot drift, and neither restates the rule. Cross-module reach is by shared package, never by importing Directory app code (constitution §3).

Resolving a principal id to a name (PLT-1095)

Naming a principal a page already references — its author or its owner — is a third question, and nothing could answer it before. identity.lookup_owner_candidates matches names and takes no id argument at all, so display surfaces browsed its first 25 rows and matched by hand; any principal outside that window rendered as "details unavailable", which in a tenant of more than 25 people is the ordinary outcome rather than an edge case.

There is no by-id endpoint, deliberately. Resolution happens server-side only, inside the reads that already return a page: POST /api/pages/signals carries each visible page's author and owner already resolved, and every surface that shows attribution reads them from there. A route taking principal ids from the caller was built during this work and then withdrawn — it let any authenticated tenant member resolve names for same-tenant principals no page they can see references, including soft-deleted ones the owner picker deliberately excludes. Deriving the ids server-side from rows the caller was already shown is a stronger guarantee than any validation on such a route could have given: every resolution originates in a page that reader can see.

Where the rule lives. identity.resolve_principals is a Directory-owned SECURITY DEFINER function that refuses unless the session's app.tenant_id is set and equals its argument, then restricts itself to users the tenant already relates to — homed in it, or holding a membership row in it, at any status. A principal with neither yields no row, deliberately indistinguishable from one that does not exist. The wiki reaches it through the shared resolvePrincipalsByIds helper in @constellation-platform/auth-next, the same seam as the candidate search, and the function caps its own id array rather than trusting the caller to.

No email is returned, and not because a caller strips one. The function has no email column: isAgent is derived from the synthetic-agent address convention inside the query, exactly as the candidate search derives it, so the address never leaves the database.

It deliberately includes soft-deleted and suspended users, and the picker deliberately does not. Naming a page's past author is a display concern; offering somebody as a new owner is an eligibility one. A resolver that inherited the picker's narrowing would render a real, named former colleague as unresolvable. The priced consequence is that within a tenant a name outlives its user's deletion on attribution surfaces.

Concurrent edits (version / expectedVersion, PLT-563)

Every page row carries a version (number) and a lastModifiedBy (string | null). Both are returned on the ordinary read, create and update projections, and both are written by the database: the trg_pages_stamp_mutation trigger advances version on every UPDATE of the row and stamps last_modified_by from the acting principal on insert and update. One update is exempt — see Reordering does not count as an edit below.

version counts row mutations, and is deliberately not the revision number. A revision is written only when the body, frontmatter or classification moves (or the caller passes an explicit edit summary), so a title, slug, status, owner, parent or space change leaves the revision number where it was. Two editors could therefore hold the same revision number and silently overwrite each other's metadata. Two editors can of course still load the same version — that is the whole scenario — but only one save carrying it can succeed, because the first one to land advances it.

Because it counts row mutations, maintenance writes advance it too — soft-delete, restore, source retraction, slug freeing, the space-index rebuild and ingest. That is intended: each of those changes persisted page state, so a client whose base predates one is genuinely looking at a stale page.

Reordering does not count as an edit (PLT-828)

An UPDATE that changes nothing but a page's position leaves version, last_modified_by and updated_at exactly as they were, and writes no revision. Manual sibling ordering (ADR-030 § 3) renormalises the whole visible sibling set on every reorder, so without this exemption dragging one page would bump the version of every page around it — 409-ing editors who were mid-edit on a page somebody else merely dragged past — reattribute those pages to whoever did the dragging, and move an updatedAt that drives the "recently updated" sort.

The exemption is decided by what the row became, not by what the caller asked for: the trigger compares the whole row before and after, and takes the exemption only when nothing but position differs. So it cannot be claimed by a write that also changes content, whatever columns that write names — a PATCH that edits the title stamps all three even if it moved the page in the same statement.

Two consequences worth stating plainly:

  • The exemption does not require position to have actually moved, only that nothing else did. A renormalisation rewrites every visible sibling, so it always contains rows whose freshly assigned key equals the one they already had, and stamping exactly those rows would leave the defect in place for most of a reorder.
  • Because of that, a raw UPDATE wiki.pages SET title = title — a statement that changes nothing at all — no longer advances version either. This amends PLT-563's original rule that any UPDATE consumes a version. No application path reaches it: a PATCH whose values all match short-circuits above the database and issues no UPDATE.

lastModifiedBy is null when no actor was stated — a background or cron write that set none. It is not "an unknown user", and must never be rendered as one. It carries no foreign key to identity.users: agent and system writers use a sentinel that is not a user row, and identity is a shared read-only surface owned by Directory.

Using it. PATCH /api/pages/:id accepts an optional expectedVersion: the version the caller read before editing. When supplied, the update is a compare-and-swap — if the row has moved on, the request is rejected with 409 Conflict and does not write. The 409 body carries:

FieldMeaning
reason"stale_version"
expectedVersionthe value the caller sent, echoed back
currentVersionthe version the page is actually at
lastModifiedBywho wrote that version, or null
pagethe current page, projected exactly as an ordinary read would project it

The body carries the current snapshot and not the caller's base: revisions store no title, status, owner, parent or space, so the server cannot reconstruct what the client was looking at. The client keeps its own base and diffs against the returned one.

A page reclassified above the caller's clearance between their load and their save returns the ordinary 404, with no conflict metadata — building a 409 there would mean disclosing a snapshot of a row whose access was just revoked.

Slug collisions on save (page_slug_conflict, PLT-929)

wiki.pages is unique on (tenant_id, space_id, parent_id, slug) — note the parent. Two pages under different parents in the same space may legally share a slug; only two pages under the same parent may not.

A PATCH /api/pages/:id that would break that constraint answers 409 Conflict with details.reason: "page_slug_conflict". Before PLT-929 it escaped as a generic 500: only the create path translated the underlying unique violation. The discriminator is what lets a client attribute the failure to the slug field, which a bare 409 cannot — a save carries parent, space, slug and status at once, and a refused status transition, a parent cycle and a child-bearing space move are all 409s of their own.

Three properties are deliberate:

  • The collider is never identified. The message names the slug it tried to place — the one you sent, or the page's current slug when the request only moves it — and the space it looked in, and the parent you supplied. When you omit the parent the message says just "at this page's current location" and never names or implies the stored one: it may sit above your clearance, where an ordinary read hands you parentId: null, and the error must not contradict that read. Withholding the id alone would not be enough, and a pre-write visibility proof would not survive to the failure — a concurrent change can revoke access in between. The occupying page itself is never described at all: the slug index has no deleted_at IS NULL predicate, so the occupant may be a page in the trash, and it may be one you are not cleared to see.
  • The Default-space root answers the same way. A root-page slug in the tenant's Default space shares one namespace with space slugs, and that collision is caught by a pre-check rather than by the constraint. It used to answer 400; since PLT-929 it answers the same 409 with the same discriminator, so one user action cannot report two different statuses depending on where the page lives. Whether the occupant is a space or a page is deliberately not revealed.
  • index and log pages are excluded only when the ambiguity is real. Those two types carry their own one-per-space unique indexes, keyed on the space and predicated on the page type. A rename that changes only the slug or the parent leaves such a page in the slot it already held, so the collision can only be the slug — and the response carries the discriminator like any other. It is withheld only when the update moves the page to another space or turns it into an index/log, where the slot genuinely may be taken and the driver does not report which constraint fired. It answers a 409 whose message names neither cause and which carries no details.reason — a client must not read it as a slug problem.

Creating a page is unchanged: POST /api/pages still answers its own 409, and a Default-root collision on create is still a 400.

Omitting expectedVersion is supported and keeps the previous last-write-wins behaviour, which the callers that predate this field rely on. Every omission is recorded in the runtime log under the event key wiki.page_update.expected_version_omitted — that logging is live, not flag-gated, because the evidence has to accrue while the route is still on its current contract. A future, separately owned cutover may go on to reject omissions with 428 Precondition Required; that path exists but is dormant behind WIKI_REQUIRE_PAGE_EXPECTED_VERSION, which activates only on the exact value true and is off in every environment today.

What the editor does with a conflict (PLT-564)

The page editor always sends expectedVersion, on both of its write paths — the Save button and the status control — so a concurrent edit is refused rather than silently overwritten. This is independent of the dormant 428 cutover above: that flip is about callers who omit the field, and the editor never does.

When a save is refused, the editor shows a confirmation dialog rather than a generic error. It names the competing principal and both version numbers, and offers two things:

  • Review changes — a field-by-field comparison of the snapshot the editor loaded against the page the 409 returned (title, status, classification, owner, parent, space, summary, frontmatter), plus a body diff only when the two bodies actually differ. A competing edit to the title, status, owner, parent or space — with no explicit edit summary, which would write one on its own — produces no revision at all (and since PLT-1166 the editor never sends one, so a competing edit made from the editor is always in this case), which is why the comparison is built from the two snapshots and never from GET /api/pages/:id/revisions/diff — that endpoint takes two revision numbers, and such a conflict produces none. (The absence of a body diff is not itself proof that no revision was written: a frontmatter or classification change writes one too, and both are reported in the field list.)
  • Overwrite — a destructive confirmation that re-issues the same edit with expectedVersion advanced to the currentVersion the conflict reported. It does not drop the field: the retry stays a compare-and-swap, so a third writer landing in between is reported again rather than beaten.

The competing author is rendered as the stored principal, unaltered but for surrounding whitespace. A write from the system sentinel or a system:-prefixed principal is labelled as automated; every other value is labelled neutrally as a principal, never as a personlastModifiedBy carries no actor kind, and the wiki's GET /api/users/lookup searches owner candidates by name rather than resolving an id. A null says outright that no attribution was recorded.

Sibling ordering key (position, PLT-525) — stored, and now written by the reorder API

wiki.pages carries a position column: an opaque lexicographic sibling-ordering key, TEXT COLLATE "C", added by the PLT-525 migrations for the manual tree ordering that ADR-030 designs.

No caller can read it, and no caller can choose it. It is deliberately absent from every API response, event payload, audit entry and concurrency token, and from CreatePageSchema and UpdatePageSchema — so a page's rank cannot be named in a request. That is not an oversight: a rank on a table whose rows are clearance-filtered is a disclosure surface, and ADR-030 forbids projecting it.

Since PLT-527 the column has a request-driven writer, and it is the only one besides the backfill and the column default: POST /api/pages/:id/reorder (below). It writes ranks the SERVER generates over a whole visible sibling set — never a value the caller supplied — through the one narrow repository capability permitted to set the column, whose production importers a test still pins to exactly that one ordering service.

A second source-level writer exists beside it since PLT-874 — the privileged compaction pass — and it is reachable from no request path at all: it has no barrel export, no route and no cron, and a test pins its set of production importers to empty. Its dormancy is enforced at the database as well as in the import graph, because migration 060 grants its definer to the migration role only.

It is documented here so that a reader who finds an unused column knows it is reserved rather than dead. The sibling order it will eventually express is (position, id) within a (tenant_id, space_id, parent_id) partition — id is the deterministic tiebreaker, and equal positions are legal, because a uniqueness violation would be an error whose occurrence depends on a clearance-hidden sibling.

Rollout state. The column ships in an expand / backfill / enforce sequence across releases: it is currently nullable with a constant sentinel default, and rows created since it landed carry that sentinel. SET NOT NULL, a catch-up backfill and the removal of the default all come later, and no ordered read may be switched on before that catch-up runs. The allocator and the reorder API are separate slices again.

The machinery below shipped unwired in PLT-526 and is now wired by PLT-527's reorder command. Three mechanisms carry the ordering scheme:

  • a sequence allocator that produces n fresh keys and has no midpoint function — the ordering scheme never allocates between two stored keys, because a stored key carries hidden siblings' history, so the operation that would do it is absent by construction rather than merely unused;
  • a per-partition advisory lock on (tenant_id, space_id, parent_id), taken as a sorted set so a multi-partition move cannot deadlock against the tenant-wide audit chain head;
  • one narrow repository write that may set position, whose permitted importer set is asserted by a test and names exactly one file — the reorder service.

They shipped ahead of their callers on purpose, and both sequencing blockers are now closed. During a rolling deploy the outgoing build takes no partition lock, so the release that may safely renormalise is the one after every writer has begun acquiring the lock — merely shipping the helper was not that step (PT-927's shape), and PLT-875 phase 1 wired the acquisition into every partition-mutating writer, shipping in the 2026-09-02 release. Separately, a renormalising write would once have bumped version, last_modified_by and updated_at on every sibling it rewrites, staling the optimistic-concurrency token of anyone mid-edit on a page they never touched; PLT-828 shipped the trigger exemption for position-only writes (see Reordering does not count as an edit above).

Reordering a page (POST /api/pages/:id/reorder, PLT-527)

POST /api/pages/:id/reorder
{ "parentId"?: "<uuid|null>", "position": "before" | "after", "anchorPageId": "<uuid|null>" }

The move is expressed against a visible anchor, never an index and never a rank, and anchorPageId names a sibling the caller can already see.

parentId is optional, and omitting it means "leave the parent alone" — that is the spelling for an ordinary drag within one sibling list, and the only one that is safe for a page whose parent is hidden by clearance. Such a page is projected to the caller with parentId: null and so appears among the roots; if the field were required, that caller's only way to say "keep it where I see it" would be null, which the server would resolve against the real stored parent and execute as a genuine re-parent. An explicit null means the space's root and an explicit UUID a named parent; both are validated for visibility, and a parent change is refused outright when the page's current parent is not visible to the caller. A null anchor means "first under that parent" and is accepted only with position: "before" — appending is expressed as after the last visible sibling, which is the honest spelling, since where that lands relative to rows the caller cannot see is deliberately unspecified.

A reorder rewrites the whole visible sibling set of every partition it touches — the destination, plus the source when parentId changes — with a freshly generated key sequence, on every move rather than only when keys collide. Doing extra work in some cases only would make that work observable to a caller whose clearance hides the rows that caused it.

Two responses are deliberately uniform, and clients must not try to distinguish the cases behind them:

  • 404 — the page, the destination parent or the anchor is missing, soft-deleted, hidden by the caller's clearance, in another space, or is the page being moved. One message for all of them: the difference between "you may not see it" and "it is not there" is exactly the existence of a classified row.
  • 409 — the sibling arrangement changed while the move was applied, whatever the reason. Re-read the tree and retry.

A 403 means either that the caller may not write pages, or that the space is a marked knowledge-base space, whose ordering is a property of the synthesis rather than of who dragged a page last.

There is no spaceId in the payload, so this endpoint cannot express a cross-space move; that stays on PATCH /api/pages/:id. Each successful move emits one wiki.page.reordered event and one routine audit entry. Neither carries the rank or the anchor; the event carries no parent at all, and the audit entry carries the destination parent as the acting user may see itnull when that parent is above their clearance, since a page's stored parent can name one they cannot read. Compaction is not part of this: whole-set renormalisation makes allocation unable to fail, so a rebalancer is storage hygiene rather than correctness. Since PLT-874 both the measurement that would justify one and the pass itself exist, written and tested, and neither is wired — see Key density is measured on demand below.

Key density is measured on demand, and compaction is written but not run (PLT-874)

Whole-set renormalisation makes allocation unable to fail, so a rebalancer is storage hygiene rather than correctness — ADR-030 § 5 therefore deferred it "until measurement justifies it". Both halves of that sentence now exist in the source, and neither runs by itself.

The measurement is an operator action, not a schedule. It inventories, per sibling partition, how far the stored keys have drifted from what a freshly generated sequence of the same size would look like. It reports a partition when any of four things is true: a row still holds the rollout sentinel, two rows have gone equal (which is legal, and is exactly what a compaction would remove), the widest stored key is wider than a fresh sequence would need, or a row carries no key at all — that last one being the case the other three are blind to, since a partition of un-keyed rows has no width, no duplicate and no sentinel and so looks pristine on every other measure. The reading is taken across every classification tier, through a tenant-bound SECURITY DEFINER function — key density is a property of the whole partition, and a clearance-filtered measurement would understate precisely the partitions most worth compacting.

Because that read crosses a classification boundary, each per-tenant pass writes one audit entry in the read's own transaction, filed at the clearance ladder's ceiling and access-scoped — who read, which tenant, when, and how many spaces were in scope, and nothing derived from what the read saw, because that entry is published to an outbox routed without a clearance filter and a count of partitions across every tier counts the partitions whose pages are all classified — written unconditionally, since an entry that appeared only when something classified turned up would make the audit log's silence an oracle in its own right (constitution §5).

Which is why it is not on the daily sweep. The entry is written in the calling code; the capability is a grant. An earlier revision of PLT-874 ran the pass on the curator cron, which meant granting the all-clearance function to the role every request path uses — handing out the crossing read with the audit obligation left behind in TypeScript. Both functions are therefore granted to the migration role only and explicitly revoked from the runtime role, so the measurement is available to an operator and to nothing else. Closing the gap properly means giving the sweep a role of its own, on which the grant and the obligation coincide; that is PLT-1078, and until it lands no partition is measured automatically.

What it emits operationally is a log record and not a wiki.lint_findings row. A finding with no page anchor is visible to every member of the tenant, so a finding carrying all-clearance counts would be a density and existence oracle for readers cleared for none of the rows it counted. Each record names its tenant and identifies its partition by a one-way digest, and carries no ordering key and no page id — the tenant because the digest is one-way and the rotation randomised, so without it a crossing surfaces without being queried for and then cannot be acted on.

The compaction pass is written and unwired. It renormalises one partition across all tiers, preserves the (position, id) order exactly, and — setting position and nothing else — takes the PLT-828 exemption, so it bumps no version, reattributes nobody and moves no updatedAt. It emits one audit entry per pass, classified at the highest tier it touched — or at its parent's tier, where the parent outranks every child — because a partition-wide operation recorded at some touched page's tier would be under-classified, and the entry names the parent it acted under.

It has no caller, and the database is what enforces that: the compaction function is granted to the migration role only, and explicitly revoked from the runtime role every request path uses — so it is unreachable even from raw SQL, not merely un-imported. That single audit entry is published to the event outbox, which routes by event type without filtering on classification, and the decision governing whether such an entry may be delivered that way is still a draft (ADR-035, Proposed). Turning the pass on is a later, separate decision — and one the daily measurement above is there to inform.

Recognized page types

page_type is a free-form TEXT column — it is not constrained to an enum. The platform recognizes a documented vocabulary by convention (KNOWN_PAGE_TYPES in apps/wiki/src/lib/schemas/page.schema.ts), but any non-empty string up to 64 characters is accepted:

  • Existing values: spec, page, doc, runbook.
  • Walking-skeleton values: source_summary (an agent's digest of an external source), index (the per-space root navigation hub), index_spoke (a per-topic catalog spoke, PLT-281 — many per space), and log (records ingest events chronologically).
  • Ingest values: source (the raw, pasted external source text — the provenance anchor for a source_summary's derived_from link), maintenance_schema (an optional per-space contract page read at ingest time to load any space-level instructions; absence is advisory-only, not fatal).
  • Fan-out values (PLT-203): entity (a named domain concept or actor), concept (a higher-level abstraction, pattern, or principle), synthesis (a cross-cutting page that compiles relationships or comparisons across entities/concepts). These are the only pageType values the ingest fan-out create op accepts — the FanoutPageSchema narrows to exactly these three while the generic CreatePageSchema.pageType remains free-form.
  • Release value (PLT-540): release_notes — the per-release "what changed for you" page the release procedure publishes, whose ## headings are its sections. Release pages published before this type existed carry doc at a release-notes-* slug and are recognised by that pair instead, so nothing was retyped; apps/wiki/src/lib/release-notes.ts owns the recogniser and release-notes-sections.ts the pure section-index parser (the heading's rendered text — link, emphasis and markup stripped, the title ending at an image, where the editor's schema splits the heading; a heading containing raw inline HTML is deliberately not indexed, because what it displays is decided by the editor's schema rather than by the markdown — plus a de-duplicated, prefixed anchor, in document order) — which reads the same markdown parser the viewer renders through, so the index names exactly the headings the page draws. It is deliberately not a durable KB-nomination type: a release page is a dated snapshot, and the candidacy policy excludes it on either the type or the slug.
    • Read view (PLT-539). A recognised release page renders as one collapsible section per top-level indexed heading rather than as a single markdown block. The same walk that builds the index also cuts each section's markdown (splitReleaseNotesBody), so an entry and its content can never be mismatched, and the heading is drawn by the app as an <h2> carrying a disclosure <button> — which is what allows the generated anchor to be applied as that heading's id, since the shared viewer emits no heading ids. A heading the index skips starts no section and simply renders as ordinary markdown inside the preceding one — and neither does an indexed heading nested in a blockquote or a list item, because cutting the line stream there would tear the container in two, so the rendered sections are a subsequence of the index rather than all of it. A release page with no section-starting heading falls back to plain rendering. Sections start expanded, a link to an anchor opens and scrolls to that section even if the reader had collapsed it, and the collapse state is per-visit and never persisted. The prototype's per-section date and version chips are deliberately not rendered: the shipped convention puts the version and date in the page title, so there is no per-section value to show.

Pages can be linked with typed edges stored in wiki.page_links. Five link types are supported:

link_typeMeaning
depends_onThe source page depends on the target page.
supersedesThe source page replaces / obsoletes the target page.
referencesThe source page cites the target page.
implementsThe source page implements the spec in the target page.
derived_fromThe source page is derived (summarised, distilled) from a specific revision of the target page.

The derived_from link type is the PLT-197 provenance edge. It differs from the others in two ways:

  1. It requires a source_revision_num — a positive integer that records which revision of the target page the summary was distilled from. This lets a reader detect when the source has since been updated.
  2. It is validated at the service layer: LinkService.create asserts that the referenced revision exists before inserting the link.

The source_revision_num column is enforced by a DB-level CHECK constraint: it must be non-null exactly when link_type = 'derived_from', and null otherwise.

Reading the graph — the relationship block (PLT-536)

A page whose space carries the knowledge-base marker (spaces.is_knowledge_base, PLT-513) renders its typed edges below the body, as a Knowledge-graph relationships card. Ordinary wiki pages do not render it and issue no links request at all, and a space that cannot be read is treated as not-a-KB-space rather than as one.

The card is two columns — edges from this page and edges to it — and each column is grouped by link_type. Group headings are worded from the page you are reading, because the stored enum is written from the source's point of view: an incoming depends_on reads Depended on by, not "Depends on", since it means the other page depends on this one. The canonical enum is still shown, once per group, on a badge.

link_typeOutgoing headingIncoming heading
depends_onDepends onDepended on by
supersedesSupersedesSuperseded by
referencesReferencesReferenced by
implementsImplementsImplemented by
derived_fromDerived fromSource for

An endpoint that cannot be resolved renders as a plain disabled entry, never as a link. Link rows carry only ids, so the reader-facing titles are resolved through the batched page-signals read; an id that read does not answer for is a page this reader may not see. Rendering it as an anchor would 404, and would also disclose that the page exists — so the row carries no id at all. Note the row itself is only reachable in the first place through a narrow window: page_links_tenant_rw admits an edge only while both endpoints are visible, and that check inherits the wiki.pages clearance gate, so an edge to a page above the reader's clearance is not returned at all — its count, direction and type included.

Revision-level provenance for derived_from (which revision was consulted) is not shown here; that belongs to the per-page provenance surface, described next.

The provenance block on the page-read view

A page in a space marked isKnowledgeBase carries a Provenance block above its body, reading GET /api/pages/:id/provenance. It shows the ingest that most recently wrote the page — the principal that ran it, the approval recorded on the batch, the source reference and the instant — the batch it ran under, and the page's outgoing derived_from edges as links to each source, each labelled with the revision the edge pins. The batch is shown as a value rather than a link: no batch-detail destination exists yet. A page outside a knowledge-base space renders no block and issues no provenance request — it still reads its own space, which is what decides the gate, and that read is cached and already issued by the breadcrumb trail on the same render. A space read that fails leaves the block shut rather than open.

Three things about it are contractual rather than cosmetic, and each exists because the endpoint's own guarantees would otherwise be undone in the presentation:

  • Nobody is personified. The ingest principal is ingest_batches.agent_principal, an opaque subject that is not necessarily an agent and not necessarily the revision's editor; the approval is free text that nothing validates against an identity. Both render as plain values, without an avatar or a person-shaped label.
  • The empty state never says "never ingested". A null ingest is deliberately indistinguishable between never ingested, every stamped batch reverted, and a classification ceiling above the reader's clearance. Wording that picked the first would be false for the third and would disclose in prose what the ceiling withholds in SQL.
  • The two halves are independent. A page may have an ingest, sources, both or neither. An absent half is omitted rather than rendered as empty rows, and the empty state appears only when both are absent.

Read-only presentation of a knowledge-base page (PLT-543)

The same marker that draws the provenance block also decides how the page-read view presents editing. On a page whose space carries isKnowledgeBase:

  • No Edit control is rendered — neither a link nor a disabled button. The state is said instead as a Read-only chip in the row that reports what the page is, immediately after the page type, so a reader is not left inferring a governed page from an affordance that silently vanished.
  • The provenance block opens with the reason and the way forward, above the ingest and source halves: knowledge-base content changes through the ingestion and review loop, and a Request change control puts the reader into that loop.
  • Request change posts to POST /api/pages/:id/request-change with a free-text note. It needs no write permission and never touches the page — it files a request_change finding for a curator. A request that converged on one already standing is reported as such, and says plainly that the note was not added: the endpoint keeps the standing request's original text.

Two properties of this surface are deliberate and easy to get wrong when editing it:

  • It states what this view does, never what the server will do. Enforcement of the knowledge-base write policy additionally requires the tenant's reconciliation verdict and a per-space provenance re-proof, and neither is projected onto anything a browser can read. On a tenant where enforcement is still dormant an ordinary write into a marked space still succeeds, so copy promising a server-side refusal would be false there. The wording is pinned as exact sentences in kb-read-only-model.test.ts for that reason.
  • An undecided or failed space read presents the page as editable — the opposite direction to the provenance block, which stays shut. A wrong read-only would take Edit away from every ordinary page in the wiki for the length of a space read; a wrong Edit costs one reader on one knowledge-base page a trip to an editor whose save the write policy refuses. The trade is available only because this is presentation and not the boundary.

R2 guard — source_summary pages must carry provenance

A source_summary page must carry an outgoing derived_from link at all times. This invariant is enforced in PageService:

  • Create: a POST /api/pages (or create_page MCP call) with pageType: 'source_summary' and no derivedFrom field returns 400.
  • Update: a PATCH /api/pages/:id that would result in a source_summary page (via pageType conversion) but the page has no existing derived_from link also returns 400.

The check runs in PageService.create/update — not just in the ingest tool — so the constraint holds for every write path.

GET /api/pages/:id/related answers "what else is about this" for one page, returning up to four pages with the metadata a card renders: { id, spaceId, slug, title, pageType, classification }.

It reuses the topic derivation described under How pages are grouped into topics, which until now only ran during an index rebuild, and it applies the two branches of that derivation in order rather than merging them:

  • Shared root. The page's outgoing derived_from edges are followed transitively to every terminal root (a page with several sources keeps all of them), and the pages whose own chains reach one of those roots are the candidates. A source page anchors by type, so its derivatives are its related pages.
  • Visible-parent siblings — only for a page with no visible provenance. The condition is participation, not emptiness: a page that takes part in the graph — a source, a member of a derived_from chain, even one whose own topic turns out to hold nothing else — keeps its topic and returns an empty list rather than its folder. Anchoring is judged on RLS-visible rows, so an ordinary page whose only provenance edge points at something above the reader's clearance is un-anchored for that reader and does fall back — a source does not, since its anchoring is a matter of type and no edge is involved. Only a page that is not a source and is in no visible derived_from edge falls back to the other pages under its parent, and those siblings must themselves have no provenance, since a sibling that derives from something belongs to that source's topic. The two branches are never merged, and the sibling list is never used to top the first one up to four.

The fallback reaches the parent by joining to the parent row as the caller can see it, never by trusting the stored parent_id — so a parent hidden by classification yields the same empty answer as a page with no parent, and the block cannot become a probe for a page above the reader's clearance.

What is deliberately not returned: source pages (raw provenance — read them through the links or provenance surfaces), pages of a lower curation tier when a higher one exists for the same root (a source_summary shows only when no higher-tier candidate shares its root — any ordinary content page outranks it, not just a synthesis), reserved navigation pages, maintenance_schema contract pages, archived pages, pages from another space, and any field naming why two pages are related. The topic key and the root are omitted because nothing renders them, not because they would leak: a root reached by walking is reachable only through a link whose both endpoints the caller can see, a fallback root is their own visible parent, and a subject that is its own root is the page they just asked for.

The whole read is a single recursive statement executed under the caller's own tenant and clearance context, so an edge whose other endpoint is above the caller is simply absent from the walk. A page the caller cannot see is a 404; a visible page with nothing related is 200 with an empty array.

Where it is rendered (PLT-535). Every visible page shows a Related pages card below the body, under the knowledge-graph block. Each entry is one link covering the whole card, named by the page title alone, with the page type and the classification tier — through the shared ClassificationBadge — reachable as its description. The page type is shown as the stored value: page_type is free text in the schema, so a humanising lookup would fall back to the raw value anyway and spell the same page two ways.

A page with no related pages renders an empty state inside the card rather than hiding the card. That is deliberate: it distinguishes "nothing shares a topic with this page" from a block that is missing or failed, which a suppressed block cannot. A failed read renders an authored error message, never the API envelope's, which the wiki's policy errors fill with role names.

Index & log pages

Each space has a reserved, agent-maintained index hub + log page (PLT-196), identified by a deterministic slug convention and capped at one of each per space by a unique partial index on wiki.pages (tenant_id, space_id), plus a set of per-topic index spoke pages:

  • index page (slug <space-slug>-index, page_type = 'index') — since PLT-281 a topics-only hub: a ## Topics table with one compact row per topic (its label and the slug of its spoke page). The hub's rendered size grows with the topic count, not the page count — adding pages to an existing topic grows that topic's spoke, never the hub. PLT-628 dropped the row's former page-count column, because the hub had grown to 84 topics / 10 362 chars and was consuming the coordinator's entire index budget — starving the very spokes the split exists to deliver. The label is deliberately not clamped tighter than the shared index budget: the coordinator ranks a topic on its rendered label, so a tighter clamp buys a few rows by making topics unsearchable. Note the size bound is proportional to topic count, not absolute: past roughly 27 topics the hub no longer fits the coordinator's hub cap and the map is delivered partially, so keeping topic count low (consolidating single-page topics) is what keeps a space fully navigable in one consult. An agent reads this first to learn what topics a space holds, then resolves the relevant spoke(s) by slug. (Before PLT-281 the index page was a single flat catalog of every page; that scaled with the page count and is the problem the hub-and-spoke split fixes. Cells are still clamped — Title ≤ 80, Summary ≤ 72 at render time, PLT-279.)
  • index_spoke pages (slug <space-slug>-spoke-<digest>, page_type = 'index_spoke', PLT-281) — one per topic, holding that topic's detailed ## Read first + ## Provenance catalog rows (the same rows that used to live in the flat index, now partitioned by topic). Many per space (not a single reserved slot). Reconciled on every rebuild: new topics get a spoke, changed spokes are rewritten (an unchanged spoke is a no-op), and a spoke whose topic no longer exists is emptied in place — kept alive at its deterministic slug rather than soft-deleted, because the page-slug UNIQUE constraint is not partial on deleted_at, so a tombstone would reserve the slug and a later rebuild for a returning topic would hit a unique violation (a hard delete is not available — the append-only revision trigger rejects DELETE). Like the hub they are is_agent_owned, UNCLASSIFIED, and excluded from the orphan-page lint.
  • log page (slug <space-slug>-log, page_type = 'log') — an append-only chronology of ingest / maintenance events. Each entry is prefixed ## [YYYY-MM-DD] <action> | <title> (UTC date) so the log stays grep-able.

Where they sit in the page tree (PLT-697)

The hub and the log are root pages; every index_spoke is a child of the hub. That is the shape in every space — the rebuild does not branch on space kind, because a space without an index silently under-serves index-first retrieval while search_pages keeps working, which is the failure PLT-479 fixed.

<space>-index "Index — <Space>" (root)
└── <space>-spoke-* the N spokes (children of the hub)
<space>-log "Log — <Space>" (root)
<human-curated folders> (root)

Two consequences worth knowing:

  • Slugs are unchanged. Page-slug uniqueness is (tenant_id, space_id, parent_id, slug), so a spoke keeps <space-slug>-spoke-<digest> under the hub. The coordinator's KB read resolves spokes by the slugs filter, never by path or root-ness, so nesting is invisible to query_knowledge_base.
  • by-path gained a segment, and bare slugs still resolve. A spoke's canonical path is now /wiki/by-path/<space>/<space>-index/<space>-spoke-<digest>. The one-segment form still works: path resolution falls back to a space-wide slug lookup when no root page holds the slug and the path is a single segment, resolving only if exactly one live page matches. That fallback is what keeps get_page(page: "<spoke-slug>", spaceSlug: "…") — the drill-down query_knowledge_base prints in its own output — working after the nesting. Root pages still win, multi-segment paths stay exact, and an ambiguous slug resolves to not-found rather than to a guess (the same rule the coordinator's fetchWikiPageBySlug applies).

The single-segment fallback is generic, not spoke-specific: any nested page becomes reachable by its bare slug when no root page holds it. That is monotone — no path that resolves today changes meaning — but a short URL that works now can start 404ing once someone creates the same slug under a different parent, so the canonical reference to a page stays its full path or its id. Ambiguity is also judged over the rows the caller can see, so a lower-clearance caller can resolve a slug that 404s for a higher-clearance one who can see both twins.

Spokes created before PLT-697 sat at the tree root. The rebuild is forward-fixing: it moves a root spoke under the hub — updating the existing page, never creating a second one at the same slug — and reports the count as spokesReparented in the per-rebuild wiki.space_index.rebuild log record. That counter should be non-zero exactly once per space. A rebuild refuses (409) if it finds two live index_spoke pages already sharing a slug, rather than rebuilding over a state in which a KB read of that topic silently returns nothing.

Run the explicit rebuild before anything ingests into a space carrying pre-PLT-697 spokes. rebuildIndex also runs inside the ingest_source / retract_source transaction, which uses Prisma's 5 s default rather than the rebuild route's 50 s SPACE_INDEX_TX_TIMEOUT_MS — and the forward-fixing pass is the one run materially more expensive than steady state (each moved spoke adds a parent-in-space check and a cycle walk). An overrun there rolls back the ingest. After one successful explicit rebuild the cost is back to steady-state no-op.

The rebuild collects content pages paginated (excluding the reserved index/log/index_spoke navigation pages in SQL, so the freshly-written spokes — which sort first by updated_at — never crowd out real content), up to a 2,000-page backstop that logs loudly if exceeded. The coordinator KB reader (queryKnowledgeBase in @constellation-platform/coordinator) reads the hub and fetches only the question-relevant spokes via the pages list API's slugs filter (GET /api/pages?...&slugs=a,b,c, comma-separated, max 50) — an O(selected) read so per-consult token cost stops scaling with the KB.

How pages are grouped into topics

Because the rebuild writes one spoke page per topic, how pages cluster determines how much work a rebuild does — and whether it fits in one transaction at all.

  • A page anchored in the derived_from graph (as either endpoint — the deriving page or the source it derives from) is keyed by the root source of its chain, so a whole source → source_summary → synthesis lineage becomes one topic. This is the ingested-space shape (PLT-271).
  • A page in no derived_from edge at all is keyed by its parent page (PLT-479). derived_from edges are only ever written by ingest_source, so without this fallback every page in a hand-authored space would be its own topic — and therefore its own spoke page. The Constellation Default space (578 content pages, zero provenance edges) produced 578 topics that way and could not be rebuilt inside the transaction budget at all; keyed by parent it is 24. A root-level page keys on its own id, so a folder page joins the topic its children form and supplies that topic's label.

Rebuild budget and failure reporting

The rebuild and log-append transactions each carry an explicit interactive-transaction timeout sized against maxWait + timeout (Prisma may spend up to its 2 s default maxWait acquiring a connection before the timeout clock starts) so it stays inside that route's serverless function budget with headroom — the transaction, not the platform, must be what gives out first, because a transaction error is attributable and a platform 504 is not. The rebuild route is granted maxDuration: 60; log-append runs under the default API cap.

A transaction that does expire returns a typed INDEX_REBUILD_BUDGET_EXCEEDED (503) naming the space, the counts reached before it died, and the budget — not a generic 500. The message names topic count as the likely cause but does not assert it: lock contention (a concurrent rebuild or ingest on the same rows) and general database slowness expire a transaction identically and clear on their own, so it tells the operator to retry once to distinguish the two. Every rebuild through the rebuild tool / POST /index/rebuild route — including a failed one — emits one structured wiki.space_index.rebuild record (content pages, topics, spokes created/updated, elapsed ms, outcome), and warns while still succeeding once it crosses a fraction of the budget, so a space approaching the cliff is visible before it goes over. Rebuilds embedded in another tool's transaction (ingest, source retraction, expired-page reaping) call the service directly, own their own transaction and budget, and currently emit no such record and surface a generic 500 on expiry — do not expect telemetry from those paths (PLT-482).

Both pages are created with is_agent_owned = true, are UNCLASSIFIED, and are kept up to date by two write-gated endpoints (and the matching MCP tools below). A rebuild is a full, idempotent regeneration from the space's current pages; a log append is read-concat-write. Soft-deleting either page frees the per-space slot, so a later rebuild can recreate it.

  • POST /api/spaces/:spaceId/index/rebuild — regenerate the space's index page. Returns { data: { indexPageId } }.
  • POST /api/spaces/:spaceId/log/append — append one { action, title, detail? } entry to the space's log page. Returns { data: { logPageId } }.

Both routes require the wiki:page:write permission and are tenant-scoped: a caller can only rebuild / append within a space their tenant owns.

Ingest loop (PLT-197 + PLT-203 fan-out)

The ingest loop is the mechanism that turns an external source into compounding knowledge inside the wiki. It is human-approved only (R7): there is no autonomous trigger and approvedBy is required and non-empty.

What an ingest does

A single ingest call (one source, one summary, optional fan-out):

  1. Acquires a per-space serialization lock so concurrent ingests into the same space run strictly one-at-a-time (transaction-scoped advisory lock, released on commit/rollback — the lock makes the entire fan-out + index/log rebuild the critical section).
  2. Reads the space's maintenance_schema contract page if one exists (advisory — absence is not fatal; the consulted revision number is recorded in the log entry).
  3. Creates a source page (is_agent_owned: true, body_md = the pasted source text). This is the provenance anchor.
  4. Creates a source_summary page (is_agent_owned: true, derivedFrom: { sourcePageId, sourceRevisionNum: 1 }). The R2 guard in PageService.create validates the edge.
  5. (PLT-203 fan-out) For each entry in derivedPages[]:
    • op: 'create' — creates a new entity/concept/synthesis page with a derived_from provenance edge to the source at revision 1. Both the revision and the link are batch-stamped.
    • op: 'update' — writes a batch-stamped revision to the target page (updated body + optional summary) and adds a batch-stamped derived_from edge from that page to the source. This lets a pre-existing synthesis page accumulate provenance from multiple ingests.
  6. Rebuilds the space's index page (includes the new derived pages).
  7. Appends an ingest entry to the space's log page (detail records the fan-out count).

All writes happen inside a single withTenantContext transaction and are stamped with a shared ingest_batch_id. This makes the ingest fully reversible (see below).

Per-space ingest policy

Every space carries an ingest_policy JSONB column (default: {"autonomy":"human_approved","maxPagesPerIngest":15}) that governs fan-out behaviour:

FieldTypeDefaultMeaning
autonomy'human_approved' | 'autonomous''human_approved''autonomous' is deferred until PLT-214 (non-bypass soft-delete). Attempting autonomous ingest is rejected 400.
maxPagesPerIngestinteger 1–5015Maximum number of fan-out pages (derivedPages) in one ingest. Over-budget requests are rejected 400 before any write.
nominateFrombooleanfalsePLT-269: the watched-space gate. When true, pages created/updated/published in this space are evaluated for KB ingestion candidacy. Opt-in — the KB space itself and scratch spaces stay false.
nomination'nominate' | 'exclude' | 'inherit''inherit'PLT-605: the per-page/folder override. Inert on a space row — a space is the root of the chain, so its contribution stays nominateFrom. See Inherited folder policy below.

The policy is configurable via the PATCH /api/spaces/:spaceId endpoint (ingestPolicy field). The source_summary page does not count toward the budget — only the entries in derivedPages[] do.

Inherited folder policy

wiki.pages carries the same-shaped nullable ingest_policy column (PLT-605). A NULL column means inherit, so every page written before this existed behaves exactly as it did. Only the nomination key is read at page level; the fan-out budget stays a per-space concern.

A "folder" is simply a page with children, so a folder's policy lives on that page row and governs its whole subtree — including pages added to it later, which is what a one-time bulk action could never cover. Resolution is page > folder > space: the nearest ancestor that sets a non-inherit value wins, and if none does, the space's nominateFrom decides. Nearest-wins is the whole rule — a nominate on a subfolder beats an exclude further up, and vice versa. An explicit per-page exclusion (POST /api/pages/:id/kb-exclude, below) is stronger than any inherited nominate.

One limit worth knowing before relying on nominate: it takes effect on the event path — a page created, updated or published fires candidacy and is nominated. The two sweeps that go looking for pages, the one-time backfill and demand-driven nomination from KB query-misses, still enumerate watched spaces first, so a subtree opted in inside a space whose nominateFrom is false is not discovered by either. Closing that is tracked separately. An exclude has no such asymmetry on the nomination paths: it is honoured everywhere, including on the demand path.

There is, however, a limit on the queue rather than on nomination, and it is worth knowing before relying on a folder-level exclude as a containment control. Setting exclude retires the excluded page's own open ingestion_candidate finding, but not those of its descendants. A descendant nominated before the exclusion keeps a pending entry that is still approvable, because no event fires for it and the ingest path does not re-check the policy when a candidate is consumed. So the control governs what is nominated from that point on; it does not retroactively clear a queue built earlier. Closing that is tracked separately. Excluding the descendant itself (POST /api/pages/:id/kb-exclude) does retire its entry immediately.

Resolution runs inside the wiki.resolve_nomination_policy SECURITY DEFINER helper rather than as an ordinary recursive read, because it must cross ancestors the caller cannot see: page classification is not required to increase down the tree, so an UNCLASSIFIED page may sit under a CONFIDENTIAL folder, and a plain read would stop there and silently stop enforcing that folder's exclude. The caller receives the resolved value; the identity and level of the row that supplied it are returned only when that row is itself visible to them, so a page never discloses a clearance-hidden ancestor (the PLT-658 contract).

Moving a page is a policy change too (PLT-606). Re-parenting a page, or moving it to another space, changes the ancestor chain the resolver walks — so the policy the page is subject to can change while its own setting stands still, and a whole subtree can cross under an inherited exclude that way. A move made through PATCH /api/pages/:id is audited under the same wiki.page.nomination_policy_changed action, with the entry showing the page's own override unchanged while the resolved value moves, and it retires that page's stale KB-review entry exactly as an explicit exclude does. A move that leaves the resolved policy unchanged writes nothing — otherwise every re-parent would bury the transitions that matter.

Two limits on that sentence, both tracked separately. It covers the moved page, not its descendants. And it covers moves made through the page API: restoring a page from the trash can also re-home it (to the Default space when its own space is gone, or to the root when its parent is), and that path re-homes through the repository directly, so it emits only its RESTORE entry. Treat the write-time hook as keeping the review queue tidy, not as the enforcement boundary.

Setting a policy (PLT-606). POST /api/pages and PATCH /api/pages/:id accept an ingestPolicy object carrying a single nomination key:

{ "ingestPolicy": { "nomination": "exclude" } }

Three properties are worth knowing before you script against it:

  • Only nomination is accepted. The space-level fan-out keys (autonomy, maxPagesPerIngest, nominateFrom) are rejected, not ignored. They do nothing at page level, and accepting one would return 200 for a request that changed nothing while persisting a value a later release could activate unattended — the mirror image of the space endpoint refusing a nomination.
  • 'inherit' is the clear, and it is stored as a NULL column rather than as an explicit value, so a page that overrides nothing never reads as one that does. Re-sending a policy a page already carries is a no-op: no revision, no audit entry.
  • A page's own override is readable back as ingestPolicy on the page. It is the page's local value, not the resolved one — null means "this page inherits", and says nothing about what it inherits. Reading the effective value is a resolver concern, and deliberately not the same question.

The folder control in the UI arrives with the wiki GUI revamp's Track L.

Classification gate

Fan-out pages are created at UNCLASSIFIED (the default). The tool layer pins the RLS context to UNCLASSIFIED when ingesting, so a fan-out cannot write a classified page and cannot read classified pages into synthesis bodies. Writes above UNCLASSIFIED by the ingest path are blocked until a separate review gate is implemented (locked decision (d) from PLT-194).

Promotion gate — unresolved error findings (PLT-561)

A page-backed ingest (one carrying sourcePageId) into a knowledge-base space is refused with 409 when the source page has an open lint finding at severity error. The destination matters: an ingest into an ordinary space is never KB coverage, so it is not a promotion and is never gated. KB identity is the same resolution the coverage derivation uses — the is_knowledge_base marker, or the hinted slug while the tenant's slug_fallback_allowed latch is still open — applied in the application and repeated in SQL for the database backstop. The knowledge base is meant to hold content the honesty loop has not flagged as wrong; before this gate, a curator could record a contradiction at severity error and the very next ingest would copy that page's body into the KB regardless.

Three things about it are worth knowing:

  • It is enforced server-side, inside the ingest transaction. The findings panel on a page shows the caller-visible findings — not the same set the gate evaluates, which is wider: see the next bullet. Either way the panel is never the enforcement point, because the ingest route is a public API and an agent calling it over MCP renders no panel.
  • The refusal names the category and nothing else — no finding id, no count. The authoritative check runs through a SECURITY DEFINER boolean precisely so that a finding whose other page (targetPageId) is classified above the ingest transaction's UNCLASSIFIED pin still blocks; such a finding is one the caller cannot list, so an id would be unresolvable and a count would disclose a blocker they cannot see. Resolve or dismiss the page's findings through the lint surface, where identities and details live.
  • The gate is enforced in the database, not only in the application. A BEFORE INSERT guard on wiki.ingest_batches refuses an ingest whose source page carries an open error finding, so a build that predates this feature — an outgoing instance during a deploy — cannot promote past it either; and a second trigger on wiki.lint_findings takes the advisory lock for a blocking row, so a writer that does not know the ordering protocol still participates in it. Both are INSERT-keyed, so a third trigger removes the other way in: severity, page_id and tenant_id are immutable on an existing finding and a resolved one cannot be reopened, which leaves the INSERT path — the one that locks — as the only way a page becomes blocked. Resolving or dismissing a finding, and refreshing its detail text, stay permitted.
  • The guarantee covers findings that exist when the ingest runs. An external detector analyses outside the database before it records, so a finding recorded after a promotion commits is not retroactively blocked — that page simply becomes needs-review post-promotion through the normal queue. Refused promotions are not audited: the gate prevents every mutation in the transaction rather than performing one.

A raw-content ingest (no sourcePageId) has no page to evaluate and is unaffected.

ingest_batches table

wiki.ingest_batches records each human-approved ingest:

ColumnTypeNotes
idUUID PKThe batch id stamped on every revision written by this ingest.
tenant_idUUIDFK to identity.tenants; RLS-enforced.
space_idUUIDComposite FK to wiki.spaces(tenant_id, id).
source_refTEXTHuman-readable label for the source (e.g. "RFC 9110 §4").
agent_principalTEXTThe sub claim of the caller who ran the ingest.
approved_byTEXTThe human who approved the ingest (R7 gate).
statusTEXT'committed' or 'reverted'.
created_atTIMESTAMPTZWhen the batch was committed.
reverted_atTIMESTAMPTZWhen the batch was reverted (null if committed).
ColumnTypeNotes
page_countINTDistinct pages the batch touched (created or fan-out-updated).
classification_ceilingwiki.classificationHighest classification across those pages and the recorded origin — a high-water mark, never lowered.
ColumnTypeNotes
source_page_idUUIDThe wiki page a page-backed ingest derived from. NULL when there is none.
source_revision_numINTThat page's revision at the moment of the ingest. NULL exactly when source_page_id is.
ColumnTypeNotes
folder_run_idUUIDThe folder bulk-ingest run this batch belongs to. NULL for an ordinary single-source ingest.

Origin provenance (PLT-687). A page-backed ingest copies the origin page's body into a fresh source page and points the derived_from edge at that copy — never at the origin. So the origin itself is unmarked, and neither page_revisions.ingest_batch_id (which records the pages a batch wrote) nor the free-text source_ref can identify it; matching on slug or title is not an option, because both are mutable and a rename would silently re-point provenance at a different page. These two columns are the durable record.

They are paired: a CHECK requires both set or both NULL, so a half-attributed origin cannot persist — a page id without a revision cannot answer "has the source changed since?", and a revision alone names nothing. Two composite same-tenant foreign keys, (tenant_id, source_page_id) and (tenant_id, source_page_id, source_revision_num), make a cross-tenant or nonexistent-revision origin unrepresentable rather than merely discouraged.

Batches committed before the columns existed read as origin-unknown. There is no backfill: the only way to infer one would be to match a mutable slug or title, and a wrong provenance that looks right is worse than an absent one.

The ceiling follows the origin too. Reclassifying the origin upward raises every batch that names it, by the same monotonic rule as a page the batch wrote — otherwise a batch built from now-classified material would keep advertising its source_ref, and the existence of its run, to a caller who is not cleared for it. (That is the batch-metadata half of the disclosure; the UNCLASSIFIED copy of the origin body that the ingest wrote is not reclassified by this mechanism.)

Neither column travels on the ingest response or the revert response. Since PLT-662 the revert lookup is ceiling-gated, so a caller the ceiling hides never reaches that response at all — the redaction is now defence in depth rather than the only control, and it still earns its place: it keeps the origin from a caller who IS cleared for the batch but has no business knowing which page it came from, and it holds for any future ungated batch read. The coverage columns are redacted less strictly — they are returned by the ingest, and omitted only from the revert response — because the ingesting caller already knows what it just wrote, while a later reverter may not.

Coverage stamping (PLT-506). Both columns are written inside the ingest transaction, after every page the batch writes exists and immediately before the ingest.batch.committed audit entry — which is what lets that entry record the stamped values. A batch row is therefore never visible without its coverage. page_count is stamped once and no ordinary write changes it afterwards — a committed batch's touched set cannot grow. (The privileged repair below may still correct it, which is how a batch committed before the code deployed gets a real count instead of the 0 default.)

classification_ceiling is different, because an ingest-time snapshot goes stale: PATCH /api/pages/:id writes classification straight to wiki.pages, and the revision that update produces carries no ingest_batch_id (only an ingest stamps one), so nothing links the reclassification back to the batch. Reclassifying a covered page upward therefore raises every covering batch's ceiling, atomically, in the same transaction as the reclassification. Since ingest itself is pinned to UNCLASSIFIED, this is the only way a batch ever becomes classified.

The ceiling never falls. A downward reclassification is a deliberate no-op: the ceiling gates who may see that a batch exists and how large it is, and a batch that has at some point been associated with classified material stays gated at that level. Comparison is always via clearance_rank() — the classification domain is TEXT-backed, so < on it would be lexical and would rank SECRET below UNCLASSIFIED.

wiki.repair_ingest_batch_coverage() recomputes both columns. The batch-stamped revisions identify which pages a batch covers and, per page, the revision at which it first touched them — that gives page_count directly. The ceiling is derived more widely: from each covered page's current classification plus the classification recorded on every revision of it from that first stamped revision onward, batch-stamped or not. That wider scan is what recovers the high-water mark for a batch reclassified during the deploy window, where the raise never ran and the page has since been downgraded; bounding it at the first stamped revision keeps a classification the page held before the batch touched it from being inherited.

A second pass then repeats that scan over the batch's origin (source_page_id), bounded at source_revision_num, and raises the ceiling again if the origin is more classified than the written pages were. It leaves page_count alone, which counts only pages the batch wrote — the origin is not one of them. The return value is the number of batch rows changed, so a batch corrected by both passes counts once, not twice; a converged repair still reports zero.

It is idempotent and monotonic, and must be run under the privileged migration role after a release deploy: migrations are applied before the code ships, so batches committed in that window carry the column defaults until the repair runs. It returns the number of rows it changed.

The table has FORCE ROW LEVEL SECURITY and a combined USING/WITH CHECK policy on tenant_id. That policy still has no clearance gate — it is unchanged — so every read of the batch row has to apply the ceiling itself, through the one shared clearance_rank() predicate the per-page provenance reads and the revert lookup both go through. That covers the row, not every trace of the batch: a direct read of the UNCLASSIFIED src-<batchId> page still reveals one, as the provenance section below records. Two consequences follow from the same fact: the revert response omits the coverage fields (their redaction policy lands with the batch-listing surface), and the revert lookup is gated — see Reverting an ingest.

folder_runs table (PLT-612)

A folder run is one bulk ingest over a folder's descendant pages, committing one wiki.ingest_batches row per page. wiki.folder_runs is its creation-time ledger:

ColumnTypeNotes
idUUID PKThe folder_run_id stamped on every batch of the run.
tenant_idUUIDFK to identity.tenants; RLS-enforced.
root_page_idUUIDThe initiating folder, which is an ordinary page row.
target_space_idUUIDWhere the run writes — not necessarily the folder's own space.
job_idUUIDThe jobs.queue row driving the run, or NULL before it is enqueued.
statusTEXTpending, running, completed, failed or reverted.
idempotency_keyTEXTClient-supplied request key, UNIQUE per tenant.
request_fingerprintTEXTWhat was requested, so a same-key retry describing different work is detectable.
classification_ceilingwiki.classificationThe run's own gate. NOT NULL with no default. Maintained since PLT-862.
source_countINTHow many sources the request previewed; checked against the manifest at commit.

Why a run is not a column on ingest_batches. A run exists before its first batch: it is created when the request is accepted, is enqueued, and can be observed as pending — or fail outright — without ever committing one. A grouping column on the batch table can only describe runs that have already produced a row, so the pending run would exist nowhere. Normalising also makes one defect unrepresentable rather than merely unlikely: with a root-folder column repeated on every batch, nothing declarative stops two batches of one run disagreeing about which folder started it.

One destination per run, enforced by the foreign key. ingest_batches.folder_run_id carries a three-column FK, (tenant_id, folder_run_id, space_id) against folder_runs(tenant_id, id, target_space_id), so a batch cannot join a run while writing somewhere else. The run's target is deliberately not required to equal the initiating folder's space — the ordinary flow is a folder in a working space producing summaries in the knowledge base.

The run ceiling is maintained, and it dominates three inputs (PLT-862). Like the batch ceiling above it, classification_ceiling is a monotonic high-water mark rather than a live aggregate — but a run is gated on it as a whole, so it must dominate the initiating folder, every committed batch, and every still-queued source. Reclassifying the folder or a queued source upward raises the run in the same transaction, as does reclassifying any page a committed batch covers; a batch itself carries no classification, so its contribution is its stored ceiling, handed up at commit. A downward reclassification is a no-op, and the database refuses one applied directly. Comparison is always through clearance_rank(): the classification domain is TEXT-backed, so < would rank SECRET below UNCLASSIFIED.

The third input is why wiki.folder_run_sources exists. A run's per-source identity otherwise lives on wiki.ingest_batches and appears only when a batch commits — never, by definition, for a source still waiting in the queue. That is the dangerous case rather than an edge one: a source reclassified between enqueue and its first batch is skipped by the per-source eligibility check and raises no batch ceiling, yet the run must still hide from a caller who can no longer see that source.

Deriving the queued set from the folder's current descendants would need no table and is unsound: a source moved out of the folder, or newly excluded by a policy set above it, is still queued while no longer a descendant — so the derived set misses members rather than over-approximating them, and the gate would fail open. The manifest is therefore recorded at creation and immutable, and every run must declare how many sources it previewed (source_count, nullable with no default) with a DEFERRABLE INITIALLY DEFERRED check enforcing an exact match against the rows it wrote. Omitting the declaration is refused; declaring N and writing anything other than N is refused; appending to the manifest afterwards is refused. What it cannot catch is a dishonest count — an explicit 0 with no rows is indistinguishable from a folder that genuinely previewed nothing, and no declarative constraint can match a child set against an application-computed preview. That residue is the run writer's obligation.

A source joining the manifest is itself a raise, and the trigger that performs it runs SECURITY DEFINER. The deferred trigger would otherwise run as the creating session, and pages_tenant_read hides a page classified above that session's clearance — so the read would match nothing for precisely the source that has just been classified above the run's creator, the case the ceiling exists for, skipped silently.

The privileged read lives only inside that trigger. An earlier revision exposed it as a callable helper granted to the runtime role, which turned it into a clearance oracle: point it at a readable run and a hidden page id, and the run's ceiling — which wiki.folder_runs carries with no clearance gate — comes back holding that page's classification. Postgres refuses a direct call to any function RETURNS TRIGGER, and EXECUTE on the trigger function is revoked from constellation_app as well as from PUBLIC: without that second revoke a role could attach the definer to a table it owns in its own temp schema and drive it with a crafted row, which would be a cross-tenant write rather than merely a read. Trigger firing itself does not consult EXECUTE, so the production triggers are unaffected.

The runtime role holds no INSERT on wiki.folder_runs or wiki.folder_run_sources: the creation-time raise happens in a plpgsql trigger, which cannot emit the auditCritical() entry §5 requires alongside an access-gate change. "No application code creates a run" was the earlier answer and is a fact about today rather than a control, so the privilege is revoked until PLT-611 ships an audited constructor to own it. FolderRunRepository has no create path, so nothing deployed loses a capability.

For the window in which migrations apply before the maintaining code deploys, wiki.folder_run_ceiling_candidates(tenant) folds the same three inputs offline, reading the page revision history of the folder and of each previewed source so an upward-then-downward change does not lose the mark. It takes one tenant per call, and it is deliberately read-only: it reports each run whose ceiling is below its high-water mark, with the value it holds and the value it should hold, and locks the tenant's runs so the caller writes what it read. npm run db:repair-folder-run-ceiling -w @constellation/wiki -- --tenant <uuid> performs the raise and emits an auditCritical() entry per raise in the same transaction. Raising a run's ceiling changes who may observe and revert it, so §5 makes it audit-critical — and keeping the write out of SQL is what makes that structural rather than a promise: a caller-settable "I will audit" flag was tried and removed, because intent is not a guarantee. There is no estate-wide mode: enumerating tenants would be an unscoped cross-tenant read, so an operator covering several runs the command once per tenant. Run db:repair-ingest-coverage to convergence first — the repair reads each committed batch's stored ceiling. It requires an RLS-bypassing role: a clearance-gated caller would fold a smaller maximum and silently write nothing.

Its transaction ceiling is WIKI_REPAIR_TX_TIMEOUT_MS, default 600000 ms — the same shape as SPACE_INDEX_TX_TIMEOUT_MS above and for the same reason: the repair folds an unbounded revision history and then issues an UPDATE plus an audit call per raised run, so Prisma's 5 s interactive default would roll the whole tenant back and leave the under-classified ceilings in place. A non-numeric or non-positive value is rejected rather than silently falling back, so a typo cannot quietly reinstate the short one. Two consequences of a transaction that long are worth knowing before running it: the command refuses to report success if any of its own audit entries committed without a chain hash — concurrent audit traffic for the same tenant can force that degradation under REPEATABLE READ (PLT-953) — and the ceiling changes are committed and correct when it does, so the remedy is to re-run when the tenant is quiet, not to treat the raise as lost.

That history is read in full, not bounded to the run's lifetime. The natural bound — only marks recorded after the run was created — cannot be expressed soundly: now() is transaction start time, so a reclassification that began before the run's creation transaction and committed after it carries an earlier stamp and would be dropped, leaving the run under-classified. Postgres exposes no commit ordering after the fact. The cost of reading everything is the opposite failure: a folder that briefly held a higher classification long before the run existed will raise it, permanently. That is the fail-closed side.

Reading a folder's runs (PLT-613)

GET /api/pages/:id/folder-runs returns one page of that folder's bulk-ingest runs, newest first, with the total. It exists because the platform jobs abstraction fetches a status only when the job id is already known and offers no listing — so without it a run is unreachable after a reload, or once its descendants are reparented.

A run is all-or-nothing to a caller. Visibility is decided once, at run granularity, and never assembled from the batches the caller happens to see. Any per-batch differential — partial progress, or a run that appears and then cannot be reverted — lets a caller infer that a batch they were not cleared for exists, so an over-ceiling run is observably absent: no placeholder row, no hidden-count indicator, and no gap in the page, because the exclusion precedes the ordering, the pagination and the total alike. pending, running, completed, failed and reverted runs gate identically, and a run that has committed no batch at all is still listed.

Inside a run that a caller can see, the progress aggregate counts every batch of the run — deliberately unfiltered. A count that shrank when one batch was reclassified would be the differential the run-level gate exists to remove.

FieldNotes
idNames the run for a whole-run revert.
jobIdThe jobs.queue row, or null before the run is enqueued.
statusThe run lifecycle value.
createdAt / updatedAt
batchCount / committedBatchCount / revertedBatchCountOver all of the run's batches.
pageCountSummed ingest_batches.page_count — durable, so a reverted run still reports what it wrote. Distinct within a batch, so repeated touches inside one batch count once, while a page touched by two different batches of the run contributes twice.

classificationCeiling, idempotencyKey and requestFingerprint never travel: the first is the gate's own input, and the other two are replay controls rather than display data. rootPageId and targetSpaceId are withheld for want of a consumer.

The folder's own visibility is a separate requirement, not part of the ceiling gate. It is page-scoping: a sub-resource of a page must not let a caller enumerate what hangs off a page they cannot see. A folder the caller cannot see — absent, soft-deleted, in another tenant, or classified above them — is a 404 in all four cases identically; a visible folder with no visible runs is 200 with an empty array, deliberately indistinguishable from "every run here is above your clearance", so a client must not render "never ingested" from it.

Per-source ingest inside a folder run (PLT-609)

A folder run is designed to commit one source per transaction, through a capability that is handed that transaction rather than opening one. That is what will keep a source which is refused — or which fails — from rolling back the sources the run has already committed, and why a run's progress is per source rather than all-or-nothing.

The capability ships here; the worker that drives it does not. The POST endpoint, the job payload and the handler belong to PLT-611, so nothing in production calls this yet — exactly as the run ledger it builds on shipped ahead of its writer. What follows describes the contract that worker will hold to, not behaviour a folder run performs today.

The capability returns a disposition, not an exception, when a source is not ingested:

Skip reasonWhat happened
already_committedA batch for this (run, source) exists — an at-least-once replay.
source_unavailableReclassified, deleted or unlockable since the run was enqueued.
outside_run_rootReparented out of the run's initiating folder since the run was enqueued.
excludedExcluded by its own kb_excluded_at, or by an ancestor's ingest_policy.
already_coveredAlready in the knowledge base per the canonical KB-status derivation.

Why eligibility is re-resolved at commit time. A run is asynchronous: the preview lists sources, the run is enqueued, and a worker executes each source later. Every answer the preview recorded is a claim about the past, so committing on its strength is what would let a run bypass a governance change that has already committed. Two of the conditions above cannot be seen from the source row at all — membership lives in the parent_id chain, and inherited exclusion is resolved by walking it — so a concurrent transaction changes either without touching the source.

Every ancestor walk stops at the same depth, and counts it the same way. Three functions walk a page's parent_id chain under a cap declared once, in wiki.ancestor_walk_max_depth() (migration 052) — wiki.is_page_in_ancestor_chain, wiki.resolve_nomination_policy and wiki.lock_ingest_governance_rows. The cap counts edges from the walk's seed row and the bound is inclusive: a chain that terminates at or before that many edges above the seed is walked in full and accepted, and refusal happens only when a row still exists beyond it. Each function refuses in its own way — a raise, an exclude, a truncated flag — but they agree on whether a given chain is within the cap. That agreement is not free: they previously disagreed by one, so a folder-run source whose initiating root sat exactly at the cap had its ancestry pinned and reported valid by the lock and then aborted the run from the membership walk immediately behind it.

Note the seed differs by caller. resolve_nomination_policy and lock_ingest_governance_rows seed at the anchor page; is_page_in_ancestor_chain seeds at the candidate parent, so its reach is one edge further from the page being written than the number suggests. That is deliberate, and it is why the shared statement is about the seed rather than about "the page".

All three also refuse a tenant they were not called for. Each is SECURITY DEFINER over an owner that bypasses RLS, so the tenant a caller passes is not automatically the tenant the caller is. Each compares its tenant argument against wiki.session_tenant() and raises 42501 on a mismatch. is_page_in_ancestor_chain gained that check in migration 052; before it, the in-loop tenant_id filter scoped the walk but could not stop the argument naming a foreign tenant — which only meant the boolean answered about that tenant's rows, making the function a cross-tenant ancestry oracle for anyone holding EXECUTE. The rejection names neither tenant: an error printing the session tenant beside the rejected one would answer, in its own text, the question the check exists to refuse.

The knowledge-base ingest predicate (migration 058). wiki.ingest_batch_is_live_kb_ingest_for_page(tenant, page, batch) answers whether that batch is a live knowledge-base ingest whose origin is this page: committed, un-reverted, both its destination space and the anchor's own current space still live knowledge bases, and the source anchor live and un-retracted. It is a SECURITY DEFINER because the anchor page and the revision that stamps it are read past their clearance-scoped policies — the batch row itself is not the crossing, since wiki.ingest_batches carries a tenant-only policy. It carries the same double tenant guard and readable-subject guard as its neighbours, answers false for a page the caller cannot read, and applies no filter on the batch's classification_ceiling, deliberately: scoping the answer by the reader is what would let a sibling page's reclassification hide coverage that is still true.

One space can count as a knowledge base without carrying the marker, and the function resolves that itself. While a tenant's slug_fallback_allowed latch is open the hinted space is treated as a knowledge base by the coverage resolver and accepted as an ingest destination — so a batch can legitimately land in a space is_knowledge_base rejects, and an identity check blind to that would miss exactly those batches. The caller therefore passes the slug its deployment hints at, never a set of space ids: a slug names one space, and whether the fallback is still honoured is read inside the function from the one-way latch. An id argument would have let any holder of EXECUTE decide, from outside, what the predicate means by "live KB ingest".

It exists because two boundaries need the same relation and ask different questions of it. The nomination boundary (wiki.ingestion_candidate_is_suppressed) additionally requires the batch's source_revision_num to still be current — is the KB copy current? — while the approval boundary omits that clause — would this create a second copy? A stale copy is still a copy, so an approval is refused for a live ingest whatever the revision, and refreshing a stale copy is a revert-and-re-ingest. One definition of the shared core, two questions over it; a boolean about a pair the caller already holds, never an enumeration. See Approving a candidate the knowledge base already covers for the behaviour it produces.

Sibling-partition locks, and what they serialise (PLT-875)

Manual sibling ordering (ADR-030) renormalises the whole visible sibling set of every partition a write touches, and that is only safe once every running instance takes the partition lock. Because a lock cannot become load-bearing during a rolling deploy — an outgoing instance takes none of them — the acquisition ships one release before anything relies on it. This is that release: the locks are taken and no ordering key is written yet.

A sibling partition is (tenant_id, space_id, parent_id), so a space's root pages are one partition and each parent page's children are another. Every writer that can change a partition's visible membership now takes the tenant :slug-namespace lock and then the :page-siblings locks for every partition it touches, before it mutates a page: creating a page, moving or re-parenting one, changing its classification (which changes who can see it among its siblings), trashing it, restoring it from the trash, and the ingest fan-out together with its revert.

What this changes for an operator is contention, and nothing else. No request or response shape changes, and no page gains a stored order yet. What does change is that these writes serialise per tenant where some of them previously did not — the namespace lock is tenant-wide, and it is now taken by every create rather than only by a create at the root of the Default space. A wait longer than the transaction budget surfaces as a failed write rather than a slow one, so a long-running writer should be given an explicit timeout (see the guidance on acquireSlugNamespaceLock).

Two consequences worth knowing:

  • A rename takes the namespace lock but no partition lock. A slug change moves the page into no other partition and changes no partition's visible membership — sibling order is (position, id), in which the slug plays no part.
  • Creating a page no longer writes to wiki.spaces first. A create used to materialise the tenant's Default space unconditionally, before taking any lock. It now reads that space and writes only when it is genuinely missing, in which case the namespace lock is taken before the write. Ordering it the other way is what would deadlock: the materialising statement waits for a conflicting transaction rather than skipping it, so a create holding an uncommitted Default-space row while waiting for the namespace lock forms a cycle with an ingest that holds that lock and then creates a page. For an operator this means the write is now invisible on every create after a tenant's first.
  • Restoring from the trash reads its destination before it restores. wiki.restore_page decides the destination and mutates in one call, so a read-only companion, wiki.trashed_page_restore_partition, reports the trashed page's own space and parent first. It mirrors that function's eligibility exactly, refuses a tenant or clearance argument that is not the caller's own session (42501), and never returns anything to a caller — it exists to choose lock keys inside the restore's own transaction.

The ancestor governance lock. Because of that, a per-source ingest pins the origin's whole ancestor chain, through the SECURITY DEFINER wiki.lock_ingest_governance_rows (migration 048). It locks the ancestors together with the rows the ingest already locks, in one statement ordered by page id, so the transaction still issues a single ascending lock sequence. It is a definer because the deciding ancestors may sit above the caller's clearance.

Its anti-oracle guarantee is narrower than "it cannot pin a row you cannot read", and the difference matters to anyone building on it. What holds: no row is pinned that the caller could not read when the strong set was validated — the same transaction, microseconds earlier — which is what defeats the attack the guard exists for, since a caller naming a row it never could read is refused before any lock is taken. What does not hold: the unqualified form. The strong set is checked once, before the ordered locking statement, and only the ANCHOR is re-checked after it, so a non-anchor strong row reclassified or trashed while that statement blocks on a lower id is pinned with nothing re-testing it. The residual is bounded — the caller could read that row moments earlier, and the pin ends with a transaction that ends almost at once because the per-row loop then re-requests it under ordinary RLS, gets nothing, and the ingest fails closed. Do not build a security property on the stronger reading. SPEC-plt-609 AC 1 states it in full, and PLT-879 tracks whether to close the residual.

It returns four booleans and nothing else: whether the walk crossed a hidden ancestor (so the constitution §5 entry can be filed for a read that crossed a classification boundary); whether the chain was too deep to lock, in which case the source is skipped as excluded — the same answer migration 037 gives the same truncation; whether the chain moved between the walk and the lock, in which case the rows held describe an ancestry that no longer exists and the source is skipped as source_unavailable; and whether the anchor itself became unreadable.

Its two refusals are deliberately asymmetric. A caller-supplied strong id the session cannot read raises 42501: lockIngestPages validated every one of them under RLS first, so an unreadable one is confusion or an attack, and aborting is right. The anchor becoming unreadable is instead reported, through anchor_unreadable, and the source is skipped as source_unavailable. It has to be, because the function runs inside the caller's transaction: a RAISE there puts it into 25P02, after which every later statement fails and the COMMIT silently becomes a ROLLBACK — so raising on an ordinary governance race would take down work the caller had already done, which is the very contract this capability exists to provide. Nothing is disclosed either way: the readability predicate is pages_tenant_read spelled out, so anchor_unreadable is returned identically whether the page is above clearance, soft-deleted, in another tenant, or absent.

The trade-off this accepts. The chain is held at FOR NO KEY UPDATE, so while a per-source ingest runs, every writer of those ancestor rows waits — another run, an ordinary PATCH on the ancestor (including a rename or a reparent), the space-decommission cascade, retraction and the staleness reaper. Ancestor chains terminate at a top-level page, so runs under one top-level page contend. This is accepted because a run drives one transaction per source and has no intra-run parallelism to lose; if throughput ever matters, the replacement is shared advisory locks keyed per ancestor, which requires every governance writer to adopt the same protocol.

None of this changes the single-source POST ingest, which takes no ancestor lock and keeps its existing gates and its thrown 4xx refusals.

The per-source identity is what makes a retry safe. The platform job queue is explicitly at-least-once, so a worker that dies after some per-page transactions commit is recovered on a later tick. Without a durable identity the recovered run would re-ingest every page it had already finished, duplicating the content and the ingest.batch.committed audit entries. A partial UNIQUE index on (tenant_id, folder_run_id, source_page_id), WHERE folder_run_id IS NOT NULL, closes that — and a CHECK rejects a run batch with no source_page_id, because Postgres treats NULLs in a unique index as distinct and would otherwise admit any number of origin-less batches into one run. Raw-content ingest is untouched: with folder_run_id NULL the CHECK is satisfied and the partial index does not select the row.

The index carries no status predicate. A revert is an in-place transition — the batch row survives and flips — so run membership is immutable, which is what lets the group revert prove a whole run was reverted against a fixed set. Re-running after a revert is a new execution: new run id, new idempotency key. "Un-revert within the same run" is deliberately not representable.

Two dedup keys, and they do different jobs. The per-source identity dedups within a run. It cannot dedup a retried run creation, whose fresh run id would make every one of its per-source rows unique — only UNIQUE (tenant_id, idempotency_key) on the run row collapses two same-key requests onto one run, and it must be reached through ON CONFLICT because a lookup-then-insert races.

job_id is nullable, and that nullability does not license a non-atomic creation. The ledger insert and the enqueue are required to commit in one transaction: PostgresJobQueue takes its runInTx as a constructor dependency, so the wiki queue adapter can inject one that reuses the ambient withTenantContext transaction. Today's adapter mirrors project-tracker's and opens a separate transaction — that is the current wiring, not a property of the queue, and the orchestration slice must change it, or ship an explicit reconciler pairing pending ledger rows with orphan jobs.

The column is nullable because the job id is not known at INSERT time within that one transaction, and because the reconciler path stamps it afterwards. Splitting the two writes across transactions would leave a permanently-pending run with no job and an orphan job whose run was never stamped, which an at-least-once retry can then execute twice — the crash windows the ledger exists to close. job_id is write-once.

The column carries no foreign key: jobs is a platform-owned schema, so a cross-module reference is a typed id rather than an FK.

The run id does not travel on the revert response: it names a group of batches, so returning it would turn a single-batch revert into evidence that a folder run exists and into a key for probing the rest of it.

Per-page provenance (PLT-508)

GET /api/pages/:id/provenance answers "where did this page come from" for a single page, and returns two independent halves:

FieldShapeSource
ingest{ batchId, author, approver, sourceRef, ingestedAt } or nullThe latest committed batch stamped on the page's revisions (page_revisions.ingest_batch_id).
coveringSources[{ pageId, slug, title, spaceId, sourceRevisionNum }]The page's outgoing derived_from edges — the sources it was distilled from, pinned to the revision.

author is the authenticated principal that performed the ingest (ingest_batches.agent_principal, written from the caller's subject) and approver the label recorded against the R7 approval gate. Neither is a verified identity, and both are easy to over-read. author is not necessarily an agent: a human ingesting through the UI is recorded there under their own subject, the same one that appears as the revision editor — read it as "who ran this ingest", not as "a machine". approver is not necessarily a person: IngestSourceSchema requires it non-empty and defines it as "the person or process that approved this ingest", with nothing validating it against a user. It is evidence that the gate was satisfied, not proof of who satisfied it.

Direction matters. coveringSources are the sources this page came from. The kb-status endpoint on the same page returns coveringKbPageIds, which is the opposite relation — the KB summaries that cover this page, inferred from similarity rather than from an explicit edge. Same word, two endpoints, two directions.

The ingest half is gated on the batch's classification ceiling, because wiki.ingest_batches has no clearance predicate of its own. A reader whose clearance sits below classification_ceiling gets ingest: null — the whole record, not a partially redacted one: the author, the approver and the timestamp would already disclose that a classified ingest happened and who approved it. The gate is applied after the latest batch is selected, so the read never falls back to an older, visible batch; being handed an older answer is itself how a reader would learn that a newer, more classified ingest exists.

Consequently ingest: null is deliberately indistinguishable between "this page was never ingested" and "an ingest exists that your clearance hides". A client must not render "never ingested" from a null — that would undo the gate in the copy.

Every one of those three checks compares through clearance_rank() and additionally requires the ceiling's rank to be non-negative. clearance_rank returns -1 for a value off the ladder — its deny-by-default arm — and -1 <= anything is true, so a bare comparison would invert the gate into "allow" for an unrecognised ceiling. That state cannot occur while the wiki.classification domain and clearance_rank agree; it becomes possible the moment a release widens one without the other, so the guard is what keeps that drift from silently opening the gate.

Two further behaviours worth knowing: a reverted batch is skipped and the read does fall back to the previous committed one (a revert is public, and the earlier ingest genuinely is the page's current provenance), and a covering source is omitted rather than returned as a blank row when it is soft-deleted, above the reader's clearance, or cites a revision the reader cannot open — so the list is a lower bound on the true edge count.

The covering sources are ceiling-gated too, and not only through the edge. An ingest names its source page src-<batchId> with the title Source: <sourceRef> and creates it pinned UNCLASSIFIED, so that page stays visible when the ceiling later rises because a different page of the batch was reclassified upward — and the list would then spell out exactly what ingest: null withheld. The ceiling predicate is applied in three places, not one: to the ingest record itself, to the edge's own batch when it carries one, and to the batch that created the target page. The last two are independent of each other. The second is not redundant — migration 010 added page_links.ingest_batch_id with no backfill, and any holder of wiki:link:write can point a fresh unstamped edge at an ingest-created source page. It keys on creation (revision_num = 1), so a page merely fan-out-updated by a gated batch keeps its own slug and stays visible. It is not narrowed further to the source anchor, even though the anchor is the only page actually named after the batch: every discriminator for "is this the anchor" — page_type, the slug, the title — is settable on an ordinary PATCH, so keying the gate on one would let a caller rename the gate away, and a generated index spoke can carry the source reference in its title regardless. The gate therefore stays deliberately broad and the sibling pages of a gated batch are withheld too. That is a missing row in the rail, accepted in exchange for not disclosing that a classified ingest exists; the durable fix is upstream, in what those pages are named.

A page that does not exist, or that the caller's clearance hides, is a 404. A visible page with no provenance is a 200 with { ingest: null, coveringSources: [] }, never an error.

Replacing the free-text sourceRef with a structured link to the originating page revision (PLT-687's origin columns) is tracked as PLT-715.

Reverting an ingest

POST /api/ingest-batches/:batchId/revert reverses a committed ingest atomically:

  • Link removal (PLT-203): Before processing any page, LinkRepository.deleteByBatch removes every page_links row stamped with the batch id. This covers both (a) provenance edges from batch-created pages and (b) edges merged onto pre-existing synthesis pages during the fan-out — removing them all in one idempotent pass.
  • For each page the batch created (earliest batch-stamped revision is revision_num = 1): frees its slug, then soft-deletes the page.
  • For each page the batch modified (earliest batch-stamped revision is revision_num > 1): writes a compensating revision restoring the pre-ingest body.
  • The index and log pages written by the ingest are also batch-stamped, so they are restored by the same mechanism.
  • The batch row is flipped to status = 'reverted'.

Slug reclaim (PLT-300). The slug unique index pages_tenant_space_parent_slug_uk has no WHERE deleted_at IS NULL predicate (intentional — it backs the disclosure-safe re-home check), so an ordinary soft-deleted page keeps its slug reserved. Reverting a batch therefore renames each batch-created page's slug to a unique reverted-<uuid> tombstone (a fresh random UUID, while the page is still live, then soft-deletes it), which frees the original slug. The result: the curation pattern "revert the old ingest batch, then ingest_source fresh at the same slug" works in a single step — no manual purge and no need to choose new slugs. This freeing is scoped to the discarded batch and does not change ordinary trash soft-delete, which keeps a deleted page's slug reserved on purpose. Re-ingesting at a slug still held by an unrelated soft-deleted page (e.g. one trashed via the page delete route) returns a clean 409 Conflict, never a raw 500.

The revert is gated on the batch's classification ceiling (PLT-662). Before anything is read off the batch row and before any write, the lookup applies the same clearance_rank() predicate as the provenance reads: a caller whose clearance does not cover classification_ceiling gets the batch as absent. Because the ceiling is a monotonic high-water mark, that stays true after the batch's pages are reclassified back down — which is exactly the state in which the completeness check below stops refusing, and in which an under-cleared caller could previously complete the revert.

An over-ceiling batch and a batch that does not exist return the same 404 with the same body, and the message deliberately carries no batch id — the two are meant to be indistinguishable, so a client must not render "no such batch" from it. Both write one ingest.batch.revert_refused audit entry (outcome: DENIED, fixed RESTRICTED classification so only the classified audit scope can read it) carrying nothing read off the batch row; auditing only the hidden case would itself be the discriminator. A revert that completed and was then discarded because a concurrent reclassification raised the ceiling mid-transaction returns that same body, and is recorded separately as ingest.batch.revert_aborted.

The gate is only as good as the stamped coverage: a batch still carrying the UNCLASSIFIED column default admits every caller. Batches predating migration 033 are not that set — the migration ends by calling the repair, which stamps them. The set that matters is the migrate-before-deploy window: batches the outgoing build commits after that backfill has run and before the new code ships. Running wiki.repair_ingest_batch_coverage() to convergence after a deploy is therefore a condition of the gate being meaningful, not just hygiene.

Unlike ingest (pinned to UNCLASSIFIED for index-rebuild safety), the revert runs at the caller's clearance — it does not rebuild the index, so there is no classified-content leak, and a sufficiently-cleared operator can free + soft-delete a batch page that a later PATCH reclassified above UNCLASSIFIED. If a batch-created page cannot be removed at the caller's clearance, the revert fails with a 409 (and the batch stays committed) rather than reporting success while the page and its reserved slug silently survive. Since PLT-662 that 409 names neither the batch nor a reason. With the ceiling gate in front of it, two situations reach it and neither is the authorization boundary: the batch's stamped coverage understates reality (a batch from migration 033's migrate-before-deploy window, or a classification written by a path that bypasses PageService.update), or a reclassification commits after the revert passed the gate but before the completeness check, hiding a covered page on coverage that is perfectly accurate. In that second, racy case the caller is legitimately cleared for the batch, which is exactly why the message must not attribute the failure to classification — it would disclose one step later what the gate withholds one step earlier.

Both the ingest and the revert emit auditCritical entries (ingest.batch.committed / ingest.batch.reverted) inside the transaction so the security-relevant lifecycle is durably recorded via the outbox.

Reverting a whole folder run (PLT-614)

POST /api/pages/:id/folder-runs/:runId/revert reverses every committed batch of one folder bulk-ingest run inside a single transaction, then flips the run to reverted. Any failure anywhere rolls the entire run back: no batch stays reverted, the run stays completed, and the entries recording the work — the per-batch ingest.batch.reverted rows and the group ingest.folder_run.reverted — go with it. There is no partially-reverted state and no "partly reverted" status to report. Refusal evidence is deliberately the exception and is described below: a refused revert commits its own entry, and a revert discarded after completing is re-recorded in a fresh transaction, precisely so a rollback cannot erase the fact that someone tried.

Nested under the folder, and that is a security decision rather than routing taste. The revert is offered on exactly the runs the runs-by-folder read lists, and that read's gate is two things: the run's own classification_ceiling, and the root folder being live and RLS-visible. Both are applied here in one statement, sharing their SQL fragments with the listing — two hand-written copies of one security rule are two things that can disagree, and a disagreement between "listed" and "revertable" is itself an oracle for the hidden batches the run-level gate conceals. The run is additionally keyed to the folder in the URL, so a run id cannot be confirmed by pairing it with a folder the caller can see.

Two independent gates, both required. Clearance gates disclosure; page-write permission gates mutation. A caller cleared for the run but holding no write capability is refused; a caller who cannot see the run gets the same 404, with the same body and no run id, as one asking about a run that never existed. Both refusals write one ingest.folder_run.revert_refused entry (outcome: DENIED, fixed RESTRICTED) carrying nothing read off the run row — auditing only the hidden case would make the two paths differ in the work they do, which is the discriminator the gate exists to remove. A revert that completed and was then discarded because the caller lost visibility of the run mid-transaction returns that same body and is recorded separately as ingest.folder_run.revert_aborted, with a deliberately generic visibility_lost_during_revert reason. The run gate has two inputs — the stored ceiling and the folder being live — so naming a ceiling raise would make a durable entry assert something that may not have happened, and resolving which input moved would record whether a classification changed in a log the caller may later read. The single-batch entry keeps its specific wording because its own gate consults the ceiling alone.

Only completed runs are accepted. A pending or running run is still the worker's: reverting the batches present in the transaction would leave the handler free to commit more afterwards and re-populate a run just declared reverted. A failed run is refused for the same reason — the retry rule the ledger records is that a failed execution is retried as a new run, and the orchestration slice (PLT-611) is what will make "failed is terminal" enforceable. Its committed batches remain individually revertable in the meantime. An already-reverted run is refused too: run membership is immutable, so "un-revert within the same run" is deliberately not representable. Each of these answers 409 naming the state, which discloses nothing past the gate the caller has already cleared.

A batch already reverted on its own is skipped, not failed. The run-scoped read selects committed batches only. Including an already-reverted one would make the group revert permanently impossible for any run whose batches were ever touched individually — and skipping it is truthful, because that batch is reverted. The group-level ingest.folder_run.reverted entry records revertedBatchCount and skippedBatchCount, so the two account for the run's batches and "the whole run was reverted" is a checkable claim rather than an assertion. It names no batch ids: each per-batch entry already identifies its own batch, and an array here would publish the run's exact composition. Those per-batch entries are themselves classified from each batch's own ceiling — unlike a single-batch revert's, which is unclassified — because a run emits one per batch under a single correlation id, and left readable at the plain tenant audit scope the set would reconstruct through the audit channel exactly the composition this endpoint withholds.

A shared page's compensation chain is walked to its earliest writer, or the revert refuses. Two batches of one run can modify the same pre-existing page — each source's fan-out may update a shared entity, concept or synthesis page. A batch revert restores such a page to the revision immediately before its own earliest batch-stamped one, so only the run's earliest writer on that page targets the pre-run revision; every other batch targets a revision the run itself produced. The revert therefore reads, per page more than one batch of the run touched, each batch's earliest stamped revision, and reverts higher-revision batches first so the chain walks down. Sorting on created_at would not do: it defaults to now(), the transaction start time, so two overlapping per-source ingests can write in the opposite order to their timestamps. And if that earliest writer was already reverted on its own, its revert has restored the page while leaving the chain above it intact — completing the rest would walk back down onto its content, which no ordering avoids, so the whole-run revert answers 409 and the remaining batches are reverted individually. Getting either half wrong is silent: every status flips and every gate passes, and only the page body is wrong. Only shared pages constrain anything, so an ordinary run pays a statement that returns nothing.

The run's batch read carries no per-batch clearance predicate, deliberately. The run's ceiling already dominates every committed batch's, so a caller past the run gate is cleared for every batch in it; a per-batch predicate could only remove a batch silently, after which the run would be flipped to reverted having skipped it.

Bounds and budget. A run above MAX_FOLDER_RUN_PAGES (200) batches is refused whole with a 409 before any mutation and before the batch and ordering reads — never reverted partially. (The run row itself and its batch count have necessarily been read by then: the count is what the cap is checked against.) The route raises maxDuration to 60 s (mirrored in vercel.json ahead of the broad src/app/api/** 10 s entry) and the transaction budget is derived beneath it — subtracting the connection acquisition, the abort-audit transaction's own reserved budget, and a response headroom — so the transaction expires before the function does and the caller gets an attributable, retryable error rather than an opaque 504. That raises the ceiling; it is not evidence that a maximal run completes within it — an over-budget run fails by rolling back.

The response is { id, status } and nothing else. No batch ids, no per-batch outcome, no counts: any per-batch detail is a differential a caller could read hidden batches off. status is re-read from the run row inside the transaction rather than written as a literal, because it is the claim a client renders.

The batch ledger (PLT-507)

GET /api/ingest-batches returns one page of the tenant's ingest batches, newest first, with a total. Query parameters: spaceId (optional — scopes the ledger to one space), limit (1–200, default 50) and offset. Each row carries id, spaceId, sourceRef, agentPrincipal, approvedBy, status, createdAt, revertedAt and pageCount. Reverted batches appear in the same list, distinguished by status and revertedAt rather than omitted.

A batch above the caller's clearance is excluded, not redacted. The listing applies the same classification_ceiling predicate as the revert lookup and the provenance reads, in the innermost scope of the query — so an over-ceiling batch is gone before ordering, before limit/offset and before the total is computed. It is observably identical to a batch that does not exist. There is deliberately no placeholder row, no "n hidden" indicator, and no gap in any sequence a caller could count: migration 033's ceiling gates who may know a batch exists, so any countable trace of an excluded batch would be the disclosure rather than a redaction of it. The total is computed after exclusion; it is never the tenant's true batch count corrected for hidden rows.

Consequently an empty page is deliberately indistinguishable between "this tenant has ingested nothing" and "every batch is above your clearance", and a client must not render "never ingested" from it. The same applies to spaceId: a space that does not exist, belongs to another tenant, or is soft-deleted returns an empty page rather than a 404, so the listing cannot be used to probe which spaces exist.

The soft-deleted case needs its own predicate rather than falling out of the filter. Space deletion is soft and the decommission cascade does not remove the ingest batches pointing at the space, so an equality on space_id alone would keep serving a decommissioned space's ledger — rows carrying a spaceId that no other wiki read will resolve. The listing therefore requires the batch's space to be live, on every listing rather than only on a filtered one: were the predicate scoped to the filter, ?spaceId=X coming back empty while the tenant-wide page still listed X's batches would itself answer "is X deleted?".

offset is capped at the PostgreSQL int maximum (2147483647). Above it the value is not a large page but 22003 integer out of range, so the cap is what makes an absurd offset a 400 rather than a 500.

The total and the returned rows come from a single statement. Two statements would run sequentially on one transaction connection under READ COMMITTED and take two snapshots, so an upward reclassification landing between them would return a total of N beside N−1 rows — a gap the caller could count, produced by exactly the event the ceiling exists to conceal. The total is therefore present even when the page itself is empty because the caller paged past the end.

The stored ceiling is the only predicate available, not merely the cheapest. A revert soft-deletes the batch's created pages and the revision RLS policy requires a live parent, so after a revert there is nothing left to aggregate a ceiling from at read time — an aggregation would fail open precisely for a batch that once covered classified material.

What the row does and does not carry. pageCount travels because the batch ledger renders it, and it is safe by construction of the gate: a returned row satisfies clearance_rank(ceiling) <= clearance_rank(session), and the ceiling is the high-water mark across every page the batch touched, so no page is counted whose recorded classification exceeds the caller's clearance. Read that as a statement about classification, not about reachability — a reverted batch's created pages are soft-deleted, so a cleared caller still receives the historical count of pages it can no longer open. The count is a durable fact about what the ingest wrote, which is what migration 033 stamps. classificationCeiling does not travel: it is the gate's own input and no consumer needs it. The origin columns (sourcePageId / sourceRevisionNum) and tenantId are absent for a different and weaker reason — no consumer — which is worth keeping distinct from the first, so that a later change guards the right field.

The endpoint is read-only and requires no permission beyond tenant membership, matching GET /api/pages/:id/provenance, which already returns the same author, approver and source reference for any page the caller can see. It writes no audit entry; the ingest and the revert are both already auditCritical.

Retracting a source (PLT-204)

revert_ingest_batch undoes a recent ingest; source retraction is the compliance path for a source whose batch must otherwise stand but whose content must go away — a GDPR erasure request, a redacted transcript, a reverted PR, a released legal hold. POST /api/pages/:id/retract (body: { reason, approvedBy } — a named human approver is required, mirroring the ingest R7 gate) runs in one transaction:

  • Destroys the source content at rest — irreversibly, unlike soft-delete: the page row's body/title/summary/frontmatter and every historical page_revisions row are overwritten with a fixed redaction sentinel (the generated search_tsv recomputes, emptying the search index), retracted_at is stamped, and the page is soft-deleted. Retracted pages are excluded from the trash listing and can never be restored. The scrub goes through a narrow, GUC-gated exception in the append-only revisions trigger; DELETE remains unconditionally rejected.
  • Walks the full transitive derived_from closure (SECURITY DEFINER, so derivers above the caller's clearance cannot escape the walk) and flags every live derived page with an open lost_source finding in the unified wiki.lint_findings store (PLT-255) — structured, queryable needs-review state (detail_json carries the retracted page id, the reason, and the traversal depth; the finding is anchored to the affected page's own space). A page "needs review" iff it has at least one open finding — one query across lost-source and lint findings alike; findings are resolved (via the lint surface), never erased.
  • Strips every link touching the source (all link types, both directions) strictly before the soft-delete, so no RLS-invisible orphan rows can exist.
  • Re-types sole-provenance source_summary pages to synthesis so the R2 guard cannot make them permanently un-editable after their only source disappears.
  • Surfaces the degraded confidence: the rebuilt space index prefixes flagged pages' Summary cells with ⚠ needs review (<check types>) — covering lost-source and open lint findings, which the coordinator read path (PLT-199) renders into consult prompts with no consumer-side change — and GET /api/pages/:id responses include openFindings (the unified lint finding shape, every open finding type for the page). Because the derived_from closure can cross spaces, every other visible space holding affected pages gets its index rebuilt too (findings are anchored to the affected page's own space). A retract entry is appended to the space log, and an auditCritical entry (source.retracted) records the reason, approver, affected/re-typed page ids, and scrub counts.

Retraction is idempotent at the source level: retracting an already-retracted source returns 409; a non-source page returns 400. A source reclassified above UNCLASSIFIED remains retractable by a sufficiently cleared caller — the retraction functions gate on the caller's own clearance, not the UNCLASSIFIED session the index/log writes run under — and the log entry then redacts the classified title to (classified source <id>) (the real title is kept only in the privileged audit context); an under-cleared caller gets 404 with no existence oracle. A source concurrently moved to another space mid-retraction aborts with 409 (the transaction rolls back; retry). Derived pages are not rewritten (v1 is deterministic and LLM-free) — they may still quote the source, which is exactly what the needs-review queue puts in front of a human. The retract_source MCP tool exposes the same capability over the REST API.

Endpoints

MethodPathDescription
POST/api/spaces/:spaceId/ingestExecute a human-approved ingest (body: IngestSourceSchema). Returns 201 with { batchId, sourcePage, summaryPage, derivedPageIds, indexPageId, logPageId }. Over-budget fan-outs return 400. An optional candidateApproval closes the KB ingestion candidate this ingest fulfils, atomically — see Approving a candidate through ingest. 409 when the knowledge base already covers the candidate (a live ingest of the same origin, a served KB page that subsumes it in any knowledge base, or the candidate's own space already being one) — see Approving a candidate the knowledge base already covers.
POST/api/ingest-batches/:batchId/revertAtomically revert a committed ingest. Returns 200 with { batch } (status = 'reverted'); 409 if already reverted or if a covered page could not be reverted; 404 when the batch is absent or its classification ceiling exceeds the caller's clearance — deliberately the same response for both.
GET/api/ingest-batchesOne page of the tenant's ingest-batch ledger: { batches, total }, newest first. Query: spaceId?, limit (1–200, default 50), offset. Read-only, no permission beyond tenant membership. A batch whose classification ceiling exceeds the caller's clearance is excluded before pagination and before the total — no placeholder row and no hidden count — so an empty page never distinguishes "nothing ingested" from "nothing you may see".
POST/api/pages/:id/retractRetract a source page (body: { reason, approvedBy }). Destroys its content at rest and flags all derived pages needs_review. 200 with the blast radius; 400 non-source; 409 already retracted.
PATCH/api/spaces/:spaceIdUpdate space including ingestPolicy ({ autonomy, maxPagesPerIngest }). Returns 200 with the updated space.
GET/api/pages/:id/provenancePer-page provenance: { ingest, coveringSources }. Read-only, no permission beyond page visibility. The ingest half is ceiling-gated — see above.

The ingest, revert, and retract endpoints require the wiki:page:write permission (GET /api/ingest-batches does not — it is a read); PATCH /api/spaces/:spaceId is a space-administration endpoint and requires wiki:spaces:admin (via assertCanAdminSpaces). The parameterized endpoints are wrapped with authedRouteWithParams; the GET /api/ingest-batches collection takes no dynamic segment and is wrapped with authedRoute.

Attachments

Files uploaded to the wiki live in the platform storage abstraction; wiki.attachments holds only the metadata plus an opaque storage_path that never leaves the server. Clients receive the public projection and fetch bytes through /api/attachments/:id (a short-lived signed URL) or /api/attachments/:id/raw (a 302 to a freshly signed one), so the provider, bucket and key layout are never disclosed.

An attachment may be associated with a page (page_id) or stand alone. The association is the durable relationship, and it is deliberately not the same as the set of attachment://<id> refs a page body embeds, and neither contains the other. Whether a ref exists depends on the path the file arrived by, not on its type: an upload made through the editor always gets one, images and other files alike, while a file attached straight through the API — or dropped on the read view — gets none. In the other direction a ref left behind by an edit can name an attachment no longer associated with the page. GET /api/pages/:id/attachments (PLT-549) lists the association, and the read view's Attachments panel (PLT-542) is built on it — one row per associated file, with its tier and a download control, below the page body. Trashing a page does not break the association. wiki.soft_delete_page sets deleted_at; it does not remove the row, and the composite FK's ON DELETE SET NULL (page_id) fires only on a physical delete, which no API path performs. So the attachment keeps its page_id, drops out of every read because the RLS policy requires a live parent, and reappears intact — row, bytes and association — when the page is restored from the trash.

Attachments carry their own classification on the same ladder as pages, so an upload is an audit-critical, classification-bearing write (PLT-509). Visibility is gated twice over: an attachment is hidden if its own classification is above the reader's clearance, and hidden if its parent page is soft-deleted or invisible to them — a reclassified page takes all of its attachments out of view with it, whatever their own tier.

Endpoints

MethodPathDescription
POST/api/attachmentsUpload (multipart). Requires wiki:attachment:write. 400 on a MIME outside the allow-list, 413 above the 25 MiB cap.
GET/api/attachments/:idAttachment metadata plus a signed URL valid for 300 s.
GET/api/attachments/:id/raw302 to a freshly signed URL, for embedding as an <img src> / <a href>.
GET/api/pages/:id/attachmentsThe attachments associated with a page, newest first. Paginated: limit defaults to 20 and is rejected above 50, offset defaults to 0; meta carries { total, limit, offset, canWrite } where total counts only the rows the caller may see.

GET /api/pages/:id/attachments answers 404 when the page does not exist, is in the trash, or is classified above the caller — identically in all three cases, so it is no existence oracle. A page the caller can see with nothing attached is 200 { data: [] }. This follows the related-pages read rather than the revisions and links listings, which run with no parent check and answer an unreadable page with an empty list.

meta.canWrite (PLT-542) reports whether the caller may attach to this page — the same wiki:attachment:write the upload endpoint asserts, judged against the page's own classification, which the listing statement returns alongside the rows so the answer describes the same snapshot they do. It rides on this read rather than on a capability route of its own because the read is already page-scoped and already carries non-row facts in meta, so a client needs no second request and no second subscription.

It is not a security boundary and must not be treated as one: POST /api/attachments enforces the same permission itself. It exists so a client does not offer an upload affordance to a caller who plainly lacks wiki:attachment:write — the shape GET /api/pages/:id/space-move already uses to report a known-negative eligibility on a 200. A client that cannot read the field, or whose read failed, must fail closed and offer nothing.

It is not a complete success predicate, and must not be read as one. The upload path additionally applies knowledge-base write enforcement, so a page in a marked, reconciled, provably ingest-authored space reports canWrite: true and still refuses every attachment with a 403. That is deliberate: folding the knowledge-base predicate into this read would either turn the provenance oracle — which PLT-1005 made auditable on the write path rather than closing — into a free, unaudited one on a GET, or make an ordinary listing emit a constitution §5 classification-crossing audit entry. Clients should surface the refusal rather than pre-empt it; attachmentUploadErrorMessage in the wiki client shows the guard's own authored message, which names no role.

Revisions & history

A save appends an immutable row to wiki.page_revisions when it changes the body, the frontmatter or the classification — or when the caller supplies an explicit editSummary (the rule is stated in full below, and a metadata-only save from the editor is the case that writes none). Rows, once written, are immutable: UPDATE and DELETE are rejected by a trigger. The reading view exposes a History panel that lists revisions (author, timestamp, edit summary) and renders a server-computed side-by-side diff between any two — the diff is built in the API route, never in the browser, so an under-cleared caller can never diff a body they cannot otherwise read.

  • GET /api/pages/:id/revisions — revision metadata for a page.
  • GET /api/pages/:id/revisions/:n — a single revision (full body + frontmatter).
  • GET /api/pages/:id/revisions/diff?from=&to= — structured side-by-side line diff between two revisions.

Restore does not mutate history: it writes the chosen revision's content back as a new revision (with an auto edit summary), preserving the append-only invariant.

The editor no longer collects an edit summary (PLT-1166). It used to carry an optional free-text field beside Save that wrote wiki.page_revisions.edit_summary; the field was removed and nothing replaced it. The column, the editSummary request field and the revision-trigger rule below are all unchanged, so the summaries you see in History still come from several places — page creation (Initial revision), restore (Restored from revision N, composed in the browser), ingest, the staleness reaper, the space-index rebuild, retraction, and any API caller that supplies one. Two consequences worth stating rather than leaving to be discovered:

  • no revision created from the editor carries a human-typed note. Revisions are append-only, so summaries typed before the removal stay in History for good, and the API still accepts an editSummary from any caller — what ended is a person annotating a revision from the page editor;
  • a metadata-only save from the editor can no longer be made to write a revision. An explicit editSummary was the only way to force one, and the editor no longer sends it — so a title, status, owner, parent or space change made in the editor writes no revision at all. (A caller supplying editSummary directly still does.) This is a deliberate limitation, not an oversight; the rationale — KB identity depends on a title-only rename leaving the stamped revision current — is recorded in .ai/specs/SPEC-plt-1166-remove-editor-revision-note.md.

Who wrote a revision (PLT-520)

wiki.page_revisions.actor_type records what KIND of actor wrote each revision — USER, AGENT or SYSTEM, the same vocabulary as audit.audit_entries.actor_type — and the revision list and single-revision endpoints above project it as actorType. The diff endpoint does not: it returns line pairs, not revision rows. It exists because the nearest alternative, page-level wiki.pages.is_agent_owned, answers a different question: that flag is create-only and describes the page's ownership regime, so a page created by an ingest and later corrected by a person still reads true for every row in its history.

The value is derived once, at write time, and never recomputed:

  1. an explicit host signal wins where one is given — an automated filing (a cycle-close retrospective reaching the KB) states SYSTEM, because the subject it runs as is an RLS delegate rather than an author;
  2. otherwise the credential decides: an interactive session is USER, and any long-lived credential (an agent API key or an internal service token) is AGENT.

Two consequences are deliberate and easy to misread:

  • null means "nobody said", and never "a human". Every revision written before the column existed carries null, and there is no backfill. No writer in the current build omits the value, but the field is deliberately optional rather than unrepresentable, so a caller that genuinely cannot say stores null instead of guessing. Either way a client must render null neutrally — never as human. Deriving a backfill from ingest_batch_id or is_agent_owned was refused for the same reason the column exists: it would re-import the page-level guess, and a wrong attribution that looks right is worse than an absent one.
  • An ingest run by a person records USER. The column answers "who wrote this?", not "did this content come out of a machine pipeline?" — the same reading ingest_batches.agent_principal already documents for itself. "Was this written by an ingest?" stays answerable from page_revisions.ingest_batch_id, independently.

A retraction scrub destroys a revision's body but preserves its actor_type, exactly as it preserves edited_by: the attribution is the evidence the scrub exists to leave behind. The append-only trigger's retraction allowlist pins the column, so no scrub can rewrite it.

What the History panel shows (PLT-537)

The panel lists revisions newest-first as a timeline. Each entry carries its revision number, the edit summary, the author, and how long ago the edit landed — the exact timestamp is on the entry's <time> element, so it survives hover and is available to assistive tech.

The author line names the kind of actor, read from that revision's own actorType and never from the page's is_agent_owned flag — the distinction the previous section exists to draw. The four stored possibilities render as three markers:

stored actorTypeshown as
AGENTan Agent chip
SYSTEMan Automated chip — the same marker, its own word
USERa Person chip
nullan Unknown chip

null gets a chip of its own rather than an empty space, because an empty space is what a human author would look like: rendering nothing would make "written by a person" and "nobody recorded who wrote this" indistinguishable, which is the page-level guess the column replaced. Any value the API returns that is not one of the three known kinds also reads as Unknown, never as a person.

Restore asks first, and says what it will do. The confirmation names the revision and states the two consequences that are easy to get wrong: the page is not rewound — the older content is written back as a new revision, so nothing in the history is lost — and the revision's original classification comes back with its body, which can raise the page's classification above its current tier. Cancelling writes nothing. A restore that succeeds, or fails, reports through a toast rather than in the panel, because a successful restore closes the panel.

The diff marks added and removed lines with a sign, not with colour alone. A removed line carries a beside its old line number and an added line a + beside its new one; the row tint is reinforcement only, and is deliberately too faint to be the signal a reader depends on. The +N added · −N removed summary above the table reads the same way in both themes.

Loading, empty and failure states are distinct, and so are the failures: a revision list that will not load, a diff that will not compute, and a restore that fails each report in their own place, so one cannot overwrite another's message.

On-page table of contents

At desktop widths the reading view carries a right-side On this page rail listing the <h2> and <h3> headings the body rendered, in document order, with the entry for the heading currently in view marked through aria-current as well as through weight and background — never colour alone. Below the lg breakpoint the rail is not rendered at all, so its links are absent from the tab order rather than merely invisible.

The outline is read off the rendered page rather than parsed a second time out of the markdown, and the same scan gives each listed heading the id its entry links to: a prefixed, de-duplicated one it generates, or — where the rendered heading already carries an id it did not issue, as the release-notes sections draw their own — that existing id, adopted unchanged so links already pointing at it keep working. A body <h1> is not listed: the page title is the document's heading, and a body opening with # Title — the shape every mirrored spec has — would otherwise list itself. A page whose body renders no <h2> or <h3> gets no rail; an empty navigation landmark would promise destinations that do not exist.

Sharing a page

The reading view's header carries a Share control that hands out a link to the page and states, in words, who that link will actually work for.

The link is always the stable /p/:id form, never a by-path URL. A path is not resolvable for every reader who can read the page: the public projection nulls out a parent the reader is not cleared to see, so a visible child under a hidden parent has no path a lower-cleared reader could walk — and reconstructing one would leak the hidden ancestor's slugs.

The scope line beneath it is derived from the page's tenant and classification only. At UNCLASSIFIED it reads Anyone in {tenant} can view · UNCLASSIFIED; above it, People in {tenant} with {CLASSIFICATION} clearance or higher can view, naming the tier literally. (Those placeholders are in code spans deliberately — this page is MDX, where a bare {…} in prose is evaluated as a JS expression and fails the build.) It never mentions spaces or membership: spaces are tenant-wide, so a per-space claim would be one the system does not enforce. The tenant is named only when the viewer's own tenant is demonstrably the page's — a viewer acting in another organisation sees this tenant rather than their home tenant's name presented as the page's scope.

Sharing grants nothing. The control discloses an existing link; access remains whatever RLS and the reader's clearance already allow.

The wiki home (PLT-574, PLT-575)

/wiki opens on a greeting rather than on a heading naming the list beneath it. That is the whole point of the change: the surface used to title itself Recent pages when the tenant had content and Welcome to the Wiki when it had none, so it had two titles on mutually exclusive branches and no stable identity. It now has one, in every state.

The greeting is derived from your local clock and your display name, and it degrades in two steps rather than one. Before the browser knows the hour — the server render and the first client render — and while your identity is still resolving, it reads Welcome to the Wiki. Once resolved without a usable display name it reads Good morning / Good afternoon / Good evening; with one, it appends your name. The hour is deliberately not read while the page is being rendered on the server: the server and the browser would pick different bands either side of noon or six, which the browser reports as a hydration mismatch and repaints. It is also not re-derived while the tab stays open, so a session left running across a boundary keeps the greeting it was given.

Start here is a grid of quick actions below the greeting. Each is a single link covering the whole card, so it is one tab stop, is middle-clickable and copyable like any other navigation, and is announced by its label with the supporting line as its description rather than as part of its name. The grid is laid out for four cards and currently carries one — New page. The other three the design draws (browse all pages, the knowledge base, the review queue) have no route yet, and a card that leads nowhere is worse than an absent one: it advertises a capability the product does not have, and spends a click to say so. They appear as the slices that build their destinations land.

Below that is the feed, behind a tab strip. Recent is the most recently updated pages in the active space, newest first; For you is the pages you own — the steward a page can be reassigned to at any time, not whoever created it. Both re-scope when you switch space.

The Recent tab names the order it is in. Its heading reads Recent pages · last updated first, and every card carries the instant it ranks on: the age at a glance and the exact timestamp beside it, as 2h ago · 20 Aug 2026, 10:00. The exact value is visible text rather than a tooltip, because a tooltip on a non-focusable element is reachable by neither keyboard nor touch, and it is part of what the card announces when you focus it — the whole card being a single link, its contents are otherwise reached only by a virtual cursor. What the card is named is still its title alone. A page whose update time cannot be read shows no timestamp at all rather than a placeholder. For you keeps naming which pages it lists rather than their order, which is the question its label leaves open.

The selected tab is in the URL, so a link to ?tab=foryou opens on that tab and the browser Back button returns to the one before. Moving across the tab labels with the arrow keys only moves focus — Enter or Space is what switches, so arrowing past a tab does not load it.

The design draws a third tab, Starred. It is not here, and that is deliberate: nothing in the platform can star a page yet, and a tab that cannot answer its own question is worse than an absent one. It appears when the capability behind it does.

Each page is a card carrying, in one place, four properties the wiki has always stored and mostly did not show: its classification, whether it is agent-managed, its position in the knowledge base, and any open findings against it. Only the first of the four appeared on the old list, and it appeared as a raw uppercase word in a single alarm colour, so an internal page and a secret one looked identical. They are fetched together, once for the whole feed, so the card shows placeholders for a moment and then the real chips — never a value guessed from somewhere else while it waits. If that read comes back without a given page, the card says so in words rather than showing nothing.

A summary line on a card is unverified metadata, and it is labelled as such. The badge beside it reads unverified metadata · not evidence of authorship, and it means exactly that: the summary is accepted from whoever saves the page and stored as given, and the agent marker is a flag supplied when the page was created. Neither is evidence of who wrote the page — which is also why the badge does not claim an agent wrote the summary.

Each card names its Author and its Owner, under separate labels. They are different facts and were previously easy to confuse: the Author is whoever wrote the page's first revision, a historical fact no later change alters, while the Owner is its current steward and can be handed over at any time. Both are resolved by id, so a name no longer depends on the owner happening to fall inside a bounded window of candidates.

Where the author is also the steward — which is most pages — the two collapse into a single value labelled Author & owner, rather than printing one name and one avatar twice. They merge only when both resolve to the same identity row, so two people who happen to share a display name stay separate, and never when the author's first revision recorded no actor kind, since that qualification belongs to the authorship alone.

Each is drawn as an avatar, the name, and the role word as a small subtitle beneath it — grouped by proximity rather than boxed, since a bordered rectangle would suggest something to press and neither value is pressable. The role is real text rather than a tooltip, so it is reachable by touch and by screen reader; putting it under the name rather than ahead of it lets the pair lead with the two names while still saying which is which.

Where a name cannot be shown, the card says which of several different things happened rather than one vague thing: an automated write, an unknown principal with a short form of the id when the lookup answered and did not know it, unavailable when the first revision could not be read at all, or No owner for a page that genuinely has no steward. A page the platform itself maintains says System-maintained rather than naming whoever last triggered a rebuild.

Its three data states are worth stating because one of them used to be a dead end:

  • While loading — placeholders shaped like the list, not a spinner.
  • Empty — a titled empty state whose wording is the tab's own: an empty Recent means the space has no pages, an empty For you means you own none here.
  • Failed — the failure is reported in place of the feed, with a Try again that re-issues the request. The greeting and the quick actions stay usable, so a transient outage on this one read no longer costs the whole surface. Previously it left a blank screen whose only exit was reloading the browser.

Recently visited pages

The top bar carries a Recent menu listing the pages you opened most recently on this browser, capped at six and newest first. Revisiting a page moves it up rather than adding a second row.

This is not the Recent tab on the wiki home above: that one is the most recently updated pages in the active space, the same for everyone who can see them. This one is the pages you opened, on this browser, and nobody else's.

It remembers page ids, not page titles. A title is clearance-gated on every read, so a stored one would outlive the clearance that permitted it — surviving a reload, an organisation switch, and a reduction in your own clearance. The titles and classifications you see in the menu are fetched when you open it, through the same clearance-scoped read every other list surface uses; the stored list has no label to fall back on. Resolution goes through that read's shared 30-second cache, so a page reclassified in another session can keep its old title in an already-open tab for up to that long.

The list belongs to one principal: it is keyed to your user, active organisation, tenant and clearance together, and a change to any of them destroys it rather than setting it aside — so switching organisation and back, with the wiki open, does not bring the previous list back.

That clearing happens when a wiki page observes the change, which is the honest limit of a browser-side mechanism: the wiki cannot see a switch you make while you are in another part of the platform. Leave the wiki as one principal, change organisation twice elsewhere, and come back as the first one, and the list you had is still there — it is your own, under the identity that stored it, and every label in it is still fetched fresh against what you may see now. Signing out is cleared on the same terms: on the next wiki page load rather than instantly.

A page the server declines to resolve — one you may no longer see, or one that has been deleted — simply does not appear. The menu gives no reason and does not distinguish the cases, because saying which it was would confirm that the page exists. Nothing is removed from the stored list on that basis either, so a page that becomes visible again, or a read that merely failed, comes back.

Nothing here grants access. The menu shows what the server is willing to show you now, not what you were once shown.

Page hierarchy & editing

Pages form a parent/child tree (pages.parent_id). The hierarchy can be reorganised from the editor, or from the left-rail tree with either a pointer or the keyboard:

  • From the editor — a searchable parent picker re-parents the page (or detaches it to the top level).
  • From the left-rail tree — drag a page onto another to re-parent it, or onto the "move to top level" zone to detach it. That zone appears in place of the filter field as soon as a drag starts — so it neither pushes the tree down nor covers a row, and the page you are dragging stays under the cursor. It is offered for every page being moved, including ones that already look like roots: a page whose parent is hidden by your clearance is shown at the root too, and withholding the zone there would make it the one page you could never detach. Dragging is held while the tree filter (below) is active, and the rail says so: a filtered tree that still has rows carries a small note under the filter field — an information glyph and the sentence “Clear the filter to move pages” — which is also attached to the filter field as its accessible description, so the sentence travels with the control. Clearing the filter removes both. The note is deliberately withheld over the no-match state — moving is still held there, but that state explains itself and has no row to gesture at. The keyboard move control described below is withdrawn under a filter for the same reason as the drag, and that line is its explanation too.
  • From the left-rail tree, without a pointer (PLT-524) — each row carries a move control that appears when you tab to it. It is deliberately invisible to the mouse — hovering a row never shows it, and neither does dragging — because the pointer already has the drag gesture. Pressing it on a page arms the move; pressing it on another row completes it; the "move to top level" zone is a button and completes it there. Escape cancels, and the tree's status region announces the armed state.

The tree does not offer manual sibling ordering — no before/after drop and no manual sort. Pages are ordered automatically, and the deferral is ADR-030's: a user-chosen order under clearance-hidden siblings is a genuine disclosure problem, decided separately from this tree.

A move is applied optimistically — the page leaves its old position as soon as it is requested — and put back where it came from if the server refuses. "Where it came from" is exact when the branch it left is still there; when it is not, the tree reloads and the server's answer stands instead. A rejected move reloads regardless, so a refusal never leaves the rail showing a shape only the browser believes in. Dropping into a folder whose children have never been fetched inserts nothing there: the folder stays collapsed and unloaded, so the page goes into it and out of view until the tree reloads, rather than the folder claiming a child list nobody fetched. Every move reports through the shared toast, naming the page and its destination; a rejection reports the server's message. There is no inline banner, so no move is reported twice.

Re-parenting is validated server-side: a page cannot become its own parent, and a move that would create a cycle (a page under its own descendant) is rejected via the wiki.is_page_in_ancestor_chain SECURITY DEFINER check — including cycles through an ancestor the caller cannot see under RLS. That check is the authority, and an ordinary detected cycle comes back as a 409 with a message the tree shows — not as an unhandled failure. The tree additionally refuses, before asking, a drop onto the moved page itself or onto a descendant it has already loaded — which removes the destinations it can prove are invalid without pretending to be the cycle check: an unexpanded branch is unknown to the browser, not empty.

Signals on each tree row (PLT-521)

Every row in the tree says what the page is, not only what it is called. The page glyph is tinted by the page's classification and named for it, so hovering — or reading the row with a screen reader — gives you the tier in words. Three further marks follow the title when they apply: a bot for a page an agent wrote, a warning triangle for a page with open findings against it, and a small ring for a page nominated as a knowledge-base candidate. A page that has both shows the triangle only: a finding is a defect and outranks a queue position, and two marks would compete for the same few pixels at the end of a row that already truncates.

Nothing here is carried by colour alone. Each of the four is a distinct shape, and each carries its own hover text and accessible name, so the row reads the same in greyscale, with colour-vision deficiency, and through a screen reader.

A row whose signals have not arrived yet says so. The marks are fetched for the whole rail in one request rather than per row, so for a moment after the tree loads a row has no answer to show. It draws the quiet neutral glyph and names itself "Classification not shown" — deliberately not "Unclassified", which is a positive statement that a page needs no clearance and would be a claim nobody made. The same is true of a page the request came back without. If the request fails, one warning sits above the tree and every row still lists and still links: losing the marks does not cost you the rail.

Filtering the page tree (PLT-522)

A filter field sits above the tree. It narrows the pages already loaded into the tree to those whose titles contain what was typed, case-insensitively, keeping each match's ancestors so the path down to it stays navigable, and marking the matched text in the row. It runs against what the browser already holds and fetches nothing: expanding a branch is what loads its children, so a branch never opened has never been searched. That is why the no-match state names the query, says so in a line, and links to the wiki's full search rather than reporting that the page does not exist.

The filtered tree is a derived view: no expansion state is written while a filter is active, so clearing it returns the tree to exactly the shape it had — a branch the reader had opened is still open, one they had not is closed again. Two affordances are held for the duration and released on clear, because both would otherwise act on a tree whose surroundings are hidden: the expand/collapse chevrons, and drag-to-re-parent. Every surviving row that still has children to show is drawn expanded instead; a matched page with nothing under it keeps its collapsed chevron.

Ordering the page tree (PLT-523)

Beside the filter sits a menu that chooses the tree's order: Recently updated (newest changes first) or A–Z (alphabetical by title). Those two are the whole set — there is no manual drag-to-arrange order and no reverse-alphabetical one. Manual sibling ordering is a separate, harder problem than it looks, because ordering siblings some readers cannot see is a disclosure question; it is deferred out of the revamp and owned by its own decision record.

The order is asked of the server, not applied in the browser. Each level of the tree is fetched a page at a time, so sorting the rows already on screen would order the most recently updated of them and label the result alphabetical; asking for the order instead means "A–Z" names the alphabetically first pages. The consequence is that changing the order reloads the tree: branches you had opened by hand close, and only the path to the page you are on reopens — the same thing that already happens when you navigate. The filter field and the sort button stay put across that reload, so the control you just used does not vanish under you.

The choice is remembered per space, for you, in this browser — switching spaces gives you back whatever you last chose there, and a new space starts on Recently updated, which is the order the tree has always used. It is stored locally rather than on your account, so it does not follow you to another browser, and it is cleared when you sign in as someone else. A browser that refuses local storage costs you nothing but the memory: the control still works for the session.

The tree and the list filters on a narrow screen (PLT-601)

Below 1024px there is no left rail. The page tree moves into the navigation menu the header already has — the same menu that carries the organisation and application switchers — and opens from the hamburger at the top left. Above 1024px nothing changes: the rail is where it has always been.

Only one of the two exists at a time. The rail is not hidden below the breakpoint, it is not built; and the copy in the menu is not built above it. That matters beyond tidiness — two trees would mean two loads of the same pages, two copies of every control, and a filter field that a screen reader or a script would find twice. The visible cost is that the tree's expansion and filter reset when the window crosses the breakpoint, exactly as they already do when the rail is collapsed and reopened.

The menu closes itself once you pick a page, including when you pick the page you are already on, so you land on the page rather than behind the menu that took you there. Rows remain ordinary links: they can be middle-clicked, opened in a new tab, and copied, and doing any of those leaves the menu open, since you have not gone anywhere. Widening the window past 1024px closes the menu too, rather than leaving it over the rail that has just appeared.

On the Pages list the same breakpoint replaces the row of filter controls with a single Filters button that opens them in a panel from the bottom of the screen. The button carries the number of filters currently narrowing the list — the status, the search text, and the space, classification and owner facets, five things at most — both as a badge and in what a screen reader announces. The sort is not counted: it changes the order of the rows, never which rows there are.

Resizing and collapsing the left rail (PLT-528)

The page-tree rail is the platform's shared sidebar shell, so it behaves like Directory's. Its width is dragged from a handle on its trailing edge, or adjusted from the keyboard once that handle has focus — arrows step, Shift coarsens, Home and End jump to the bounds — and it persists per user across reloads, tabs and zones. A chevron at the foot collapses the rail to a narrow strip and back.

Width and collapse are separate preferences, so collapsing and reopening restores the width you had rather than resetting it. Both are shared across the whole platform on one origin: a rail collapsed in Directory arrives collapsed here, on a first visit to the wiki. While collapsed the rail is deliberately near-empty — filling it is a later slice — but the space switcher inside it stays mounted, because it is what resolves the active space for the rest of the application.

The rail is hidden below the md breakpoint, as it was before; moving it into a drawer on smaller screens is tracked separately. The drag handle carries an accessible name and reports its current width, and the <aside> itself is named "Page tree" so it is distinguishable from the editor's "Page metadata" panel, which is a second landmark on the same page.

Creating a page (PLT-565)

The New page action in the navigation bar opens a dialog rather than navigating away, so the page you were reading stays behind it and Escape puts you back exactly where you were, focus included. The dialog asks for a title, the kind of page, its parent, its space and its classification. The slug is derived from the title and stays folded away — it appears by itself when the title you chose is already taken at that location, which is the only case where the derived value is not what you want.

/new still works and opens the same dialog, because links generated inside page content point at it: a wikilink to a page that does not exist yet, and the offer to create one from a search that found nothing. Closing there returns you to the wiki home and hands focus to the navigation bar's own New page action.

A page created here is always a draft. The status control belongs to the editor, where the moves the server accepts are the only ones offered; a page cannot be born approved from the browser. Validation messages attach to the field they belong to and are read out with it, and a failed create leaves the dialog open with what you typed still in it.

Choosing a space (PLT-529)

One control switches spaces, and the same control picks a page's space in the editor — they are the same composition over the platform's shared dropdown, so the two cannot drift apart.

It is a real menu: arrow keys move between spaces, typing jumps to a space by name, Escape closes it and focus returns to the trigger. The active space is marked two ways, never by colour alone — a check beside it, and aria-current for assistive technology. A knowledge-base space carries a marker and reads Curated · read-only, so a curated space is recognisable at the moment you pick where to work rather than after you have opened it. Each space also gets a tinted two-letter monogram, derived from its identity so renaming a space does not recolour it.

The two surfaces differ in one deliberate way. The sidebar switcher changes the application's active space, which re-scopes the page tree and the home page. The editor's field only sets the space of the page being edited, and never touches the active space — so choosing where one page lives cannot silently re-scope everything else.

The switcher also follows the active space when something else changes it — the space crumb in a page's breadcrumb trail, or the wiki open in another tab. Its label re-reads immediately, without a reload, in step with the page tree it shares that space with.

A tenant can have more spaces than one page of the space list returns. When the active space is one of those, the switcher looks it up on its own and names it, rather than quietly dropping you into the default space — following a link into such a space is not undone by the control that reports where you are. While that lookup is in flight the trigger says it is loading.

If the lookup cannot complete, what you see depends on how you arrived and on why it failed.

Arriving with the page freshly loaded, the answer follows the cause. If the space is simply not yours to read — it was deleted, or your access to it was removed — you are moved to the default space, because there is nothing to keep you in. If the lookup merely failed to complete, you stay where you are and the switcher says it cannot name the space, rather than naming a different one.

Arriving because something else switched space while you were reading, any failure leaves you in the new space and the switcher keeps showing the space you were in a moment ago, with no failure message. The space list is fine, so claiming otherwise would mislead — and the alternative, naming the default space while the page tree is somewhere else, is the very thing this control exists to avoid. The label is briefly out of step with the tree until the next lookup succeeds.

Switching organisation clears the active space rather than carrying it over (PLT-796). The space you were in belongs to the organisation you have left, and the wiki cannot read it under the new one — so while the incoming organisation's space list is still arriving, the page tree, the home feed and the "Add to knowledge base" destination all wait instead of showing you something. The switcher says it is loading, the tree is absent rather than listing another tenant's pages or briefly listing every page in the new one, and the home feed keeps its placeholder rather than answering a question it cannot yet ask. The same holds for a space switch made in another tab: a tab acting in a different organisation can no longer re-scope this one, because a space this tenant cannot read is refused rather than adopted.

The price is that the tree, the feed and that button now wait for the space list on every load, where they used to appear at once from what the browser had remembered. What was remembered could not be trusted to belong to the organisation you are in, which is the whole reason it is no longer used.

When the space list cannot be loaded, each surface says what is actually true of it rather than sharing one message. The sidebar keeps the space you were last in and says so — or, on a first visit with nothing to fall back to, warns that pages from every space are on screen. A new page's space field stays usable, because the Default space is always offered, and warns that the page will be created there instead of in the space you were browsing. In the editor, where there is no fallback, it says the space cannot be changed.

Creating a page keeps offering Default even on a brand-new tenant whose Default space does not exist yet; choosing it lets the server create that space when the page is first saved. Editing an existing page lists the real Default space instead, which is what makes moving a page back into it possible.

Changing a page's status (PLT-557)

The editor's sidebar offers Draft, Review and Approved as a segmented control, plus Archive and Unarchive on the adjacent menu. It offers only the moves the server accepts, so an illegal one cannot be chosen:

FromCan move to
draftReview, Archived
in_reviewDraft, Approved, Archived
approvedArchived — and nothing else
archivedDraft (Unarchive) — and nothing else

Four things follow from that table and are worth knowing before you use the control:

  • A status change applies on its own, the moment you make it — it is not staged until Save, and it confirms with a toast. Unsaved title and body edits are neither sent nor disturbed by it, and no revision is written: a status-only change records no new version of the page.

  • Save no longer carries the status. Saving after a transition cannot undo it.

  • Archiving and unarchiving both ask for confirmation, and neither is on the segmented control. Each has its own labelled action on the menu and its own dialog. That is why the Draft segment stays greyed out for an archived page even though Draft is a legal move from there: crossing into or out of archived changes whether the page is eligible to be answered from the knowledge base at all, which is a decision worth a sentence rather than a click on a three-way switch.

  • Unarchiving returns the page to Draft — not to the status it held before. The pre-archive status is not recorded, so a page archived out of Approved comes back as a Draft and goes through Review again to be re-approved. This is a different axis from trash restore (POST /api/pages/:id/restore), which recovers a deleted page.

    What archiving reaches is worth stating exactly, because the surfaces differ. Unarchiving lifts the first two exclusions and nothing else — it makes the page eligible for those surfaces again rather than guaranteeing it appears, since each applies further conditions of its own (knowledge-base answers also require UNCLASSIFIED and an eligible page type):

    SurfaceEffect of archiving
    Knowledge-base answers (kb-read)excluded immediately — filtered in SQL
    Related-page suggestionsexcluded immediately — filtered in SQL
    Space-index hub and spokesstill listed until that index is next rebuilt: the exclusion applies when the index is built, and nothing rebuilds it on archive
    KB nomination (ingestion_candidate, the "Add this page to KB" affordance)unchangedgetPageKbStatus derives from exclusion, coverage and open findings and never reads the page's status, so an archived page with an open candidacy still reports candidate

The segment for the status the page is already in stays selectable rather than greyed out, so the control keeps a keyboard tab stop and can always tell you where the page stands. A line beneath the control names the moves the current status has, so a greyed-out segment always comes with the reason it is greyed out.

Tagging a page (PLT-554)

Tags are edited in the editor's sidebar as removable chips rather than as one comma-separated line. Type a tag and press Enter or comma to add it; pasting a, b, c adds three tags rather than one. Each chip carries its own remove control, reachable by keyboard like any other button, and removing one keeps focus in the row — on the control that takes the freed position, or on the input once the last chip is gone.

Unlike a status change, tags are staged until you Save. A tag still sitting in the input when you save is added by that save rather than discarded, so nothing typed is silently lost — including when the sidebar has collapsed into its mobile sheet, where the field is not on screen at all. If that last-moment tag is refused, the save still succeeds without it and the reason is announced.

Three rules decide what is accepted, and all three exist because of how tags are used rather than for tidiness:

  • Duplicates are refused, and matching is exact. API and api are two different tags, not one typed twice. The page-tree filter matches tags exactly and case-sensitively, so treating them as the same would refuse a tag you can legitimately hold — and kb-candidate, which drives knowledge-base nomination, is matched the same way.
  • A new tag is capped at 64 characters, the longest the tree filter accepts. A longer tag can exist in stored frontmatter, but nothing could ever filter by it. Tags already on a page are never rewritten or dropped by this rule: an over-long one from before still displays and can still be removed.
  • Surrounding whitespace is trimmed and internal runs collapse to single spaces. Nothing else is normalised — no lowercasing and no character filtering.

A refused tag stays in the input with the reason shown beneath it, so it can be edited rather than retyped, and long tags wrap inside their chip so the full value stays readable and the remove control stays reachable.

Choosing a page owner (PLT-555)

The owner is the page's steward — the person responsible for it now, not whoever wrote it. Ownership is reassignable at any time, and reassigning it writes no revision.

The field is a searchable person picker rather than a box you type an id into. Opening it lists the people who may own a page in this tenant; typing filters that list. Each entry shows an avatar and a name, and an entry that is an agent account is marked as one. Choosing No owner leaves the page unowned, which is a valid state and an explicit choice — a page never loses its owner as a side effect of being edited.

Agent marking describes the owner, not the page. A page can be flagged as agent-authored and still be owned by a person, since ownership can be handed over after the page is created. The marker in this field always follows whoever currently owns the page.

When the owner cannot be identified. The field sometimes reads Owner details unavailable above a shortened id. That means the page has an owner and this screen could not put a name to them — not that the page is unowned, and not that the owner is invalid. It used to happen whenever the owner fell outside the first page of picker results, which in a large tenant was common; the field now looks the id up directly, so it means the tenant has no such principal rather than that the list was too short. The stored owner is left exactly as it is: saving the page while the field reads this preserves that owner rather than clearing it, so the page never quietly loses its steward because a screen could not render a name. Choosing someone else, or No owner, replaces it as usual, and the full id is available to a screen reader so it can be quoted or looked up.

The database decides who may own a page, not this control. An owner who is not a member of the page's tenant is refused on save with a validation error rather than accepted and silently dropped. The picker only ever offers people who would be accepted, so meeting that error in normal use is unlikely — it exists for the case where someone's membership changes between the moment the list is drawn and the moment you save.

Restoring a deleted page (PLT-598)

/trash lists the pages deleted in the last 30 days as a table of what was deleted, when, how long is left, and where the page would land if restored. That last column is not decorative: wiki.restore_page rewrites parent_id to NULL when the original parent is itself in the trash, so such a page comes back at the top level rather than under the parent it was deleted from, and TrashService.restore re-homes it to the Default space if its original space has since been deleted.

A page whose original parent is not visible to you — because it was deleted, or because it sits above your clearance — also reads Top level, and that is exact rather than a hedge: wiki.restore_page keeps the original parent only when it is live and within your clearance, so a parent you cannot see is one the restore will not use. The trash listing and the restore path gate on the same clearance, which is why the column can state the outcome without disclosing whether such a parent exists at all (PLT-658).

  • Restore asks for confirmation. The dialog names the page and repeats where it will land, so the relocation above is stated at the moment of consent rather than discovered afterwards.
  • A restore keeps you on the trash list rather than opening the restored page, so several pages can be recovered in one sitting. The confirmation toast carries an Open action for the page that was just restored. The row leaves the table immediately, before the list is refetched, so it cannot be restored a second time while that request is in flight.
  • One restore at a time. While a restore is running, every Restore button and the dialog's confirm action are disabled.
  • A failed restore explains what to do next, from the status code alone rather than from the server's message: a 409 means another page already occupies the target location and must be renamed first; a 404 says only that the restore did not happen, without asserting why — it covers a page that is genuinely no longer in the trash and a blocked restore whose blocker sits above your clearance, and the two are deliberately indistinguishable. The row is therefore not removed on a 404; the list is refetched and what comes back decides.

What the 30 days actually bound is restorability, not retention. The window is enforced inside wiki.list_deleted_pages and wiki.restore_page, so after it a page stops being listed and cannot be restored even by a caller who knows its id. The row itself is not erased: the background hard-delete job that would purge it is noted as out of scope in migration 005 and is not implemented, so treat ordinary deletion as reversible for 30 days, then inaccessible — never as destruction. When content must genuinely go away (an erasure request, a redacted transcript), the compliance path is source retraction, which overwrites the body and every historical revision at rest.

Restoring is a page write, so it needs the same permission as editing the page. Retracted sources never appear here and can never be restored.

Reviewing what has been archived (PLT-932)

/archive lists the archived pages of the space you are currently in — the Archive entry in the wiki toolbar, beside Trash.

The two are deliberately different lists, and confusing them is the easy mistake. Trash holds soft-deleted pages inside the 30-day restore window described above. Archive holds live pages whose status is archived: they stay in the tree, stay readable by link, and keep every permission they had. What archiving takes away is presence in knowledge-base answers and in related-page suggestions. Before this view, that made archiving a decision nobody could review — an archived page simply blended back into the tree, and one archived by mistake was found only by stumbling on it.

Three properties of the list are worth stating, because each is a deliberate limit rather than an omission:

  • It is scoped to one space — the active one, and it says so by never showing anything else. Switching spaces reloads it. This is not cosmetic: the underlying query, asked without a space, answers with every archived page in the tenant you may see, which would read as one space's archive while being nothing of the kind.
  • It is complete for a list that is holding still, and says how complete it is. The footer reads Showing N of T, and a space holding more archived pages than one window offers Load more. A list that silently stopped at its first window would be indistinguishable from a space with exactly that many. What it does not promise is a consistent snapshot while someone else is archiving or unarchiving in the same space: pages are fetched in windows by position, so a page unarchived between two presses can shift the window and leave one row unseen until the view is reopened, and a page archived can offer one twice (the second copy is discarded). Reopening the view starts the windows again — though page lists are briefly cached in the tab, so a reopen within about half a minute can be answered from that cache rather than from the server.
  • It shows no archive date, and that is not an oversight. Nothing in the schema records when a page was archived: a page carries its creation and last-modified times, and revisions record edits rather than status changes. The last-modified date is not a stand-in — an archived page can still be edited — so a date column would label the most recent edit as the archive time.

Nothing can be archived, unarchived or deleted from this view; it is a place to look. To bring a page back, open it and use Unarchive in the editor's status menu — see Changing a page's status. A page you may not otherwise see does not appear here either: the list runs under your own identity and clearance, exactly as any other page list does.

Drafting a page summary with AI (PLT-558)

Every page carries a short summary (pages.summary, up to 500 characters). It is what the space index shows for the page, and until now only an ingest could write it. The editor's sidebar now leads with a panel that drafts one with AI.

Drafting sends the page — not its editor contents — to the summary endpoint, which reads the body and the classification from the stored row. The draft comes back as editable text: review it, change it, then Accept.

Three things about that panel are worth knowing:

  • Accepting does not save. The accepted text is held with the rest of your unsaved edits and is written by the ordinary Save, in the same request as the title and the body. The panel says so while the text is pending. Leaving the page without saving discards it, exactly as it discards an unsaved title.
  • The current summary is shown before a run replaces it. A page that has one displays it; a page that does not says so.
  • Only UNCLASSIFIED pages can be summarised. The knowledge base operates at UNCLASSIFIED, and routing a classified body through the model provider would be a classification leak — so on a classified page the control is disabled and names the level that blocked it. Setting the classification control to a higher tier disables it too, before you save, because that is the classification you have asked for.

When no model provider is configured on the deployment, or a draft comes back unreadable, the panel says so. There is no hand-written summary field yet — the panel is the only way to set one, so on a deployment with no provider the summary stays as the ingest left it.

When a page will not load

Failure and not-found are one shared composition, declared once at the wiki's root and inherited by every route below it (PLT-599). The loading placeholder is part of the same set but is not a root boundary: a page renders it while its own request is in flight, so what you see corresponds to the data actually being fetched rather than to the navigation. The wording is derived from the HTTP status and error code alone — the API's own error message never reaches the screen, because the wiki's policy errors build theirs by joining role names.

Not found. A page that is absent, in the trash, or above your clearance answers identically, and the surface says so as a disjunction: "This page may have been moved to trash or you may not have clearance for its classification." It names both possibilities on purpose, so it explains why you are seeing it without confirming that the page exists. A 403 is presented the same way, for the same reason.

Failures that a retry can clear — the service being briefly unavailable, a request that never reached it, a timeout, or the wiki throttling you — offer Try again, which re-issues the request in place rather than reloading the page.

Failures that a retry cannot clear offer no retry button, and are not drawn as alarms (PLT-918). An expired session and a malformed page link both answer the same way however many times the request is repeated. Neither is a fault of the service, so since PLT-918 they are shown on the same calm surface the not-found state uses — untinted, and announced to a screen reader politely rather than interrupting whatever it is currently reading. The danger-tinted, interrupting surface is kept for the cases where something is genuinely broken: a server error, a timeout, a crash while rendering.

What happenedWhat you see
Your session expiredYour session has expired — sign in again, then reopen this page. One way out, back to the wiki home. It does not offer to create a page: /new needs the sign-in that just lapsed. It does not tell you to reload either — reloading /wiki/p/<id> cannot re-authenticate, because the wiki's auth middleware guards /api only.
The link is malformedInvalid page link — the page in this link is not a valid page address. Check the link, or create a new page. Two ways out: create the page, or go home.

A malformed link is deliberately not reported as "page not found". The address is rejected before anything is looked up, so no page was ever asked about, and borrowing the not-found wording would imply a search happened and came back empty. The same distinction is already drawn one level up for a malformed space link on the insights route.

While a route is still arriving. Between clicking a link and the destination being ready, three surfaces — the trash, the spaces admin table and search — now show a placeholder shaped like the page you asked for, rather than leaving the previous page on screen (PLT-600). For the trash and the spaces table it is the same placeholder they show while their own request is in flight, so the wait looks identical whichever half of it you are in. Search shows that placeholder until its view is running, and then reports its own query as it always has. A page you open fills the wait from inside the view instead, so what you see corresponds to the data actually being fetched; the editor has had its own boundary since PLT-557, which narrows — without closing — the window in which the previous page's controls are still live while the next one loads.

/new is the one route that stays blank on purpose: it is a dialog opened over an empty pane, so there is no content for a placeholder to stand in for.

An uncaught render error below the root layout resolves inside the wiki shell — nav and page tree survive — and offers both a retry and a way home, quoting an error id when one is available. A crash in the root layout itself still falls through to the framework default.

Scheduled endpoints (cron)

The wiki zone publishes three scheduled endpoints. All three are declared in apps/wiki/vercel.json — an exact functions entry raising them to a 60-second budget (the zone default for src/app/api/** is 10 seconds), plus a crons entry naming the schedule. Vercel invokes them by GET, so each exports GET as an alias of POST.

EndpointScheduleWhat it does
/wiki/api/cron/dispatch-eventsevery minuteDispatches the transactional outbox for this zone. Needs DISPATCHER_DATABASE_URL (the platform_dispatcher role) and soft-skips when it is unset.
/wiki/api/cron/curator-lintdaily, 07:00 UTCThe mechanical curator lint sweep over every agent-owned space, plus the kb_reads retention prune.
/wiki/api/cron/process-jobsevery minuteDrains the background job queue (jobs.queue) — see below.

All three are gated on CRON_SECRET, and all three fail closed. A request must carry Authorization: Bearer <CRON_SECRET>; a mismatch is 401, and a missing CRON_SECRET in the environment is 500 rather than an open endpoint. They are exempt from the zone's session-auth middleware precisely because a cron carries the shared secret rather than a session JWT — the exemption is per exact path, so a new cron gets no bypass by living under /api/cron/ and must be allow-listed on its own.

The job worker (/wiki/api/cron/process-jobs)

Wiki background work is queued on the platform job queue: enqueuing happens in the tenant's own request, and running it happens here, because a queued job with no worker is never executed. The worker ships ahead of its workload — today the only registered job type is an internal no-op that proves the path end to end; the asynchronous half of folder ingest and rollups registers against this same worker as it lands.

Each invocation drains at most 10 jobs, round-robin across the registered job types (so one busy type cannot starve the others), and stops early on a 45-second soft budget or on a complete pass that finds nothing. The response reports { processed, unsettled, byType, stoppedBy }processed counts jobs this invocation settled, and unsettled the rare case where a job's handler ran but nothing was settled, because another worker had already reclaimed the row or the settle matched none. unsettled is normally 0; a run where it is not is doing work that drains nothing. A backlog therefore drains at up to 10 jobs per minute; nothing is lost when a pass ends early, since unclaimed rows stay queued and a job whose worker died mid-flight is reclaimed by the queue's 15-minute stale-claim lease.

Cross-tenant, and audited as such. The worker has no session and jobs belong to every tenant, so it claims and settles rows across tenant boundaries under a scoped, documented deviation from the platform's tenancy rule (ADR-031). Every crossing is recorded: each claim writes a wiki.job.claimed audit entry and each settle a wiki.job.settled one, attributed to the system actor and written in the same database transaction as the queue mutation they describe — so a mutation cannot commit without its audit row. The entries carry the job type, its status transition and its attempt count; they deliberately carry neither the job payload nor its error text, since both are handler-authored and may hold tenant content.

Agent access (MCP)

The wiki is a first-class participant in the consolidated constellation MCP server. When WIKI_BASE_URL is configured, 24 wiki tools are registered:

  • Spaceslist_spaces, get_space, create_space, update_space, delete_space, decommission_space
  • Pagessearch_pages, get_page, list_child_pages, create_page, update_page
  • Links & dependencieslist_page_links, link_pages, traverse_dependencies
  • Attachmentsget_attachment
  • Maintenancerebuild_space_index, append_space_log
  • Lint (honesty loop)lint_space, list_lint_findings, record_lint_finding, resolve_lint_finding
  • Source ingestioningest_source, retract_source, revert_ingest_batch

traverse_dependencies walks the depends_on graph breadth-first with a depth cap, so an agent can answer "what does X transitively depend on?" in a single call. rebuild_space_index and append_space_log maintain the per-space index & log pages. decommission_space is the human-confirmed bulk teardown of a populated space — see Spaces; the lint tools drive the honesty loop. Space tools accept spaceId (UUID payload field) and spaceSlug (slug-path resolution context) — see Spaces for the distinction.

Module-boundary rule. The MCP server has no database access — wiki tools reach the module exclusively over its REST API using the caller's Directory-issued token (valid across zones). A CI gate (scripts/check-wiki-mcp-boundary.ts, in the Quality Gates job) fails the build if a wiki tool imports a Prisma client or any app source.

KB page lifecycle (PLT-306 Phase 1)

Every wiki page header now shows a KB lifecycle status badge driven by GET /api/pages/:id/kb-status. The badge communicates four mutually-exclusive states (highest priority wins):

BadgeStateMeaning
In KB (green)ingestedEither an existing KB source summary covers the page body (≥ 0.8 overlap), or a live ingest batch names the page as its origin (PLT-503).
KB candidate (amber)candidateAn open ingestion_candidate lint finding exists — the page is nominated for KB review.
Excluded from KB (red)excludedAn editor has explicitly excluded the page from KB nomination.
(no badge)noneThe page is not in the KB system yet.

ingested has two independent sources, and the second one is why a curated rewrite still counts. Text coverage alone answers "does something in the KB repeat this page's vocabulary", which a compressed summary or a rewritten explanation does not — so a page that was genuinely ingested would read as candidate or none and the badge would invite a second ingest of a page that is already there. Since PLT-503 the derivation also consults wiki.ingest_batches.source_page_id (the durable origin PLT-687 stamps at ingest time), and treats the two signals as alternatives.

The identity signal is deliberately narrow. It counts a batch only while all of the following hold. Each is a way for the identity signal to stop counting — not necessarily for the badge to change, because text coverage is an independent alternative and can still report the page as ingested on its own:

  • the batch is live and unreverted — reverting an ingest withdraws the claim;
  • both the space the ingest targeted and the space its source page sits in now carry the knowledge-base marker (PLT-513). An ingest into an ordinary space is never KB coverage — moving its page into a knowledge base afterwards does not retroactively qualify it — and moving the page out of a knowledge base withdraws coverage. Moving it between two knowledge bases preserves coverage, with one known exception: if the space it was originally ingested into is later unmarked, coverage lapses even though the page still sits in a marked one (tracked separately);
  • its stored source_revision_num still equals the origin's current revision — a new revision on the origin makes the KB body stale, so the identity claim is withdrawn. A revision is written when the body, frontmatter or classification changes (or an edit summary is supplied), so a title- or slug-only rename does not withdraw it;
  • the batch's source anchor page is still live and un-retracted — retracting a source withdraws its KB coverage;
  • the batch's classification ceiling is within the caller's clearance, so the derivation cannot reveal that a batch exists to someone who may not know.

A page ingested before PLT-687 carries no origin and is reported through text coverage alone; no provenance is invented for it.

Protecting knowledge-base content from ordinary writes (PLT-546; active since 2026-09-05, PLT-1074)

Every ordinary mutation path in the wiki now consults the knowledge-base write policy before it writes: page create, update and delete, link create and delete, page-scoped attachment upload, KB-nomination exclusion, and trash restore. A write whose target space carries the knowledge-base marker is refused with 403 and a machine-readable details.reason naming which class was refused (kb_page_update_forbidden, kb_link_delete_forbidden, and so on), so a client can tell the denied operations apart instead of receiving eight identical errors.

The maintenance paths now carry their own authorisation. Approved ingest, a revert's compensating revisions, staleness archival, the space-index hub/spoke/log rebuild and coordinator file-back write into a marked space by design and reach the same guarded methods. Each of them now mints a typed, transaction-scoped capability inside the transaction that performs the write and threads it to the guard, naming the operation it is performing — so an index rebuild that runs inside an ingest is recorded as an index write rather than as an ingest. Activation is a no-op for them.

No service mints that authority on a caller's behalf, and the distinction is load-bearing rather than stylistic. POST /api/spaces/:id/index/rebuild and POST /api/spaces/:id/log/append are ordinary write endpoints that reach the same space-index service, and the second takes its action, title and detail straight from the request body. A service that minted for whoever called it would therefore have authorised those two routes along with the trusted callers — an ordinary writer refused every page edit in a marked space, and still able to append arbitrary text to its log. So the capability is minted by the caller, and those two routes mint none: once enforcement is active, their writes into a marked space are refused like any other ordinary write.

Enforcement is ON by default (PLT-547). WIKI_ENFORCE_KB_WRITES is now a kill switch, not a feature flag: it disables enforcement only on the exact value false. Unset, empty, FALSE and anything else all leave it active. The inversion is deliberate — a typo now fails towards protecting knowledge-base content rather than towards silently not protecting it, and in the steady state no deployment has to set the variable to get the intended behaviour. In that steady state — every instance running this build — set it to false only to back out an incident; doing so drops knowledge-base write protection for every tenant the deployment serves. Crossing into that state is a different matter, and is the subject of the next paragraph: there false is the required transition value rather than an incident lever.

During the transition, the variable must hold an explicit value in every environment. "Steady state" above means every instance running this build. It is not the state a deploy passes through, and the difference is the whole hazard: the two builds agree on the two exact strings true and false, and disagree on every other value — unset, but also FALSE, 0 and an empty string, each of which is OFF on the outgoing build (which compared against the exact string true) and ON on the incoming one. So "an explicit value" means exactly true or exactly false; anything else diverges. Wherever the variable is still unset — which was every constellation-wiki environment as of 2026-09-03, externally managed installations not having been inspected — a deploy carrying PLT-547 activates enforcement in every environment it reaches, with no operator act, and leaves a mixed old/new fleet disagreeing for the length of the rolling window.

So before that deploy, give WIKI_ENFORCE_KB_WRITES an explicit value wherever it does not already hold one — false is the safe value, because it means OFF under both builds and so makes the window uniform. Set false in every environment, including one that already holds true, recording any pre-existing value first so a deliberate choice is carried forward rather than lost. Note what changing a true does and does not buy: both builds read exact true as ON, so it buys nothing in window uniformity — that argument covers the unset and ambiguous values. What it establishes is a fleet-wide OFF baseline, so that turning enforcement on is one deliberate act taken after somebody has verified which spaces are marked and which tenants hold a clean reconciliation verdict. The accepted cost is that an environment which was refusing knowledge-base writes stops refusing them until that activation happens. On a dedicated or on-prem installation the platform operator is the customer's own team and the installation takes a release on its own schedule, so this is their act to perform, at their own upgrade rather than at ours. And false is not inert: it matches the current behaviour only where the variable is unset and the outgoing build is running, so where the incoming build is already serving with it unset — enforcing today — normalizing turns enforcement off. Only apps/wiki reads the variable. Turning enforcement on is then a separate, deliberate act: set true, which means ON under both builds. This was a registered pre-deploy prerequisite of the release that carried PLT-547, not a step left to whoever happened to run it — and it has run its course: the 2026-09-04 release normalized every constellation-wiki environment to false, and on 2026-09-05 PLT-1074 ran the readiness audit below against every production and staging tenant, set exact true in all three environments, redeployed, and retired the prerequisite row. Since then the variable is only the kill switch the first paragraph describes. An externally managed installation still owes the same transition at its own upgrade to a build carrying PLT-547.

Setting the variable is not the same as applying it, and this is what makes the kill switch slower than it looks. On Vercel the value is bound to a deployment at build time, so a change reaches only deployments created after it — setting it before the release works because the release's own deploy picks it up, but backing enforcement out during an incident needs a redeploy as well as the value. Preview is the environment to act on first rather than at release time: every sub-zone PR gets its own preview deployment, so during the transition previews built from develop since PLT-547 merged were already running with enforcement on. Since activation (2026-09-05) the Preview environment holds true, so it is ON in production, in the rebuilt develop preview, and in every preview built after the flip — but a preview built while the environment held false (between the 2026-09-04 normalization and the flip) keeps false until it is rebuilt, so do not pick an older preview expecting enforcement.

Active is not the same as frozen, and this is the part worth reading before enabling anything. The refusal is decided per space, inside the writing transaction, and only where both of these hold:

  1. the tenant's knowledge-base reconciliation verdict is clean — a tenant that has never been scanned reports pending and is not enforced at all; and
  2. the space is still provably ingest-authored, re-checked live through wiki.kb_space_enforceable_inventory rather than read from the verdict the scan stored. (That is migration 059's function, not migration 039's wiki.kb_space_provenance_inventory. The two exist side by side and must answer an EMPTY marked space differently — marking says no, enforcement says yes — so naming the wrong one here is not a synonym.)

The second condition is the one that matters in practice. A marked space is protectable exactly while every live page in it was created by ingest; add one human-authored page and the space stops being enforced — even though the stored verdict still says clean, because that verdict was computed before the page existed. Enforcing from the stored verdict alone would make such a page permanently uneditable and, since trash restore is guarded too, permanently unrestorable: a page trashed before activation could never come back. Being under-enforced is recoverable; freezing content nobody can restore is not.

When a marked space is left unprotected for either reason, the wiki logs a structured warning naming the tenant, the space and which condition failed. The two conditions have different remedies, and only one of them is a scan:

becauseWhat restores enforcement
tenant-not-reconciledRun the reconciliation scan. That is exactly what it is for: it settles the tenant's verdict, and enforcement follows.
space-provenance-unprovableNot a scan. Remove the non-ingest content from the marked space — move the offending page out, or delete it past the trash window.

Before you activate: the readiness audit

Switching enforcement on is a decision about which spaces will actually start refusing writes, and the three conditions above make that different from "which spaces are marked". A read-only audit answers it per tenant:

npm run db:audit-kb-activation-readiness -w @constellation/wiki -- --tenant <uuid>

It prints the tenant's reconciliation verdict and, for every knowledge-base-marked space, the live enforceable answer the guard itself will consult — then says whether anything blocks activation. It changes nothing in wiki.*: no space is unmarked, no tenant reconciled, no page touched. The only writes it makes at all are the ones auditCritical() performs on its own behalf — the pair of entries constitution §1 requires for a platform-operator read across a tenant, plus whatever bookkeeping the audit subsystem does around them (an events.outbox row, an audit.chain_heads update). That list belongs to the audit package, not to this script.

Exit codes: 0 the named tenant has no blocker; 1 it has marked spaces and at least one of: a non-clean verdict, an unenforceable marked space, or more than one marked space at all — a tenant may have at most one (ADR-038). Do not record activation readiness; remediate per the table above and re-run. 2 the run did not complete, so it is not an answer.

The cardinality class is derived live from the marked-space set, never from the stored verdict, and that is the point of it: a tenant that was multi-marked before the enforcement shipped carries whatever its last scan wrote, so one still reading clean would otherwise pass this audit and have enforcement switched on over an invariant violation. The audit does not choose which marker to keep — it lists them all and stops.

It reads one explicitly named tenant and never discovers tenants for itself, so fleet evidence is one run per tenant aggregated against your own tenant list. Paste the output on the activation ticket — that record is what makes the decision auditable rather than remembered.

It runs under either the migration role or the non-bypass runtime role, and narrates which database it actually reached — read that line rather than assuming, because the generated Prisma client loads .env at import, so a DIRECT_URL in the checkout wins over a DATABASE_URL you exported. Its per-transaction budget is WIKI_AUDIT_TX_TIMEOUT_MS (default 10 minutes, shared with the page-owner audit); a non-integer or non-positive value is rejected rather than falling back, so a typo cannot quietly reinstate Prisma's 5-second default.

The second row is worth being blunt about, because the obvious action does nothing. A reconciliation scan skips a space that is already marked: reconcile inventories the hinted space only while it is unmarked ("an already-marked hinted space is left alone… it is the settled state this scan is trying to reach"), and isInventoriableTarget excludes marked spaces from the provenance inventory altogether. So for a tenant carrying one marked space, re-running the scan against a drifted space leaves the verdict clean, reports nothing, and leaves every ordinary write in that space still allowed — while looking like a successful remediation.

That exclusion is scoped to the provenance question. It does not apply to cardinality: since ADR-038 the scan enumerates the markers separately and reports a tenant carrying several as blocked, listing every one of them under multiple_marked_spaces.

Two further limits, so the trail is not read as more than it is. The warning names the space, not the page: nothing today reports which page broke the provenance, so finding it is a manual query. And where the unprovable content was written by a trusted path — the common case, see the caution below — there is nothing to remediate at all, because that content belongs there; it needs PLT-1002 rather than an operator. A durable, queryable signal is deferred to the provenance generation counter (PLT-816).

Known limitation — the provenance verdict is observable to ordinary writers

wiki.kb_space_provenance_inventory runs SECURITY DEFINER so that it sees pages an ordinary reader cannot — above-clearance ones, and ones sitting in the trash. Enforcement turns its answer into a distinguishable outcome: a space it vouches for refuses your write, a space it cannot vouch for allows it. So someone who can attempt a write to a page they can see learns one bit about pages they cannot: whether the space holds a live-or-restorable page that was not created by ingest.

It is one bit per space rather than content, and today it is nearly constant for the reason in the caution below — a knowledge base the coordinator writes to answers "cannot vouch" regardless. That is an accident of the current gap, not a property of the design, and closing that gap would make this bit informative. The two are tracked together and must be scheduled together.

Known limitation — a trusted write un-protects the space it wrote into

"Provably ingest-authored" is decided by whether every page in the space carries an ingest batch on its first revision. A page filed by a trusted writer carries none — coordinator file-back creates knowledge-base pages with no ingest batch, and nothing durable records that the write was authorised. So the first file-back into the knowledge-base space makes that space unprovable, and enforcement declines for it from then on.

This is fail-open: the space is left exactly as unprotected as it was before enforcement existed, and nothing is damaged. But it means enforcement protects a knowledge base the coordinator writes to only until that writer next runs. Spaces whose content is purely ingest-created are unaffected. Closing it needs a durable record of a trusted write, which is a schema change and is tracked separately.

Source retraction — the designated irreversible compliance path for GDPR erasure, redaction and released legal holds — is conditioned on the tenant verdict only, not on per-space provenance. A tenant that has not been reconciled keeps its pre-activation behaviour there, as everywhere; but in a reconciled tenant the retraction gate applies even to a marked space whose provenance is not provable. The asymmetry with ordinary writes is deliberate: that gate refuses only when the source moved into a different marked space than the caller was authorised for, which freezes nothing and is fixed by re-probing and retrying — and asking provenance there would let the destructive cascade run before the pre-existing conflict aborted it, which is precisely what gating it early avoids.

Enforcement is decided per space, and the decision performs a privileged read that is audited (PLT-1005). Once the latch is on, a marked space is enforced only where the tenant's reconciliation verdict is clean and the space is still provably ingest-authored — re-checked live against wiki.kb_space_provenance_inventory rather than read from a stored verdict, so a page created since the last scan leaves its space writable and restorable. That inventory runs SECURITY DEFINER precisely so it sees pages an ordinary reader cannot: ones above the caller's clearance, and ones soft-deleted but still restorable. Constitution §5 owes an audit entry for such a read, and all ten guard sites are now covered by one.

What the entry records, and what it deliberately does not:

  • Action wiki.space.kb_provenance_check_applied_outside_clearance_scope, against the space that was guarded — never the page being written, and never a page on the other side of the boundary.
  • Exactly one entry per operation, filed on the first guard site that reaches a marked space and before the tenant verdict is consulted. It says the check was APPLIED, not that the SQL ran: keying it on the definer actually executing would make the entry's presence disclose whether the tenant is reconciled, which the caller cannot otherwise see.
  • The COUNT does not vary with the verdict either. A refusal short-circuits the guard sites after it, so one entry per read would have meant fewer entries exactly when the guard refused — one for a refused upload against two for an allowed one, and one against one-per-page for a space-index rebuild. The trade is that an operation checking two different marked spaces (a cross-space move, or an upload whose page moves mid-flight) is indexed under the first only; the second crossing is covered by that entry but is not separately queryable.
  • The same payload on both outcomes. It carries no verdict, no reconciliation state, no decline reason, and no existence, count or identifier of a hidden row — so an audit reader cannot recover from the entry the bit an ordinary writer infers from being refused or not. That bit remains inferable from the surrounding trail, because enforcement deliberately keeps allow and refuse distinguishable; making them identical would freeze legacy pages already sitting in marked spaces.
  • It survives the refusal. The entry rides the write transaction, and when that transaction rolls back — which a refusal always causes, and which trash restore in particular requires, since its guard runs after the restore UPDATE — the owning tool rewrites it in a fresh transaction before the error propagates.

A separate structured warning (not an audit row) names the tenant, the space and which of the two conditions failed whenever a marked space goes unprotected. That reason is exactly what the audit entry must not carry, which is why the two are different records.

The two check-then-write windows are now closed (PLT-915). Under active enforcement a guard resolves the space it decides on from a read, and the write happens in a later statement — so a concurrent page move into a marked space, or a flip of the space's own knowledge-base marker, could invalidate the verdict in between. Every guarded transaction except attachment upload now takes the tenant :slug-namespace lock at its entry point, which is the lock both page moves and both marker writers already take, so neither can commit inside that window.

That includes the trusted paths, and a revision of this work wrongly excluded them. The tempting argument is that a capability bound by identity to its own transaction and space is already immune: a marker going off leaves the space ordinary and the write allowed anyway, and a marker going on leaves the capability still scoped to that very space. That covers the marker and not the MOVE. LinkService.create resolves the source page's space unlocked, so an ordinary move from A to B followed by an admin marking B lands the link in protected B under a capability minted for A — the mover and the marker serialise with each other and with nothing at all if the trusted transaction holds no lock. So every entry point hoists.

What IS reserved to ordinary callers is the held-lock assertion: a trusted write is checked against its capability rather than refused, so it never reaches the assertion at all. An authoritative guard reached WITHOUT that lock raises INVARIANT_VIOLATION (500) rather than deciding on facts nothing is holding still — deliberately not a 403, because "cannot tell" is not "may not".

Page-scoped attachment upload is the one exception, and it is one on purpose: its blob upload is a non-transactional network call, and holding a tenant-wide lock across it would block every namespace-fenced write in the tenant for the length of an upload. So it keeps an unlocked early refusal before the upload — which is not authoritative and deliberately raises no invariant error — then takes the lock itself AFTER the upload and revalidates immediately before the metadata insert. A refusal there deletes the blob and fails the request — a completed upload thrown away, which is the correct outcome when the alternative is an attachment on protected content.

The blob's cleanup belongs to the calling tool, and that placement is what keeps the lock cheap. The delete runs in uploadAttachment's catch, outside the transaction, so it happens only after the transaction has ended and released the namespace lock. Doing it inside the service instead — the obvious place, since that is where the refusal is raised — would await a storage round-trip while the whole tenant is fenced behind that lock, so an unreachable storage provider would stall every namespace-fenced write in the tenant until the transaction timed out. To make the outside placement possible the service publishes the storage path to its caller the instant the upload succeeds, rather than only on return.

The analysis lives in .ai/specs/SPEC-plt-546-ordinary-path-kb-write-wiring.md, .ai/specs/SPEC-plt-545-trusted-path-kb-write-wiring.md and .ai/specs/SPEC-plt-915-kb-write-race-safety.md. Refusals are ordinary ForbiddenError responses and emit no separate audit entry or event today.

Source retraction is a named trusted exception, not a refusal (PLT-991). Retraction destroys a source page inside a space, so it is reached by neither the space-level knowledge-base freeze — which covers deleting, decommissioning or renaming the space itself — nor the ordinary-write guard above. Under active enforcement that left an asymmetry worth naming: an ordinary page delete into a marked space is refused, while retracting the same page was not — the one destructive route through a knowledge base was the one the policy never saw.

It stays permitted, because it is the designated irreversible compliance path — GDPR erasure, redaction, a released legal hold — and refusing it would force a tenant to unmark a whole knowledge-base space, dropping protection for every page in it, before one source could be erased. What it gains is an authorization decision — taken under the name source-retraction, and capable of refusing — that resolves the space it decides on under the tenant namespace lock, so neither a concurrent page move nor a marker flip can invalidate the decision between the check and the cascade. A source that moved into a different marked space in that window is refused with 403 before the cascade runs; a move into an ordinary space is not a knowledge-base question for the source itself and still surfaces as the pre-existing 409 conflict. The gate's opinion is confined to the source's own space — the cascade also flags derivers in other spaces, which may themselves be marked, and gating those is deliberately left to a separate change. The gate also takes the space's own row lock, because deleting a space does not take the namespace lock and its emptiness guard counts live pages only — so a space whose one remaining page is the trashed source being retracted is deletable underneath the transaction. A space that cannot be resolved, or whose delete won that row lock, fails closed with a 403 carrying no details.reason — that vocabulary belongs to the ordinary paths.

This changes what is authorised, not what is recorded: the operation name is an in-process capability, nothing persists it, and retraction was already identifiable in the audit log by its own source.retracted action.

Space decommission is deliberately not given the same exception. It is already refused outright while the space is marked, and a marked space stays frozen until it is explicitly unmarked, so a trusted exception would remove that protection rather than add one.

Restore is the one refusal that writes before it refuses. A trashed page carries no space, so wiki.restore_page has to run before the destination is knowable; a refused restore therefore relies on the surrounding transaction rolling that UPDATE back, and leaves no page state, audit entry or outbox row behind.

This governs the reported status, not the nomination queue. The candidacy funnel runs its own, separate identity dedup — it matches the origin's slug and title against the KB source page (PLT-290, findKbSourcesReferencingPage) — and that check does not consult the batch's origin columns. So a page that stops reporting ingested here is not thereby re-nominated: making suppression consume the durable origin and its revision is PLT-683's (H8b) job.

Page-aware "Add this page to KB" dialog (AC1 / AC2)

When an UNCLASSIFIED page is not yet ingested or excluded, a "Add this page to KB" button appears next to the badge (classified pages do not get the button — the KB operates at UNCLASSIFIED). The button targets the tenant's resolved KB space (kbSpaceId from the status read, by DEFAULT_KB_SPACE_SLUG) — never the source page's own space — and is hidden when the tenant has no KB space yet. It opens the standard ingest dialog pre-filled with the current page's body, title, and slug. A "Draft summary" action inside the dialog calls POST /api/ingest/draft-summary, which asks the AI provider to generate a { title, slug, body, tldr } summary for human review before the actual ingest. In page-aware mode the request carries the originating sourcePageId, and the server reads the page's real classification and body from the page row — it never trusts a client-supplied classification or content — refusing anything above UNCLASSIFIED with 400 (ValidationError). Both the draft and the final ingest apply this server-side guard, so a classified page can never be copied into the UNCLASSIFIED KB (AC3).

Drafting is an optional assist, not a hard dependency: the AI provider singleton is wrapped with withAudit() / withBudget() (a per-tenant TokenBudget) at app boot and resolved through getAIProvider(). If the provider was never initialised on this deployment (AI_CORE_PROVIDER unset), or the model's response does not parse, the endpoint returns 200 with { available: false, reason: 'provider_unavailable' | 'unparseable_response' } instead of erroring — the human writes the summary by hand. It never returns 500 for this case.

Excluding a page from KB nomination (AC6 / AC7)

The "Exclude from KB" button (visible to users with wiki:page:write permission) calls POST /api/pages/:id/kb-exclude. This:

  • Sets kb_excluded_at and kb_excluded_by on the page row (SQL migration 023).
  • Resolves any open ingestion_candidate finding for the page.
  • Emits an auditCritical entry (wiki.page.kb_excluded) so the security-relevant action is durably logged.

Excluded pages are never re-nominated by the deterministic candidacy handler — evaluateEligibility returns 'explicitly_excluded' and sets eligible: false regardless of the page's other fields.

Endpoints

MethodPathDescription
GET/api/pages/:id/kb-statusReturns { status, coveringKbPageIds, excludedBy, excludedAt, kbSpaceId }. kbSpaceId is the tenant's resolved KB space id (or null), used to target the page-aware add affordance. coveringKbPageIds reports text coverage only, so it is empty for a page whose ingested state came from source identity — an empty list is not evidence that the page is absent from the KB.
GET/api/pages/:id/kb-rollupReturns { counts: { ingested, candidate, excluded, none }, total, truncated } over the page's descendants — see the subtree rollup below.
GET/api/pages/:id/kb-folder-previewReturns what a folder bulk-ingest run would do — see the folder bulk-ingest preview below. Either { refused: false, pages: [{ pageId, title, disposition }], eligibleCount } or { refused: true, reason, maxPages }. Requires wiki:page:write.
GET/api/pages/:id/nomination-policyReturns { resolved: { nomination, sourceLevel, synthesised }, stored, version, canWrite } — the page's resolved KB nomination policy, its visible provenance, and whether this caller may change it. See the folder policy control below.
POST/api/pages/:id/kb-excludeExplicitly excludes the page from KB nomination. Returns 200 on success, 409 if already excluded. Emits auditCritical.
POST/api/pages/signalsBody: { pageIds: string[] }, 1–100 canonical lowercase uuids (a non-canonical spelling is a 400, so the key you read is always the id you sent). Returns { data: { [pageId]: { pageId, title, href, classification, isAgentOwned, kbStatus, openFindingCount } } } — the per-row signals every list surface renders, in a fixed number of statements rather than one call per row. Keyed by page id, and an id the caller cannot see is simply absent (another tenant's, soft-deleted, or above their clearance) — never a placeholder record, so the endpoint cannot be used as a 404-oracle. An empty array and more than 100 ids are both a 400: silent truncation would be indistinguishable from that clearance omission. Duplicate ids are deduplicated. openFindingCount counts open findings about the page (page_id), of any check type and severity — not findings that merely target it.
POST/api/ingest/draft-summaryBody: { sourceRef, sourceContent, classification, sourcePageId? }. Returns { available: true, title, slug, body, tldr } on success, or { available: false, reason } when the AI provider is not configured or its response could not be parsed (never a 500). In page-aware mode (sourcePageId set) the server reads the page's real classification + body and ignores the client classification/sourceContent. Refuses non-UNCLASSIFIED content with 400.

All seven routes require the caller to be authenticated (authedRouteWithParams / authedRoute). The exclude, draft-summary and folder-preview routes additionally require wiki:page:write permission; the four remaining read routes (kb-status, kb-rollup, nomination-policy and signals) gate on page visibility alone. Three of those four return the same body to a write-capable and a read-only caller; nomination-policy returns the same resolved policy to both and differs only in its canWrite flag, which reports the caller's own write permission and is deliberately not a filter on the answer.

The folder preview is the one read among the permission-gated routes, and the asymmetry with kb-rollup next to it is deliberate. The rollup returns counts and no page ids; the preview enumerates descendants and attaches each one's policy and coverage disposition — a bulk association the rollup does not offer — and it exists to be approved and acted on. It is gated by the ingest's own permission so that preview and operation refuse the same callers.

Folder policy control

GET /api/pages/:id/nomination-policy answers the question the folder page's Knowledge base policy control is built on: given this page's own override and every ancestor's, what policy actually applies here, and where did it come from? The three resolved fields are wiki.resolve_nomination_policy's output forwarded rather than folded into a verdict — its value, the visible source LEVEL, and the synthesised flag. The resolver's fourth column, the source page's id, is deliberately not on the wire (see below).

Read a null sourceLevel as "no visible source", never as "the space decided". The resolver nulls sourceLevel and the source page's id together when the row that supplied the policy sits above the caller's clearance, so a redacted ancestor and an absent one are deliberately indistinguishable. The route reports the level only: the resolver's visibility test covers the winning row alone, not the rows between it and the page, so a visible winner behind a hidden intermediary would put on the wire an ancestry link the page projection redacts. Only nomination: 'inherit' establishes that nothing in the page chain overrode and the space gate decides. synthesised: true is a third case again — the ancestor walk ran out of depth without finding any policy row, so the exclude it returned is the fail-closed default that no row decided. The control renders the three as Inherited, Space policy and Safety default respectively, and names a level only for a source the resolver actually returned.

inherit is not the whole verdict. The resolved value is the policy chain's answer; whether a page is nominated additionally weighs the space's own nominateFrom gate and an explicit per-page exclusion, which outranks every policy above it. The control says so rather than letting a reader take a policy for an outcome.

This read is audited when it crosses a classification boundary. A resolution whose value came back without a source means the caller has learned a decision made by a row above their clearance, and constitution §5 exempts a read only when it does not cross such a boundary — so the route emits wiki.nomination_policy.resolved_from_hidden_source, one entry per crossing resolution, naming the visible page and the value and never the withheld ancestor. The entry is filed at that page's own classification floored at the SIEM default, not flat at the floor: it names the page and states its policy, so an entry below the page's tier understates the event both in the record and in the CEF severity the SIEM adapter derives from that field. It is not a readability argument above the floor — audit_access is binary, so RESTRICTED and CONFIDENTIAL are read by exactly the same scopes; the floor itself is the readability guard, since an unclassified entry is readable at every scope and dropped by the SIEM filter. A visible source, an inherit and a synthesised value each emit nothing. It is otherwise read-only: GET alone is exported, and changing the policy goes through PATCH /api/pages/:id with { ingestPolicy: { nomination } }.

One read answers both halves, and carries the write's precondition. resolved is what applies here; stored is this page's OWN override (null when it sets none), which is a different quantity — a folder that stores nothing while inheriting exclude must not present that as its own setting. version is the row version to send back as expectedVersion on the PATCH. They are read in a single SQL statement, so they describe one snapshot rather than two, and they travel together because the folder card is unmounted and remounted routinely — it renders nothing while its children read is in flight, so any cache reset takes it down — and a client that held either value locally lost it there and fell back on a stale page snapshot. Neither field is a new disclosure: GET /api/pages/:id returns both to the same callers.

Take the precondition from this read, not from a previous PATCH response. The row's version advances after the PATCH answers, because retiring the page's open ingestion candidate runs through the outbox — so a base carried over from one write is already stale for the next, and a client that reuses it gets a stale_version 409 on every second change. Re-read after such a conflict; the same call returns both the fresh base and the current stored value.

canWrite is reported, not enforced, and unlike GET /api/pages/:id/space-move a caller who may not write is answered rather than short-circuited: the resolved policy governs whether this subtree reaches the knowledge base, so a reader is given the value and the control simply omits the affordance. The flag mirrors the predicate PATCH /api/pages/:id asserts for a policy-only body, so the surface reporting eligibility and the one enforcing it cannot disagree.

Subtree KB rollup

A folder — any page with children — can be asked how much of what sits beneath it is already in the knowledge base. GET /api/pages/:id/kb-rollup answers with per-state counts over the page's descendants. The folder page renders it as a Subtree KB status block under its "Pages in this section" heading and above the child table, kept separate from that list's own count because the two count different things — descendants versus direct children — a truncated reading is displayed with its counts and an explicit note that it is partial rather than suppressed or presented as a total, and a failed read is reported rather than rendered as silence, which would be indistinguishable from a subtree with no KB state.

The counts come from the same derivation as the per-page badge: the recursive parent_id walk is supplied to the canonical KB-status relation as its page set, so a page cannot be counted as one state here and shown as another on its own badge.

Four things about the answer are worth knowing before building on it:

  • The anchor is not in its own rollup. The counts are descendants only; the folder's own state comes from kb-status. A view that wants "folder plus subtree" combines the two.
  • total is the number of visible descendants the counts were derived from, so the four counts sum to it. It is not the size of the subtree when truncated is set.
  • Invisible descendants are absent, not none. A descendant above the caller's clearance, soft-deleted, or in another tenant is excluded from the counts entirely rather than reported under a state — the same rule the batch status read follows when it omits ids the caller cannot see.
  • The walk stops at a hidden ancestor. Because it runs under ordinary page RLS, a descendant sitting beneath a folder the caller cannot see is not reached, even when that descendant is individually readable. So a rollup can under-count relative to a flat listing. This is deliberate: a count publishes nothing and authorises nothing, so stopping early is the harmless direction — unlike the folder policy resolver, where stopping early would leave an ancestor's exclude unenforced, which is why that one is a SECURITY DEFINER walk and this one is not.

truncated: true means the counts MAY be incomplete. The walk is bounded in depth (1000 edges), against parent_id cycles, and in width (at most 1000 pages counted); reaching the depth or width bound returns the counts with the flag rather than an error or a silently short number. The width bound caps how many pages the rollup counts — the walk itself pulls one row beyond it, which is how overflow is detected — and it does not cap the work Postgres does to find them: a folder with an enormous number of direct children is still an expensive read.

The flag is deliberately conservative rather than exact. A walk that reached the depth cap stopped expanding there, and nothing tells it whether anything lay below — a subtree exactly 1000 edges deep is reported truncated even though its counts are complete. Over-reporting is the harmless direction; the opposite, a short count presented as a total, is what the flag exists to prevent.

truncated: false does not promise the counts cover every visible descendant. It reports only the walk's own bounds. The hidden-ancestor case above is a separate source of incompleteness and is invisible to the flag by construction: the walk cannot see the branch it stopped at, so it cannot know it stopped. A rollup is therefore best read as "of the subtree reachable at your clearance, here is the breakdown" — exact within that reach when truncated is false, and never over-counting.

The cycle guard is deliberately not one of the bounds that sets the flag. A page has exactly one parent, so cutting a repeat visit ends the walk without dropping anything — every page in a malformed cycle is still counted once — and reporting that as truncated would label an exact answer partial. The counts stay usable for display, but a truncated rollup is not an authoritative total — a caller that would act on it, such as a bulk-ingest preview, must refuse to treat it as its target set.

Folder bulk-ingest preview

Before a folder is bulk-ingested into the knowledge base, GET /api/pages/:id/kb-folder-preview answers what such a run would actually do, page by page, and commits nothing while doing it.

Each descendant comes back with one disposition:

dispositionMeaning
eligibleThe run would ingest this page.
not_unclassifiedAbove UNCLASSIFIED, so the run — which executes at that pin — could never see it.
excludedExcluded by its own kb_excluded_at, or by a nomination: exclude on it or one of its ancestors.
already_coveredAlready in the knowledge base, per the canonical KB-status derivation.

Two of those four — excluded and already_covered — are extracted from the per-source ingest's own skip vocabulary rather than restated, so renaming one is a compile error in both places. eligible and not_unclassified have no counterpart there: the ingest has no affirmative disposition, and it never sees a classified page at all. The dispositions are resolved in the run's own order, which matters where a page qualifies for more than one — an excluded page is very often also already covered, and both surfaces have to pick the same one.

A folder that is too large is refused, not truncated. More descendants than one run may cover, and the response is { refused: true, reason: 'too_many_pages', maxPages } with no page list. A preview is the artefact an operator's approval rests on, so a silent prefix would have them approve a folder while seeing only part of it — which is exactly the failure the kb-rollup docs warn a bulk-ingest preview against. The refusal deliberately reports no total: the walk that produced the verdict pulled one row past its cap and stopped, so it knows a further page exists and nothing more, and any number here would be the truncation the refusal exists to avoid. It is a 200, because the request was well-formed and the answer is one the folder view renders inline.

The rollup's other bound — hierarchy depth — has no counterpart here, and that is arithmetic rather than an omission: a chain of depth d costs d rows, and the preview walks at most maxPages + 1, so no request can measure a depth reaching the 1000-edge bound while the page cap sits below it. A too_deep reason would be one no response could ever carry.

The walk runs at the caller's clearance, not at the run's UNCLASSIFIED pin, and this is what the not_unclassified disposition pays for. Pinning the read would lose pages in both directions: an UNCLASSIFIED page beneath a folder the pin cannot see would be missing from the preview — and therefore from the run, which is driven by it — while the run's own membership test would have crossed that folder to reach it; and a folder whose anchor is above UNCLASSIFIED would 404 outright even though every page under it is individually ingestable. Walking at the caller's clearance is strictly better coverage than the pin, and the price is that a page the caller can see and the pin cannot must be reported as skipped rather than offered.

It is better, not complete, and the residual is worth knowing before building on the preview. The walk still runs under ordinary page RLS, so it stops at an intermediate folder the caller cannot read — and an UNCLASSIFIED page beneath such a folder is absent from the preview even though the run's own membership check, a SECURITY DEFINER walk, would accept it. Read a preview as "of the subtree reachable at your clearance", the same caveat the rollup carries. Closing it needs a privileged traversal that crosses hidden intermediates while projecting only caller-visible descendants; that is a separate slice.

It is a read that writes audit entries, and that is not a contradiction. It creates no page, revision, ingest batch or run. But resolving a descendant's inherited nomination policy can return a decision made by an ancestor the caller cannot read, and constitution §5 requires an audit entry for exactly that — a read that crosses a classification boundary. One entry for the whole request, naming the folder and carrying every crossing descendant with its resolved value — never the withheld ancestor. It keeps the ingest path's RESTRICTED floor and redaction but has its own action, wiki.folder_preview.resolved_from_hidden_source, because it is a differently-shaped event: a per-page consumer must not start seeing one row where it expected one per resolution. The ingest files per source because it resolves one source per transaction; a preview resolves up to the cap in one, and a per-page loop there exceeded the read transaction's budget — which rolls back, so the previews producing the most crossings would have produced no entry at all.

Where an operator sees it (PLT-615)

The folder page carries a Bulk ingest block, directly under the section's knowledge-base policy control. It reads nothing when the page loads — the preview above is an audited read, so issuing it for everyone who merely opens a folder would write §5 entries nobody asked for. Preview bulk ingest opens a modal, and only then is the request made.

The modal states the plan as the split PLT-306 AC11 names — Eligible, Skipped, Already covered — over the four dispositions above: excluded and not_unclassified are two causes of one outcome, so they share the Skipped count while each page's own row still names which of the two applies. The table below the counts lists every previewed page, so an operator approving a bulk write can see exactly which pages it covers.

Start bulk ingest appears only when the preview settled, was not refused, and reports at least one eligible page — an all-skipped section and an over-cap refusal both withdraw the action rather than disabling it, and the refusal names the cap. Pressing it POSTs /api/pages/:id/folder-runs with an idempotency key minted when the plan was read, not when the button was pressed: two presses of one approved plan are one intent, and the run is then enqueued rather than performed in the request, so the toast reports a started background run rather than a finished ingest.

A failed start keeps the modal open, with the plan and that same key, and reports inside it. That is the key doing its job rather than a presentation choice: a request that fails after the server committed is indistinguishable from one it refused, so discarding the key would make the retry a second run instead of a replay of the first.

The destination is the one GET /api/ingest/capability resolves — the folder's own space when that space is itself an approved knowledge-base destination, otherwise the configured KB target. Where neither resolves there is nowhere correct to write, and the block does not render at all.

A folder's run history and the whole-run revert are a separate surface and are not part of this block yet; the endpoints behind them are documented above.

Usage insights (PLT-581)

/wiki/insights reports how a space's knowledge base is actually being read, over a rolling window. It shows four figures — KB reads, Miss rate, Approx. tokens and Tokens per read — for one space at a time, with the space and the window both carried in the URL (/wiki/insights?space=<slug>&days=<7|30|90>), so a view can be linked and shared.

The surface is always scoped to one space, and that is deliberate. The underlying report endpoint treats the space as optional and aggregates the whole tenant when it is omitted — a result that is indistinguishable, on screen, from one space's figures. So the page has no tenant-wide mode at all: arriving without a space canonicalises the URL onto your active space (else the tenant default, else the first space you can see), and a link whose space cannot be resolved shows a stated state rather than falling back to the aggregate.

Three of those states are worth telling apart:

What you seeWhat it means
Space unavailableThe space does not exist, was deleted, or you cannot see it. The three are deliberately indistinguishable — a space you have no access to must not be detectable from one you could see if it existed.
Invalid space linkThe space in the link is not a valid space name at all, so it cannot name a space in any tenant. A corrupted or hand-edited link, not a missing space.
An error with Try againThe lookup itself failed — a server error or a dropped connection. Nothing is claimed about whether the space exists.

Why these four figures and not others. The report's ranked facets — top queries, top-cited pages, most-injected index spokes, never-read pages — are each returned as a capped page of rows, so their lengths are a page size and not a total. A card built from one would read as a total and would happen to be one whenever the space had fewer rows than the cap, which is exactly when nobody would notice it was not. The four figures above are the ones the report computes over the whole window without a cap. The ranked facets get their own explicitly top-N surfaces.

The header carries a compact usage strip (PLT-588) — KB reads, query misses and the miss rate, with a badge stating the verdict in words. It is a second projection of the same report, not a second read, and two of its three figures deliberately repeat what the cards below show: the point of a header strip is that the answer is above the fold. The one figure the cards do not headline is the raw query misses count, because a rate alone cannot separate "2 of 4" from "500 of 1 000". The strip appears only where the figures are real — a placeholder while the read is open, and nothing at all when the space is unavailable or the read failed, since a row of zeros above such a message would read as a report that loaded and measured nothing.

The strip does not say whether usage recording is on, and cannot. Recording is disabled by an unset emitter secret, which fails silently by design and is reported nowhere on the wire, so a disabled recorder and a genuinely quiet window are the same all-zero report. The strip stays neutral rather than guessing; the empty state below it is where the troubleshooting sentence lives.

The miss rate is the share of reads that found nothing groundable, and it is flagged when it reaches the same threshold the KB usage service uses for its own miss-rate anomaly — and left unflagged, with the reason stated on the card, below the same minimum number of reads, so the dashboard and the anomaly detector never disagree about the same number. A window with no reads shows zeros and an em dash for the two ratios, never a blank card and never NaN.

KB reads by day (PLT-582)

Beneath the cards, a bar chart plots the reads per day, so the page answers when the space was read and not only how often. Every bar carries its count as text and the whole series is mirrored into a screen-reader table, so the chart is never a colour-only signal; the plot scrolls horizontally and is reachable by keyboard, which is what lets a 90-day window keep one bar per day rather than compressing the numbers past reading.

The chart plots the span it observed, not the whole window, and it says which. The underlying per-day figures come from a grouping that emits nothing at all for a day with no reads, so quiet days between two active ones are filled in as explicit zero bars — plotting the rows as they arrive would draw three equal bars for three active days in a month and read as "three days of data". Days before the first read and after the last are a different matter: the report identifies neither the window's edges nor the timezone its days were bucketed in, so filling out to them would place bars on days the response cannot locate, and would show the oldest bucket — which the rolling window makes a partial day — as a full one. The card therefore states the first and last day it plotted, next to the title.

Day labels are weekday names over a 7-day range and dates over 30 and 90; the screen-reader table always carries the full date, so two identical weekday labels a week apart are still told apart.

If the per-day figures cannot be read, the chart says so rather than showing part of them. A day that names no calendar date, or a set of days spanning more than the window could contain, makes the whole series unplottable — and the card states that the figures could not be read instead of drawing the points it happens to understand, or falling through to "no reads in this range", which would assert that a busy window was quiet.

Two of the four cards above survive that and two do not, which the message says. KB reads and miss rate come from a separate total and stay correct. Approx. tokens and tokens per read are computed from the per-day figures themselves, so when those cannot be read the two are withheld — shown as an em dash with the reason — rather than presented as numbers derived from data the page has just said it cannot vouch for.

Top cited pages, and the never-read list (PLT-587)

Below the cards the same report is shown as two tables — the first of the ranked facets to get the explicitly top-N surface the section above promises them.

Top cited pages ranks the synthesis pages agents actually cited, most-cited first. Index spokes are cited too, but they are a separate facet that this table does not show — so "no citations in this range" means no synthesis citations, and a space whose reads only matched spokes will still see it. A citation is recorded as a slug handle, and a wiki slug is unique per parent rather than per space, so one handle can match several pages. Where it matched exactly one, the row links to that page. Where it matched more, the row is not a link and says so in as many words — "Ambiguous citation — N pages share this slug, so none is linked" — because the report deliberately refuses to name one of several candidates, and a link that guessed would be worse than no link. A row that resolved uniquely always links, even when its page happens to be titled with the same placeholder an un-upgraded client would print.

Never-read pages is a separate table, and every row links: those rows name a real page rather than a handle. It is a curation queue, and it labels what each row means, because the two findings must not be actioned identically — an unread synthesis page is a prune or merge candidate, whereas an uninjected index spoke is a signal about the hub's topic set, and spokes are regenerated rather than deleted.

"Never read" is narrower than it sounds — read the four qualifiers before retiring anything. The list asks whether a read visible in this report touched the page in the selected window. So: a page busy last month appears in a 7-day window; a page touched only by a read above your clearance appears too, because the report cannot count reads you are not cleared to see; only pages eligible for this curation facet are considered at all — live, unclassified, non-archived, unexpired synthesis pages plus the index spokes the hub can actually reach; and a page sharing its slug with a cited sibling counts as read, because a citation records a handle and the handle cannot tell them apart.

Both tables state their own cap and their clearance scope. Each is a capped page of rows (20 by default), so each caption says so — and each adds that the figures are over reads visible in this report. That second half applies to both facets for the same reason: a page read only at a higher clearance is listed as a never-read prune candidate, and a citation count is missing those reads too, so an unqualified ranking reads as the space's actual top citations. Acting on either without the caveat is how an actively-used page gets retired.

A space with four hundred never-read pages otherwise renders identically to one with exactly twenty. The rows are shown in the order the server ranked them, never re-sorted in the browser — the cap is applied under that order, so re-ranking the rows after the cut would leave the list disagreeing with its own truncation.

An empty table is a real answer rather than a gap, and the two say different things: no citations in the window means nothing groundable was cited, while an empty never-read list means nothing eligible is left to act on — not that every page in the space has ever been read, and not that each candidate was individually reached (reads are matched by slug handle, so a page can count as read through a same-slug sibling).

Both tables warn when their window may mix two measurements. How KB reads are recorded changed at the PLT-471 read-path update, which happens per deployment: before it, a citation was recorded for every page selected, even one the context budget then truncated away, and index spokes carried no identity at all. So across that point topCited is not comparable and never-read membership shifts — a page can enter or leave either list because the measurement changed rather than because its usage did. A long range is the one that reaches back past it. The surface cannot name the date (the cutover is a deployment moment, recorded nowhere), so it says so unconditionally above each populated table and leaves the range judgement to you.

Every one of these statements is about what the report can see. Each facet filters reads through your clearance, so "nothing cited this" and "nothing read this" are claims about visible reads, never about what happened: a read at a higher clearance did cite the page, and the report excluded it. Both empty states and the never-read caption say so explicitly, in one shared sentence, so the two facets cannot come to disagree about it.

The ingested-sources ledger (PLT-584)

Below the cards and the chart, /wiki/insights lists the space's ingest batches — the source each one ingested, who approved it, how many pages it wrote, and whether it is still committed or has been reverted. Reverting a batch removes the pages that ingest created and restores the ones it changed; the batch itself stays in the ledger, marked reverted, so the history of what was ingested is never erased by undoing it. The confirmation names the source and the page count, and the revert is recorded in the universal audit log like every other covered wiki mutation.

Revert appears only for people who could perform it. It needs the same write capability an ingest needs, so the ledger is readable by everyone in the tenant while the control is shown only to those the server would accept — and it is withheld, rather than shown and refused, whenever that check has not answered.

An empty ledger is not evidence that nothing was ingested, and the wording says so. A batch whose recorded classification ceiling is above your clearance is excluded before the list is ordered, before it is paged and before its total is counted — the ceiling governs who may know the batch exists, so a placeholder row or a "3 hidden" note would be the disclosure it exists to prevent. There is therefore no gap to count and no total to reconcile: the number the page reports is the count of the batches you may see. "Nothing here" and "everything here is above your clearance" are deliberately the same screen.

The list shows the most recent 50 batches and is not filtered by the range selector above it, which scopes the usage figures only.

Anomaly findings (PLT-583, PLT-591)

Under the cards, /wiki/insights lists the open findings the daily KB usage detector raised for the space in view. Three of them report something operationally odd — a space-wide miss-rate spike, an actor reading far above the space's baseline, or an actor re-asking one question in a loop — and the fourth, the promotion signal, reports the opposite: a synthesis page that enough separate KB reads leaned on in the window to be worth a curator's attention. It asserts no defect; sustained demand is the whole of its evidence. Each row carries the severity as a labelled chip (never colour alone), the signal in words, the detector's own sentence, and when it was raised.

The promotion signal is recorded at Info, below the operational anomalies' Warning, and the list is ordered by severity before recency — so a promotion signal sits below every warning however recently it was raised. That is deliberate: an escalation is the thing nobody should have to scroll for, and a demand signal is not urgent.

There is no page column, and no identity column either. The three operational types are properties of a space or of an actor and record no page at all; the promotion signal does name one, but its own sentence already names it in full, so a page cell would be a second, abbreviated copy of text already on the row. That is the same rule the actor follows: who or what a finding is about is carried by the detector's sentence, which wraps rather than being clipped, so the part a curator acts on cannot be truncated away. An abbreviated copy of the actor id was tried and removed — it restated the sentence beside it, and a raw identifier is not something a reader can act on: not clickable, not filterable, and not yet resolvable to a person's name (that is PLT-881; the wiki can currently look users up only by searching on name).

A promotion-signal row carries a Review link; the other three do not. It opens the /wiki/review queue with that exact finding selected, rather than the queue's front door — the destination is a real link, so it can be copied and back-navigated like any other. The restriction is a scope decision rather than a limitation: every one of these four types is triaged in the same Needs review queue, so the same handoff would resolve for any of them. The promotion signal is the one it exists for, because it names a page and asks for a judgement about that page, which makes the finding itself the thing to open.

The list follows the range control, so it covers the same window as the cards above it. Findings are read one check type at a time and capped, so when a type fills that cap the page says the list may be short rather than presenting it as complete. It says may: a full page is equally what "exactly that many exist" and "more exist" look like, and nothing in the response separates them. Two failure modes are kept apart, for the reason the space states above are: a findings read that fails says so and offers a retry, and never renders as Nothing unusual — and it leaves the metric cards, which come from a different endpoint, standing.

Only open findings appear. A resolved or dismissed anomaly is triage history and belongs to the review queue, not to a dashboard section reporting what is currently unusual.

Curator findings (PLT-585)

Beside the anomaly list, /wiki/insights summarises the open lint findings the curation loop has raised for the space in view, one row per kind — Orphan page, Stale claim, KB nomination and so on — with the count beside it. Each row links into the /wiki/review queue that kind of finding is triaged into.

Only open findings are counted, and a kind with none produces no row. The per-kind routing is an open-findings concept: a resolved or dismissed finding is triaged as Closed whatever its kind, so a row that summed every status would attach its number to a queue holding only part of it. And a kind with nothing open is simply absent rather than shown at zero — there are eighteen kinds and a real space carries a handful, so listing them all would be fifteen zero rows each pointing at a queue they contribute nothing to. Rows are ordered by count, most first.

The counts describe this space; the queue they link into is wider. /wiki/review reports every space you can see and carries no space filter, so a row reading Orphan page 3 here can open a queue that says 40. That is two answers to two different questions rather than a disagreement, and the section says so above the rows instead of leaving it to be discovered. Narrowing the review queue to one space would remove the gap at its root and is tracked separately.

The counts are the review queue's own. Nothing is counted on this page: both surfaces read the same endpoint through the same client reader, over one server-side grouping — so the two numbers are never two independent summations of the same rows. They are two requests, not one: that reader shares a request only between callers asking for the same scope, and these two ask for different ones. They can therefore differ both by scope and by the moment each was read, since a lint pass or a resolve between the two moves one and not the other. What cannot happen is the two disagreeing about the same rows at the same instant.

The total can exceed what the rows account for, and the section says so. The per-kind breakdown is parsed leniently on purpose, so that shipping a new lint rule cannot take the page down — which means an open finding of a kind this build does not yet know is missing from the rows while still counted in the total. The badge reports whichever is larger, the total or the rows, so neither can hide the other, and a line below the rows names the remainder — rather than letting the page under-report a queue in exactly the case where the browser is older than the server. For the same reason No open findings you can see requires both to be zero, never just one of them — and it is worded that way deliberately: findings are row-level filtered by your clearance, so a lower-cleared curator can legitimately read zero in a space that holds open findings. The card reports what you may see, and says so, rather than declaring the space clear on your behalf.

While the read is in flight the section shows placeholders and no number at all: a zero would say the space is clean on a read nobody has made. No open findings appears only once the read has settled and every kind is genuinely zero — and it says nothing is waiting rather than that nothing was ever raised, because a space whose findings were all resolved reaches the same zero. A failed read says so and offers a retry rather than rendering as clean.

See also

  • MCP server setup — wire the constellation server (incl. WIKI_BASE_URL) into your agent runtime.
  • Universal audit log — the platform audit contract the wiki's covered mutations satisfy. See Classification auditing above for which wiki writes are covered, and the one that is deliberately not.