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 (UNCLASSIFIED … SECRET) 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.

  • The page editor — one Save writes the body, the status and every metadata field, while Archive and Unarchive apply on their own after a confirmation; the Details panel sits beside the body from 1280 px and in a sheet below it, and a concurrent edit is refused rather than overwritten. See Page editor for the whole surface.

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

  • Query parameters are single-valued, and a list travels as one comma-separated value (?spaceIds=a,b). A parameter repeated in the query string — ?spaceIds=a&spaceIds=b, or ?limit=1&limit=2 — is a 400 on every wiki route that reads its query string, naming each repeated parameter in details.repeated, rather than being folded to one of its values (PLT-719). Before that the last value won silently, so a repeated filter answered a successful, narrower result the caller could not tell from a smaller one.

  • 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 list with an empty entry — ?spaceIds=, or ?spaceIds=<id>,,<id> — is a 400 rather than a repaired list, because an empty list means "no filter" and a repaired value would silently run the search unscoped; a bare ?spaceIds= still means absent (PLT-864). 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. Each hit carries the page's status (draft, in_review, approved or archived, PLT-1367) as a plain field of the response: it is projected, never filtered on, so search still returns archived pages (PLT-1192) and now says which ones they are. Each hit also carries updatedAt (the page's last change, ISO 8601) and excerpt (PLT-1004): null, or { text, highlights }, where text is a plain-text passage of the body and highlights lists the { start, end } runs of it that matched, as UTF-16 offsets with end exclusive. The excerpt is text, never markup — render text as text and wrap each run yourself; nothing in it needs sanitising and nothing in it may be injected as HTML. It is derived in the same statement that ranks the hit, against the same query, and only when the body alone satisfies that query, so a title-only match has excerpt: null. Only the first 65,536 characters of a body are read for it. Search is paged with a cursor (PLT-718): limit is the page size, at most 50, and the response meta carries total, hasMore and nextCursor. Send nextCursor back unchanged as cursor to get the next page; it is null on the last one. total is the exact number of pages matching the query and filters that the reader may see — it never counts a page above their clearance or outside their tenant, and it does not change with limit or cursor. The cursor is opaque and belongs to one search: reused with a different q or different filters, or malformed, it is a 400 rather than a page of the wrong results. It is a position and not a credential: it is not signed, and it cannot show a reader anything their clearance does not already allow. Paging is by position rather than by offset, so a page that starts or stops matching while a reader is paging never causes another result to be repeated or skipped; because total is recounted on every request, it can differ between two pages of the same walk. The content search runs under its own 2 s statement budget (PLT-2035): the match is served by the search index through a definer that re-applies the reader's tenant and clearance, and a search the database cannot finish in 2 s is cancelled and answered as an error rather than holding the wiki's connection to the 5 s transaction budget every other wiki read inherits.

  • Codes and ticket ids are searchable as typed — the query is split into words and numbers, each matched as a prefix and all of them required, so nato tax finds a page about the NATO taxonomy. A number that follows a hyphen matches whether the page's text was indexed with it signed or unsigned: PLT-261, inf-190 and a three-part code such as HLY-SENTINEL-4417 all find the pages that contain them, in any case and while still being typed (hly-sentinel-44). A segment that starts with digits and continues with letters, after a number (1-2b, x-1-2b, 0-0A-a), matches whether the page's text was indexed with it whole or split into the signed number and the letters (PLT-2079); after a word (x-2b) it is matched whole, as the page indexes it. The trade is on the recall side — PLT-261 can also match a page that holds plt and a number beginning 261 elsewhere, and 1-2b a page that holds 1, -2 and a word beginning b apart (PLT-371, PLT-1881, PLT-2079). A code joined by - or + is also searched as one piece, read the way the page's text was read and still matched as a prefix, which is what finds a code holding a number in exponent form (0E-6, 1-0e-6, 1-2e5x, a0-0e0B-0, 1E+6) or a + sign (1+2) (PLT-2108); a partially typed exponent (1-2e for 1-2e5x) is not matched. Punctuation never reaches the query parser, so no search text can cause a query parse error; text with no words or numbers in it returns no results.

  • 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 with a route of its own, GET /api/pages/titles?q=…&limit=… (PLT-885), while the full-text group is the ts_rank search above and keeps its full result cap. The lookup answers rows of six fields — id, title, slug, page type, classification and status — and never a page document, and it orders them by how the title matches rather than by when the page was last edited: a title equal to the query first, then titles that start with it, then titles where it starts a later word, then titles that merely contain it; within each of those the shorter title leads, and recency only breaks what is still tied. meta.total counts every match, not just the returned window. q is required (1–200 characters) and limit is 1–50, default 10; anything else is a 400. It reads exactly what the caller's tenant and clearance permit, includes archived pages and excludes trashed ones. That the predicate is the title alone is what makes the group correct rather than merely tidy: the general list's default search also matches the slug, and a limit is applied by the database to whichever predicate is in force — so asking for that 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; the list keeps searchIn=title for callers that want title matches as full list rows). 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 is one list (PLT-2040). /wiki/search merges the two reads into a single list: pages whose title matches the query come first, with the matched words marked in the title, and the full-text hits follow in rank order. A page that matches both ways appears once, as a title match. The title read is unfiltered, so while a facet is set the title-only rows are hidden and the list shows only what the facets narrowed. Each row shows the page's knowledge-base state (in the KB, or nominated as a candidate), its status when it is not approved, its classification when it is above Unclassified, a Needs attention label when it has open review findings, its owner, and when it last changed (Updated 3d ago, with the full date and time on hover and for assistive technology). The classification comes from the row that produced the match; the other labels arrive for the whole result window in one batched read rather than one request per row. A row that matched only by title shows no update time, because the title read does not return one. Each row has an always-visible quick-preview button, since a touch screen has no hover. Relevance is shown by order only: the rank score is not printed, because Postgres' ts_rank has no calibrated scale.

    The filters live in the address (PLT-2040). Space, classification and owner are written to the URL as space, classification and owner, so a reload, a shared link and Back restore them. Choosing or clearing a filter adds a history entry, so Back undoes it. A new query keeps the filters, because refining a search is not starting a new one. A search started from the navbar panel starts without filters. Filters arriving in the URL belong to the person and organisation that set them: after an organisation or user switch they are dropped from the request and then from the address bar. Each history entry records who wrote it, so an entry Back restores from another organisation has its filters dropped too, and a reload keeps them while the session is still loading. One narrow case remains: reloading right after a switch made in another tab can send one search with the old filters before the session has loaded; row-level security scopes it to the new organisation, and its answer is never shown. From 1024px the filters sit in a row under the query box. Below that, a Filters button with the number of active filters opens them in a bottom sheet. The owner filter is the same control the /pages list uses (PLT-1021), and a person picked from it is remembered only for the organisation it was looked up in.

    The count is said once, in the footer under the list (PLT-1666, PLT-2040). It reads Showing N of M content matches, where M is the API's total: every page whose content matches that the reader may see, not the rows on screen. Title-only rows are not counted, because the title read has no total of its own; a list made only of title matches shows no footer. A Load more button beside it fetches the next fifty for as long as more exist. Changing the query or a filter starts again from the first results. A next page whose cursor the server refuses (a 400) restarts from the first results rather than showing an error; any other failure to load more keeps the results on screen and offers a retry. An archived row is labelled Archived (PLT-1367), with the same outline chip the wiki uses for the archived status everywhere else, here and in both groups of the navbar panel. Search keeps returning archived pages while the tree and the lists hide them, so a search result is where a reader most often meets one. The MCP search_pages tool marks the same hits ARCHIVED on their result line, because an archived page still holds its slug and therefore still counts as an existing page for the duplicate check an agent runs before create_page.

    Offering to create the page is a claim that it does not exist, so it waits for both reads. When nothing matches, the page offers a Create a page named “…” button, but only once the title lookup has also settled with nothing and no filter is set. While the title lookup is still loading the page keeps showing its loading state rather than “No results”. If the title lookup failed, the empty state says that no page contains the query rather than that nothing matched, and offers no create button, because an unread is not a no and the cost of being wrong is a duplicate page. With a filter set, the empty state says the filters may be hiding results; below 1024px it also offers to clear them, while on wider screens the Clear filters control is the one in the filter row above.

    Each row shows the passage that matched (PLT-1004). The excerpt is up to two short fragments of the page body with every matched word marked. It is shown only when the body alone contains everything searched for: a page that matched on its title, or with one word in the title and another in the body, shows no excerpt rather than the opening of its body, which would look like the match and is not. The text is the page source as written, so Markdown punctuation such as ## or ** can appear in it.

    Every control on the page is large enough for a finger. On a touch screen each button, field and menu trigger drawn on the page has a tap area of at least 44×44 px: the query field is drawn 44 px tall, and the other controls keep their look and gain an invisible tap area. The option rows inside the shared menus keep the design system's size.

  • Pages list filtering and sorting — GET /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. Every comma-separated list on this endpoint — spaceIds and slugs included (PLT-864) — rejects an empty entry: ?spaceIds=, and ?spaceIds=<id>,,<id> are a 400, never a repaired list, while a bare ?spaceIds= still means absent. 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, title, archivedAt, or position) and sortOrder (asc / desc); results always carry a stable secondary sort on the page id, so the order is a deterministic total order and paging over an unchanged result set cannot skip or repeat a row. Paging by offset over a list that changes between two requests still can: a page that leaves the list ahead of the offset makes the next window skip one row, and a page that joins ahead of it makes one row arrive twice. Page by cursor to avoid both (PLT-1007): every list response carries meta.nextCursor, and passing it back as ?cursor= returns the rows after that position rather than the rows from a count on. meta.nextCursor is null exactly when no row follows the window — the server reads one row past it — so a walk ends on null, not on a short page; meta.total is the whole filtered count on every window. A cursor is opaque, valid only under the sortBy / sortOrder it was issued for, and not combined with a non-zero offset; either mismatch, a cursor this endpoint did not issue, and a cursor with sortBy=position (which has none, and always answers nextCursor: null) are each a 400. What the cursor guarantees is that a row whose own sort key does not change during the walk is returned exactly once. A page edited during a walk by updatedAt, or renamed during a walk by title, moves to its new place in the order, and can therefore be missed or returned twice by that walk. The stable tiebreak's direction belongs to the sort key and never follows sortOrder: updatedAt, title and archivedAt break ties by id descending in both directions, which means their two directions are exact reversals only up to ties. sortBy=archivedAt orders by when each page entered its current archived state (PLT-1621) and is admitted on every list read. A page with no archive date — one that is not archived, or one archived before that date was recorded — sorts after every page with a date, in both directions, and those pages are ordered among themselves by id descending; so newest-first and oldest-first both begin with known dates. Its cursor walks across that boundary like any other. Only the Archived view offers this sort, because on any other view almost every row has no date. sortBy=position returns the manual sibling order (PLT-1362) and is narrower than the other sorts: it requires a parentId and exactly one spaceIds entry, because a manual order exists only among the children of one parent in one space; it is ascending only — an omitted sortOrder means ascending there, and sortOrder=desc is a 400 rather than a silently reversed list; it cannot be combined with kbStatuses; and its ties break by id ascending. Every other combination is a 400. The response never carries the rank itself — only the order it produces. 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. A person the lookup returns but cannot name is shown as such rather than as blank space (PLT-1168): identity.users.name is NOT NULL, which excludes the null value and nothing else, so a row whose name is empty or whitespace is storable and the Directory user API accepts one. The picker offers such a person as a selectable row labelled Unnamed person above their abbreviated id — stacked on two lines, because on one the pair does not fit the popover and the id, which is the only thing telling two unnamed people apart, is the half that gets clipped. Once selected, the facet trigger shows the same abbreviated id under no claim about who they are, announces the complete id to assistive technology, and goes on filtering by it. A filter that arrives in the URL is named by the list it filters (PLT-881): a pasted link has no pick behind it, but every rendered row carries its owner already resolved by id, so the trigger takes the name from the first row whose owner is that id. When no rendered row can vouch for it — an empty result, or rows answering with a different owner because the page was reassigned between the two reads — it shows the abbreviated id, as above. The rule is that a row which answered with no usable name is, to a display surface, the same evidence as no row at all. The derived knowledge-base state is filterable with kbStatuses (ingested, candidate, excluded, none), and it is the one facet on this endpoint that can be refused. Unlike every sibling it does not read a column: it resolves the same derived state the per-page badge shows, whose similarity half measures how much of each page the pages in the tenant's knowledge-base spaces cover, and — because it too must be applied before the window — it runs over the whole set the other facets leave. That work is bounded to 1,000 candidate pages, measured on production rather than guessed; a request whose other facets leave a larger set is answered 400 naming the bound and asking you to narrow, never a shortened list with matches quietly missing. A request that passes the bound but whose derivation still runs out of its time budget is a 503, which is retryable, rather than a generic failure; that answer is given only when the database reports the derivation's own time limit, so a failure it cannot attribute to the filter keeps the endpoint's ordinary error. Expect to scope it (PLT-1249): the similarity half skips every page/knowledge-base pair that provably cannot reach the coverage threshold before scoring the rest (PLT-1199), which keeps the answer exactly what it was and makes the cost grow linearly with the set — but on production that growth is roughly 2 ms per page on top of a fixed cost set by the size of the knowledge base, so an unscoped request that leaves more than 1,000 pages visible to the caller exceeds the bound and is refused. Combine kbStatuses with a space, a parent or a search term. A set under the bound can still run into the time budget — a knowledge base that has grown since the bound was measured, or a busy database — and is then answered with the 503 described above. projection=summary returns rows without their document (PLT-1079): every page field except bodyMd and frontmatter, which the statement does not read. The two keys are absent rather than empty, so a summary row cannot be mistaken for a page whose body is empty. projection=full, the default when the parameter is omitted, returns the whole page as before, and any other value is a 400. The projection changes which columns are read and never which rows, so every filter, sort and kbStatuses behaves the same under either value. The page tree and the folder section index read their rows this way, because they draw titles and would otherwise transfer up to a hundred documents per level.

    excludeStatuses (PLT-1186) removes pages in the named statuses, and is the predicate behind the archived exclusion below. It follows excludePageTypes in every respect — comma-separated, applied in SQL before the window, rejecting an empty token so a malformed value cannot silently return the unfiltered list, and admitting only real statuses — with two differences. It admits the whole status vocabulary rather than a reserved subset, because it began as an internal predicate over all four; and it is conjunctive with status, so naming a status in both returns nothing at all. That is a legal query rather than an error, and it is why the browse list resolves the two against each other before it builds a request rather than sending both.

    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. Like the other list facets it 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.

  • Starred pages (PLT-998) — a reader stars a page with the Star toggle in its header, and the home feed's Starred tab (?tab=starred) lists the starred pages of the active space, most recently updated first. A star is private to the person who set it: it is stored per user and per organisation in wiki.page_stars, whose row-level security scopes every read and write to the acting user, so no one else — not another organisation, and not another member of the same one — can see, list or remove it. A star also never outlives the reader's right to see the page: a page that is trashed or reclassified above their clearance drops out of the tab with no row touched, and comes back if that changes. The API is GET /api/pages/:id/star (whether the caller starred the page), PUT (star it) and DELETE (clear it), each answering { data: { starred } } and each idempotent; all three answer 404 for a page the caller cannot read, exactly as for one that does not exist. The list is not a separate endpoint: GET /api/pages?starred=true restricts the ordinary page list to the caller's stars, in SQL before the window, so it pages, sorts and combines with every other filter above, and meta.total counts the same set. starred=false is the same as omitting the parameter. Setting or clearing a star is recorded in the audit log only when it actually changed something.

  • One pages list, six views (PLT-2039) — the wiki has one pages list, and a segmented view switcher above it chooses which collection it shows: All, Knowledge base, Mine, Starred, Archived and Deleted. Four routes render it, each opening on its own default view — /pages on All, /knowledge on Knowledge base, /archive on Archived and /trash on Deleted — and ?view=<id> on any of them selects another; Mine and Starred have no route of their own and live at /pages?view=mine and /pages?view=starred. The switcher navigates to the destination view's own route, so /archive and /trash stay the addresses those views have always had, and every older link keeps working. A view decides which facets it offers — space, status, owner, classification, knowledge-base state and page type, in the live views — and a facet it fixes is not offered: Mine fixes the owner, Archived and Deleted fix the status, and Deleted offers neither a space facet nor a Space column because the trash read carries no space. Changing view drops every facet the destination does not offer, so a hidden facet can never keep narrowing the list out of sight; the search survives the move, and the sort survives unless the move crosses between the live list and the trash, which sort on different keys, or the destination does not offer it — the archive-date sort exists only in the Archived view (PLT-1621). Each view has default columns, and View ▸ Columns adds or removes the optional ones (classification, knowledge-base state, needs attention); a long space name, owner name or restore destination is cut with an ellipsis at its column's width, with the full text on hover, so a fitted table never scrolls sideways; that choice is a presentation preference, kept in this browser per tenant, organisation, user and view. Under the rows, a line reads Showing N of T pages — the rows drawn against the filtered total — beside Load more while more remain; when a page changes elsewhere, the list re-reads as many windows as the reader had loaded, so paging past the first window is not undone. A title carries the agent marking beside it when the page has one. The columns are fitted to the list's measured width rather than to the window: when space runs out the optional columns drop first, then Owner, then Space, and a column the width removed comes back when the width returns, while one the reader hid does not. Below 640px the list stops being a table and becomes stacked rows. Each row's ⋯ menu offers Open, Preview, Star or Unstar, and — in the Archived view, to a reader who may edit pages — Unarchive…; a Deleted row carries a visible Restore button instead. On a narrow viewport the facets move behind a single Filters button that opens a bottom sheet, whose label carries the number of active facets. The View menu holds Sort, Columns, Show navigation pages and Reset view, which restores the default sort, hides the navigation pages again and forgets the column choice for that view; where the list is stacked rows, Sort and Columns are inline sections of that menu rather than submenus, and the chosen columns appear in each row's second line.

  • The pages list 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 ?view=<view>&space=<space uuid>&classification=<tier>&owner=<user uuid>&status=<page status>&kb=<knowledge-base state>&type=<page type>&search=<text>&sort=<field>:<asc|desc>&showReserved=true; each key is optional and the sort is written only when it differs from the view's default (updatedAt:desc, or archivedAt:desc in the Archived view), so a bare /pages and an explicitly-default one are the same link rather than two spellings of one view. A sort the view does not offer — archivedAt:… outside the Archived view — reads as that view's default, and changing view drops a sort the destination does not offer or that is its default (PLT-1621). For every narrowing facet an absent key means "do not filter on it"; showReserved is the exception and is described below, since absent there means the exclusion is on. The status facet offers draft, in_review and approved: archived pages are a view, not a status value, so the question has one control. One legacy spelling is honoured: ?status=archived on /pages or /knowledge opens the Archived view (on /trash it is ignored, and the Deleted view stays) rather than an All view filtered to nothing, and Clear filters keeps it, because there it names the view. kb is the knowledge-base state facet, offering ingested, candidate and excluded (shown as In knowledge base, Suggested for knowledge base and Not for knowledge base); none, the state of most pages, is not offered. It maps to the API's kbStatuses filter described above, which is bounded and can answer 400 when the other facets leave too many pages to check; the list then says so in words — choose a space first, then filter by knowledge base — rather than reporting a generic failure. Values are validated on arrival: a space or owner that is not UUID-shaped, a classification outside the five tiers, a kb outside its three values and a type outside the known page types 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 Clear filters, which removes the facets — space, status, owner, classification, kb, type — and keeps the search, the sort and the view. type is the page-type facet the archive carried before (PLT-1273), now offered in every live view, so an older /archive?type=… link still narrows the Archived view. Moving into the Knowledge base view from another view also drops space, because a space chosen elsewhere need not be a knowledge-base space.

    showReserved is the one key that WIDENS rather than narrows: the list hides the wiki's own navigation pages by default (PLT-1096), 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. Show navigation pages in the View menu 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 count is of filters that narrow, and a reader who asked to see more rows should not be told they have one filter applied — and for the same reason Clear filters leaves it alone; unchecking the menu item, or Reset view, is the way back. 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.

    Archived pages are hidden from every view except Archived (PLT-1186). The earlier ?showArchived=true opt-in, which admitted them alongside everything else, is still parsed for old links but no view acts on it, and changing view removes it: a reader who wants archived pages opens the Archived view.

  • The Knowledge base view is the same list under a knowledge-base scope (PLT-569) — 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. Everything else — the filter grammar above, the sort, paging — is unchanged, with one facet fewer: it does not offer the knowledge-base state facet or column, because every page it lists is in the knowledge base, so that facet could only answer one way. ?space=<uuid> overrides the scope with one space, and clearing that key returns to the knowledge-base scope rather than widening to every space, so this view deliberately cannot express a tenant-wide query; All is that view. 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 offers a space search to reach one, 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. Like every view it hides the wiki's own navigation pages by default and carries the same showReserved opt-in, and here that default is 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 view where a rebuild most easily fills an updatedAt-ordered window with maintenance, and also the one where an administrator has the best reason to want them. An administrator who does turns them on.

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

  • Per-page classification (UNCLASSIFIED … SECRET) 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.

Classification and clearance​

Every page and attachment carries a tier on the platform ladder UNCLASSIFIED < INTERNAL < RESTRICTED < CONFIDENTIAL < SECRET, and a request reads and writes only rows at or below its own clearance. Since PLT-977 the wiki reads and writes all five tiers. Row-level security enforces the rule. Since PLT-1010 the clearance a request runs at is read from the user's Directory record whenever the credential carries no clearance of its own, which no session token does. The model is described in full in Classification and clearance: the ladder, why reading and writing a tier are validated separately, which policies enforce it, and where a request's clearance comes from.

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. A routine UPDATE names its fields the same way since PLT-701 — changed_fields lists every field the edit touched, with no changes at all, and is an empty list rather than NULL for a PATCH that carried only an edit summary; the same holds for the UPDATE entries a broken-crossref remediation, an expiry archive and a space-index rebuild's hub or log overwrite file. Entries written before that release have changed_fields NULL, and still name their fields under context.changedFields, which every one of these entries keeps. 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 also adds a wiki.nomination_policy.resolution_applied_outside_clearance_scope entry — the audited READ described at the end of this section, which is a separate event on a separate action. Since PLT-1301 that one is filed on every write that resolves policy, not only where the resolution crossed a hidden ancestor, and since PLT-1535 once per resolution: a policy PATCH resolves before and after the write, so it files two, and a create under a parent files two as well (the parent's and the new page's). Count per action, never per request: since PLT-1726 the candidacy funnel files one per resolution too — one per page-locally eligible page the event path or the backfill evaluates, and one per eligible hit a KB query-miss scan examines. Since PLT-1772 a page that fails the page-local eligibility rules on the caller's own read — a draft, a non-durable page type, a kb-excluded page — is refused before the resolution and files no entry. 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 — and on a reclassifying PATCH, which resolves twice, each of the two entries carries the tier the page held when that resolution ran — 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.

A write into a marked knowledge base names the trusted path that authorised it (PLT-917). Since PLT-917 an entry that records a write into a marked knowledge-base space carries context.trustedKbOperation, set to the trusted operation the knowledge-base write guard validated for it. The trusted operations are approved-ingest, revert-compensation, staleness-archival, space-index-write, coordinator-file-back, source-retraction, merge-remediation, merge-remediation-revert, quoted-copy-withdrawal and sibling-rank-compaction. The guard resolves the name from the capability it checked, so the caller never states it. It appears on ingest.batch.committed, ingest.batch.reverted, ingest.folder_run.reverted, the staleness archive's UPDATE, source.retracted, wiki.merge.applied and wiki.merge.reverted, each re-tiered copy's wiki.page.reclassified, wiki.sibling_rank.compacted, the coordinator file-back's page and link CREATE, and every space-index entry (hub and log CREATE / UPDATE, space_index.spokes_created, space_index.spoke_owners_cleared, spoke moves). Five things to know:

  • No key at all when nothing was authorised. A write into an ordinary space, an ordinary caller's write, and an update on a deployment with WIKI_ENFORCE_KB_WRITES=false (whose guard checks nothing) carry no trustedKbOperation, so those entries keep exactly their previous shape. A create still names it under the kill switch, because a create validates a supplied capability whatever the setting.
  • It is in context, not changes. The label says how a write was authorised, not which field changed, so it is on the audit row and is not published on the audit.entry.created event.
  • It does not change the channel. Each entry keeps the helper its mutation was given (the staleness archive, the file-back link and the routine space-index overwrites and moves stay routine). The label is evidence on the record, not a new security event.
  • One entry names one operation, and says what it covers when that is not everything. An entry that records several writes (a batch, a revert, a merge, a rebuild's spokes) names the single operation they share; mixing two is refused rather than mislabelled. Where an entry records more than the guard checked, context.trustedKbOperationCovers names the writes the label is evidence for, and it is evidence for nothing else on that entry. On ingest.batch.committed it is ['sourcePage', 'summaryPage', 'derivedPageIds']: the index and log pages it names carry space-index-write on their own entries. Both revert entries carry no qualifier: since PLT-1885 the revert's created-page soft-deletes and link deletes are authorised under revert-compensation like its compensating revisions, in whatever space each page or link source now sits; entries written before that carry ['restoredModifiedPages'], the compensating revisions only. On source.retracted it is ['cascadeRetypes', 'cascadeLinkDeletes'] (PLT-1886): the cascade's retypes and derived_from link deletes in every marked space of the tenant, each authorised as source-retraction and listed in context.trustedKbAuthorisedSpaceIds — with 'sourcePageSpace' in front when the source's own space is marked and its gate authorised it. That list is every marked space, not only those the cascade reached, because the cascade's closure runs past the caller's clearance; its lost_source findings and the index and log pages are not covered; entries written before that carry ['sourcePageSpace'], the source's own space only.
  • A refused write leaves no entry. The guard refuses before anything is written, so there is nothing to label.

The exception. Index spokes get no per-page entry when the space-index rebuild writes them. Since PLT-702 the rebuild records the spokes it creates as one space_index.spokes_created entry against the space, naming every id; since PLT-1094 the spokes whose owner it clears as one space_index.spoke_owners_cleared entry; and each spoke it moves under the hub as a routine UPDATE. A steady-state rewrite of an existing spoke's body, and the emptying of a spoke whose topic vanished, get no entry unless the same write also moves the spoke or clears its owner. Writes to a spoke 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 coverage, and it does not explain it.)

The folder-run governance lock is an audited READ (PLT-609, PLT-1418, PLT-1432). Each source of a folder run calls wiki.lock_ingest_governance_rows, a SECURITY DEFINER that walks the source's whole ancestor chain and locks it — including ancestors above the run's clearance, which is the point: they still decide the source's membership and exclusion. Since PLT-1418 every call files one wiki.ingest.governance_lock_applied_outside_clearance_scope entry, naming the source page at the session's clearance floored at RESTRICTED and carrying nothing the walk found — no ancestor, no flag, no disposition. Since PLT-1432 the definer writes it itself (migration 119, through audit.write_entry), so any SQL caller of that overload leaves the entry, not only the folder-run worker; it publishes no audit.entry.created event. A source that commits, one that is skipped (excluded, outside the run root, too deep, moved, or no longer readable) and one whose transaction rolls back all leave the same single entry; a rollback is answered by replaying a copy staged under the same entry id in a fresh transaction. The 048 form the previous build called, which recorded nothing itself, is dropped (migration 123, PLT-1609), so the audited overload is the only one. A source skipped before the lock — already committed, or vanished before the walk — files none. It replaces two retired actions, wiki.ingest.governance_walk_crossed_hidden_ancestor and wiki.ingest.governance_lock_anchor_unreadable, which were written only when the walk met a hidden ancestor or the source turned unreadable: their presence was itself the protected fact. Entries already written under those actions stay in the log. It is a separate action from the nomination entry below because the walk runs before any policy is resolved, and on several skip branches none is.

The candidate withdrawal is an audited READ inside a write (PLT-1035). The KB-candidacy event handler runs pinned to UNCLASSIFIED. When a page event names a higher tier, it calls wiki.withdraw_reclassified_candidates, a SECURITY DEFINER that locks the page row whatever its tier and resolves that page's open ingestion_candidate findings if the row is above UNCLASSIFIED. Every call that is not refused files one wiki.kb.candidate_withdrawal_applied_outside_clearance_scope entry through audit.write_critical_entry, so it also publishes audit.entry.created. The entry names the page and carries nothing the call found: not whether a nomination was open, and not whether one was closed. It is filed at the page's tier floored at RESTRICTED, and at SECRET when no such page exists or when the entry is a replay written after a rollback. Expect one per delivered wiki.page.created, .updated or .published about a page above UNCLASSIFIED, whether or not the page was ever nominated; the actor type is SYSTEM. A nomination it withdraws is resolved by wiki-kb-candidacy, which the review queue shows as an automatic closure, not a reviewer decision (PLT-1762; before migration 20261105163231 it read as a closure with no reason given).

The nomination READ. The tabulated entries above record writes; wiki.nomination_policy.resolution_applied_outside_clearance_scope records a read, like the governance-lock entry, and is listed separately because that difference matters to anyone writing a rule over these entries.

Resolving a page's inherited KB nomination policy runs wiki.resolve_nomination_policy, a SECURITY DEFINER whose ancestor walk admits rows the caller's clearance would exclude. Since PLT-1301 the entry is written on EVERY resolution and is verdict-free: it names the page and says a check authorised to read past the caller's clearance was applied — never what the check found. It carries no resolved value, no source-visibility flag, and, as before, never the ancestor that supplied the answer.

Since PLT-1535 the definer writes it. A 4-argument overload of wiki.resolve_nomination_policy writes the entry itself, through audit.write_entry, before it walks — so a SQL caller holding EXECUTE on it cannot get the resolution without the record. One entry per invocation, filed at the page's tier floored at RESTRICTED, under the same action and with the same verdict-free shape. Two things change for a reader. The entry is no longer published as an audit.entry.created event, because a read-only crossing written at the definer publishes nothing (ADR-048 R1); read it from the audit log. And no path records at the call site any more. The folder ingest preview and folder-run plan keep their own wiki.folder_preview.resolution_applied_outside_clearance_scope, written in the database since PLT-1727 (see the folder preview below). The page PATCH, create and reorder, the policy GET, the ingest approval, the folder-run worker, the candidate detail and — since PLT-1726 — the candidacy funnel (event-driven nomination, the backfill and KB query misses) file per resolution. A rollback does not lose an entry on any of these paths: the owning tool writes a copy after it, and the folder preview and folder-run plan replay theirs the same way. The original 2-argument form of the function recorded nothing on its own. PLT-1728 took it from the runtime role, and since PLT-1716 it is gone: its walk now lives inside the audited 4-argument form and the folder preview's 3-argument form, so not even its owner or the migration applier can resolve a nomination without the record.

A KB query-miss resolves every eligible hit. When a topic crosses the miss threshold, the scan reads the search hits the caller can see, keeps the ones that pass the page-local eligibility rules and can still be locked (a page deleted or moved out of reach since the search is dropped), and resolves the policy of every one of them before it picks the first it may anchor to — up to 50, the search window. It used to stop at the first match, so how many resolutions it made depended on what earlier ones returned. Expect up to 50 entries for one miss, under one correlation_id. A topic that ends suppressed or undecided is not retired, so each later miss on it runs the scan again and files them again; measured on a local database, a 50-hit scan with its 50 entries took about 50–80 ms.

Three consequences for a rule written over these entries, and none of them is cosmetic. The action string changed, so a rule selecting the old wiki.nomination_policy.resolved_from_hidden_source stops matching rather than silently changing meaning. The entry no longer tells you whether the resolution crossed a hidden ancestor — it previously did so by existing at all, which is exactly why it changed: under constitution §5 an entry written only for some results is a waiver from per-invocation coverage, permitted only where the condition is safe for every audience that can observe the entry, and the bus is not clearance-filtered. And the volume is higher: PLT-1301 filed one entry per transaction that reaches the resolver, rather than one per crossing, and since PLT-1535 and PLT-1726 every path but the folder preview and folder-run plan files one per resolution — so a single KB query-miss can file up to 50.

One per request, not one per resolution — on the folder preview and folder-run plan only. Those resolve every descendant in one statement and file their own wiki.folder_preview.resolution_applied_outside_clearance_scope once for the request, through an intent (PLT-1727), because the number of entries must not vary with the protected result. Every other path files per resolution and keeps the count independent of the result another way: it decides how many resolutions to make from facts the caller can see — the demand scan resolves its whole list before reading any answer.

Two more properties, both deliberate:

  • The entry claims only that the check was applied. It does not say the resolution crossed a hidden ancestor, does not say it did not, and carries no resolved value — so nothing about it varies with what the resolver found. That is what makes it safe for every audience that can observe it, which a result-keyed entry was not: its existence alone carried the fact. (PLT-768 separates an above-clearance source from a soft-deleted one; under this shape that distinction no longer strengthens any signal, because there is none to strengthen.)
  • It is filed at max(RESTRICTED, the page's own tier). Where an entry is published, the floor is 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. The forwarder reads the outbox, so the entry the definer writes since PLT-1535 never reaches it whatever its tier (ADR-048 R1): there the floor sets the tier of the record, not whether it is delivered. The raise is Audit Rule 5: an entry naming a CONFIDENTIAL page must not be filed at a tier that understates it, in the record or in the CEF severity derived from that field. Since PLT-1535 a reclassifying PATCH files one entry per resolution, so each carries the tier the page held when its resolution ran. The folder preview and folder-run plan file theirs at the highest of the folder's tier and the tiers of the descendants you can see.

Volume therefore tracks resolutions, not crossings: a bulk candidacy sweep emits one entry per page it evaluates, whether or not any of those resolutions came from a hidden row.

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.

When a reader's clearance is lowered in an open tab (PLT-1641)​

The server decides what a request may read from the clearance in force when the request arrives, so a reload always shows a reader only what they may currently read. A tab that is already open learns of a lowered clearance when the client next re-reads the signed-in identity — on a token refresh, not on a timer — and from that moment the wiki's own screens stop showing what they read earlier:

  • The trash list and the navbar search start over: rows and results are dropped and read again. A query typed into the search box is cleared with them.
  • The parent picker in the new-page dialog and the editor drops its search results, its "will be placed here" trail and its sibling count, and reads them again. The selected parent stays selected, shown as … until its title has been read again; a parent the reader can no longer read is reported as an unreadable location rather than cleared, because clearing it would move the page on the next save. Text the author typed into the new-page dialog is kept.
  • The spaces admin table is read again, including the visible-page count the delete confirmation quotes.
  • A restore, move or reorder still in flight reports its outcome when it finishes, without naming the page.
  • The editor is replaced by a refusal when the reader's clearance no longer covers the page it has open (PLT-1016).

The same happens on a change of signed-in user or organisation, which also closes any dialog open on the spaces admin page — a dialog there names a space of the organisation being left. None of this changes how soon a tab learns of the change.

How the browser client reads responses (PLT-793)​

The wiki's browser client checks every response it reads data from against the contract before any surface renders it, rather than trusting the declared type. Three consequences are visible:

  • A response that has drifted is shown as a failure, not rendered. A page row, search hit, revision, trash row, knowledge-base status or ingest result with a field of the wrong type — or missing a field the server always sends — is rejected, and the surface shows the failure wording it already uses for a failed load or save. A page status outside draft, in_review, approved and archived counts as drift: wiki.pages.status has no database CHECK, so a row written by hand with another value fails every list that contains it, for that reader. A new status therefore has to be accepted by the client a release before the server can emit one.
  • A refusal still reads as a refusal. A 4xx keeps its status whatever its body says — including a body that is not JSON at all, such as a proxy's error page — so a refused write is still known to have saved nothing. Only a 2xx body is held to the contract.
  • A write the server accepted but whose reply could not be read still refreshes the screen. A 2xx means the write committed, so the client drops the cached pages, lists and counts that write would have changed before it reports the failure; reloading shows the saved state.

Page timestamps (createdAt, updatedAt, deletedAt, archivedAt, kbExcludedAt) reach the client as ISO-8601 strings and are validated as such; a number or a boolean in a date field is rejected. The API itself is unchanged — this is the client's reading of it. The page contract the client checks is the same schema the wiki's OpenAPI registry publishes as Page, which now lists spaceId, kbExcludedAt and kbExcludedBy alongside the rest.

Reading a page's sub-resources: an unreadable page is a 404 (PLT-807)​

The four collections read under one page — GET /api/pages/:id/revisions, /links, /related and /attachments — follow one convention:

  • A page the caller cannot see answers 404. That covers a page that does not exist, one in the trash, one in another tenant and one classified above the caller, and the four answers are identical — same status, same error envelope — so a sub-resource read is no existence oracle.
  • A page the caller can see with nothing under it answers 200 with an empty collection: { data: [] }, or { data: { outgoing: [], incoming: [] } } for links. Children the caller may not read are left out the same way, so a visible page whose every revision or attachment is above the caller is still 200 with an empty list.

Each read decides the page's visibility and lists its children in one statement, so the two cannot describe different moments: a page trashed while the request runs is either listed whole or a 404, never an empty 200 about a page that no longer exists.

Changed in PLT-807: revisions and links used to answer every unreadable page with an empty 200. A client that treated an empty history or an empty link bundle as "the page is gone" must now handle the 404 instead.

Checking many sources at once: POST /api/readability (PLT-1937)​

A conversation thread is only as readable as the sources it was derived from. POST /api/readability lets a host module, such as Project Tracker, ask the wiki in one call whether the current caller can read each wiki.page and wiki.attachment source. The host forwards the caller's own credential (a session cookie, a bearer token, or an API key with x-act-as-org), and the wiki decides as that caller, in the tenant and at the clearance its tenant wrapper resolves.

The request is { "pageIds": [...], "attachmentIds": [...] }. Both lists are optional. The answer is 200 { data: { pages, attachments }, meta: { servedScope } }, with one { id, readable } verdict per element sent, in the order sent, duplicates included, and id exactly as sent.

  • What a verdict means. readable: true exactly when the kind's canonical read, GET /api/pages/:id or GET /api/attachments/:id, would not answer 404 for the same caller now. That is the one-404 convention. Archived and draft pages are readable. An attachment needs its own tier within the caller's clearance and, when it belongs to a page, that page readable. An attachment with no page follows its own tier, as its canonical read does.
  • What it collapses. An id that does not exist, a row in the trash, a row above the caller's clearance, a row in another tenant, an id that is not a UUID, and an id of the other kind (an attachment id sent as a page) all answer the same { id, readable: false }. Nothing else in the response varies with them, so the check is no existence oracle.
  • Malformed ids. Every element must be a content-source id: 1 to 200 characters of [A-Za-z0-9._:-], starting with a letter or a digit. Anything else is a 400. A content-source id that is not a UUID is answered unreadable, not refused. A UUID in either case is looked up, and answered under the spelling sent.
  • The cap. At most 1,000 ids across both lists. Above that the request is a 400, never a truncated answer. An unknown key in the body is a 400 too, and an empty request answers two empty lists.
  • Statuses. 200 with verdicts; 400 for a malformed request; 401 with no valid credential; 403 when the caller holds no ACTIVE membership in the acting tenant. A failed or timed-out lookup answers 5xx with no verdicts, so a host reads it as unavailable, never as a set of denials. The lookup is one statement whatever the batch, bounded at 2,000 ms.
  • The served-scope receipt. meta.servedScope is the receipt GET /api/pages/:id serves: the user the membership was resolved for, the acting organisation's id spellings, and the clearance the check ran at. A host should refuse an answer whose receipt names a different user or tenant.

The check is read-only. It records no audit entry and publishes no event, because it applies the classification boundary under the caller's own access rules and crosses nothing.

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.

  • The form starts at Unclassified (PLT-2043). classification is optional on POST /api/pages, and the form now sends Unclassified unless the author picks another level under More options → Access level, from the same ladder the editor uses, offering only the levels the author's clearance permits. Until PLT-2043 the form refused to submit without a choice; that made creating a page a classification decision before it had any content, and the level can be raised in the editor's Details panel before or after the first save.
  • 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 knowledge-base space whose write state refuses an ordinary write 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 such a space with a 403 carrying details.reason = "kb_page_create_forbidden". The rule applies when a tenant's Default space is itself marked too.
  • A marked space follows its write state, not only its marker (PLT-1500). The form reads GET /api/spaces/:spaceId/kb-write-state for each marked space it lists, and offers one as a target once that reads allowed — where enforcement declines (an unreconciled tenant, a space whose provenance cannot be re-proved, the operator kill-switch) the server accepts the create, so the form does too. It fails closed: while the answer is being read the row says so and cannot be chosen, a failed read says it could not be checked, and the submit stays blocked with the same sentence, including when the active-space cookie pre-selects the space. Ordinary spaces issue no read.
  • 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 green (success), the settled state the design asks for. The Badge success variant labels its fill with --color-success-ink, 5.11:1 light / 6.53:1 dark (PLT-942), so the green clears WCAG AA in both themes (PLT-1646). Until then approved was blue, because the plain green measured 3.03:1 in the light theme and had no ink token.

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 — or to no owner for a reserved navigation type, below — 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, so the index's next write takes back any page it maintains that still carries an owner — an agent-owned reserved page at its canonical slug; a rebuild writes the hub and the spokes, and a log append writes the log. A reserved-type page the index does not maintain is left alone by the rebuild.

Assigning one is refused (PLT-1112). PATCH /api/pages/:id answers a 400 naming ownerUserId (details: { field: "ownerUserId", reason: "reserved_page_owner" }) when the write would carry a non-null owner on a page whose type is index, index_spoke or log (since PLT-818 a PATCH can no longer move a page into or out of that set at all — below). That includes re-sending an owner the page already carries alongside another edit, because such a write sets the owner again; a PATCH that only echoes it and changes nothing else is still the no-op 200 described above. Clearing the owner (null) and omitting it remain allowed. The rule keys on the page type alone, not on the agent marking. Before this, the PATCH succeeded: the page named the person as its steward and lost the chip until the next rebuild undid it. The editor no longer offers the owner field on these pages, and its save leaves the owner out rather than sending null, so a body edit never clears one as a side effect. A reserved page given an owner before the refusal existed keeps it until a rebuild or the repair below clears it — the rebuild reaching the hub and spokes, a log append the log, and all of them only agent-owned reserved pages.

Creating one, or moving a page into the set (PLT-1209, PLT-818). Reserved pages are system-only since PLT-818 (ADR-058 D6): only SpaceIndexService — the index rebuild and log-append routes — creates them, always with no owner. POST /api/pages with a reserved pageType answers a 400 with details: { field: "pageType", reason: "reserved_page_type" }, whatever the request names as owner and whatever the space already holds. A PATCH that changes pageType into or out of the reserved set (a change between two reserved types included) answers the same 400, and so does one that moves a reserved page to another space (field: "spaceId"), reclassifies it (field: "classification") or changes its slug (field: "slug", since PLT-1624); restoring a reserved page from the trash answers a 400 with the same reserved_page_type reason. The refusal is unconditional so that its answer never depends on what the destination slot holds, which may be a row the caller cannot read. An edit to a reserved page that keeps its type, space and classification, including one that still carries an owner assigned before PLT-1112, is not affected by this rule.

The index owns the reserved slugs. The hub and the log live at <space-slug>-index and <space-slug>-log, and each spoke at a slug derived from its topic; readers resolve spokes by that slug and the hub prints it, so a hand rename would strand every reference. Renaming a space leaves its hub and log at the old space slug, and the next rebuild moves them back to the canonical slug (a log append does the same for the log). If another page already holds the canonical slug, the rebuild answers a 409 instead, and the reserved page stays where it is until that page moves.

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. When the latest revision's origin is SPACE_INDEX (PLT-1758), the page details' author line says the stronger Assembled by the space index instead: the index composed the body now on screen. The claim needs that latest revision to be readable in the reader's History; when History withholds it (it quotes a page the reader cannot see), the page keeps System-maintained. A hand edit or a revert (OTHER) and history written before the column existed (null) keep the System-maintained wording, and the History sheet marks each SPACE_INDEX revision with a Space index chip. "Assembled", not "generated", because a log entry's text is still its caller's.

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. When the chip shipped, nothing else in the schema identified 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. Since PLT-1104 every new revision records what composed it (page_revisions.origin, see What composed a revision), but revisions written before that carry null, so the chip still claims only the regime; making it claim composition where the origin proves it is PLT-1758.

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 refused audit chain step rolls the pass back and is retried (PLT-1591). Concurrent audit traffic for the same tenant can make the repair's audit chain step fail under REPEATABLE READ. Every entry the command writes, the scan record and each per-page entry, refuses that rather than committing a NULL hash (onChainFailure: 'reject', PLT-953). The refusal rolls back the transaction it happens in, so no owner is cleared and no clearing is recorded. Where it happens decides what remains: a refused scan record rolls back with its own transaction and the repair never starts, while a refusal inside the repair leaves that attempt's scan record durable — it was committed first, and the read it records did happen. A lost snapshot race (40001) or a deadlock (40P01) is retried as a whole new pass, scan record included, up to three attempts. After that the command exits 1 and names the cause; re-run when the tenant is quiet. Any other chain failure fails the command at once. Only a pass that commits prints its cleared pages and their previous owners, so a rolled-back attempt never reports a clearing that did not happen.

As defence in depth against a call site losing the option, the repair also reads its own entries back before committing and refuses the pass if any is unchained: nothing is cleared, and the pages are still repairable once the option is restored. 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. Since PLT-891 the same holds for the two write-attribution surfaces: the revisions list resolves the editors of the revisions it returned, and the stale-version 409 resolves the lastModifiedBy of the snapshot it carries. 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 requires expectedVersion: the version the caller read before editing (required since PLT-890). 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
lastModifiedByAttributionoptional — lastModifiedBy resolved for display (PLT-891); see below

lastModifiedByAttribution is one of the attribution states the page signals use (PLT-1095): unknown-kind with the principal's name — last_modified_by records no actor kind, so a name is never a claim that a person wrote it — resolved-agent for a synthetic agent account, automated for the system sentinel or a system: principal, and unknown-principal for an id the resolver answered about and did not name. It is resolved after the refused write has rolled back, in a transaction of its own, from the lastModifiedBy of the snapshot the caller was just shown. It is absent when the writer is null, and when the resolution could not be read — the 409 is then exactly what it was before PLT-891, so a resolver failure never costs the caller the conflict.

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 space move waits for the page's findings (PLT-1763). A PATCH that changes spaceId — and a trash restore — carries the page's lint findings into its new space in the same transaction (PLT-1072). When another operation holds one of them at that instant, the move waits for it, within the request's budget, and then completes. Before PLT-1763 it answered a retryable 409 with details.reason: "findings_busy"; a move and a restore no longer answer that reason. Deleting a space still does (see Spaces). See Lint and findings.

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, classification) — note the parent and, since PLT-818 (ADR-058), the classification. Two pages under different parents in the same space may legally share a slug; two pages under the same parent may share one only at different classifications. The constraint (pages_tenant_space_parent_slug_classification_uk, migration 109) has no deleted_at predicate, so a trashed page keeps its slug reserved — within its own classification. Because a write's classification is never above the caller's clearance, a row above that clearance can never collide with the write, so the outcome of a save no longer depends on a page the caller cannot read.

The service still refuses any write whose destination holds a live page you can read with the same slug, at any classification you can see. A same-slug pair therefore only arises across a clearance boundary: a lower-cleared writer created one beside a page it could not see. A reader who can see both sees both in the tree and listings; every slug-keyed lookup answers "exactly one readable match, or nothing", so GET /api/pages/by-path/... answers 404 when a path segment matches two readable pages, while lookup by id keeps working.

A PATCH /api/pages/:id (a slug, parent or space change) or a reorder into that would break that rule answers 409 Conflict with details.reason: "page_slug_conflict", whether the collision is caught by that visible-page check or by the constraint itself (a same-tier trashed page). Since PLT-818 a POST /api/pages collision carries the same discriminator. Before PLT-929 the update 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.

The child-bearing check is audited, whatever it answers (PLT-1545). A page may not change space while it has children, and that includes children classified above your clearance, so the check reads past it and constitution §5 requires an entry. Every check — from a space-changing PATCH and from GET /api/pages/:id/space-move alike — files one wiki.page.child_existence_check_applied_outside_clearance_scope entry naming the page and the check, filed at the page's classification (never below RESTRICTED) and carrying nothing the check found. The database function writes it before it reads, so a direct SQL caller of that form is recorded too. A move refused for its children rolls back, and the entry is rewritten in a fresh transaction afterwards, so a refused and an admitted move leave the same single entry. It replaces the eligibility read's former wiki.page.space_move_eligibility_read entry. The earlier two-argument function, which recorded nothing, was kept for one release for the build a deploy replaced, and is dropped by migration 125 (PLT-1610); the audited function is the only one left.

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. It cannot be a page above your clearance, but it may be a page in the trash at a tier you can read — possibly one outside the trash listing's window, or a retracted one, that the listing does not show.
  • 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. On an update or reorder 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. Spaces carry no classification, so the message says the slug is held by a space; a page colliding at that root is refused by the visible-page check above like anywhere else.
  • index and log pages keep their slug discriminator on update. Those two types carry their own one-per-space unique indexes, keyed on the space and predicated on the canonical page shape (UNCLASSIFIED, agent-owned). 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. The two updates that could put the slot at risk — moving the page to another space, or turning a page into an index/log — are refused up front since PLT-818 with a 400 reserved_page_type (see Reserved pages have no owner). Only a create of an index/log page, which only the space index performs, answers a 409 whose message names neither cause and which carries no details.reason, because there the slot genuinely may be taken and the driver does not report which constraint fired.

A Default-root collision with a space slug on create is still a 400.

The Default-root pre-check no longer reads pages past your clearance (PLT-818). A page write into the Default-space root checks only space slugs, plus the visible-page check above; it no longer calls wiki.default_root_slug_exists and files no audit entry for it. The all-classifications page probe survives only on space create and rename — the one accepted residual of ADR-058 (D4), because a space may not take a slug a hidden Default-root page holds without silently shadowing that page for its readers. That read goes past your clearance, so constitution §5 requires an audit entry for it (PLT-1298): every space create or rename that reaches it — and every sample-scenario state read or install, which run the same guard — files one wiki.space.default_root_slug_check_applied_outside_clearance_scope entry. The entry names the Default space, the root scope, the slug you asked for and the predicate. It is filed at the Default space's classification (never below RESTRICTED) and carries nothing about the answer. It is written identically whether the slug was free, taken by a page you can see, or taken by one you cannot, and when a taken slug rolls the write back, it is rewritten in a fresh transaction afterwards. A write refused earlier because a space already holds the slug never runs the check, and files no such entry.

The database function writes that entry itself (PLT-1537). Since PLT-1537 the five-argument wiki.default_root_slug_exists writes the entry before it reads, so a direct SQL caller of that form is recorded too, and the entry is no longer published as an audit.entry.created event (a read-only crossing publishes none). It answers only for your tenant's Default space and refuses any other. The earlier three-argument function, which recorded nothing, was kept for one release for the build a deploy replaced, and is dropped by migration 20261009082748 (PLT-1718); the audited function is the only one left.

Omitting expectedVersion is refused with 428 Precondition Required (code: "PRECONDITION_REQUIRED", details.reason: "expected_version_required"). There is no last-write-wins path any more: PLT-563 shipped the rejection dormant behind WIKI_REQUIRE_PAGE_EXPECTED_VERSION while first-party callers migrated, and PLT-890 migrated the last of them and removed the flag. This is a breaking change for any integration that PATCHes a page without reading it first — read the page, then echo its version. The refusal comes after authorization, visibility and the knowledge-base write guard: a caller without write permission still gets 403, a missing or hidden page 404, and an edit a marked knowledge-base space refuses its 403 with details.reason: "kb_page_update_forbidden" — never 428 (PLT-916). A PATCH that would change nothing is not refused by that guard, so without a version it is still answered 428. Every 428 is still recorded in the runtime log under the event key wiki.page_update.expected_version_omitted, naming the credential family that sent it.

What each first-party caller does with a 409:

CallerVersion it claimsOn a stale 409
Page editor (Save, Archive, Keep mine)the version it loadedSave and Keep mine: the save-conflict dialog below; Archive / Unarchive: a toast asking the reader to reload and try again
Folder KB-policy controlthe version it last readre-reads, and says so
Tree move (PATCH with parentId)the version of the row the reader draggedreverts, reloads the tree, asks the reader to move it again
Tree placement into another folderthe version of the row the reader dragged (reorder, below)reverts, reloads the tree, asks the reader to move it again
Revision restorethe version of the page the reader is viewingtells the reader to reload before restoring
MCP update_pagethe Version its get_page printed (a required parameter)reports the new version and author; never retries on its own
Spec → wiki mirror (PLT-302)the version it reads immediately before each writere-reads and retries once — the mirror is meant to overwrite — then fails the spec

The drag-to-position path (POST /api/pages/:id/reorder) can also change a page's parent, and since PLT-1632 it carries the same precondition whenever its body names parentId — see Reordering a page. The tree is its only first-party caller; no MCP tool, script or seed calls it.

The page tree never repeats the server's message for a failure (PLT-858). A tree that fails to load, a branch that fails to open and a move the server refuses each show fixed wording chosen from the HTTP status — plus, for a move, the stale-version discriminator the table above describes: a move refused with a 400 or a non-stale 409 reads “<title>” can’t be moved there., and a 403 and a 404 on a tree read both read as unavailable. The API envelope's message is not shown, because the wiki's policy errors fill it with role names — so a refused move no longer says why it was refused beyond what the status carries.

No other wiki surface repeats it either, unless it says why (PLT-1647). Every list, feed, search, panel and editor load words a failure from the HTTP status through one shared helper — a session that expired, a resource that is unavailable (a 403 and a 404 read the same), too many requests, a temporary outage, or the surface's own fallback — and every page write, including excluding a page from the knowledge base and restoring a revision, uses the write wording above. Two dialogs keep a 400 or 409 exactly as the server wrote it, because the reader acts on it in the form that is still open: the knowledge-base ingest dialog (a summary slug that is already taken, a batch that cannot be reverted yet, a summary draft refused for classified content) and the declassification approval dialog. A permission 403 — the one whose body enumerates role names — is never shown as written; the only 403 shown verbatim is the knowledge-base guard's authored attachment refusal described under attachments, which names no role. The wiki test suite enforces this: a client file that reads a caught error's message without a // @server-message: safe <reason> comment beside it fails the wiki unit suite.

Changing a page's slug in the editor (PLT-930)​

The editor's Details panel has an Address field under Advanced (PLT-2043; it was labelled Slug beside Parent and Space before), pre-filled with the page's current slug. The value is checked in the browser against the same schema the API enforces: lowercase letters, digits and hyphens, starting with a letter or digit, at most 120 characters. A value that fails is refused on the field, and nothing is sent. Surrounding spaces are trimmed; case is not changed for you.

A save carries slug only when it differs from the stored value, so an ordinary save never takes the slug-namespace lock. When the server refuses the slug as taken, the editor puts the message on the field. It recognises that refusal only by details.reason: "page_slug_conflict" (see above): every other 409 a save can raise keeps its existing handling. A stale version opens the conflict dialog described below, and the rest keep the generic wording. The message says the holder may be an archived or deleted page, because a trashed page keeps its slug.

Things to know before renaming:

  • Old links stop working. A rename changes the page's by-path address, and the old address is not redirected. The page's id-based link is unaffected.
  • Reserved navigation pages show their slug read-only. The space index derives the slugs of index, log and index_spoke pages, and readers resolve spokes by them, so the editor does not offer to change them. The API does not refuse such a rename yet (PLT-1624).
  • A concurrent rename is part of the conflict review. If another editor renamed the page while you were editing, and your save also changes the slug, the conflict dialog lists it among the changes Keep mine would replace, under the label Address.

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

The page editor always sends expectedVersion, on both of its write paths — the Save button, which also carries a staged status, and the Archive / Unarchive actions — so a concurrent edit is refused rather than silently overwritten.

When a save is refused, the editor shows a confirmation dialog rather than a generic error. It names the competing principal when one was recorded and offers Review changes, Keep mine, Reload their version and Cancel (PLT-2043; version numbers are no longer shown). Reload their version loads the newer page and keeps the author's title and text in a callout to copy back, naming any Details changes the newer page replaced. The other two work as follows:

  • 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.)
  • Keep mine (formerly 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 named when the 409 carries a resolved lastModifiedByAttribution (PLT-891): the sentence leads with the name ("Alice Chen saved a newer version after you opened the editor. …"; version numbers are not shown since PLT-2043) and the author line is drawn by the same component as the page header's Author, so an agent's docked marker and the "not known if automated" mark mean the same thing in both places. The name is never a claim that a person wrote it — lastModifiedBy carries no actor kind. An id the resolver could not name reads as an unknown principal with its abbreviated id, never as a guessed or partially matched name.

Everything else keeps the rendering the dialog had before the resolver existed: the stored principal, unaltered but for surrounding whitespace, beside a worded chip. A write from the system sentinel or a system:-prefixed principal is labelled as automated and is never looked up; any other unresolved value is labelled neutrally as a principal, never as a person. That is also what the dialog shows when the resolution was unavailable, or arrived malformed or bound to a different id — the attribution is parsed separately from the conflict, so it can degrade the author line but never stop the dialog opening. 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 see 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: POST /api/pages/:id/reorder (below). Since PLT-1271 every write that places a page renormalises too — creating a page, a PATCH that actually moves one to another parent or space, restoring one from the trash, and the ingest and the space-index rebuild, each of which renormalises every partition it touched once rather than per page. An anchorless write places the page last among its visible siblings, and a move renormalises the partition it left as well; a PATCH that echoes a page's current placement moves nothing. All of them write ranks the SERVER generates over a whole visible sibling set — never a value the caller supplied, and never the key a page carried from another partition or from before it was trashed — through one renormalisation service, the only production importer of the narrow repository capability permitted to set the rank.

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.

The sibling order it expresses 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.

The order is readable, the key is not (PLT-1362). GET /api/pages?sortBy=position lists one partition — a parentId plus exactly one spaceIds entry — in (position, id) ascending order; see the pages-list filtering and sorting notes above for the full rule. The rows it returns carry no rank, so a reader learns the order among the siblings they can see and nothing about where hidden siblings sit. A listing that spans partitions, such as a space's roots or the browse list, has no manual order to offer and cannot ask for one.

Where the rank is stored, and why it moved (PLT-1348). The key lives in wiki.page_ranks — one narrow row per page, (page_id, tenant_id, position) — rather than in a column of wiki.pages. Nothing above changes: the order is still (position, id) within the same partition, equal keys are still legal, the key is still projected to nobody, and a renormalisation still regenerates a whole visible sibling set from empty.

The reason is cost. A renormalisation rewrites every key after a mid-partition gap, and on wiki.pages each of those writes recomputed the page's GENERATED … STORED search vector over its whole body and re-inserted every lexeme into the search index — 33 ms per sibling in production, which put a create into the 1,012-page specs folder ~33 s past its 10 s budget. A rank row carries no body, is covered by no index on the key, and is rewritten in roughly a thousandth of that: the same 1,011-key shift measured 2.0 s and ~45 MB of write-ahead log before, and ~50 ms and ~0.3 MB after.

Rollout state. wiki.pages.position itself shipped in an expand / backfill / enforce sequence that completed — NOT NULL (081, PLT-1286), a catch-up onto canonical keys, and the rollout default dropped (081a, PLT-1361) — and PLT-1348 then began moving the rank off it, expand first. Migration 082 creates and backfills wiki.page_ranks, mirrors the outgoing build's writes of the old column into it for the length of one release, and relaxes that column's NOT NULL so the current build need not write it. The column, its index and the mirror are dropped by the contract release, which may ship only once no instance still writes them (PT-927's shape 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 a rank, whose permitted importer set is asserted by a test and names exactly one file — the sibling renormalisation service every writer above shares.

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>", "expectedVersion"?: <int> }

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 UUID names a parent, validated for visibility. An explicit null is read against what you can see, not against the stored row (PLT-1065, ADR-030 § 4): if you can see the page's current parent, null moves the page to the space's root; if the page is one you already see as a root — a true root, or a page whose parent you cannot see, which includes a parent in the trash — it names the place the page already is, so nothing is re-parented and the request is processed as if parentId were omitted. PATCH /api/pages/:id reads an explicit null the same way within the page's space; a null sent together with a different spaceId still moves the page to the other space's root. A page under a hidden parent and a true root therefore answer the same move identically. One known exception remains: a PATCH that also renames such a page is still checked for slug collisions where the page is actually stored, so the rename can succeed for one and collide for the other (PLT-1851). In the page tree, dropping a page whose parent you cannot see on the top level no longer detaches it from that parent: it stays where it is, as a true root does. To take it out, move it into a folder you can see. Any other destination you name is a move, carried out the same way whether or not the page's current parent is visible to you. 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 move into your root list takes no anchor: with parentId: null, or with parentId omitted for a page you see as a root, a non-null anchorPageId is answered with the uniform 404. The root list mixes pages from different stored partitions and has no manual order (the tree draws it alphabetically or by date), so an anchor there would have nothing consistent to mean; anchorPageId: null remains valid. This is a breaking change for an integration that ordered true roots through this endpoint with an anchor; the tree never did.

Naming parentId requires expectedVersion (PLT-1632) — the moved page's version as you last read it, exactly as PATCH /api/pages/:id requires it, because naming a parent is how this endpoint moves a page into another folder. It is required whenever parentId is present — even when it names the page's current parent, in which case nothing is re-parented and the version is still compared. Omitted, the request is refused 428 (details.reason: "expected_version_required") and leaves the same runtime record, with operation: "reorder"; stale, it gets the 409 with details.reason: "stale_version" and the current snapshot that a stale PATCH gets. This is a breaking change for an integration that moves pages between folders through this endpoint without reading them first. The requirement follows the body, not whether the stored parent differs: explicit parentId: null needs a version too, so a page whose parent you cannot see is answered exactly as a true root is. Omitting parentId still needs no precondition — an in-place reorder writes only the order, which never bumps any page's version — but a version you supply anyway is compared. Precedence: 403 for a missing permission or a knowledge-base space and the uniform 404 come first, then the uniform stale-arrangement 409, then the 428, then the stale_version 409, then the root-list anchor 404 (PLT-1065), and only then is the destination decided.

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. (The stale_version 409 above is the other conflict this endpoint can return; it is about the moved page's own row, not the arrangement, and carries its details.reason.)

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 it — null 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; since PLT-1078 the measurement runs daily on a role of its own, while the pass stays unwired — see Key density is measured on the daily sweep below.

Key density is measured on the daily sweep, and compaction is written but not run (PLT-874, PLT-1078)​

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 runs on the daily curator sweep, on a database role of its own. 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 records 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, since 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). Since PLT-1543 the function writes that entry itself, before the read, and it is not published to the outbox. The 2-argument form that recorded nothing is gone since PLT-1719 (below).

Which is why it has a role of its own. PLT-1078 wrote the entry in the calling code, and the capability is a grant, which cannot see the entry. 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. That grant was retired rather than argued, and for a release nothing measured anything.

PLT-1078 closes it by splitting the roles. Migration 079 creates the NOLOGIN authority role wiki_maintenance and grants it EXECUTE on the inventory definer alone — constellation_app stays revoked, and the migration asserts the whole effective grantee set at apply time rather than assuming the grants took. The daily curator-lint cron opens a second confined connection on the dedicated constellation_wiki_maintenance login (WIKI_MAINTENANCE_DATABASE_URL) and runs the pass there, once per discovered tenant, immediately after that tenant's discovery audit entry commits and before its spaces are swept. So no request path can reach the definer.

The audit is bound inside the database (PLT-1543). The tool calls a 4-argument overload that writes the constitution §5 entry itself, through audit.write_entry, before the read and whatever it finds: wiki.sibling_rank.density_inventory_read, filed at SECRET against the tenant, with spacesInspected as its only context. So a holder of the maintenance credential calling it by hand files the entry too. The entry is not published as audit.entry.created, since it records a read-only crossing (ADR-048 R1). If the inventory's transaction fails after the read, the tool files an identical copy in a fresh transaction. It is the only signature: the 2-argument form the previous build called, which recorded nothing, was dropped by PLT-1719 once no deployed build called it. What remains outside the database is direct SQL by the function's owner or the migration applier, an operational control.

The role holds only what the pass uses (PLT-1732). Migration 079 had also granted wiki_maintenance a direct audit and outbox surface for the old TypeScript entry. Migration 20261009071525 (PLT-1402) revoked the direct audit writes, and migration 20261105170435 revokes the rest: SELECT on audit.chain_heads, every privilege on events.outbox and USAGE on schema events. What the role keeps is EXECUTE on the inventory and on audit.write_entry, SELECT on three wiki.spaces columns, EXECUTE on wiki.session_tenant(), and USAGE on schemas wiki and audit. The writer is a SECURITY DEFINER, so it reads and locks the chain head as its owner, never as the caller.

The compaction half is still not wired — its own grant stays revoked, and ADR-035 is what unblocks it (PLT-1041).

Operationally, the pass degrades rather than fails. An unset or refused WIKI_MAINTENANCE_DATABASE_URL skips the inventory and the sweep still runs — a 200 whose summary carries status: "incomplete" and aggregates.run.inventoryRan: false. That is the signal to watch: an unprovisioned environment looks healthy to anyone reading status codes alone. On a run touching exactly one tenant the summary is the status alone (ADR-040 § D2), so read the route's density inventory skipped log line there instead. Provisioning is an operator step in the pre-deploy prerequisite registry. The pass is bounded twice — no transaction is begun more than 22 s into the run, and each tenant's gets 4 s, plus 3 s for the replay of its audit entry should that transaction fail — so a slow inventory can never be the reason a discovered tenant goes unswept, and the kb_reads retention deadlines are untouched.

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.

The partition count audits itself (PLT-1538, migration 107). Sizing the new key sequence needs the partition's live row count across every tier, so wiki.sibling_partition_live_count crosses on every call. It locks and counts the partition's live rows in one statement, and locks their rank rows, before it writes one verdict-free entry through audit.write_entry — so the audit chain head is still the last lock the pass takes. The entry names the space and the predicate and is filed at the space's default tier floored at RESTRICTED, read under a share lock; it carries no count and no parent. It is dormant like the compactor: only its owner can execute it.

A raise takes its row locks before its first audit write (PLT-1612). Two steps of a page PATCH can take the audit chain head and hold it to commit before the raise writes anything: the child-page check of a space move, which writes its entry through audit.write_entry (PLT-1545), and the KB-write guard, when a marked space admits the write. So a PATCH that raises the classification first locks the rows the raise will write: the copies an ingest wrote from the page, which the withdrawal re-tiers, and the ingest-batch and folder-run rows whose ceiling moves. A folder-run settle and a space-move eligibility read on one of those copies each hold such a row and then audit, so locking the row behind the chain head closed a deadlock cycle. Its victim was either the PATCH, which answered 500, or the other side, which committed an audit entry outside the hash chain. Since PLT-1605 the same prologue also locks the reserved navigation pages the withdrawal's rebuild writes, and those spaces' rows.

Two more rows are taken before the first audit write (PLT-1667). A PATCH that names a space locks that space's row FOR SHARE before the KB-write guard: the page UPDATE takes it anyway, through the live-space trigger, and a change to the space's default classification holds the row and then audits. A trash restore locks the ingest-batch and folder-run rows its re-tier will raise before its own KB-write guard, and, when it re-homes the page to the Default space, that space's row. In each case the lock refuses nothing and only moves an acquisition earlier.

A nomination exclusion locks the candidate first (PLT-1606). A PATCH that changes a page's nomination policy or moves it, and a reorder that re-parents it, retire the page's open ingestion_candidate findings when its policy then resolves to exclude. The retirement runs after the transition's audit writes, and a bare dismissal of the same candidate holds that row and then audits, so the two deadlocked and the PATCH answered 500. Both paths now lock the page's open candidates right after the page row, before their first audit write, and retire them before the nomination-policy entry. A dismissal that loses the race answers the ordinary 409: the finding is already resolved.

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.
  • Authored knowledge values: decision (a recorded decision and its reasoning), guide (how-to guidance) and reference (lookup material). KNOWN_PAGE_TYPES does not name these three, but KB candidacy nominates them as durable content and the new-page dialog offers them (PLT-808).
  • Types the new-page dialog offers (BROWSER_PAGE_TYPES in apps/wiki/src/lib/new-page-form.ts): spec, page, doc, runbook, decision, guide, reference, release_notes, maintenance_schema and concept. The reserved navigation types are not writable by any ordinary caller since PLT-818 — only the space index creates them (see Reserved pages have no owner). The rest of this list can still be written through the API and the MCP tools, but the dialog does not offer it: source and source_summary, and since PLT-1219 entity (an ingest fan-out may overwrite it) and synthesis (coordinator file-back output that the redundancy and KB read paths select by type).
  • 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.

Provenance in the page's Details sheet​

A page in a space marked isKnowledgeBase shows where it came from in its Details sheet (PLT-2042), which the reader opens from the meta line under the title. It reads GET /api/pages/:id/provenance and shows, as rows:

  • the source reference;
  • a From page link when the ingest read a wiki page, labelled with the revision it copied;
  • the approval recorded on the batch, labelled Approval recorded;
  • when the page was added.

The page's outgoing derived_from edges are also links, each labelled with the revision the edge pins.

The ingesting principal and the batch id are audit facts. They stay in the knowledge-base administration ledger and are not shown on the reading page. A page outside a knowledge-base space shows no provenance rows and issues no provenance request. It still reads its own space, which is what decides the gate; that read is cached, and the breadcrumb trail already issues it on the same render. A space read that fails leaves the rows out rather than shown.

The From page row comes from ingest.origin: { pageId, slug, title, spaceId, revisionNum } (PLT-715). The slug, title and space are the page's current values; only the revision is pinned. The source reference stays alongside it, because it is the label the ingester wrote and the page may have been renamed since. origin is null, and the row is omitted, in any of these cases, which are not distinguished:

  • the batch recorded no origin: pasted text, or a batch written before the origin columns, which were not backfilled;
  • the origin page has been deleted;
  • the reader's clearance hides that page, or the revision that was read.

Because the origin is part of the ingest record, the batch's classification ceiling gates it too: a reader who gets ingest: null learns neither the origin nor that one exists.

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 approval is free text that nothing validates against an identity, so it renders as a plain value under Approval recorded — never "Approved by", an avatar or another 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; when both are absent the sheet shows no provenance rows at all. A failed read says so, and is announced.

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

The same marker that gates the provenance rows 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. Above the body the page says instead, once, that changes to knowledge-base pages go through review, with a Request change control beside it that puts the reader into that loop (PLT-2042).
  • On a knowledge-base page whose writes are allowed, Edit is the primary action and Request change sits in the page's More menu. The two are never both in the action bar.
  • 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 rows, which stay out. 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 that then refuses them (below). The trade is available only because this is presentation and not the boundary.

The editor route (PLT-1147)​

Reaching /p/<id>/edit for a knowledge-base page another way — a deep link, browser history — does not open the editor. The route shows the page title, the same read-only statement, the same Request change control and a Back to page link, and no editable control. The page view and the editor decide from the same rule over the same space read; the editor then holds its first answer (below), while the page view follows the live read.

  • While the space read is undecided the editor shows its loading skeleton, neither the form nor the refusal. So an ordinary page never flashes a refusal, and a knowledge-base page never opens a form that closes once the answer lands. A failed read opens the editor, the page view's direction, for the same reason.
  • The decision is taken once per page. A later re-read of the space — a cache reset, an organisation switch — or a save does not close an open editor or turn it into a refusal. Creating a page in, or moving one into, a knowledge-base space is not gated by this route; the create form and the space picker below refuse those at the moment the space is chosen.

The space picker (PLT-1281)​

The editor's Space field does not offer a knowledge-base space as a move destination as if it were an ordinary one. Such a row is still listed, with the reason on the row itself and the same way forward the create form gives — keep the page in a regular space and use Add to knowledge base for approved ingest.

  • The row follows the space's write state, not only its marker. A marked space is offered only once GET /api/spaces/:spaceId/kb-write-state reads allowed. Where enforcement declines — an unreconciled tenant, a space whose provenance cannot be re-proved, the operator kill-switch — the space accepts the move, so the row offers it. The create form follows the same rule since PLT-1500.
  • It fails closed, the opposite direction to the page view. While the write state is being read the row says so and cannot be chosen, and a failed read says it could not be checked. This is a pre-save control, like the child-page move lock beside it: a destination that is briefly operable while its answer is unknown is one a fast author can pick, and the save then fails.
  • The page's own space is never withheld, marked or not, so a page already in a knowledge base can still be moved out and an unsaved pick can always be undone. Ordinary spaces issue no write-state read at all.

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 answered as every page sub-resource answers it — see Reading a page's sub-resources.

No step of the walk visits a page twice, however many roots the subject has or how many pages share them (PLT-724): the work no longer multiplies the subject's roots by the pages that share them. How much work a given graph costs is still the database planner's choice, so like every wiki statement this one also runs under the transaction's statement_timeout (PLT-725). Nothing is capped or sampled along the way, so the four cards are always chosen from the complete candidate set — the answer is never a partial one, even on a deliberately dense provenance graph.

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.

Space relationships (PLT-1067)​

GET /api/spaces/:spaceId/relationships returns the relationship graph of one space as the caller can see it, in one response: { spaceId, spaces, nodes, edges, topics, truncated, limits }. It is the read the KB correlations explorer is built on (SPEC-plt-589), and it replaces assembling a space view from one GET /api/pages/:id/links request per page.

  • Nodes are the space's live content pages: not in the trash, not archived, and not a reserved navigation page (index, index_spoke, log). Each carries { id, spaceId, slug, title, pageType, classification }.
  • Edges are stored page_links rows of the five link types — derived_from, depends_on, supersedes, references, implements — each as { sourceId, targetId, linkType } in its stored direction. Wikilinks in a page body, the parent hierarchy, shared tags, embedding similarity and duplicate-candidate pairs are not edges here. This ontology was ratified by the PLT-304 epic owner on 2026-10-14.
  • Across spaces. A link does not have to stay inside one space. An edge whose other endpoint is a page you can read in another space is kept: that page is returned as a node carrying its own spaceId, and its space is listed in spaces with its slug and name. The other endpoint must pass the same rule as an in-space node (live, not archived, not a navigation page, in a live space); an edge whose endpoint fails it is not returned. A page from another space appears only because an edge names it.
  • Topics group the space's own nodes the way the space index does — by the root of each page's derived_from chain, otherwise by its parent — but computed only from the nodes and derived_from edges in this response. A parent that is not a returned node counts as no parent. A topic's key is always the id of a returned node, but not always one from this space: a page deriving from a page in another space is grouped under that page, which is never listed among the topic's pageIds. Topics depend on your clearance and are not a fact about the space.

Only edges whose both endpoints you can read are returned, and nothing in the response is counted, ordered or grouped using a page you cannot read: there is no placeholder for a hidden neighbour and no hidden-inclusive count. The response is identical whether or not such a page exists.

The read is capped at 500 nodes from the space and 2,000 edges. The caps bound the response, not the work: every link touching those nodes is read before the edge cap applies, under the transaction's statement timeout. The caps are fixed by the server and reported in limits; truncated is true when either cut something you could otherwise see. The space's own nodes come first, ordered by title, then the pages from other spaces, also ordered by title. The node cap applies to the space's own nodes, and an edge into a page it left out is left out with it. A space you cannot see is a 404; a visible space with no content is 200 with empty lists.

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) — since PLT-818 predicated on the canonical shape (UNCLASSIFIED and is_agent_owned), so a non-canonical row can neither occupy nor block the slot, and migration 109 retyped any existing non-canonical index/log/index_spoke row to page, keeping its classification and body — 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.
    • An ingest's entry is ingest | a source (PLT-1451). Earlier builds wrote ingest | <source title>, and a page raised before PLT-1451 shipped was not withdrawn from the records that quoted it: its ingest copies kept their tier, and the index and the log kept naming it. npm run db:sweep-stale-quoted-copies -w @constellation/wiki -- --tenant <uuid> reports, per tenant and read-only, the copies below the highest tier on any chain of origins behind them and the logs that still name a source (PLT-1568). With --apply it re-tiers those copies, rebuilds every index the wiki maintains in the tenant at the UNCLASSIFIED pin, and sanitises the maintained logs, then scans again and fails unless nothing it repairs is left. The scan runs under the privileged DIRECT_URL; the writes run through DATABASE_URL and are refused on a role that bypasses RLS, because the pin that keeps classified titles out of the index is an RLS predicate. A copy in the trash and a log the wiki does not maintain are reported and not written, and the revisions of the index and log pages are not rewritten. It is a registered post-deploy repair. See .ai/specs/SPEC-plt-1568-sweep-stale-quoted-copies.md.
    • A retraction's entry is retract | a source at every tier (PLT-1355). Builds released before 2026-09-24 wrote retract | (classified source <page id>) for a classified source, and those lines are still in the body and revisions of any log page that has one. Measure them per tenant, read-only, with npm run db:redact-legacy-retract-log-lines -w @constellation/wiki -- --tenant <uuid> under the privileged DIRECT_URL (PLT-1454). The command writes nothing except its own SECRET audit entry. It refuses --apply: the redaction is added only if some environment reports a non-zero count. See .ai/specs/SPEC-plt-1454-legacy-retract-log-lines.md.
    • A retraction's detail line is reason: <reason> (PLT-1452). Builds without PLT-1452 (merged 2026-11-03) appended counts to it — — N derived pages flagged needs_review (M re-typed to synthesis), L links removed, R revisions scrubbed — and those counts include records an UNCLASSIFIED reader cannot see. npm run db:redact-legacy-retract-counts -w @constellation/wiki -- --tenant <uuid> reports them per tenant under the privileged DIRECT_URL, writing nothing but a SECRET wiki.page.legacy_retract_count_scan_started entry; with --written-before <YYYY-MM-DD> (the UTC date PLT-1452 reached that environment) it previews, and with --apply, which requires that date, it rewrites each such line directly under a retract heading of a log page dated before it to reason: <reason>, in the current body and in every revision, and leaves every other byte alone (PLT-2100). The date is the provenance: a reason typed since PLT-1452 may end in the same counts-shaped text, so a line dated on or after the cutoff, and a reason that itself ends in such a suffix, are reported and never cut. The revisions are rewritten the way PLT-1589's redaction rewrites them: by the role that owns wiki.page_revisions, one page per transaction, with the append-only trigger suspended under a table lock, no revision appended, and one wiki.page.legacy_retract_counts_redacted entry per page that records how many lines it rewrote and never the counts. The phrase anywhere else — another page, a hand-edited line — is reported for a person, not rewritten. See .ai/specs/SPEC-plt-2100-redact-legacy-retract-counts.md.

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, classification), 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 (each segment resolving only on exactly one readable match since PLT-818), 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 24.5 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 explicit bounds — the connection wait (maxWait), the transaction timeout and the per-statement statement_timeout — sized so that every transaction the route opens fits 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. All three terms count: Prisma may spend the connection wait before the timeout clock starts, and it rejects an expired transaction only once its running statement returns, so a statement begun just before the timeout runs on for up to its cap. The route also reserves the transaction that records a refused write's audit entry after the rollback (PLT-1779).

RoutemaxDurationOperation (wait + timeout + statement cap)Audit replay after a rollback
POST /index/rebuild60 s2 + 24.5 + 24.5 s1 + 3 + 3 s
POST /log/append20 s2 + 5 + 5 s1 + 3 + 3 s
POST /ingest60 s2 + 26 + 10 s1 + 3 + 3 s
POST /retract60 s2 + 32 + 10 s1 + 3 + 3 s
PATCH /api/pages/:id with a classification60 s2 + 32 + 5 s2 × (1 + 3 + 3 s), then 1 + 2 + 1 s

A PATCH that carries a classification rebuilds the affected space indexes inside its own transaction (PLT-1451), so it has a budget of its own (PLT-1781). After a rollback it can open two audit replays, each in its own transaction, and, on a stale_version 409, one short read that names the competing writer. Its statements are capped at the wiki's default 5 s rather than at half the budget, because the withdrawal and the rebuilds it carries are many short statements: a reclassification that waits more than 5 s for a lock fails and rolls back, and can be retried. Before PLT-1781 it had a 50 s timeout with an uncounted 50 s statement cap, and the transactions after a rollback ran on the default bounds. Every other PATCH keeps the default bounds.

Until PLT-1779 the rebuild had a 50 s timeout with an uncounted 50 s statement cap, and log-append ran under the default 10 s API cap. A rebuild that needs more than 24.5 s now fails with the attributable error below instead of risking a 504. The same bounds apply when the coordinator's consult file-back rebuilds an index or appends to a log.

The single-source ingest (POST /api/spaces/:spaceId/ingest) and the source retraction (POST /api/pages/:id/retract) rebuild the index inside their own write transaction, so they carry the same explicit bounds (PLT-705). Their statement cap is 10 s rather than the wiki's 5 s default, because an approving ingest runs knowledge-base scoring reads that have taken more than 4 s in production. The ingest's budget also reserves its principal lookup (1 + 2 + 1 s) and its source-page check (2 + 2 + 2 s); the retraction's reserves the lookup. Until PLT-705 both ran on Prisma's 5 s default, and every ingest into the production knowledge-base space failed once that space outgrew it.

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 emit no such record — do not expect telemetry from those paths (PLT-482). An ingest or retraction that expires answers its own typed 503, INGEST_BUDGET_EXCEEDED or RETRACTION_BUDGET_EXCEEDED, naming the space or source and the budget (PLT-705). details.outcome says what the error proves about the writes. rolled_back means a statement failed inside the transaction, so nothing was written and the request is safe to retry. unknown means the expiry may have reached the commit, so check whether the write took effect first. An identical retry after a commit answers 409, which itself shows the commit; only a retry with a changed slug writes a second batch. Expired-page reaping still surfaces a generic 500.

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?, classification } entry to the space's log page. Returns { data: { logPageId } }. classification is required and must be "UNCLASSIFIED" (PLT-1453): the log is UNCLASSIFIED and readable by everyone in the tenant whatever the space's default classification, so the caller names that tier rather than inheriting it silently. It is an acknowledgment, not a tier the server applies, and it is not stored. A request without it, or with any other value, is a 400 VALIDATION_ERROR that writes nothing. The ingest, retraction, consult file-back and curator-sweep appends write constant or sanitised text and do not take it.

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

In a marked knowledge-base space the rebuild route is refused wherever KB-write enforcement applies to that space, with a 403 whose details.reason is kb_index_rebuild_forbidden (PLT-1880). It is an ordinary write and carries no knowledge-base capability — see Protecting knowledge-base content from ordinary writes below for why no public route mints one. Such a space's index is rebuilt by the trusted maintenance paths that change its content — every one in the same transaction as its write except the coordinator consult file-back, which rebuilds in a transaction of its own after filing: approved ingest (ingest_source), source retraction (retract_source), an ingest batch revert that restores navigation (revert_ingest_batch), the scheduled curator sweep when it archives expired pages, and the coordinator consult file-back. Where enforcement is dormant, or declines to protect the space, the rebuild runs as before.

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-only (R7): there is no autonomous trigger, and the principal calling it must be a HUMAN.

What R7 checks (PLT-1152). Every ingest entry point — POST /api/spaces/:spaceId/ingest, source retraction, both reverts (revert_ingest_batch and the folder-run group revert) and POST /api/pages/:id/folder-runs — resolves the calling credential's identity.users.principal_kind and refuses anything but HUMAN with 403, even when the caller holds wiki:page:write. A SERVICE principal (for example the spec-wiki-sync CI credential) and an AGENT principal are refused; so is a principal the acting organisation has no live relationship with, whose kind therefore cannot be read. GET /api/ingest/capability answers canIngest: false for all of them, so the UI never offers what the server refuses.

  • approvedBy is a label, not an identity. It is still required and non-empty (a blank one is 400), and it is recorded on the batch as provenance, but nothing validates it against a user. The human guarantee comes from the principal check, not from this string. The two reverts pass the fixed label revert: undoing an already-approved ingest carries no new approval, but its caller must still be human.
  • A human acting in an organisation other than their home one is admitted (PLT-1236). identity.users is row-level-secured on the user's home tenant, so the kind is read through Directory's relationship-scoped identity.resolve_principal_kind resolver (ADR-056), which answers for a principal homed in the acting organisation or holding an ACTIVE membership there — a guest or multi-org member can ingest, retract and revert where they are a member. A principal with no such relationship still cannot be classified, and is refused.
  • The refusal is not audited, like the permission refusal beside it: it is decided before any resource is read.

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

What the response says about the candidate approval (PLT-986)​

An ingest carrying candidateApproval performs a second, separable effect: it closes the nomination and writes the approved outcome. The response says which of those happened, in a candidateApproval field on the result:

{ "applied": true, "findingId": "…", "outcome": "approved", "status": "resolved", "reasonRecorded": true }
{ "applied": false, "reason": "not_requested" }

Read its ABSENCE as "this wiki predates the acknowledgment", never as "nothing was applied" — that is the whole reason the applied: false form exists rather than a nullable field. Before it, a client that sent an approval to a wiki that silently ignored it got a response identical to one that recorded it, which is why the ingest_source MCP tool still words the approval as SUBMITTED, and sends the caller to confirm in the review queue, whenever the field is absent. When the field is present the tool reports what it says (INF-624): APPLIED with the finding's resulting status, or NOT APPLIED with the reason. resolve_lint_finding reads the PATCH's meta.candidateOutcome the same way, reporting RECORDED with the outcome the wiki stored.

The field is present on every committed ingest. A folder-run ingest reports not_requested, which is exact there: a run may not carry an approval at all (the pairing is refused as an invariant violation, since a run has no reviewer and its replay path can skip after the candidate is already locked).

What it deliberately does not carry. The nominated page id (PLT-687 keeps provenance off records that cannot follow a later reclassification), the approval reason (it lives only in the ledger row, which is gated on the decision-time classification), and that classification. findingId is the one the caller supplied, so it discloses nothing back. The values are composed from the write itself — the arguments the ledger insert was given, and the status the finding's flip returned — and never re-read: an approved row is legitimately invisible to its own writer above the batch ceiling, so a read-back would report "nothing applied" on exactly the ingests that applied something.

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.
nominateFrombooleanfalseRetired (PLT-1867): nothing reads it. It was the per-space watched gate (PLT-269); KB nomination is now on by default — see KB nomination is on once a knowledge base exists below. Since PLT-1890 neither pt release-kb-curate nor the MCP update_space tool uses it. The wiki still accepts, stores and serialises it, so a pt CLI built before PLT-1890 keeps working; PLT-2116 stops serialising it.
nomination'nominate' | 'exclude' | 'inherit''inherit'PLT-605: the per-page/folder override. On a space row only 'inherit' (the default) is accepted, and it is inert; 'nominate' and 'exclude' are refused — a space is the root of the chain. 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.

KB nomination is on once a knowledge base exists (PLT-1867)​

Creating a knowledge base is the opt-in. A space is watched — its durable, approved, UNCLASSIFIED pages are suggested for the knowledge base as they are created, updated or published — exactly when:

  • the tenant has a usable knowledge base: exactly one space marked as the knowledge base (PUT /api/spaces/:spaceId/kb-marker, the admin Spaces screen, or the reconciliation scan). Only a marker write can switch it on, and every marker write that does enqueues the backfill below. The transitional slug fallback — an unmarked knowledge-base space in a tenant never reconciled — still resolves ingest, but does not switch nomination on; mark the space; and
  • the space is not one of the tenant's knowledge-base spaces. A knowledge-base space is never a source, whatever its pages' policies say.

No per-space setting is involved, and nomination stays a suggestion a human approves. To keep a subtree out, set a folder or page exclude (below). In a tenant with live pages and no usable knowledge base, a holder of wiki:spaces:admin sees a notice on the wiki home that links to the Spaces screen; nobody else sees anything new.

Marking starts the backfill. Pages already approved before the tenant had a knowledge base fire no candidacy event of their own, so marking a space — through the marker endpoint or the reconciliation scan — enqueues the wiki-kb-candidacy-backfill job in the same transaction. So does a space rename (PATCH /api/spaces/:id with a slug) that moves the unmarked knowledge-base space out of knowledge-base coverage while the slug fallback is open, which makes it an ordinary, watched space beside the marked one. Each of these writes compares the watched spaces before and after itself and enqueues only when a space became watched. The job sweeps every watched space through the same per-page check the event path runs, yields at the job drain's deadline and resumes where it stopped, and records nothing twice: an already-open or already-reviewed page is skipped. POST /api/kb-candidacy/backfill still runs the same sweep on demand.

GET /api/kb/nomination-sources answers, for any tenant member, { hasUsableKnowledgeBase, hasLivePages, canAdminSpaces, sources: [{ spaceId, slug }] } — the watched spaces by the same predicate the nomination verdict uses. pt release-kb-curate reads it to label which spaces nominate.

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: the nearest ancestor that sets a non-inherit value wins. 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.

The space gate comes after the policy, and a nominate does not reach past it (PLT-1867). An exclude refuses wherever it is set; otherwise the page is nominated only if its space is watched (KB nomination is on once a knowledge base exists, above). Since every space that could be opted in is already watched, nominate now matters only below an ancestor's exclude, where nearest-wins lets it re-admit a subtree.

The backfill (POST /api/kb-candidacy/backfill) and demand-driven nomination from KB query-misses both cover exactly the watched spaces, and accept a page only where the event path would nominate it. The demand path reads past the top five hits, up to the top 50, and when more matches lie beyond those 50 it records nothing — neither an anchored nomination nor a "the KB is missing this topic" gap finding — and leaves the topic to be re-attempted on a later miss. The backfill response reports watchedSpaces; every perSpace row carries watched: true. The always-0 optedInSpaces field is gone since PLT-1890. A re-run started by hand does not resume where a maxPages cap stopped — when the response says capped: true, cover the rest with a larger maxPages, or none; the marker's backfill job resumes on its own.

The queue is not the authorization, and since PLT-814 it does not have to be. Setting exclude retires the excluded page's own open ingestion_candidate finding but not those of its descendants — no event fires for them, so nothing re-evaluates them, and their entries stay in the review queue. What has changed is that such an entry is no longer approvable: approving a candidate through POST /api/spaces/:spaceId/ingest re-resolves the page's inherited policy and its space's watch gate under the locks the approval already holds, and refuses with a 409 when either forbids it. So a folder-level exclude is a containment control from the moment it is set, backwards as well as forwards — for every approval that reads the policy after it commits (one already in flight is covered below) — write-time retirement keeps the queue tidy, it is not what enforces the policy. Excluding the descendant itself (POST /api/pages/:id/kb-exclude) still retires its entry immediately, which is the difference a curator sees: the entry disappears rather than merely becoming unapprovable.

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

What a resolved policy is — a snapshot, not a lock (PLT-810). The resolver walks the chain under the default READ COMMITTED and locks nothing it reads. Every decision that composes it with the page's space — nominating a page, approving a candidate, and the candidate pane's verdict — reads the chain, the page's current space and its per-page exclusion in one statement, so the three always describe the same state of the hierarchy. (Read in separate statements they could not: a chain read before a folder's exclude committed, beside a space read after that space started watching, admitted a page that was refused before, between and after both changes.) That guarantee holds only while the resolver stays STABLE, and a check in migration 122a and an integration test both refuse the drift. What it does not give you:

  • A governance change that commits after the read does not stop the decision already in flight. For an approval, that is the same outcome as the approval having committed a moment earlier: excluding a folder or unwatching a space never retracts content already in the knowledge base, and never touches a descendant's review entry, so nothing the approval wrote is undone by the change it raced. A change to the approved page itself cannot race it at all — the approval holds that page's row lock.
  • Nomination has a narrower residual. It decides before it locks the page, so an exclude set on that very page in the same instant can leave a fresh review entry behind the exclusion that was meant to retire it. That entry can never be approved — approval re-resolves the policy and refuses it with a 409 — so the residue is a queue entry, not knowledge-base content.
  • Audit-chain order is not decision order in that window. The approval and the governance change both append to the tenant's audit chain, and the approval's policy-resolution entry is written at the end of its transaction, so the chain can show the governance change first.
  • The audit pair of a policy change is two reads. A wiki.page.nomination_policy_changed entry's resolvedNomination.before is resolved under the page's row lock before the write and resolvedNomination.after after it, in separate statements. An ancestor changed between them can make the pair differ for a reason other than the write being audited; read the pair as two best-effort observations, not a serialisable before/after.
  • Demand-driven matching is advisory. Matching a KB query-miss to a page runs a multi-statement scan (watched spaces, search, each page, its policy) and consults only an inherited exclude; it takes no single snapshot either.

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, and both are about the queue rather than about enforcement. 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. In every one of those cases the page's stale entry survives in the queue and is nonetheless refused at approval, because the consumption-time check resolves the chain the page sits in now. Read the write-time hook as what it is: it keeps the review queue tidy, and the enforcement boundary is consumption.

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 same setting is available in the UI (PLT-616). On a folder page it sits in the Section settings sheet, opened from the page's More menu (PLT-2042) and offered to whoever is offered Edit or can add to the knowledge base. The sheet opens with a Knowledge base policy block. Its This page's setting select offers Inherit, Nominate and Exclude, with Inherit listed first as a real choice rather than a blank: choosing it sends the clear described above. Beside it, Applies here shows the resolved value for the page, which is not necessarily the page's own setting. A source label sits next to the value, and it names a level — Set on this page or A parent folder — only when the resolver returned a source row the caller can see. Otherwise the label is generic: Space policy when nothing in the chain overrides, and Source not shown, with a tooltip saying only that no source is shown, for every other value — one whose source was withheld, and the fail-closed exclude the resolver fills in when its walk runs out of depth, which the control deliberately does not tell apart (PLT-768). So the control never reveals an ancestor the caller cannot see (the PLT-658 contract above). A reader who may not edit the page sees the resolved value and a note instead of the select. The read behind it is described under Folder policy control.

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, other than an ingestion_candidate. A nomination never blocks, at any severity (PLT-896): it asks for exactly this promotion and claims nothing about the page being wrong, and while it counted, a page nominated at error could not be promoted even by the approval ingest naming that nomination. The record boundary now refuses an ingestion_candidate at error outright. 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, check_type, 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 database backstop's finding check is an audited read (PLT-1547). Deciding whether the source page carries an open error finding means reading findings past the caller's clearance: that is how a finding with a hidden target still blocks. So constitution §5 requires an audit entry. Since migration 114, the database function that performs the check, wiki.page_has_open_blocking_finding, writes one wiki.page.kb_promotion_finding_check_applied_outside_clearance_scope entry every time it runs the check, before it reads. The entry names the page and the check, never the answer. It is filed at the page's classification, and never below RESTRICTED. The function refuses a source page the session cannot read, and a session with no actor or correlation id, before its privileged read of the findings and before it writes the entry. It used to answer "not blocked" instead. So a page-backed batch inserted into a knowledge-base space under a tenant context files one entry from the BEFORE INSERT guard, whoever inserts it. A session with no tenant context is not checked, as before, and its batch carries no entry. The entry lives in the inserting transaction, so when the guard refuses a batch, the rollback takes the entry with it: a checked batch that commits carries its entry, and a refused one leaves none. That is a known gap in coverage, not a disclosure, and PLT-1601 owns it. The function's earlier two-argument form, which the previous gate called, is dropped by migration 20261005123129 (PLT-1600); the four-argument form is the only one.
  • 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.
  • The gate's read is audited, whatever it answers (PLT-1546). Seeing a finding whose other page is classified above you is a read past your clearance, so constitution §5 requires an entry for it. Every check through the four-argument wiki.page_blocks_kb_promotion — the only form the ingest calls — files one wiki.page.kb_promotion_gate_read_outside_clearance_scope entry, naming the source page and the predicate, filed at the page's classification (never below RESTRICTED). The database function writes it before it reads, so a SQL caller of that form gets the entry too, and it carries nothing the check found. The earlier two-argument form, which recorded no entry of its own, was kept for one release for the build a deploy replaced, and is dropped by migration 20261005112922 (PLT-1598); the audited function is the only one left. A refused promotion rolls the ingest back, so the entry is rewritten in a fresh transaction afterwards: a refused and an admitted promotion leave the same single entry. The refusal itself files nothing further — 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 approval label supplied with the ingest (unverified).
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.

Resolving the origin writes an audited read, whatever it finds (PLT-1294). Before stamping the origin's latest revision, the ingest asks wiki.page_latest_revision_is_visible whether that revision really is the latest. The function reads revisions above your clearance — that is how it can tell — so constitution §5 requires an audit entry for the read. Every call of that function from an ingest files one wiki.page.latest_revision_check_applied_outside_clearance_scope entry, naming the origin page and filed at its classification (never below RESTRICTED). It carries nothing the check found, and it is written identically whether the ingest commits or is refused because a hidden later revision exists. Since PLT-1425 the function's 4-argument form writes that entry itself (migration 118), so a direct SQL caller of that form gets the entry too, and a caller whose page it cannot read is refused before anything is read or written. Since PLT-1611 the finding verification pass calls the 4-argument form too, filing one entry per provenance_drift decision it checks. The earlier 2-argument form, which recorded nothing of its own, was kept for one release for the build a deploy replaced, and is dropped by migration 20261007103004 (PLT-1711); the audited function is the only one left. The refusal on a hidden later revision rolls the ingest back, so the entry is rewritten in a fresh transaction afterwards. The check runs after the candidate approval's locks, so a raw-content ingest, or a page-backed one refused or skipped before that point, runs no such check and files no such entry.

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.

The offline coverage repair 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. A batch corrected by both passes is reported once, not twice; a converged repair reports nothing.

Since PLT-922 (migration 110) the repair has the run ledger's shape: wiki.ingest_batch_coverage_candidates(tenant) does the fold for one tenant, read-only, returning each batch's stored and target values and locking the tenant's batches, then its runs (the page-reclassification path's order), each in ascending id order (PLT-1668) — the order the folder-run group revert locks a run's batches in, so a repair that queues behind a revert waits for it and is then refused with 40001, changing nothing, rather than deadlocking it; re-run it; npm run db:repair-ingest-coverage -w @constellation/wiki -- --tenant <uuid> writes each batch beside its own auditCritical() entry (wiki.ingest_batch.coverage_repaired), after recording the privileged scan. It replaced wiki.repair_ingest_batch_coverage(), which swept every tenant and audited nothing. It is idempotent and monotonic and must run under the privileged migration role. It is the standing backstop for a reclassification that bypassed PageService.update, and ran after the release that shipped 033 to close its migrate-before-deploy window. Since PLT-729 every release also runs its read-only half, npm run db:repair-ingest-coverage:check -w @constellation/wiki -- --tenant <uuid>: the same candidate read, no batch write, one scan entry, and exit 1 when a batch's ceiling is below its high-water mark (origin included) — the prompt to run the repair. A page_count difference alone is reported but exits 0.

What the database enforces, and what the application does (PLT-922). Two ceiling rules are enforced by the table itself, for every writer: the ceiling never falls (trg_ingest_batches_ceiling_monotonic refuses a lowering), and at commit a run-owned batch lifts its folder run to at least its own ceiling (trg_ingest_batches_propagate_ceiling, deferred). The deferred trigger is a no-op behind the application, which raises the run itself in the same transaction; it only acts for a writer that went around it, and then audits the raise (wiki.folder_run.ceiling_propagated, SYSTEM actor, the session's actor in context). The third rule — raising a batch when a page it covers is reclassified — stays in the application (PageService.update); the offline repair above is its backstop. The runtime role can disable neither trigger.

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. What the ingest wrote outside the row is covered upstream instead, by not naming anything after the batch — see The source page's name. 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 -- --tenant <uuid> for the same tenant 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: concurrent audit traffic for the same tenant can make the repair's audit chain step fail under REPEATABLE READ, and every entry the command writes refuses that rather than committing a NULL hash (onChainFailure: 'reject', PLT-953). The refusal rolls back the transaction it happens in, so no ceiling changes and no raise entries commit. Where it happens decides what remains: a refused scan-start entry rolls back with its own transaction and the repair never starts, while a refusal inside the repair leaves that attempt's scan-start entry durable — it was committed first, and the crossing read it records did happen. A failure caused by concurrent work — a lost snapshot race (40001) or a deadlock (40P01) — is retried in a fresh transaction, up to three attempts, after which the command fails and names the cause — re-run when the tenant is quiet; any other chain failure fails the command at once. As defence in depth against a call site losing the option, it also reads its own entries back inside the repair transaction, before the commit (PLT-1113), and fails the pass if any carries a NULL chain hash. That refusal rolls back the raises together with their entries, so that pass changes nothing and its runs stay repairable: restore the option, then re-run. An earlier revision read them back only after committing. The raises were then already durable and a re-run found nothing to raise, so it wrote no replacement entries and the break was permanent. The one exception is the scan-start entry, written in its own transaction beforehand: if that is the unhashed one, it stays committed and unchained, because it records a read that did happen, and the re-run writes a fresh one under a new correlation id.

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 — which does not count a retired (archived or expired) KB page (PLT-806).

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.

Since PLT-1716 the walk and the resolution have no unaudited form of their own: the 3-argument is_page_in_ancestor_chain and the 2-argument resolve_nomination_policy were dropped after their logic was copied into the audited callers. The audited walk answers a chain past the cap as no answer (a NULL hit), the folder-run constructor's membership walk raises for it, and the resolver answers exclude.

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 (migrations 058, 078a, 102, 20261005132345, 20261016073436, 20261016124920, 20261105100447 and 20261105132915). Since PLT-1716 this predicate is no longer a function of its own: migration 20261105132915 copied it into its two audited callers, wiki.ingestion_candidate_is_suppressed (fallback arm off) and wiki.page_has_live_kb_ingest (fallback arm on), and dropped wiki.ingest_batch_is_live_kb_ingest_core, so no role can ask it without the caller's audit entry. Per batch, it answers whether that batch is a live knowledge-base ingest whose origin is this page: committed, un-reverted, qualified — its destination carried the knowledge-base marker when the batch was written (kb_qualified, PLT-809), the same stored fact the read side's identity relation tests, so unmarking the destination later changes nothing and a batch written before that column (NULL) is not qualified (PLT-1896) — the anchor's own current space still a live knowledge base, and the source anchor live, un-retracted and still served — not archived, and not provably past its expires_at (PLT-1028). The anchor is the page the batch recorded in anchor_page_id when it was written (PLT-805), never a page's mutable page_type, and a batch written before that marker existed has no recorded anchor and is not live. Its callers are SECURITY DEFINERs because the anchor page is read past its clearance-scoped policy — 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.

The approval boundary asks it through one audited call (PLT-1557, migration 102). wiki.page_has_live_kb_ingest(tenant, page, actorType, entryId) asks the whole question — does any live knowledge-base ingest name this page as its origin? — in one call, and writes the constitution §5 entry through audit.write_entry before the shared core reads the anchor: one verdict-free entry per call, filed at the page's tier floored at RESTRICTED, whatever the page's batch count, and no outbox row. It refuses a page the caller cannot read with 42501 instead of answering false. The per-batch entrypoint the approval boundary used before it, wiki.ingest_batch_is_live_kb_ingest_for_page_resolved, wrote no entry; it stayed granted for one release for the outgoing build, and migration 20261005132345 (PLT-1561) dropped it together with the deprecated 4-argument wiki.ingest_batch_is_live_kb_ingest_for_page, so the runtime role has no path to the anchor read that does not write its entry first. The core's owner and the migration applier can still call it directly, and that call writes none; PLT-1716 owns that residual.

One space can count as a knowledge base without carrying the marker, and the function resolves that itself — from the database, not from its caller. 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 predicate takes no slug and no mode (PLT-1033): the fallback space's slug comes from wiki.kb_fallback_slug(), an owner-owned IMMUTABLE function no runtime write can reach and that an operator changes by migration, and whether the fallback is still honoured is read inside the function from the one-way latch. Migration 058 took that slug as an argument, which let any holder of EXECUTE decide from outside what the predicate means by "live KB ingest" — confined to its own tenant, inert once that tenant's latch had closed, and one unmarked space per call, though a caller free to vary the argument could name a different one on the next call.

The two boundaries differ by the mode the shared body is asked in, and that is not a caller's to choose either. The core is granted to no runtime role; wiki.page_has_live_kb_ingest asks it with the fallback arm on, and wiki.ingestion_candidate_is_suppressed asks it with the arm off, so nomination never sees the fallback space — on the destination side it reads the write-time qualification alone since PLT-1896, which is a change from migration 049 for a batch written before that record (no longer a hold) and for a qualified batch whose destination was unmarked later (still a hold while its anchor sits in a live knowledge base). On the destination side the arm is the only thing besides kb_qualified that can answer: an ingest admitted into the hinted space stamps false, so the approval boundary still sees it through the arm while the latch is open, and stops seeing it once reconciliation closes the latch — as the read side never counted it (SPEC-plt-1896). Those two are its only callers, and each writes its own §5 entry before calling it; the core writes none itself, because it answers per batch inside an EXISTS and an entry there would follow the query plan. Migration 20261005132345 re-derives the core's grants from those two callers and refuses to apply if the runtime role can reach it by any route.

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. PLT-875 phase 1 (2026-09-02) was that release; PLT-1271 is the later one in which these same writers renormalise.

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. Since PLT-2104 the two cascades that trash pages without going through the page API take it too: a space decommission locks every partition the space's live pages sit in, and a source retraction locks the partition of the source page it trashes — both before their first row lock.

Since PLT-1579 the database enforces the lock for additions and rank writes, and since PLT-2109 for removals too. A statement that adds a page to a partition's live set — an INSERT, a restore from the trash, or a move into the partition — or takes a live page out of one — trashing it, or the source side of a move — and any write to wiki.page_ranks fails with SQLSTATE 55000 unless its transaction holds the :page-siblings lock of the partition concerned. A move therefore needs both partitions' locks. Removals were enforced one release after every removal path started taking the lock (PLT-2104), so the outgoing build was never refused. A hard DELETE is not checked; no runtime path hard-deletes a page. A session whose role is superuser or BYPASSRLS — migrations, seeds, operator repairs — is exempt. Every application writer already takes the lock, so this changes nothing for a request; it is what turns the lock from a convention into a constraint for any future or hand-written SQL writer. Directory's partner provisioning script takes the root partition's lock before it creates the specs folder.

What this changes for an operator is contention, and nothing else. No request or response shape changes. 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).

Since PLT-1271 these writers also hold row locks on the siblings they reorder. Creating, moving or restoring a page locks the visible pages it shares a parent with, from the start of the write to its commit; an ingest locks every visible page at the root of its space (and under its index hub) for the whole ingest. An ordinary edit of one of those pages waits for that write to finish. The locks are taken before any audit entry is written, which is what keeps them from deadlocking against an edit that is itself waiting to audit.

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 (the audited overload, migration 119; migration 048's original form was dropped by 123). 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 (a retained definer result the ingest no longer reads: since PLT-1418 the constitution §5 entry is filed on every invocation, whatever this reports); 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.

The source page's name (PLT-727)​

An ingest creates its source page at slug src-<random UUID> — a fresh UUID that is neither the batch id nor the page id. Until PLT-727 it was src-<batchId>, and because the page is pinned UNCLASSIFIED and stays readable when the batch's ceiling rises (a sibling page, or the origin, reclassified upward), anyone holding a batch id could confirm the batch existed by resolving that slug, searching for it, listing source pages, or reading the log line and index spokes that quote it. For every ingest since, naming nothing after the batch closes all of those at once, with no per-read predicate on any page surface. For a page created before it, the repair below closes the slug itself — resolving, searching or listing by the batch id no longer finds the page — and text already written that quotes the old slug is rewritten by the redaction described at the end of this section. The log line keeps its source: src-<uuid>, summary: … shape, so older and newer entries parse alike.

Two page-addressed reads named the batch without going through the slug, and neither does any more:

  • GET /api/pages/:id/revisions/:n returns exactly the fields the OpenAPI revision envelope declares. It used to serialise the stored row whole, ingestBatchId included, which put the batch id on revision 1 of every page an ingest wrote. The stamp itself is untouched — the revert still reads it — it is simply not on the wire.
  • A revert's compensating revisions carry the constant edit summary Revert ingest batch. They used to append the batch id, and the revision list returns edit summaries.

Existing source pages are renamed by an operator repair, not a migration, because the outgoing build keeps creating src-<batchId> pages until the deploy completes. npm run db:repair-source-page-slugs -w @constellation/wiki -- --tenant <uuid> (privileged DIRECT_URL, one tenant per call, registered as a post-deploy cutover repair) renames every page whose revision 1 is stamped by an ingest batch and whose slug is still src- plus that batch's id (or its first 8 hex digits, the form the very first ingest build used) — the append-only creation stamp, not the mutable page type or title, decides which pages qualify. Trashed pages are included; retracted tombstones are not, because their slug is part of the evidence that the retraction happened and no reader can reach it. Every pass files a SECRET wiki.page.source_slug_scan_started entry before it reads, and each rename a wiki.page.source_slug_repaired entry at the page's own tier that records the transition only — never a slug, since the old one is the batch id. The run proves convergence with a second pass and prints the spaces it touched: rebuild each one's index so its spokes stop quoting the old slugs. Nothing resolves a batch from the slug — provenance, KB status, candidate approval and revert all join revision 1's stamp, and derived_from edges are keyed on page ids — so a renamed batch stays revertible and its edges keep resolving. Links to a renamed page by its old slug from outside the wiki (a PR body, a pasted citation) stop resolving; that is the rename working.

Text written before PLT-727 is redacted by a second operator command (PLT-1589). Until it runs for a tenant, the current body of every space log that recorded an ingest still reads source: src-<batchId> and is searchable, and so do spoke bodies until their space's next rebuild, any page that cites a source by its old slug, every earlier revision of those pages, and the edit summary of every compensating revision a past revert wrote. npm run db:redact-legacy-batch-ids -w @constellation/wiki -- --tenant <uuid> reports them, one tenant per call under the privileged DIRECT_URL: per page, the tokens in the current body, the revisions it would rewrite and the legacy revert summaries. It writes nothing but a SECRET wiki.page.legacy_batch_id_scan_started entry. With --apply it rewrites exactly those tokens, in current bodies and in the revision rows themselves:

  • src-<batchId>, or its first 8 hex digits, becomes the batch's source page's current slug. When the batch has no single such page — it was reverted, its source was retracted, or the page is gone — the token becomes the fixed placeholder src-00000000-0000-0000-0000-000000000000, which keeps the shape the log parser recognises and names nothing.
  • an edit summary that is exactly Revert ingest batch <batchId> becomes Revert ingest batch.

A src-<uuid> that is not one of the tenant's batch ids is left alone: that is what every source page has been called since PLT-727. A batch id anywhere else in those texts (a bare id in prose, an id inside another page's slug) is reported and not rewritten; it needs a person.

Revisions are append-only, and the redaction adds no exception to that rule. For each page, in one transaction, the role that owns wiki.page_revisions locks the table against other writers, switches the update trigger off, rewrites that page's rows, and switches it back on before the transaction writes anything else. No other session can write a revision while it is off, and none ever sees it off. So --apply needs a credential that owns the table, and revision writes in every tenant wait for the length of one page's rewrite: run it when the wiki is quiet. No revision is appended, because a new revision would keep the old text as history. The rewrite is attributed to the tenant's seeded wiki agent, so a tenant without one has its --apply refused. A page whose current body changed advances its version, names that agent in last_modified_by, and publishes wiki.page.updated (a spoke stays silent, as always, and so does a page in the trash); each redacted page files one wiki.page.legacy_batch_ids_redacted entry at its own tier that records counts and never an id or a slug.

It runs after db:repair-source-page-slugs and refuses while a source page is still named after its batch, since that page has no other slug to quote yet. A page whose row is locked by a concurrent write is skipped and the run exits 1; re-run it. After the pass it scans again and fails unless no token is left. The revision routes already withhold the pre-existing revisions of the hub, the spokes and the log (PLT-1569); the redaction removes the text from the rows as well, and covers what that rule does not: current bodies, ordinary pages' history, and edit summaries.

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, origin, 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. What R7 does verify is the caller — since PLT-1152 the ingest was run by a HUMAN principal — so author on a batch written after that change names a human; approver still proves nothing about who approved.

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 creates its source page with the title Source: <sourceRef>, pinned UNCLASSIFIED (and, before PLT-727, at slug src-<batchId>), 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 titled after the batch's source: 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 page-name half of the problem is fixed upstream (The source page's name); this gate is about the batch's association with the page, which no naming change removes, so it stays.

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.

origin is the structured pointer beside the free-text sourceRef (PLT-715). It is the batch's origin columns (PLT-687) resolved to { pageId, slug, title, spaceId, revisionNum }: the page the ingest was read from, named by its current slug, title and space, and the revision it copied. sourceRef is unchanged — it is the label the ingester wrote. origin is null when the batch recorded no origin (pasted text, or a batch written before PLT-687, which was not backfilled), when the origin page has been soft-deleted, or when the reader's clearance hides that page or the pinned revision; the cases are not distinguished. It is resolved after the ceiling gate has selected and admitted the batch, so it neither changes which batch is reported nor survives a gated one: a reader who gets ingest: null learns nothing about the origin either.

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 and summary. The summary is part of the restore since PLT-1599: a summary-only change writes no revision, so the batch's own revision records the summary it replaced. A batch committed before then recorded none, and a revert leaves its summary as it is, because the pre-ingest value cannot be recovered and clearing it would destroy a summary the batch may never have changed. A summary edited after the ingest is overwritten by the restore, as the body is.
  • 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'.

A batch of a folder run is revertable only once the run has settled (PLT-1120). While its run is pending or running the revert answers 409 and changes nothing. A recovered worker re-walks the run's manifest and counts a source as already_committed whenever the run holds a batch for it, reverted or not — so a batch reverted mid-flight would let the run settle completed with that source's pages gone, and ingest_batches_folder_run_source_uk would stop the source from ever being ingested again through that run. Once the run is completed, failed or reverted the batch reverts as any other. To undo a run that is still in flight, revert the whole run instead: that also stops it (PLT-1055). The refusal names no run, as the success response withholds folderRunId.

Finding a run that settled before PLT-1120 shipped (PLT-1622). The refusal prevents the state; it does not find a run already in it. A read-only scan does, one tenant per run:

npm run db:audit-folder-run-revert-race -w @constellation/wiki -- --tenant <uuid> --dry-run
npm run db:audit-folder-run-revert-race -w @constellation/wiki -- --tenant <uuid>

It lists every completed run holding a batch whose reverted_at precedes the run's settle. wiki.folder_runs stores no settle time, so the scan reads it from the run's ingest.folder_run.completed audit entry, which the worker writes in the same transaction as the transition. Both timestamps are the start of their transaction, not its commit, so near the settle they do not order the two commits in either direction: a batch reverted within 60 s on either side of the settle (WIKI_REVERT_RACE_SETTLE_WINDOW_SECONDS widens it) is reported unverified — never cleared, never claimed. Only a revert more than the window before the settle is confirmed. A run with no settle entry is judged the same way against updated_at, which the settle stamped and later writes only move later. A run still pending or running that already holds a reverted batch is reported in flight: it would settle into the same state. It changes nothing in wiki.*; a real run writes only the constitution §1 audit pair (READ / READ_RESULT on wiki_folder_run_revert_race, SECRET), each with the audit.entry.created outbox event and the chain bookkeeping auditCritical() does beside it. Both entries are verdict-free — they record that the scan ran, never what it found, because audit.entry.created is delivered without clearance filtering; the counts are in the terminal report only. --dry-run connects and checks the role, reads no tenant row and writes nothing: it prints the target, the SQL and the entries a real run would write. It needs an RLS-bypassing role, because the runtime role would read no run and no audit entry and report a clean tenant. Exit codes: 0 clean, 1 confirmed, unverified or in-flight findings to triage, 2 the run did not complete. Remediation of a finding (for example a re-run as a new execution, migration 044) is decided per finding and recorded on PLT-1622.

Slug reclaim (PLT-300). The slug unique constraint pages_tenant_space_parent_slug_classification_uk has no WHERE deleted_at IS NULL predicate (intentional — the per-classification key keeps a trashed page's slug reserved within its own classification, PLT-818), so an ordinary soft-deleted page keeps its slug reserved at its tier. 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 of the same classification (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 the coverage repair (db:repair-ingest-coverage, per tenant since PLT-922) to convergence after that deploy was 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.

Time budget (PLT-1477). The route runs under a 60 s maxDuration, and every transaction it opens is bounded with all three of its terms — the connection wait, the transaction timeout and the per-statement statement_timeout — so the transaction, not the platform, is what gives out first. In order: the principal-kind lookup (1 + 2 + 1 s), the revert itself (2 + 42 + 5 s), the one audit transaction a rolled-back revert opens afterwards (2 s in all), and 4 s for the response, with 1 s never spent. Until PLT-1477 the revert had a 50 s timeout with an uncounted 50 s statement cap. Any single statement — including the wait for the tenant's slug-namespace lock behind a concurrent ingest — is now capped at 5 s, and a revert that exceeds either bound fails and rolls back whole, answered by the route itself (a generic 500, not a typed timeout error) rather than cut off by the platform, so it is safe to retry; it is never partially applied. The 42 s was checked against the costliest batch there can be: reverting one batch with the schema-maximum 50-page fan-out, navigation repair included, measured 494 database round trips, about 13.3 s at a pessimistic 25 ms each.

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.

The completeness check is an audited read, whatever it answers (PLT-1300). The check that refuses the revert asks wiki.batch_has_live_unreverted_page whether any page the batch touched is still live un-reverted. It reads past your clearance — that is how it finds the page you could not revert — so constitution §5 requires an audit entry. Every revert that reaches the check files one wiki.ingest_batch.completeness_check_applied_outside_clearance_scope entry, naming the batch and filed at its classification ceiling (never below RESTRICTED). It carries nothing the check found. Since PLT-1540 the check writes that entry itself, in the database, before it reads, so no caller can skip the record. The older 2-argument form, which recorded nothing itself and was kept for one release so a build still running during a deploy kept working, was removed by PLT-1720. When the check refuses, the revert rolls back and the entry is rewritten in a fresh transaction, so a refused revert and a completed one leave the same record. A revert refused earlier — the batch absent or above your clearance — never reaches the check and files no such entry. The check runs last, after the revert's other work, so a revert that fails for another reason first files nothing for it.

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.

Every status but reverted is accepted (PLT-1055). A pending or running run is reverted and stopped: the revert flips it to reverted before its first batch, and the worker's per-source fence admits a commit only into a running run its own attempt owns, so nothing the worker does afterwards can land in it. The revert holds the tenant and space locks a source transaction takes before it ingests, so every batch committed before the revert is reverted with it. A failed run is reverted the same way. The group entry records the status the run was reverted from as context.previousStatus. An already-reverted run is refused: run membership is immutable, so "un-revert within the same run" is deliberately not representable. That refusal answers 409 naming the state, which discloses nothing past the gate the caller has already cleared.

One completeness-check entry per batch, and every batch is checked (PLT-1540). Once every batch is reverted, the run checks each of them with the completeness check above, and only then refuses if any of them left a page un-reverted. So the run files one wiki.ingest_batch.completeness_check_applied_outside_clearance_scope entry per batch it reverted, each naming its batch at that batch's classification ceiling (never below RESTRICTED), whatever any of them answers. Stopping at the first refusal would make the number of entries count the batches checked before it, and so point at the batch that held the hidden page. A refused run's entries are rewritten after the rollback like the single revert's. Before PLT-1540 the run stopped at the first refusal and filed one entry naming the run instead.

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 also records previousStatus, the status the run was reverted from, so a revert that stopped a run still in flight reads differently from one that undid a finished run (PLT-1055). 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 (50 since PLT-1056; 200 before) 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 70 s (mirrored in vercel.json ahead of the broad src/app/api/** 10 s entry) and the transaction budget is derived beneath it, counting every transaction the route opens with all three of its terms (PLT-1477): the principal-kind lookup (1 + 2 + 1 s), the revert (2 + 46 + 5 s), the completeness-check replay and the abort audit after a rollback (4 s each), and 4 s for the response, with 1 s never spent. So the transaction expires before the function does: the route answers the failure itself (a generic 500, not a typed timeout error) rather than the platform cutting it off with a 504, and the rollback makes a retry safe. Any single statement, including the wait for the namespace and space locks behind a concurrent ingest, is capped at 5 s. That raises the ceiling; the cap is what makes a maximal run fit inside it. PLT-1056 measured the revert at about 34 database round trips per batch, linear in the batch count, and derived the cap from this 46 s budget at a pessimistic 25 ms a round trip: 50 batches project to about 44 s, where the provisional 200 projected to about 180 s. Until PLT-1477 the route was 60 s and the same 46 s left the statement cap and the lookup uncounted; counting them inside 60 s would have cut the budget below that projection, so the function grew instead. A run that still overruns — a slower database than the one assumed — fails by rolling back, never partially.

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.

Where an operator sees a run's history and reverts it (PLT-1165)​

The folder page's Section settings sheet carries a Run history block directly beneath the bulk-ingest block — the action first, its record second. It is a disclosure, closed by default, and it reads nothing until it is opened. Unlike the preview above that is a cost decision rather than an audit one: this listing files no clearance-crossing entry, but a folder page has no reason to pay for a ledger nobody asked to see.

Opened, it lists the runs on a table — when the run started, its status, how many of its batches committed out of the total, and the pages it touched. jobId is not shown: it names a queue row, and the status the reader needs is already in the row beside it. The empty state states what is true of the reader, never that the section has never been bulk-ingested, because the listing's gate makes those two indistinguishable on purpose — it says there are no runs visible to you, and that runs above your clearance are not counted either.

Revert is offered on every run that is not already reverted, and only to a caller holding the ingest capability — the same two conditions the endpoint enforces, so the control is not a cosmetic guard but an affordance that would otherwise only produce a 409 or a 403. A run that committed no batch still offers it: the server's gate reads the status and nothing else, and refusing client-side would strand such a run in its status forever. For a pending or running run the confirmation adds that reverting also stops the run, and that the sources it has not reached will not be ingested.

The confirmation names the run by when it started and bounds what its page count claims: the figure counts pages touched, a page touched by more than one batch counting once per batch, and it says the run stays in the history marked reverted rather than disappearing. A landed revert is announced through the Toaster, a refresh of the listing is scheduled for whatever surface survives, and only then are the client's page caches cleared — in that order, because a clear that took the section card away would leave an outcome announced after it announced to nobody. On a section already shown to the reader the card stays mounted through the clear, whether it is a declared index or a folder recognised by its children, so the listing re-reads in place, the run shows as reverted, and keyboard focus moves to the history region rather than falling to the top of the page when the row's Revert control disappears. A failed revert is reported in a banner beside the history; if the reader has changed principal by the time it settles, it is reported through the Toaster instead and names no run, since that message can outlive the tenant it was about.

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 } — an approval label is required, and the caller must be a HUMAN principal under the same R7 gate as ingest) 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 and the reason, and no depth: since PLT-1513 the distance from the source is withheld, because it was measured across pages above the reader's clearance and could reveal a hidden page between a visible one and the source; 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 — every one of them, hidden derivers included, so the entry is classified at the highest tier among the source, the pages it names and the far endpoints of the links it counts (never below RESTRICTED; PLT-1459).

The response names only what you may read (PLT-1459). The walk and the sweep reach pages above your clearance, and those pages are still flagged — but the 200 response's affectedPages lists only derivers your clearance admits, and deletedLinkCount counts only swept links whose other end it admits. Nothing stands in for what was left out: no placeholder row and no hidden count, the same rule as GET /api/ingest-batches. A caller cleared for the whole closure sees the whole closure.

No affected page carries a depth (PLT-1513). Each entry of affectedPages has depth: null, for every caller, and the list is ordered by page id. The distance from the source was a minimum over the whole closure, hidden pages included, so a visible page reached only through a hidden one could reveal that page; the same value was persisted in the page's lost_source finding, so it is withheld there too, and a CHECK on wiki.lint_findings (lint_findings_lost_source_no_depth) refuses any lost_source finding that carries one. The finding itself still appears on every affected page you may read: that it depends on the retracted source, possibly through pages you cannot see, is the warning a retraction exists to give.

Every retraction writes an audited read, whatever the closure finds (PLT-1309, PLT-1458). The walk, the sole-provenance check and the edge sweep read wiki.page_links past your clearance — that is how a hidden deriver is still flagged. So every call into the retraction cascade files one wiki.source.retraction_link_sweep_applied_outside_clearance_scope entry naming the source, separate from source.retracted, and publishes its audit.entry.created event. The cascade function writes it itself (migration 098), so a direct SQL call of that signature gets the entry too, not only the API; a call it refuses up front (a missing or mismatched tenant, no source page named, no actor or correlation id, or an actor type other than USER, SYSTEM or AGENT) reads and writes nothing. The previous signature, which wrote no entry itself, was kept for one release for the outgoing build and is now dropped (migration 101, PLT-1548), so the audited signature is the only one. It carries no affected page, no link count and no result, and it is always filed at SECRET, because the source's own tier is not readable before the call. It is written identically whether or not a link reaches a page you cannot see, and whether the retraction commits or rolls back afterwards (a concurrent move's 409, a failed index rebuild): on a rollback the API writes the same entry, under the same id, in a fresh transaction, while source.retracted is not written (if that second transaction fails too, the failure is logged and the entry is lost — the replay helper's documented residual). A request refused before the cascade is called — the permission gate, the probe's 400/404/409 — files none.

A retraction locks the rows its index rebuilds write, for every space it can find in advance, before its audited read takes the audit chain head (PLT-1550). The cascade's entry takes the tenant's audit chain head and holds it to commit, and the retraction then rebuilds the index of every affected space and appends its log entry. So before the cascade runs it finds the spaces it will rebuild — the source's, and those of every page whose derived_from chain reaches the source, read at the UNCLASSIFIED pin its index and log writes run under, whatever your clearance — and locks their hub, spoke, log and partition rows and their space rows. A page star, a space-move eligibility check or, with KB-write enforcement switched off, a page edit on one of those pages, or a default-classification change of one of those spaces, then waits for the retraction, or the retraction waits for it, before anyone holds the chain head, so neither side is aborted with a deadlock and no audit entry is committed outside the hash chain. One window remains: a space reached only through a page hidden at that pin — every space but the source's when the source itself is classified above UNCLASSIFIED — or by a derived_from link added with enforcement switched off while the retraction runs, is still rebuilt, but its rows are locked after the chain head. See .ai/specs/SPEC-plt-1550-retraction-chain-head-lock-order.md.

Retraction is idempotent at the source level: retracting an already-retracted source returns 409; a page that is not an ingest batch's recorded source anchor returns 400. Since PLT-805 the anchor is recorded on the batch itself (wiki.ingest_batches.anchor_page_id, written once by the ingest that created the page and refused by the database for any other page or any later change), so changing a page's type neither makes it retractable nor stops it being retractable. Two consequences: a caller who can read the source page but whose clearance does not cover its batch's classification ceiling gets the same 400, because eligibility is read off the batch row (a caller who cannot read the page at all still gets 404, below); and a source page ingested before PLT-805 carries no recorded anchor, so it is no longer retractable through this endpoint, and its content cannot be destroyed at rest until its anchor is reconciled — revert_ingest_batch still soft-deletes it, which is restorable and erases nothing. The reconciliation is an operator command (PLT-1894): npm run db:reconcile-ingest-anchors -w @constellation/wiki -- --tenant <uuid> (privileged DIRECT_URL, one tenant per call, registered as a post-deploy repair) proves each anchor-unknown batch's anchor from the commit's own ingest.batch.committed audit entry — read through the platform audit reader, never from the page's current type — and lists what it would record and every batch it cannot prove (no commit entry, an unhashed or malformed one, entries that disagree, or a named page the database would refuse: missing, in another space, not written by the batch, trashed or retracted, not a source page, or already another batch's anchor). It writes nothing but a SECRET wiki.ingest_batch.anchor_scan_started entry unless --apply is passed; then it records each proven anchor through the same write-once check, files one wiki.ingest_batch.anchor_reconciled entry per batch at the batch's tier, and proves convergence with a second pass. A reconciled batch's source is retractable again and its origin counts as identity coverage again; an unprovable batch stays anchor-unknown. See .ai/specs/SPEC-plt-1894-reconcile-ingest-batch-anchors.md. 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, and so does a NUL character in any free-text field, which a PostgreSQL text column cannot store (PLT-895). 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 the caller's clearance admits (PLT-1459); 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. A signed URL opens PNG, JPEG, GIF, WebP and PDF files in the browser, and every other file type, SVG and plain text included, downloads when opened (PLT-356); an image embedded with <img> displays either way.

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, or on a filename — the filename field, else the file part's own name — that is empty, over 300 characters or contains a NUL; 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.

Every attachment the API returns — the GET /api/attachments/:id metadata, the upload response and each row of GET /api/pages/:id/attachments — carries tenantId, the owning tenant (PLT-694). It is a pinned part of the published Attachment contract (AttachmentWireSchema), not an incidental column: the Agents zone resolves a capability-gap resourceRef of kind wiki.attachment by comparing it with the report's tenant, and npm run check:resource-kinds plus a route-level projection test fail if a later change narrows it away. It discloses nothing new — a caller outside that tenant receives a 404, never the row.

GET /api/pages/:id/attachments answers a page the caller cannot see, and a visible page with nothing attached, as every page sub-resource does — see Reading a page's sub-resources.

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 History — a sheet opened from the page's More menu or from Details (PLT-2042) — 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. Each row carries an optional editedByAttribution (PLT-891): editedBy resolved for display, folded with that revision's own actorType, and absent when the resolution could not be read — the History sheet then shows the raw id. A revision of the space index still names the person who triggered it; its origin is what says a machine composed the body.
  • 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.

Since PLT-767 the wiki's audit entries follow the same two steps, so a write's revision and its audit entry carry the same actor_type. Before that, an audit entry written through an API key recorded USER. Entries already stored were not rewritten.

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 composed a revision (PLT-1104)​

wiki.page_revisions.origin records what composed each revision's body, which is a different question from who started the write. The revision list and single-revision endpoints project it as origin:

  • SPACE_INDEX — the space index built the body: the index hub, its spokes and the log, on every write the index service makes to them (create, rewrite, spoke emptying, log append, log withdrawal);
  • OTHER — any other write: an edit in the editor or through the API, an ingest, a revert, a remediation, an archival;
  • null — unknown. Every revision written before the column existed carries it, and there is no backfill.

actor_type (above) cannot answer this. A person pressing Rebuild index in a browser records actorType: "USER" on bodies the index service composed in full; that revision now also records origin: "SPACE_INDEX". The two columns are meant to be read together.

Four rules hold the value honest:

  • It is never caller-supplied. No request body, MCP input or tool argument can set it; an origin field in a create or update request is ignored. The server stamps SPACE_INDEX only when the write carries the index service's reserved-page capability for that very transaction and the page is a reserved navigation page (index, index_spoke, log). A person editing the hub by hand records OTHER.
  • It names the composer, not the author of every sentence. A log entry's action, title and detail are text the caller supplied, so SPACE_INDEX on a log revision means the index service assembled the body, not that a machine wrote each line.
  • null never means either value. Render it neutrally. Deriving it from is_agent_owned or the page type was refused for the reason actor_type refuses it: a wrong provenance that looks right is worse than an absent one.
  • A retraction keeps it. The scrub destroys the body and preserves the origin, and the append-only trigger's retraction allowlist pins the column so no scrub can rewrite it.

A revert's compensating revision records OTHER: the revert is a fresh write by whoever ran it, even when it restores a body the index composed. Since PLT-1758 the page attribution reads that origin: a system-maintained page whose latest revision records SPACE_INDEX says Assembled by the space index, and one whose latest revision records OTHER or null keeps the PLT-1094 System-maintained label. The History sheet marks each SPACE_INDEX revision with a Space index chip and draws nothing for OTHER or null.

What the History sheet shows (PLT-537)​

History opens as a sheet: from the right on a tablet or desktop, from the bottom on a phone. It never narrows the article. The sheet 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 sheet, because a successful restore closes the sheet.

The diff is side by side where the sheet is wide enough, and stacked below 35rem of sheet width. Either way it 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​

From 1280px 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 1280px the rail is not rendered; the same outline opens as a Contents sheet from the page's action bar, and choosing an entry closes the sheet and scrolls to the heading (PLT-2042).

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 and no Contents button; 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 this page; above it, Only people in {tenant} with {access level} access or above can view this page, naming the tier by its label. (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 organisation 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, PLT-2039)​

/wiki opens on a greeting rather than on a heading naming the list beneath it, so the surface has one title in every state instead of one for a tenant with content and another for an empty one.

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. Beside the greeting is a New page button.

Below it is a compact view of the one pages list (see Capabilities), behind a segmented switcher with three views: Recent is the most recently updated pages in the active space; Mine is the pages you own — the steward a page can be reassigned to at any time, not whoever created it; Starred is the pages you starred. All three are ordered by last update, newest first, are scoped to the active space and re-scope when you switch space, and hide the wiki's own navigation pages and archived pages with no opt-in, because the home offers views rather than filters. Each lists at most 20 pages, without paging, as the same rows /pages draws — title, owner (left out of Mine, where it is always you), status and when the page was last updated — with the same ⋯ row menu. View all opens the full list at the matching view and keeps the active space as its space filter, so it continues the feed rather than widening to every space without saying so: /pages?space=<id> from Recent, /pages?view=mine&space=<id> from Mine and /pages?view=starred&space=<id> from Starred.

The selected view is in the URL, as ?tab=recent, ?tab=foryou (Mine — the id predates the label and is kept so older links still work) or ?tab=starred. An absent or unrecognised value opens Recent, and switching view adds a history entry, so the browser Back button returns to the one before.

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

  • While loading — placeholder rows shaped like the list, not a spinner.
  • Empty — a titled empty state whose wording is the view's own, with a Create page action: an empty Recent means the space has no pages, an empty Mine that you own none here, an empty Starred that you starred none here.
  • Failed — the failure is reported in place of the list, with a Try again that re-issues the request. The greeting and New page stay usable, so a transient outage on this one read does not cost the whole surface.

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 view 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. A page changed in another tab of the same browser is dropped from that cache in every other open tab as soon as the browser delivers the notice, so an already-open menu re-reads its title rather than keeping the old one (PLT-889). That delivery is best-effort and carries no guaranteed latency; a change made in another browser profile or on another device, or one that lands while a tab is frozen in the background, can still keep its old title for up to the 30 seconds, until the next read.

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.

Following a link inside a page — a result on the home feed, a wikilink in a page body, the Edit link in a page's header — replaces the content the link was in. Keyboard focus moves to the start of the content region, the same place Skip to main content leads, so the next Tab continues into the new page instead of starting again from the top of the window. This holds while a loading placeholder is shown and after the page has arrived.

Focus is moved only when nothing else holds it. A link in the left rail stays focused after it opens a page, so you can go on to the next row, and a destination that puts focus in a field of its own, such as the new-page dialog's title, keeps it there.

Page titles and navigation announcements (PLT-1640)​

Every wiki route names its destination in the browser tab, as Name | Constellation Wiki: Pages, Knowledge base, Archived, Deleted, Search, Insights, Review, New page, Spaces, and Page not found or Something went wrong where those states are shown. The wiki home keeps the plain Constellation Wiki. A search's query is not put in the title, so typed text never reaches the tab title (the search page's address still carries it).

A page is named by its own title, and its editor by Edit: title, once the page has loaded for you. Until then the tab reads Page or Edit page, and it never shows a title you are not cleared to read: the name comes from the page the wiki shows you, which the server has already filtered by your clearance.

A screen reader announces each client-side navigation by the new title. When a page's title arrives after the navigation — a page that was not already loaded — the wiki announces it once more when it lands, so following a link in the page tree, which keeps focus on the link, says which page opened. Nothing extra is announced on the first load of the wiki, where the screen reader reads the page itself.

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 so does pressing the armed row's own control again. The tree's status region announces the armed state, and (PLT-984) the two moments either side of it: a cancellation — "Move cancelled", naming the page and saying it stays where it was, so the key press is not answered by silence — and a move being saved, naming the page and where it is going, from the moment the request is sent until its result, while every move control is held. That last sentence clears when the move settles either way, and the toast reports the outcome. A destination that turns out to move nothing is said too (PLT-1218): "Move to top level" on a page that already shows there because its parent is archived names the page and says it was not moved and why, and so does a destination chosen after a reload has dropped the armed page from the tree. No toast accompanies either, since nothing changed. Ending a drag says nothing: the drag was watched, and its end also follows a successful drop.

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. A refusal that arrives after you have moved on — followed a link, switched spaces, or gone somewhere and come back — is not put back into the tree at all: the reload alone settles it, because the position the page came from belongs to a view you have already left. 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.

The cycle walk is audited (PLT-1544). It reads past the caller's clearance, so each walk made by a page PATCH, a tree move or a folder run's membership check files a verdict-free constitution §5 entry, wiki.page.ancestor_walk_applied_outside_clearance_scope. (wiki.create_folder_run also walks through the function internally; that constructor is covered by its own entry, wiki.folder_run.constructor_check_applied_outside_clearance_scope, one per attempt.) The entry names the page the walk starts from (the new parent, or a folder run's source page) and is filed at that page's tier, floored at RESTRICTED. It never records the answer, and it is written on a refused move as well as on an accepted one. A move under a page that sits at the root of its space needs no walk, because the answer is already visible to the caller, so it files nothing. Every walk the application makes goes through a 5-argument overload of the function (migration 117), which writes the entry itself through audit.write_entry: the folder-run membership check since PLT-1544, and the page PATCH and the tree's move since PLT-1605. The constructor above is the one exception: it calls the older form inside its own audited function. That entry takes the tenant's audit chain lock and holds it to commit, so the PATCH and the move take the row locks their write needs before it, with two exceptions that already sat behind an earlier entry: the KB-candidate finding a policy change retires, and the destination space of a move to another space that does not raise the classification. They lock the new parent in the same pass that locks the moved page and its siblings, one ascending order by page id. A PATCH that also raises the classification locks the navigation pages and space rows its index rebuild will write. A refused move rolls back the function's entry with everything else, and the tool writes a copy of it afterwards, so a refusal is still recorded. The older 3-argument form records nothing on its own, so since PLT-1713 the runtime role cannot execute it: only its owner can, which is how the 5-argument overload and the constructor call it.

Reading a page's ancestors (GET /api/pages/:id/ancestors, PLT-786)​

A page read carries parentId but no ancestor chain. GET /api/pages/:id/ancestors returns the chain in one request, with ids and titles only:

{
"data": {
"page": { "id": "…", "title": "Weld procedure" },
"ancestors": [
{ "id": "…", "title": "Engineering" },
{ "id": "…", "title": "Decisions" }
],
"truncated": false
}
}

ancestors is ordered root first and does not include the page itself, which is returned separately as page. The page breadcrumb and the editor's placement preview both read it; before it existed, each climbed parentId with one full page read per level.

The chain is relative to the caller, in the same way parentId is. It ends at the first ancestor the caller's clearance hides, and nothing at or above that page is returned — not its id, not its title, and no indication that it exists. An ancestor in the trash ends the chain the same way. A page the caller cannot read answers 404, identically to a page that does not exist.

truncated reports the read's own limits, and nothing else. The walk returns at most 50 ancestors; truncated is true when a visible parent lay beyond that, or when the walk met a page it had already visited. It is false when the chain ended because no further parent was visible — so a chain that stops at a hidden ancestor is indistinguishable from one that stops at a real root. Treat truncated: false as "complete within the hierarchy you may see". A consumer that only navigates can show a truncated chain; one that states where a page lives, as the placement preview does, must not present it as the whole location.

The route requires an authenticated tenant member and nothing more: visibility follows the page. It is an ordinary read under the caller's own row-level security, so it writes no audit entry.

Opening a page: one bootstrap read (GET /api/pages/open, PLT-2034)​

Opening a page used to cost the zone seven requests in parallel — the page, its space and ancestor chain for the breadcrumb, its KB status for the chip, the caller's ingest capability for the add-to-KB affordance, the sidebar's Review count and the organisation list — each its own tenant transaction on the Production pool's single connection (ADR-019). They queued, and any that waited past Prisma's 2 s maxWait failed with P2028: a burst of 500s on one navigation.

GET /api/pages/open?id=<uuid> (or ?path=<space-slug>/<page-path…>, the by-path shape; exactly one of the two, else 400) answers the five page-level parts from one tenant transaction:

{
"data": {
"page": {
"…": "the GET /api/pages/:id projection: the viewer page, openFindings, reviewedState"
},
"space": { "…": "the page's space summary, or null" },
"ancestors": { "page": { "id": "…", "title": "…" }, "ancestors": [], "truncated": false },
"kbStatus": {
"status": "none",
"coveringKbPageIds": [],
"excludedBy": null,
"excludedAt": null,
"kbSpaceId": null,
"kbGeneration": "…"
},
"ingestCapability": {
"canIngest": true,
"kbDestinations": [],
"kbTargetSpaceId": null,
"kbGeneration": "…"
},
"widgets": {
"star": { "starred": false },
"findings": { "findings": [], "total": 0 },
"attachments": {
"data": [],
"meta": { "total": 0, "limit": 20, "offset": 0, "canWrite": true }
},
"related": []
}
},
"meta": { "servedScope": { "userId": "…", "activeOrgIds": ["…"], "clearance": "UNCLASSIFIED" } }
}

Each part is exactly what its own route serves — the same reads, the same projections, and a 404 for a page the caller cannot read that is identical to an absent one — so the bootstrap discloses nothing the routes it composes did not. meta.servedScope is the receipt the Recent menu attributes a visit on (PLT-905). Every one of those routes still exists for every other consumer and for every later re-read.

The page widgets ride along (PLT-2036). widgets carries the first read of the four widgets that used to read after the article painted, each exactly what its own route serves for the request the widget makes: the star toggle's GET /api/pages/:id/star, the needs-review banner's GET /api/lint/findings?pageId=…&status=open&limit=200, the attachments block's default page of GET /api/pages/:id/attachments (its data and meta, canWrite included) and the related-pages block's GET /api/pages/:id/related. The banner's rows are all open, so the bootstrap reads them without the closure decoder the listing route uses (an open finding has no closure to decode) and writes no audit entry; a row that is not this page's open finding fails the bootstrap rather than being served. A failure of any widget read fails the page open, since the bundle is one transaction. The section index (its query waits on the tree's sort order) and the coalesced POST /api/pages/signals batch stay reads of their own, so a cold open of a leaf page issues two tenant transactions after the article paints. A section page (its KB rollup and its children's signals) and a page in a knowledge-base space (its links and the space's write state) added reads of their own after those, until PLT-2080.

A section page's and a KB page's dependent reads ride along too (PLT-2080). The bundle also carries sectionIndex, links and signals. sectionIndex is the section index's first window of children (GET /api/pages?parentId=…&spaceIds=…&excludeStatuses=archived&limit=100&projection=summary, data and meta as that route serves them) once per tree sort order — recentlyUpdated, titleAsc, manual — because the order is the reader's own per-space preference, held in the browser, and the LIMIT decides which rows a window holds; when the first window is empty the other two are not read. links is GET /api/pages/:id/links' answer for a page in a marked knowledge-base space and null otherwise. signals is POST /api/pages/signals' answer for every id the page's surfaces ask about — the page itself, every child in any window and every relationship endpoint — with pageIds naming the ids asked, so an id absent from the map is one that route would have omitted. The ids are bounded at 400 in priority order — the page, its relationship endpoints, the default order's window, then the other windows — and an id past the bound is read live by its consumer. The client seeds the children windows and the signals into the caches their own reads use and holds the links for the relationship block's first read only. Two reads stay their own: the section's KB rollup (a recursive walk with its own time budget) and the space's write state (which can write an audit entry). A cold open then issues, after the article paints, no tenant transaction for a leaf page, one for a section page or a KB page, and two for a KB section page, as long as its deduplicated signal ids fit the bound. Three disjoint full windows and the page are 301 ids, so only a page's relationship endpoints, which are not paged, can push it past 400; the overflow then adds a signals batch per 100 ids.

The page view opens through it. The space and the page land in the browser's shared caches, so the breadcrumb's and the KB gate's own GET /api/spaces/:id are cache hits; the ancestors and the KB status — both deliberately uncached — are held for the one read each consumer makes when the page mounts and consumed by it, so a later read of either is live, as before. The four widget answers are held the same way and consumed by each widget's first read; a write to a widget's own state (a star, an upload) and every cache reset drop what is still held, so each later read is live. An older instance that serves no widgets during a rolling deploy leaves the widgets reading their own routes. The capability is handed to the shared capability store, which adopts it for the principal the receipt names instead of probing, and holds its first probe while a page open is in flight.

Two shell reads are gated rather than composed. The sidebar's Review count reads the lint aggregate only once the capability says the reader may ingest (every finding decision needs wiki:page:write), and GET /api/organisations answers Cache-Control: private, max-age=600 with Vary: Cookie, so the shared organisation switcher's re-fetches — on mount, on every organisation signal, on each focus after the cookie moved — are served from the browser's own cache for ten minutes per cookie state. A switch to an organisation the cached list no longer holds is still refused server-side.

A transaction that does have to queue now waits as long as its route allows: the wiki's tenant-context wrapper sets maxWait to max(2 000, 10 000 − timeout − 500) — 4 500 ms on Prisma's default budget — instead of failing at 2 s, so a slow neighbour delays a request rather than erroring it. A caller passing maxWait is never second-guessed, and the long paths that size their own timeout keep Prisma's default through the floor.

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, PLT-1270)​

Beside the filter sits a menu that chooses the tree's order: Recently updated (newest changes first), A–Z (alphabetical by title), or Manual (the order people have arranged). There is no reverse-alphabetical order. Manual is offered only when the tree shows one space, since the arrangement is stored per space.

Manual orders the pages under each page, not the top level. The top level of a viewer's tree can mix a space's real top-level pages with pages whose parent is hidden from that viewer, and those do not share one arrangement (ADR-030 § 3). So in Manual the top level stays in recently-updated order, and a note under the filter says so. Manual is never the default: a space starts on Recently updated, and switching the order is a deliberate choice.

Arranging pages (PLT-1270). In Manual, dragging a page over the top or bottom edge of a row draws a line there, and dropping places the page above or below that row; dropping on the middle of a row still makes it a child, shown by the row's outline as before. The row can be in another folder, which moves the page there at that position. A row that is open with its children showing takes a drop on its bottom edge as a child, because a line there would sit above its first child. Top-level rows offer no line above or below them, since the top level has no arranged order; dropping on the middle of one still makes the page its child. From the keyboard, each page under another page has Move up and Move down controls that appear when you tab to them; they stay available until the page reaches the end of its list, and keep focus after each press so a page can be moved several places in a row. The page moves on screen at once. If the move is refused, it goes back and a message says why in general terms — the page or destination is unavailable, a page with the same slug is already there, the pages changed meanwhile, or you cannot reorder pages here — never naming anything you cannot see. Knowledge-base spaces refuse ordinary reordering, and say so the same way. A successful move keeps the folders you had opened open. The tree reloads instead in three cases: when something else changed it at the same moment, when re-reading the destination folder after the move fails, and when the page left a different folder that already showed as many pages as one level of the tree loads (100), so the page that moves up into view is fetched — the move itself is kept in every case.

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.

A folder page's Pages in this section list uses the same order as the tree for that space, so the folder page lists a folder's children in the order the tree shows them (PLT-1272). The card says which order it is using — "Sorted by: Recently updated", "Sorted by: A–Z" or "Sorted by: Manual" — and changing the tree's order updates an open folder page in the same tab without a reload. A change made in another tab of the same browser profile follows too: the tree and any open folder page in every other tab switch to the new order once the browser delivers the notice, without a reload (PLT-1274). A change made in another browser profile or on another device is not shared. The one exception is a browser that refuses local storage: there the tree's choice lives only in the tree, so the folder page stays on Recently updated and its "Sorted by" line says so.

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. On a phone the dialog is a sheet from the bottom. The form asks for the title first; Where it lives holds the space and the parent, and More options holds the kind of page, its access level (Unclassified unless you choose another), its owner and its address. The address is derived from the title; More options opens on its own when the address or the access level needs attention, for example when the derived address is already taken at that location. After Create, the editor opens with the caret in the body.

/new renders the same form as the page's own content, 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. Cancel there goes back to where you came from, or to the wiki home in a tab that started on /new (PLT-2043).

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.

Linking to another page (PLT-790)​

Write [[Page Title]] in a page body and it renders as a link to that page. If no page has that title yet, it renders instead as an offer to create one, which is one of the two places /new is reached from. On save the title is resolved once and rewritten to the canonical [[Page Title|id:<uuid>]] form, so the link keeps working after the target is renamed.

Titles containing symbols resolve too (PLT-1303). The editor stores a label like any other prose, so Release [Draft] notes is saved as Release \[Draft\] notes and A < B as A &lt; B. The title is looked up as it reads, not as it is stored, so both links find their page. The "create page" offer on a link that does not resolve proposes the readable title as well. A | inside a label keeps the link's target after an edit, outside a table; inside a GFM table cell the editor still splits the row at that pipe on the next load (PLT-1485). A title that ends in ] links by title too (PLT-1484): [[Release [Draft]]] is one link to Release [Draft]. A link's closing ]] takes along each following ] that closes a [ the title left open, so a link written inside brackets, [see [[Page]]], still ends at Page, and a link that already names its page, [[Label|id:<uuid>]], always ends at its first ]]. A title ending in a ] that closes nothing, such as x], still needs the [[x\]|id:<uuid>]] form. A body the editor saved before this fix as [[Release \[Draft]]\] keeps its old reading until the page is next saved from the editor, which rewrites it.

A wikilink survives being edited. Until PLT-790 it did not: the shared editor escaped the square brackets when it serialised, and it did so the moment the editor mounted rather than on a keystroke — so opening a page and saving it for any reason, even fixing a typo in the title, rewrote every wikilink on that page into literal text and broke it. Nothing in the interface showed this happening.

Repairing pages broken before that fix (PLT-1293). npm run db:repair-escaped-wikilinks -w @constellation/wiki -- --tenant <uuid> reports, per space, the pages whose body still carries \[\[…\]\]; adding --apply repairs them. The report is the default and --apply is the only thing that changes a page, because this repair makes a judgement about content rather than correcting an unambiguous invariant. (A report is not silent: it records its own privileged read as an audit entry, as every mode does.)

The judgement is this: \[\[x\]\] in a stored body is exactly what an author who escaped the brackets on purpose would have written, so nothing in the current page distinguishes the two cases. What does is the revision history — a revision written before the corrupting save still carries the span unescaped. An occurrence with such a revision behind it is repaired; one without is counted, reported and left alone, so a page can come back repaired in part. Occurrences of the same escaped text are treated as one group and repaired or refused together — with N identical copies and fewer historical links, nothing says which of them was the link. Occurrences inside inline code or a fenced block are excluded on both sides — neither rewritten nor counted as evidence — because the pages most likely to hold \[\[…\]\] in a fence are the ones documenting this very bug. A [[Label|id:…]] link whose < or > the old editor stored as &lt; / &gt; is matched against history with the bracket restored (PLT-1322); a title-only one is left alone until title lookup decodes entities (PLT-1303), and a live copy of a link already on the page counts against the history, so one historical link cannot restore a second. A backtick line carrying trailing text inside a block does not close it, as in CommonMark, so a nested fence example stays excluded (PLT-1319).

Three further classes are left alone even when the history evidences them, and the report says so rather than counting them as repaired: a label markdown split across text nodes (the editor re-escapes it on the next save, so the repair would unmake itself); an occurrence contending for the same evidence as another where no single revision carries both and repairing only the covered part would leave a second pass with more to change (PLT-1322 repairs that part when the planned second pass is idle); and a canonical [[Label|id:…]] inside a GFM table block, where that separator | is also a cell boundary — the editor parses the link's delimiters into different cells and re-escapes it on the next save, the same way. The last one is decided from the block, not the line: a piped link on a prose line that merely contains a pipe builds no table and IS repaired, and a link whose label holds no | is repaired even inside a real table, because no cell boundary can divide it.

Each repaired page's body is updated and the same transaction appends a new revision holding the result, so the body it replaced survives as the preceding revision and the repair can be undone from history. The revision is attributed to the tenant's seeded wiki agent user, plus an auditCritical() entry and a wiki.page.updated event in the same transaction; a tenant with no such agent user has its --apply refused. Like the other two repairs it is a post-deploy step rather than a migration, takes WIKI_REPAIR_TX_TIMEOUT_MS, requires an RLS-bypassing role, has no estate-wide mode, and proves convergence with a second pass that must repair nothing. One cost worth planning around: every repaired page's updated_at moves, so a tenant-wide run pushes them to the top of "recently updated" once.

A refused audit chain step rolls the attempt back and is retried (PLT-1591). Every entry the command writes refuses a failed chain step rather than committing a NULL hash (onChainFailure: 'reject', PLT-953): the scan record in both modes, and each per-page entry of an --apply. Concurrent audit traffic for the same tenant is enough to cause one under REPEATABLE READ. The refusal rolls back the transaction it happens in, so no body is rewritten and no repair is recorded; an attempt's scan record survives only if it committed before the refusal. A lost snapshot race (40001) or a deadlock (40P01) is retried as a whole new pass, scan record included, up to three attempts. The report is retried the same way, so a dry run does not fail over a race an ordinary page save can cause. Once the attempts run out the command exits 1 and names the cause; re-run when the tenant is quiet. Any other chain failure fails the command at once. As defence in depth against a call site losing the option, the run still reads its own entries back and fails on any unchained one: the scan record in both modes, and the per-page entries inside the repair transaction, before the commit, so a refused pass changes nothing.

Two details worth knowing when you author one:

  • To show the syntax literally rather than link with it, use inline code — `[[Page Title]]` renders as text and is never resolved. Escaping the brackets with backslashes does not work, because the editor cannot tell an escaped \[\[ from a real wikilink once the page has been parsed.
  • A label carrying formatting is not a wikilink. [[A *bold* title]] stays literal text, because the emphasis splits the label before the link is recognised. Keep the label plain.

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 toolbar offers Draft, In review and Approved in its Status menu, plus Archive and Unarchive in its More 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 is staged and saved with Save (PLT-2043) — it marks the page as having unsaved changes, goes out in the same PATCH as the body, and confirms with a toast once saved. No revision is written for the status itself. Until PLT-2043 the status applied the moment it was chosen, in a request of its own, which gave one page two save moments.

  • Archiving and unarchiving both ask for confirmation, apply at once, and neither is in the Status menu. Each has its own labelled action in More and its own dialog. That is why Draft stays disabled in the menu 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)unchanged for the archived page itself — getPageKbStatus derives from exclusion, coverage and open findings and never reads the status of the page it is asked about, so an archived page with an open candidacy still reports candidate
    KB coverage of other pages (the In KB badge, Layer-1 nomination dedup, folder-run already_covered)withdrawn immediately (PLT-806) — an archived knowledge-base page no longer covers the pages it was ingested from or summarises; see A retired knowledge-base page is not coverage below
    Global search (GET /api/search, the navbar panel, /search, MCP search_pages)unchanged, deliberately (PLT-1192) — search is how a reader finds a page the tree no longer shows, and it is the duplicate check an agent runs before create_page; each archived hit is labelled Archived (PLT-1367)

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

The Deleted view (/trash) lists the pages deleted in the last 30 days: what was deleted, when, how long is left, and where the page would land if restored, with the classification as an optional column. It has no space facet or Space column, because the trash read carries no space. That read returns the whole restore window in one response, so the view's title search, its owner and classification facets and its sort (deleted newest or oldest first, title A–Z or Z–A) run in the browser over every row, never over a partial window that would make a filter look complete when it is not. The destination 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).

Every trash listing writes one audit entry, whatever the trash holds (PLT-1296, PLT-1479). wiki.list_deleted_pages reads the trash, which your own access cannot, but it bounds both the trashed page and its parent by your clearance, and refuses a clearance above yours. The entry was added when the listing was read as crossing a classification boundary under constitution §5. PLT-1594 records that it does not: bypassing only the trash is not a classification crossing (ADR-045 § Amendment (PLT-1594), pending ratification). The entry is still written until PLT-2096 removes it. Each listing files one wiki.trash.parent_join_applied_outside_clearance_scope entry naming the tenant, filed at SECRET, with no page, no parent and no count. It is written identically whether or not any parent was hidden, and for an empty trash too, so neither its presence nor the number of entries says anything about what the listing hid. The database function writes the entry itself, before the join, so it is recorded however the function is called; since PLT-1514 it has one signature, so no caller can reach it through the earlier two-argument form, which recorded every listing as a user's whoever made it. As a read that changes nothing, it is kept in the audit log and not published as an audit.entry.created event.

  • 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 reserved navigation page is listed but offers no Restore (PLT-1574). A trashed index, log or index_spoke page shows "Maintained by the space index — not restorable" in place of the button, and a dash in the destination column: wiki.restore_page refuses those pages with a 400 (reserved_page_type, PLT-818), because they are system-only.
  • A failed restore explains what to do next, from the status code and, for a 400, the details.reason — never from the server's message: a 409 means another page already occupies the target location and must be renamed first; a 400 reserved_page_type says the page is maintained by the space index and cannot be restored, and any other 400 that a space already uses the page's name; a 404 says the page can no longer be restored — it may already have been restored, or its restore window has ended (being above your clearance folds into the same answer). The row is therefore not removed on a 404; the list is refetched and what comes back decides.

A collision is decided on rows you can read (PLT-818). Both wiki.restore_page and the re-home into the Default space (wiki.rehome_collision_status) decide a collision only on rows at or below your clearance: a live page at any tier you can read, or a trashed page at the restored page's own classification — the only trashed rows the per-classification slug key rejects. A visible collider answers 409. A page above your clearance can never share the key (ADR-058 D5), so it no longer affects the outcome at all: the former "hidden collider answers the same 404 as not-found" behaviour and the re-home's collision_hidden answer are gone. The collider may still be a trashed page you cannot find in the listing — one outside the 30-day window, or a retracted one — at a tier you can read. Because these reads no longer cross classification, the PLT-1419 wiki.page.restore_collision_check_applied_outside_clearance_scope and PLT-1291 wiki.page.rehome_collision_check_applied_outside_clearance_scope audit entries are retired and no longer written. Both functions also refuse a caller-supplied clearance above the session's own, so a direct SQL call cannot widen the read.

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

The Archived view (/archive) lists archived pages across every space you can see, with a space facet to narrow it to one, beside owner, classification, page type and a title search. The page-type facet is the archive's PLT-1273 filter, now offered in every live view, so an older /archive?type=spec link still narrows the list.

The Archived and Deleted views are deliberately different lists, and confusing them is the easy mistake. Deleted holds soft-deleted pages inside the 30-day restore window described above. Archived holds live pages whose status is archived: they stay readable by link and keep every permission they had. What archiving takes away is presence in knowledge-base answers, in related-page suggestions, and — since PLT-1186 — in the page tree and every other view of the pages list. It does not take a page out of global search, and that asymmetry is a decision rather than an oversight (PLT-1192): search is a recall surface, so a reader who remembers a page can still find it — the tree's own no-match state sends them there — and both halves of the search panel, the ranked matches and the title matches, keep answering the same way. Before that last part, archiving was 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. It also made unarchiving invisible, because nothing reappeared where nothing had disappeared.

Two properties of the view are worth stating:

  • It pages like every other view. A tenant holding more archived pages than one window offers Load more, and the facets, search and sort apply in the request, so Load more pages through the filtered result, with the Showing N of T pages count beside it. Unarchiving or any other change re-reads as many windows as were loaded (PLT-1644), so the reader keeps their place.
  • It opens on the most recently archived pages (PLT-1621). Its sort offers Archived: newest — the default, which /archive does not write into the address — and Archived: oldest, ahead of the last-update and title orders every live view offers; the Archived column header sorts the same way. A page whose archive date is unknown is listed after every page with a date, in both directions, so neither order begins with undated rows. The archive-date sort is not offered in the other views, where almost no row has a date; a link carrying it into another view opens that view on its own default order.
  • Its Archived column says when each page entered its current archived state (PLT-945), and shows a dash, announced as Date unknown, for a page archived before that date was recorded. The date comes from the page's archivedAt field, which the database stamps on the way in, keeps while the page stays archived — editing an archived page does not move it — clears on unarchive, and stamps afresh on a later re-archive; no writer can set it. The last-modified date is never used in its place, because an archived page can still be edited, and pages archived before the column existed are deliberately not backfilled from it: nothing recorded when they were archived, and an estimate shown as a date would read as an observation. The date reads as a relative age; hover it for the exact time. Last updated is an optional column.

Unarchiving (PLT-946). If you may edit pages, each row's ⋯ menu offers Unarchive…. It is the same transition as Unarchive in the editor's status menu — see Changing a page's status — behind a confirmation dialog, and it returns the page to Draft. The row leaves the list as soon as the change lands, without a reload. Two things decide whether the action can be completed, and both are the rules the editor already applies:

  • Your permission to edit pages. The list reports it alongside the rows; a reader who may not edit pages sees no Unarchive… item at all, which is also what an older server that does not report it produces.
  • The space. For a page in a knowledge-base space whose direct writes are refused, the dialog offers no confirm action and says why: that content changes through the ingestion and review loop, not by direct status changes.

Neither is the security boundary — the server checks both again when the change is made. Nothing can be archived or deleted from this view. 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.

How current an open tab is (PLT-901)​

The browser keeps what the wiki reads for 30 seconds. When you come back to a wiki tab — it becomes visible again, or its window regains focus — anything it read longer ago than that is re-read in the background. That is how an open tab picks up what changed elsewhere: findings the daily curator pass resolved, a page another person edited, or your own work in a second tab.

  • What is refreshed on return, keeping what you see on screen: the signal badges on every list row, the /review counts and the sidebar badge, the /review queue while you are still on its first page, and the finding pinned beside it; the blocks on a page — related pages, attachments, provenance, the knowledge-base relationship, a section's index and a folder's policy (PLT-1213) — and search results; and the page you are reading — its body, title, status and classification (PLT-1212); and the rows of the page tree, the pages list, the archive and the home feed (PLT-1211) — a page someone else created, archived or moved appears, disappears or changes place. The fresh answer replaces the old one when it lands; if the re-read fails, what you saw stays as it was, and your next return tries again. A folder's policy is not re-read while a change you made to it is still saving.
  • Where you are is kept. The page tree refreshes every level you have opened in place, so no branch you expanded closes. The pages list and the archive are refreshed only while you are still on their first page of results: once you have used Load more, they are left as they are rather than taking you back to the top, and the archive is not refreshed while an unarchive you started is still saving.
  • The one exception: a page you can no longer see. If the re-read of the page you are reading answers that it was deleted, moved to trash or reclassified above your clearance, the page is replaced by the usual not-found state rather than left readable. An expired session also replaces the page, with the session-expired notice. If your keyboard focus was on the page, it moves to that state.
  • Two places showing one page agree (PLT-1215). When a surface opened later reads a page's badges again — a search result, say, showing a page the tree has shown since you arrived — every other row showing that page takes the newer answer at once, without asking for it again. If that later read fails instead, your next return to the tab tries again for every row still showing the page.
  • Coming back within 30 seconds costs nothing — nothing has expired, so nothing is asked for, unless a re-read failed on your previous return, or a page whose saved answer had expired was read again since your last return, as above — either is followed up straight away. A hidden tab is never re-read, and nothing is re-read in the moment after you switch organisation in another tab: the switch itself reloads everything once this tab notices it.
  • What is not refreshed — reload to see changes made elsewhere: the pages list, the archive and the /review queue once you have paged past their first page (re-reading them would take you back to the top).

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 four scheduled endpoints. All four 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, the sibling-rank key-density inventory, and the kb_reads retention prune. Discovers on the scheduler login (WIKI_SCHEDULER_DATABASE_URL) and fails closed when it is unset; the inventory runs on the maintenance login (WIKI_MAINTENANCE_DATABASE_URL) and is skipped, not fatal, when that one is unset.
/wiki/api/cron/process-jobsevery minuteDrains the background job queue (jobs.queue) — see below.
/wiki/api/cron/verify-decisionsdaily, 07:30 UTCThe verification sweep: settles the stated Mark resolved decisions still pending that it can safely recheck — see Lint → The daily verification sweep. Discovers on the scheduler login (WIKI_SCHEDULER_DATABASE_URL) and fails closed when it is unset.

All four are gated on CRON_SECRET, and all four 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 curator-lint cron holds a THIRD connection (PLT-1078). Its key-density inventory calls a SECURITY DEFINER that reads across every clearance tier, granted to the wiki_maintenance role and to nothing else, so it runs on the dedicated constellation_wiki_maintenance login through WIKI_MAINTENANCE_DATABASE_URL — checked by the same confinement preflight as the scheduler connection. Unlike the scheduler DSN, an unset or refused value is not a 500: the sweep skips that one pass and reports status: "incomplete" with aggregates.run.inventoryRan: false, because failing the whole daily sweep for a credential the release that needs it has only just introduced would be a self-inflicted outage. Watch the summary, not the status code — and on a single-tenant run, where § D2 publishes the status alone, the route's warn line. Provisioning: the same registry.

The two daily sweeps run on their own database login (PLT-1180). curator-lint and verify-decisions call the wiki's cross-tenant discovery functions, whose authority is the wiki_scheduler role, so that discovery runs on the dedicated constellation_wiki_scheduler login through WIKI_SCHEDULER_DATABASE_URL; everything they do inside a tenant afterwards stays on the ordinary DATABASE_URL connection. The scheduler connection is checked before any work — it must not bypass RLS, must hold wiki_scheduler, and must not inherit constellation_app — and an unset or misconfigured value is a 500 with no fallback. Provisioning: the pre-deploy prerequisite registry in .claude/skills/release-and-migrations/references/pre-deploy-prerequisites.md.

Since migration 091 (PLT-1256) the database enforces that boundary. The three discovery functions — wiki.list_agent_owned_spaces(), wiki.kb_reads_tenants_with_expired() (its (INT) form until PLT-2099 dropped it) and wiki.tenants_with_pending_verifications() — are executable by their owner and wiki_scheduler only, and each refuses with 42501 any session whose login cannot use wiki_scheduler, even if EXECUTE is granted back. A request running on DATABASE_URL therefore cannot list tenants at all. wiki.assert_scheduler_enumerator_grants() checks that grant set, the role's membership and every direct privilege the two scheduler roles hold; migration 091 runs it before recording itself, and aborts on a grant made outside the repository. Since migration 20261104131304 (PLT-2081) it excuses only the applying role's exact CREATEROLE auto-grant (ADMIN, no INHERIT, no SET), and only when that role is a non-superuser CREATEROLE role, on wiki_scheduler and on the scheduler login alike. It refuses every other role granted either, so a creator of either role other than the release applier must have its edge revoked first. Since migration 20261103161746 (PLT-1480) the retention discovery calls the zero-argument wiki.kb_reads_tenants_with_expired(), with the same gate and grant set; the (INT) form, whose argument had been ignored since migration 073, stayed one release for the outgoing build and was dropped by migration 20261106081315 (PLT-2099), which also took wiki_scheduler's EXECUTE on wiki.kb_reads_retention_days(). The rule and its conditions are ADR-040 § D8 and § D9 item 2.

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, yielded, byType, deadRunsFailed, deadRunsUnreconciled, stoppedBy } — processed counts jobs this invocation settled and drained, 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. yielded counts jobs whose handler stopped on purpose at the invocation's deadline with work left and went back to the queue for the next tick (a large folder run reports one per tick until it finishes); a yield counts toward the 10-job cap but not toward processed. The two deadRuns* counts are described under When the job dies. 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, 33 wiki tools are registered:

  • Spaces — list_spaces, get_space, create_space, update_space, delete_space, decommission_space, provision_knowledge_base
  • Pages — search_pages, get_page, list_child_pages, create_page, update_page
  • Links & dependencies — list_page_links, link_pages, traverse_dependencies
  • Attachments — upload_attachment, list_page_attachments, get_attachment
  • Maintenance — rebuild_space_index, append_space_log
  • Lint (honesty loop) — lint_space, list_lint_findings, record_lint_finding, resolve_lint_finding, recheck_lint_finding_verification
  • Source ingestion — get_ingest_capability, ingest_source, retract_source, revert_ingest_batch
  • Declassification approvals — list_declassification_approvals, get_finding_declassification_context, grant_declassification_approval, amend_declassification_approval, revoke_declassification_approval

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.

upload_attachment attaches a file of at most 2 MiB to a page through POST /api/attachments, and returns an attachment://<id> reference and an embed for update_page; list_page_attachments lists a page's attachments, and get_attachment returns a PNG, JPEG, GIF or WebP of at most 3 MiB as image content by default (includeImage: false skips it). The pt CLI mirrors all three, and pt upload-attachment --file is the route for a file on disk, up to 4 MiB in production — see Attachments and size limits.

get_ingest_capability reads GET /api/ingest/capability and reports whether the caller may ingest, the exact set of spaces an ingest may land in, and the default target. The set comes from the resolver ingest_source enforces, so it includes the configured knowledge-base space of a tenant whose fallback latch is still open, a space list_spaces does not mark [KB]. It is a read, not a reservation: ingest_source re-checks the destination when it runs. The pt CLI mirrors it as pt get-ingest-capability.

recheck_lint_finding_verification is the review queue's Recheck now for an agent: it calls POST /api/spaces/:spaceId/lint/findings/:findingId/verification and reports the outcome and reason verbatim. It needs wiki:page:write and never changes the decision itself — see Rechecking a pending decision. The pt CLI mirrors it as pt recheck-lint-finding-verification.

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)ingestedA KB page the reader still serves covers the page body (≥ 0.8 overlap), or a live ingest batch names the page as its origin (PLT-503), or the page is itself a served KB page (PLT-1975).
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;
  • the ingest went into a marked knowledge base — recorded once, when the batch was written, from the marker its target space carried then (PLT-809) — and the space its source page sits in now carries the knowledge-base marker (PLT-513). An ingest into an ordinary space is never KB coverage — marking that space later, or moving its page into a knowledge base, does not retroactively qualify it — and moving the page out of a knowledge base withdraws coverage. Moving it between two knowledge bases preserves coverage, and so does unmarking the space it was originally ingested into once the page sits in another marked one. An ingest admitted into the transitional unmarked fallback space of a tenant that has not reconciled its knowledge base is not qualified, and a batch written before PLT-809 is treated as unqualified rather than guessed; both are still reported through text coverage. Folder runs and the folder-ingest preview apply this same rule, so an origin whose only coverage was such an earlier batch is offered — and ingested — again. Nomination suppression and the approval-time duplicate check (which folder runs do not pass through) still read the target space's current marker;
  • 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;
  • that anchor has not been retired — it is not archived, and not provably past its expires_at (PLT-806, below);
  • 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.

A retired knowledge-base page is not coverage (PLT-806)​

Coverage means content the knowledge-base reader still serves. A knowledge-base page that is archived, or provably past its expires_at, is never returned in an answer, so it neither badges the page it came from In KB nor refuses that page's nomination. The rule is one predicate — the same one the approval boundary already used for similarity (PLT-990) — applied identically in four places:

  • both halves of ingested: a retired page is not counted as text coverage and is not listed in coveringKbPageIds; a batch whose source anchor is retired no longer counts as identity;
  • both Layer-1 lookups of the candidacy funnel: the text-overlap dedup and the slug/title match against KB source pages.

So the badge and the funnel cannot disagree about a retired page. Three things are worth knowing:

  • "Provably expired" is conservative. Only a canonical expires_at timestamp that parses and is in the past retires a page; a malformed value leaves it counted, exactly as the reader keeps serving it.
  • On the identity half the anchor decides, not the pages derived from it. A source anchor is provenance and is never itself returned in an answer, so for it the predicate is purely a retirement test: archived or provably expired means the ingest it anchors has been retired. The staleness reaper archives expired synthesis pages and never a source anchor, so reaper-driven retirement lapses text coverage only; identity lapses when a curator archives the anchor itself. Unarchiving restores it — nothing is destroyed, which is the difference from retract_source.
  • A folder run follows the status. Its already_covered disposition comes from this derivation, so a page whose coverage has lapsed this way is eligible again and a run ingests it afresh, as it already does for an origin edited since its ingest.

The two database boundaries apply the same anchor rule since PLT-1028: the check that suppresses re-nomination of a page a live batch already names at its current revision, and the identity check at candidate approval, both ask one shared core that treats a batch whose anchor is retired as no longer live. So for a page whose anchor has been archived the badge shows no coverage, a nomination is no longer suppressed, and an approval is no longer refused on identity — the badge, the funnel, a folder run and both boundaries agree.

A knowledge-base page is in the KB, and is not measured against its siblings (PLT-1975)​

A page the knowledge base serves (in a knowledge-base space, not deleted, not retired) is In KB because it is there, and text coverage is no longer measured for it. Asked of such a page, text coverage would only say whether some sibling repeats its vocabulary, which is not a fact about the knowledge base. It was also the most expensive question the derivation could be asked, and on a large knowledge base it made /knowledge and the knowledge-base space's own tree time out.

An exclusion still wins: an excluded knowledge-base page shows Excluded from KB. For a member the covering list is empty, as it is for a page counted by ingest provenance, and the badge tooltip then reads "In the knowledge base", which is true of both. Because ingested is what the folder-run eligibility, the ingest disposition and the Add to KB action read as "already there", a knowledge-base page is never offered for ingest into the knowledge base it lives in, and kbStatuses=ingested lists every served knowledge-base page. A retired page in a knowledge-base space is not served, so it is still measured like any other page. The rule applies identically to the page badge, the list signals, the subtree rollup, the kbStatuses list filter and a folder run.

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. The rebuild answers that refusal with its own reason, kb_index_rebuild_forbidden, naming the trusted paths that do rebuild a knowledge-base index (PLT-1880), rather than the reason of whichever hub or spoke write was refused first.

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. If the content is an index spoke or a consult synthesis that a trusted path wrote before the space was marked, run the trusted-stamp backfill instead (see the caution below).

The scan's provenance read is audited, whatever it finds (PLT-1420, PLT-1431). To decide whether the space carrying the default knowledge-base slug can be marked, the scan asks wiki.kb_space_provenance_inventory whether every page in it was created by an ingest. That function reads pages and revisions above your clearance and in the trash — that is how it can tell — so constitution §5 requires an audit entry for the read. The function writes it itself, before it reads: one wiki.space.kb_provenance_inventory_applied_outside_clearance_scope entry per call, naming the space and filed at its default classification (never below RESTRICTED). It carries nothing the read found, and it is written identically whether the space is proven or blocked. Since PLT-1431 that holds for a direct call to the function as well, and the entry no longer publishes an audit.entry.created event. A space the caller cannot read is refused instead of answered false. The earlier array form of the function, kept for one release for the build a deploy replaced, is dropped by migration 20261005121525 (PLT-1604), so the one-space form that writes its own entry is the only one left. When the proven space cannot be marked — its default classification has been raised, for example — the scan rolls back, and the entry is rewritten in a fresh transaction. A scan with no such space, or whose space is already marked, makes no such read and files no such entry. The scan's own wiki.kb.reconciliation_scanned entry is unchanged.

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 before the space was marked — the common case, see the caution below — nothing should be moved or deleted, because that content belongs there; the operator backfill db:backfill-kb-trusted-stamp (PLT-1132) records its provenance instead. The provenance generations (PLT-816) say that something the scan reads has changed since it ran — the reconciliation read reports the verdict as stale — but not what, and a marked space's own pages do not bear on the stored verdict at all, so naming the page that broke a marked space's provenance remains a manual query.

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 — trusted content written before the space was marked

A page counts as knowledge-base authored when its first revision carries an ingest batch or a trusted-write stamp (trusted_kb_operation, PLT-1002). A trusted writer — the index rebuild, coordinator file-back — stamps its pages only once the space is marked: in an unmarked space the stamp is withheld on purpose, so that marking a space later cannot turn unvalidated content into knowledge-base provenance. So index spokes and consult synthesis pages filed into a space before it was marked carry no stamp, and one of them keeps the marked space unprovable — enforcement declines for it, fail-open.

The remedy is an audited operator backfill, one tenant per run (PLT-1132):

# Report (the default): lists what would be stamped, every residual, and whether each
# marked space would become enforceable. Writes nothing to wiki.*.
DIRECT_URL=… npm run db:backfill-kb-trusted-stamp -w @constellation/wiki -- --tenant <uuid>

# Stamp: needs the role that owns wiki.page_revisions, and the explicit confirmation.
CONFIRM_APPLY=1 DIRECT_URL=… npm run db:backfill-kb-trusted-stamp -w @constellation/wiki -- --tenant <uuid> --apply

It stamps only an agent-owned index_spoke at its space's spoke slug (space-index-write) or an agent-owned synthesis at synthesis-consult-<uuid> (coordinator-file-back), and only when the first revision was written before the space's kb_marked_at — after that instant the trusted writers stamp for themselves, so an unstamped row is an ordinary write and is never touched. These are the values those writers record today, so a backfilled row is indistinguishable from a fresh one in the table; the per-page auditCritical() entry is the record that it was attested rather than written under a capability. Everything else is reported as a residual and left alone. The stamp is the bounded override of the PLT-1002 no-backfill rule that SPEC-plt-1132 records.

Before you apply, read the report and check three things:

  • The space was marked once. The cutoff is the space's current kb_marked_at. Unmarking a space and marking it again moves that instant later, which would let content written while it was unmarked look pre-marking. If the space's audit history shows more than one marking, stop and ask. Look closely at any row the report lists within seconds of kb_marked_at.
  • The rows are what they look like. is_agent_owned is set by whoever created the page, so it is not a protection: an agent-owned page at a matching slug is stamped on your say-so. Read the STAMP rows — author, actor_type, edit summary — before confirming.
  • The wiki is quiet. Each page's stamp takes a lock that blocks revision writes in every tenant for the length of that one page's transaction. Run the apply when the wiki is idle. A page whose lock is busy is skipped rather than waited for; the run then exits 1, and you re-run it.

Every run writes a wiki.kb_trusted_stamp.backfill_started entry before it reads and a …backfill_completed entry when it ends, whether it succeeded or failed (result says which), plus one wiki.page_revision.trusted_kb_stamp_backfilled entry per page it stamped.

After an apply the reconciliation read reports the tenant's verdict as stale, because the space's provenance generation advanced. Nothing that gates reads that status; re-run the reconciliation scan to refresh it if you want the read to say clean again.

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_enforceable_inventory (the enforcement half PLT-1002 split out of 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 since PLT-1541 the inventory writes it itself: its audited overload records the entry through audit.write_entry before it reads, so a SQL caller holding EXECUTE gets the entry with the read, not only the guard.

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 — with the context { "check": "wiki.kb_space_enforceable_inventory" }. Until PLT-1541 the context named the guard site instead; the database cannot know the call site.
  • One entry per space asked about, asked before the tenant verdict is consulted. The guard asks on the marked-space branch, whatever the tenant's reconciliation state, so the entry's presence never discloses whether the tenant is reconciled.
  • The COUNT does not vary with the verdict either. A refusal ends an operation, so asking once per guard site would file fewer entries exactly when the guard refused. So the guard asks about each space at most once per operation (a space-index rebuild asks once, not once per page), a cross-space move asks about both endpoints before refusing either, and the attachment upload asks only after the upload, under the namespace lock — an upload into an enforced space therefore stores its file and deletes it before answering 403. Two marked spaces in one operation now leave two entries, each findable under its own space.
  • 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 definer's 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 an identical copy, under the same entry id, in a fresh transaction before the error propagates.
  • Not published to the outbox. A read-only crossing writes its entry and publishes no audit.entry.created event (ADR-048 R1).

The inventory's older array form, which recorded nothing itself and was kept for one release so a build still running during a deploy kept working, was removed by migration 20261106091746 (PLT-1722). The one-space form that writes its own entry is the only one left, so no caller of the inventory can skip the record.

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

The cascade's writes in other marked spaces are authorised too (PLT-1886). Outside the source's space the cascade re-types a deriver whose only provenance was the source from source_summary to synthesis, and deletes the deriver's derived_from link to the source — a page write and a link write into the deriver's space. Under the same namespace lock the retraction reads every marked knowledge-base space of the tenant and authorises source-retraction for each, one capability per space. Every marked space rather than the exact set, because the cascade's closure runs past the caller's clearance and no ordinary read can name the spaces of derivers above it; a tenant can mark at most one wherever migration 070's singleton index is applied. This half never refuses a marked space — the wiki has no per-space authority to consult, and refusing would block a compliance erasure — so it adds evidence, not a veto: the source.retracted entry names the operation, says it covers cascadeRetypes and cascadeLinkDeletes (and sourcePageSpace when the source's own space is marked), and lists the authorised spaces in trustedKbAuthorisedSpaceIds. The lost_source findings the cascade records are review metadata no knowledge-base guard covers on any path. A tenant with no marked space, and a retraction with enforcement off, are unchanged; the kill switch WIKI_ENFORCE_KB_WRITES=false stays the operator's escape.

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 behind. What it does leave is the §5 crossing-read entry this guard owes, rewritten with its audit.entry.created outbox row in a fresh transaction once the refusal has rolled the restore back. (The restore's own collision checks owe none since PLT-818: they read only rows within your clearance.)

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. The one rule the two do share is which KB pages count at all: since PLT-806 both ignore a page the reader no longer serves (see A retired knowledge-base page is not coverage).

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

When an UNCLASSIFIED page is not yet ingested or excluded, an "Add to KB" item appears in the page header's "More page actions" menu (classified pages do not get the item — the KB operates at UNCLASSIFIED). The item 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 wiki builds the AI provider in the code that serves the request: getWikiAiProvider (apps/wiki/src/server/services/ai-provider.service.ts) reads AI_CORE_PROVIDER (mock, or claude with AI_CORE_API_KEY) once per server instance, and wraps the provider on every call with withAudit() and withBudget(), charged to the organisation the user is acting in (ctx.user.tenant_id, as withTenantAuth resolved it) rather than the tenant the token claims. If no provider is configured (AI_CORE_PROVIDER unset or unknown, or claude without a key), or the model's response does not parse, the endpoint returns 200 with { available: false, reason: 'provider_unavailable' | 'unparseable_response' } and the human writes the summary by hand. A draft over the acting organisation's token budget (AI_CORE_DEFAULT_TOKEN_BUDGET, 200,000 tokens by default) is refused with 429 AI_BUDGET_EXCEEDED before the model is called. An error from the provider itself answers 502 KB_DRAFT_PROVIDER_FAILED with a fixed message and is reported as a server error, without passing the provider's own status or message to the client, and so does a provider call that has not finished within 45 seconds, which is cancelled so that the route answers inside its 60-second limit; any other failure answers a reported 500. The budget is counted per server instance, and starts from zero when an instance starts, until PLT-358's shared store lands. A KB candidate's summary is drafted once by the job its nomination enqueues, and again only when a curator asks for it or the page changes (see The candidate's agent summary below); those drafts are charged the same way. Opening a candidate in the review queue drafts nothing.

Excluding a page from KB nomination (AC6 / AC7)​

The "Exclude from KB" item in the same menu (visible to users with wiki:page:write permission) calls POST /api/pages/:id/kb-exclude once the reader confirms it; on the reading page the item asks first (PLT-2042). 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, kbGeneration }. kbGeneration is the KB-configuration digest the status was resolved under — the same digest GET /api/ingest/capability carries (PLT-1001), from the same coverage read — so the chip can tell a status resolved under the capability's own configuration from one that predates it (PLT-2034). 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 }, 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, needsReviewCount } } } — 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. The two counts answer different questions and a consumer must not substitute one for the other. openFindingCount is the INVENTORY: every open finding about the page (page_id), of any check type and severity — not findings that merely target it. needsReviewCount (PLT-1155) is the VERDICT: the subset whose check type degrades the page's confidence, and the only one a needs-review mark may be derived from. They are equal on the ordinary page and diverge wherever a confidence-neutral type is present — kb_promotion_signal is one, raised because a page is heavily READ, so deriving a warn marker from openFindingCount would flag the most-cited pages in a space for being popular. Both are always present; neither is optional.
POST/api/ingest/draft-summaryBody: { sourceRef, sourceContent, classification, sourcePageId? }. Returns { available: true, title, slug, body, tldr } on success, or { available: false, reason } when no AI provider is configured or its response could not be parsed. Answers 429 AI_BUDGET_EXCEEDED when the acting organisation's token budget is spent, and 502 KB_DRAFT_PROVIDER_FAILED for an error from the provider or a call that misses its 45-second deadline. 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 candidate's agent summary (PLT-2091)​

A KB candidate reaches the review queue with the assistant's summary already written. When a page is nominated, the nomination enqueues a wiki-candidate-agent-summary job in the same transaction; the job drafts the summary with the acting organisation's AI provider and budget and stores it on the candidate (detail_json.agentSummary), stamped with the page's reviewedState token. The review pane reads that stored summary and makes no AI call when a candidate is opened. A summary drafted from an older state of the page is not shown: editing a nominated page re-runs the job, which drafts again. Running the KB candidacy backfill also enqueues the job for every candidate it leaves open, which is how candidates nominated before this change get a summary.

When no summary could be drafted, the reason is stored and shown — no AI provider configured on the deployment, the organisation's drafting budget spent, or a provider failure — and the deployment reports it: a missing provider and a provider failure go to error reporting, a spent budget is logged. A curator can also ask for a summary from the pane with Ask the assistant for a summary, which calls POST /api/kb-candidacy/agent-summary (body { pageId }, wiki:page:write), drafts synchronously, stores the result and answers { summary } — null when the page has no open candidate or changed while the draft was written.

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 resolved value and visible source LEVEL are wiki.resolve_nomination_policy's output forwarded rather than folded into a verdict. The resolver's other two columns are deliberately not on the wire: the source page's id and its synthesised flag (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. A walk that runs out of depth without finding any policy row answers the fail-closed exclude with a null source too, and the route does not say which of the two it was: the resolver's synthesised flag was true only for a walk 1 000 levels deep, so forwarding it told a reader whose visible chain is short that a hidden chain runs that far (PLT-768). The response therefore carries no synthesised key. PLT-768's release sent a constant false in its place so clients from the build before it still parsed the response; since PLT-1743 the key is gone, and the client still accepts it as optional for one more release, in case an outgoing server sends it. The control renders a null source as Source not shown and inherit as Space policy, 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 gate (is the tenant's knowledge base usable, and is this space not one of its KB spaces — PLT-1867) 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 — on every call, not only when it crosses. wiki.resolve_nomination_policy is a SECURITY DEFINER whose ancestor walk admits rows the caller's clearance would exclude, and constitution §5 exempts a read only when it does not cross such a boundary, so the route emits wiki.nomination_policy.resolution_applied_outside_clearance_scope — one entry per call, written by the resolver itself since PLT-1535, naming the visible page and never the withheld ancestor. Since PLT-1301 it carries no resolved value: an entry written only where the source came back withheld was a waiver from per-invocation coverage, and its mere existence then told every audience that could observe it — including the event bus, which is not clearance-filtered — that the answer came from a row the caller could not read. A visible source, an inherit and a synthesised value now each emit an entry too, and the entries are identical but for the subject. The entry is filed at that page's own classification floored at the SIEM default, not flat at the floor: 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. 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, on a folder recognised as a section by its children rather than a declared index, the card used to be unmounted and remounted routinely — it rendered nothing while its children read was in flight, so any cache reset took it down — and a client that held either value locally lost it there and fell back on a stale page snapshot. The card now stays mounted through a cache reset once the section is known (such a folder's card is still remounted when the signed-in principal changes), so a control re-reads in place, and the single read is what keeps that re-read consistent too. 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.

Time budget (PLT-1777). The route has its own 16-second maxDuration, above the module's 10 s. The rollup's one transaction is budgeted whole, and Prisma rejects an expired transaction only after its running statement returns, so the budget counts three terms: 2 s to acquire a connection, a 6 s transaction timeout, and a 6 s cap on the statement still running past it. With 2 s left for the response, that is 16 s. A rollup that outgrows its 6 s fails with an attributable transaction error rather than a platform timeout. A tenant whose folders need more than that needs a cheaper aggregate, not a longer budget.

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 — a retired (archived or expired) KB page is not coverage (PLT-806).

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. The bulk-ingest modal says so on every preview plan it shows (PLT-1658), in the same words whatever the subtree holds: it reports no count, and it never appears only when something was left out, since either would need the rejected traversal below — and a notice shown only then would itself disclose that a hidden folder exists. This is deliberate, not pending work. Closing it needs a privileged traversal that crosses hidden intermediates while projecting only caller-visible descendants. That design was proposed and rejected (ADR-047 § Addendum (PLT-919), PLT-1565): its internal bound has to count hidden rows, so a caller who can write pages could recover how many hidden rows sit under a folder and how deep a hidden chain runs, which nothing discloses today.

It is a read that writes an audit entry, and that is not a contradiction. It creates no page, revision, ingest batch or run. But resolving each descendant's inherited nomination policy runs wiki.resolve_nomination_policy, a SECURITY DEFINER whose ancestor walk admits rows the caller's clearance would exclude, and constitution §5 requires an audit entry for that privileged read. One entry for the whole request, wiki.folder_preview.resolution_applied_outside_clearance_scope, naming the folder and nothing the resolver found — no resolved value, no count and no descendant, and never the withheld ancestor. Since PLT-1727 the database writes it, in three steps (ADR-064). The preview opens an intent on the folder, resolves every descendant under it, and completes it, which writes the entry. A transaction cannot commit while an intent it opened is still open, so a read under an intent commits only with its record, whether it came through the API or straight from the database. A caller that rolls back, or rolls back to a savepoint taken before the open, discards the intent and the record together, as a rollback discards any audit entry written in its transaction. It is written once for every preview the folder admits, an empty folder included, whatever each resolution returned. It is filed at the highest classification among the folder and the descendants you can see, floored at RESTRICTED. A preview refused because you cannot see the folder writes nothing, and neither does one refused as too wide by the open: both refusals come before anything is resolved. The open and the preview's own walk share the run cap of 50 (PLT-1872), so the one refusal that comes after the open is a folder that grew past the cap in between. Nothing is resolved, but its intent was opened, so that request files its one entry. The entry is not published as an audit.entry.created event (ADR-048 R1); read it from the audit log. A run started over the folder files the same entry, after the run is created. Since PLT-1317 this replaces wiki.folder_preview.resolved_from_hidden_source, which was written only when a descendant resolved from a hidden ancestor and carried the crossing count and the crossing pages with their resolved values; its existence and its payload were both the protected result, readable by audiences — the event bus among them — that were never shown it. A rule selecting on the old action stops matching rather than silently changing meaning, and the per-descendant detail is no longer in the audit trail. It is a different action from the per-page wiki.nomination_policy.resolution_applied_outside_clearance_scope because its subject is the folder, not a page. The request files one entry rather than one per descendant because a preview resolves up to the cap in one transaction, and a per-page loop there exceeded the read transaction's budget — which rolls back, so the largest previews would have produced no entry at all.

Where an operator sees it (PLT-615)​

The folder page's Section settings sheet (More → Section settings, PLT-2042) 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.

Bounds and budget (PLT-1777). The start route raises maxDuration to 60 s, mirrored in vercel.json ahead of the broad src/app/api/** 10 s entry. Every transaction the request can open is reserved out of it, each counted as its connection wait plus its timeout plus the cap on a statement still running past that timeout:

  • the principal-kind check that precedes the activation: 1 + 2 + 1 s;
  • the activation itself: 2 s to acquire, then an 18 s timeout and an 18 s statement cap;
  • the two audit replays a rolled-back start can open: 1 + 3 + 3 s each;
  • 4 s for the response.

So the transaction expires before the function does, and a rolled-back start still has time to write its audit records. An activation that needs more than 18 s of work rolls back whole, never half-created, with an error the modal reports and the same key retries.

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 block directly beneath this one, in the same sheet — see Where an operator sees a run's history and reverts it.

Usage insights (PLT-581)​

/wiki/insights reports how a space's knowledge base is actually being read, over a rolling window, and nothing else (PLT-2044). The page header reads Knowledge base over the title Usage insights; its toolbar holds a space control, a range control (7, 30 or 90 days) and a More menu. It covers 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.

Top to bottom, the page shows:

  1. a usage strip with the headline figures;
  2. Reads by day, the chart;
  3. Unusual activity, the open findings of the usage detector;
  4. Most read and Never read, two lists whose rows each carry an action menu;
  5. Needs attention, one link into the review inbox;
  6. one footnote — the only caveat on the page: Counts include only reads you are cleared to see. Days are in UTC, and the first and last day of a range are partial.

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 the tenant's knowledge base (PLT-1214) — else 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. While the knowledge base is still being determined the page waits, and if that lookup fails it says so with Try again instead of opening another space. The space selector lists the knowledge base first and still offers every space, because reads can be recorded against any of them.

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.

The usage strip is the page's one set of headline numbers (PLT-588, PLT-2044): Reads, Unanswered and the Unanswered rate, each shown once, with a badge giving a one-word verdict on the rate — No reads, Too few reads, High or Healthy. The raw unanswered count sits beside the rate because a rate alone cannot separate "2 of 4" from "500 of 1 000". The two token figures, Estimated tokens and Tokens per read, describe cost rather than whether the knowledge base answers, so they sit behind a Token estimates disclosure in the strip. All five are computed by the report over the whole window without a cap; the ranked facets (top-cited pages, never-read pages and so on) are capped pages of rows, so their lengths are never shown as totals. 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 unanswered rate (the report's miss rate) is the share of reads that found nothing groundable. It is judged High when it reaches the same threshold the KB usage service uses for its own miss-rate anomaly, and Too few reads below the same minimum number of reads (5), so the strip and the anomaly detector never disagree about the same number. A window with no reads shows zeros, an em dash for the rate and No reads, never NaN.

The miss rate is the true-empty miss only (PLT-1798). A read whose question matched no topic and no page still returns content (the first spokes and the newest pages), so it is a hit there. The report counts those reads in a separate facet, zeroRelevanceRate: { reported, zeroRelevance, rate }, which this page does not show yet:

  • each read records the retrieval's zero-relevance verdict in wiki.kb_reads.zero_relevance, NULL when the emitter reported none;
  • rate is zeroRelevance / reported, over the reads that carry a verdict rather than every read, and null (not measured) while none in the window does. Reads recorded before Project Tracker forwards the verdict (PT-1270) carry none, so compare reported with the miss rate's total to see how much of the window the rate covers;
  • the daily rollups carry both counters, so rolling a day up never changes the rate.

A KB query miss now says which kind it is (PLT-1798). The read path posts reason (no_content for a space with nothing to return, zero_relevance for a populated space where nothing matched) and the read's declared readPurpose. Both kinds count toward the same nomination threshold, and each topic keeps zero_relevance_count beside miss_count; a demand nomination's details show it as Times nothing in the KB matched. A miss whose read declared ci-review-grounding or session-orientation is not demand — a machine chose the question — so it is acknowledged with recorded: false and nothing is written, the same list the promotion signal excludes (PLT-1796). A miss that declares no purpose still counts.

A busy answer is retried once before you see it (PLT-1723). The wiki serves each instance from one pooled database connection, and the page fires the report beside the four anomaly listings, so the report can lose the connection to them and be answered 503 KB_USAGE_BACKEND_BUSY — the report never ran. The page then waits until those listings' first attempts are over, and at least two seconds, and asks once more; the anomaly section's own busy retries wait for that second answer, so the two never queue behind each other. Only that code is retried: a report that ran out of budget, any other error, and a second busy answer are shown as before, with Try again. A plain load issues the report and each listing once — the page no longer re-issues them when your sign-in finishes resolving, only when the person, organisation, tenant or clearance actually changes.

Reads by day (PLT-582)​

Beneath the usage strip, 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.

The strip's three figures survive that: Reads, Unanswered and the Unanswered rate come from a separate total and stay correct. The two Token estimates are computed from the per-day figures themselves, so when those cannot be read both are withheld — shown as an em dash — rather than presented as numbers derived from data the page has just said it cannot vouch for.

Unusual activity (PLT-583, PLT-591)​

Under the chart, /wiki/insights lists the open findings the daily KB usage detector raised for the space in view, under the heading Unusual activity. Each finding is named in plain language rather than by its check type:

Check typeShown asWhat it reports
kb_miss_rate_spikeSpike in unanswered questionsThe space-wide miss rate jumped.
kb_query_volume_spikeSurge in questionsAn actor read far above the space's baseline.
kb_degenerate_query_loopSame question asked repeatedlyAn actor re-asked one question in a loop.
kb_promotion_signalPage read oftenDemand for a synthesis page — the opposite of an operational fault.

The promotion signal fires for a synthesis page that at least five distinct sources sought in the window — declared sessions, plus distinct questions from reads that declared no session, counting no CI grounding or session-orientation read — and that is not cited on more than 15 % of all reads, the mark of a page retrieval delivers by default (PLT-1796; the lint reference gives the reasons and the costs). It asserts no defect; demand is the whole of its evidence, and its sentence states the breadth and the share it qualified on. Each row carries the severity as a labelled chip (never colour alone), 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. An escalation is the thing nobody should have to scroll for, and a demand signal is not urgent.

Every row, of every type, has Open in review (PLT-2044). 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. All four types are triaged in the same Needs review bucket, so the hand-off resolves for each, and a finding resolved since the read still links — the queue falls back to the plain bucket. Rows are stacked rather than laid out as a table, so the action stays on screen at every width. The list shows five rows, with Show all N for the rest.

There is no page or identity column. 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. A raw actor id is not something a reader can act on, and naming the actor needs the producer change PLT-949 tracks.

The list follows the range control, so it covers the same window as the strip. Findings are read one check type at a time and capped, so when a type fills that cap the section says Showing the newest activity only. rather than presenting the list as complete. A findings read that fails says so and offers a retry, and never renders as Nothing unusual — and it leaves the usage strip, which comes 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 section reporting what is currently unusual.

A fifth signal never appears here, by design. The detector also looks for an UNCLASSIFIED actor whose reads keep coming up empty in a space that holds classified content. A finding on this list would tell every tenant member that the space holds such content, so that signal goes only to the access-scoped audit log (wiki.kb.overclearance_pattern_detected). Deciding whether the space holds classified content at all is a privileged read past the sweep's clearance, so every time that check runs the database function performing it files one wiki.kb.classified_content_probe_applied_outside_clearance_scope entry, before it reads. It names the space and the check, never the answer, and it is filed whichever way the check comes out, so neither its presence nor its count says anything about the space (PLT-1292). Since PLT-1414 the function writes the entry itself, so a direct call to it is recorded too, and the entry no longer publishes an audit.entry.created event. Since PLT-1576 the function has one signature, which takes the actor type and the entry id: the older 2-argument form, kept for one release for the build a deploy replaced, derived the actor type from the session and is dropped (migration 20261105182543).

Most read and Never read (PLT-587, PLT-2044)​

Below the unusual activity, the report's two ranked facets are shown as two stacked lists. Each shows five rows, with Show all N for the rest, and carries the caption Showing the top 20. only when the list reached the server's cap (20 by default) — a shorter list is the whole answer. 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 after the cut would leave the list disagreeing with its own truncation.

Most read is the report's topCited facet: the synthesis pages agents actually cited, most cited first, with the read count on each row. Index spokes are cited too, but they are a separate facet this list does not show, so an empty Most read means no synthesis citations. 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 names and links that page. Where it matched more (Matches N pages) or none (No longer matches a page), the row shows the slug and links nothing, because the report deliberately refuses to name one of several candidates.

Never read is the report's neverRead facet. Every row names a real page; a page that is not an ordinary synthesis page is labelled with its kind (for example Index page), because the two are not actioned alike — spokes are regenerated rather than archived.

Every row has a ⋯ action menu (PLT-2044) — a dropdown on a mouse, a bottom sheet on a phone:

ItemOffered onWhat it does
Open pageevery row that names one pageOpens the page.
Find pagesa Most read row whose slug matched several pages, or noneSearches the wiki for the words in the slug.
Archive…Never read synthesis pages onlyArchives the page after a confirmation dialog.
Suggest a mergeevery Most read row, and Never read synthesis pagesOpens the review inbox for this space, where merges are judged.

Archive… is offered only on settled evidence that the write is allowed, and fails closed. It needs the page-write permission (read from the page list's canWrite) and a space settled as not a knowledge-base space, because a knowledge-base space refuses direct status changes. Both are read only when a row could be archived at all; a pending or failed read offers nothing. Neither check is the boundary — the server re-checks both inside the write. The confirmation is a dialog titled Archive this page?; a conflict, a page that can no longer be archived or a page that has gone is reported in words, and an archived row leaves the list.

"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. On the page, the clearance qualifier is carried by the one footnote; the others are why archiving asks for confirmation and merging goes through review.

An empty list is a real answer rather than a gap, and the two say different things: No cited pages in this range means nothing groundable was cited, while No unread pages found means nothing eligible is left to act on — not that every page in the space has ever been read.

Both facets can shift across the PLT-471 read-path update. 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. The cutover is a deployment moment recorded nowhere, so the page cannot name it and no longer carries a per-list warning about it; keep it in mind when reading a long range from an older deployment.

Needs attention​

Below the lists, one Needs attention block links to the space's Needs review queue with Open review (/wiki/review, narrowed to the space with ?space=<slug>). It replaces the per-kind curator-findings counts the page used to show (PLT-585): counts across many finding kinds are a number with no meaning here, and the review inbox already shows them per bucket. The block is rendered whenever a space is resolved — also when the usage report fails, so the hand-off survives a failed read.

Every lint pass writes an audited read for its merge candidates (PLT-1300). To tell a page with one source from a page with a second source you cannot see, the lint pass counts each derived page's derived_from edges with wiki.derived_from_out_degree, which reads past your clearance. So a lint pass — the daily curator sweep or a manual lint — files one wiki.lint.derived_from_degree_read_performed entry per run, naming the space, filed at RESTRICTED. It carries nothing the count found. A verification you ask for — verify-now or a reviewer's recheck — files this entry too, beside its own wiki.lint.verification_crossing_read_performed entry, which is written only when the verification finishes. Since PLT-1539 the audited 5-argument form of the function writes the entry itself, so a direct call to that form is recorded too; the 2-argument form the previous build calls records nothing until PLT-1637 drops it. Every pass that needs the counts reads them in a short transaction of its own that commits before the pass begins, so the entry stands even when the pass then rolls back; a verification whose finding names no space to lint reads none and files none. Only when that short transaction fails is the entry rewritten in a fresh one, by the lint tool, verify-now or a recheck. The scheduled verification sweep, which files the entry as well, rewrites nothing: a failed degree read there is reported like any other failed space pass. The entry no longer publishes an audit.entry.created event.

The More menu: relationships and added sources​

Two jobs that are not knowledge-base usage open from the toolbar's More menu, each in a sheet that slides in from the right on a desktop and up from the bottom on a phone: Relationships and Added sources. Both sheets mount the existing sections unchanged, so their reads, retries, Undo confirmation and audit behaviour are exactly what they were, and neither reads anything on page load — each reads only when its sheet opens. These are temporary homes: the page details sheet (PLT-2042) and knowledge-base administration (PLT-2045) are building the permanent ones, and each menu item becomes a link there once its home lands.

Added sources: the ingested-sources ledger (PLT-584)​

The Added sources sheet lists the space's ingest batches, newest first — 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 sheet 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 page's range control, which scopes the usage figures only.

Relationships (PLT-1068)​

The Relationships sheet lists how the space's pages reference each other, read from GET /api/spaces/:spaceId/relationships. It is the first form of the knowledge-base correlations map: a filterable list rather than a diagram.

  • One row per link, read as a sentence. Each row reads From → relationship → To — for example Pricing model · Depends on · Currency rules — with the stored link type (depends_on) beside the wording as text. Nothing is carried by colour or position alone. Both pages are links. A page in another space is shown with the line in <space name>.
  • Grouped by topic. Rows sit under the topic of the page they start from, or of the page they end at when they start in another space, so each link appears once. The topics come from the pages you can read, and the section says they can differ for another reader.
  • Only what you can see. The section says above the rows that it shows only links between pages you can read, and that another reader may see more or fewer. There is no row, count or placeholder for a page you cannot read. When the server's caps cut the read, a notice says the view is partial and names the caps.
  • Filters. Narrow by relationship type, or by page title (either end, ignoring case and accents). A filter that matches nothing says so and offers Clear filters.

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.