Skip to main content

Wiki lint — the honesty loop

The lint pass (PLT-200) keeps a compounding wiki space truthful: contradictions, stale claims, orphan pages, hallucinated cross-references, and rotten provenance become queryable structured rows in wiki.lint_findings — never prose buried in a page body that a future ingest would overwrite. Retrieval can join against open findings and down-weight affected pages (ranking integration lands with PLT-201).

It is one of the two preconditions (with source retraction, PLT-204) for ever enabling autonomous ingest on a space.

Since PLT-255, wiki.lint_findings is the single finding store for the whole module: the lost_source findings written by source retraction live in the same table.

One store, two questions. A page has an open-findings inventory and a needs-review verdict, and since PLT-590 they are not the same set:

  • Inventory — every row with page_id = <page> and status = 'open'. Surfaced as openFindings on GET /api/pages/:id, as openFindingCount on POST /api/pages/signals, as the pages-list findings column, as the "N open findings" label on the home feed, and — since PLT-559 — as the Open findings panel in the page editor's metadata sidebar. It is deliberately unfiltered: a curator reading a page should see everything open on it, including what speaks in its favour.
  • Verdict — the subset of that inventory whose check_type degrades the page's confidence, per the exhaustive DEGRADES_PAGE_CONFIDENCE map. Every needs-review MARK applies that one rule, but they do not all read it from the same place, and the difference is worth keeping straight: the page tree's warn triangle, the search-result review chip and the section index's "Needs review" badge read needsReviewCount on POST /api/pages/signals, while the ⚠ needs review (<check types>) annotation in the rebuilt space index and the page's needs-review banner hold the finding ROWS and filter them directly (buildNeedsReviewMap and needsReviewFindings respectively). One rule, one map, three carriers — a surface holding rows filters them, and a surface holding a count is given the filtered count.

Until PLT-590 every page-anchored check type asserted a defect, so the inventory and the verdict were one set and the contract could be stated as a single query. kb_promotion_signal is the first type that does not — it names a page because it is heavily read — so the old equivalence would have marked the most-cited pages in a space as the least trustworthy. PLT-1155 separated the two on every surface; before it, only the space-index rebuild applied the filter.

A check_type the running build does not recognise degrades: the column's CHECK constraint and the TypeScript union are widened by different commits, and a redundant marker is visible to a reader while a missing one is not.

That panel is a reader and never a gate. It is RLS-scoped like any other caller, while the KB promotion gate reads through a SECURITY DEFINER function that deliberately is not, so the gate can refuse a promotion on a finding the panel cannot list. An empty panel therefore means "nothing to show for this page here", never that the page is clean or promotable, and it is worded that way.

Data model​

One row per finding in wiki.lint_findings:

ColumnMeaning
space_idThe linted space.
page_idThe page the finding is about (nullable — space-level findings have no subject page).
target_page_idThe other page involved: the contradicting page, or the drifted provenance target.
check_typeOne of the check types below.
severityinfo / warning / error.
statusopen → resolved / dismissed. A status flip with attribution — never deleted.
detailActionable description quoting/locating the offending claims.
detail_jsonMachine-readable payload (PLT-255). See the Verifiable payloads for the six types whose payload a decision's condition fingerprint is projected from. For lost_source: { retractedPageId, reason, depth, retypedFrom? }; for lost_space_reference: { spaceId, spaceSlug, reason, lostLinkCount }; the usage anomalies carry their own (below); {} otherwise.
detected_bywiki-lint for the mechanical pass; an agent principal (e.g. wiki-curator) otherwise.
page_revision_numRevision of page_id at judgement time (staleness anchor).
source_anchorWhere in the page body the finding is (PLT-560) — see Source anchors below. Nullable; a finding about a whole page has none.

At most one open finding per (space, check type, page, target page) — enforced by a NULLS NOT DISTINCT partial unique index. Resolved/dismissed history accumulates freely. lost_source is the one exception: a page can derive from several retracted sources, so its open-dedup unit is (page, retracted source) instead — enforced by a dedicated partial unique index over detail_json->>'retractedPageId' (the retracted page cannot be the target_page_id anchor: it is soft-deleted, and the RLS visibility gate would hide the finding).

Findings are RLS-protected like page_links: a finding referencing a page above the caller's clearance (or soft-deleted) is invisible, so the findings table cannot serve as an oracle for classified pages.

Space-level findings (no page_id / target_page_id) have no page for RLS to clearance-gate and are visible to every tenant member — the same effective classification as the space's reserved log/index pages (spaces carry no classification). Writers must therefore keep space-level detail text at UNCLASSIFIED discipline; a finding about classified content must be anchored to the classified page via page_id so the policy hides it from under-cleared readers. This is enforced as a convention (wiki-curator hard rule 7), mirroring the existing convention for the space log.

Candidate outcomes (PLT-679)​

status has three values and the ingestion review queue takes four decisions, so an approval, a rejection and a terminal exclusion all land on resolved and the difference is unrecoverable. wiki.ingestion_candidate_outcomes records the decision itself — one immutable row per ingestion_candidate finding, keyed on finding_id with no surrogate id.

ColumnMeaning
finding_idThe nomination this decision closes. Also the primary key.
outcomeapproved / rejected / dismissed / excluded.
reasonWhy. A !~ '^[[:space:]]*$' CHECK rejects any POSIX-whitespace-only value; NOT NULL admits '' and btrim strips spaces only, so both would admit a tab. U+00A0 is outside that class and is deliberately not covered.
decided_byThe acting principal: a human id, or an agent principal such as wiki-curator.
reviewed_stateH9's reviewedState token — the opaque rs1:<sha256-hex> string, stored whole and never parsed. Null for a page-less candidate, which submits none.
reviewed_classificationThe nominated page's classification at decision time; UNCLASSIFIED when page-less. Frozen, and gated on — see Visibility.
ingest_batch_idThe wiki.ingest_batches row an approval produced; NULL for every other outcome.

dismissed is not a synonym for rejected. The resolve surfaces already offer status: dismissed for a false positive, so folding the two together would record "this nomination is noise" as "this page was judged and refused".

One decision per finding, and that is not one per page. Only open findings dedupe, so a page re-nominated after its reviewed state changed produces a new finding and therefore a new outcome row. What the primary key forbids is rewriting a decision in place.

approved carries a batch reference and no other outcome does — a biconditional CHECK, not "a batch where one exists". Every approval flows through an ingest, and an approval without provenance would be indistinguishable from one the reader is not cleared to see.

Visibility. The table is FORCE ROW LEVEL SECURITY with a permissive read policy, a permissive insert policy, the constitution's canonical ..._tenant_isolation policy declared AS RESTRICTIVE (it is ANDed, so it can only narrow — a permissive FOR ALL here would grant back everything the other two withhold), and no UPDATE or DELETE policy; the runtime role is granted SELECT, INSERT only. A read is gated on the visible finding, so an outcome inherits whatever gate the finding itself carries: a page-backed candidate is clearance-gated through its page, while a page-less demand-gap candidate is visible to every tenant member — 012's standing rule for a finding with no page, which is why its reason must be written at UNCLASSIFIED discipline. An approved row is additionally gated on the linked batch's classification_ceiling, since a batch's very existence is hidden above the ceiling, and that gate does not depend on a page. Counts also exclude decommissioned spaces, matching the finding aggregate. The write is deliberately not ceiling-gated: an approval may legitimately produce a batch classified above the approving session's own clearance, so that row is written and then invisible to its own writer. A read is also gated on reviewed_classification, and that is not redundant with the finding gate: the finding follows the page's current classification, so a page that is rewritten and legitimately downgraded would otherwise expose the reason a curator wrote while it was classified. The two cover opposite directions — the finding gate closes on an upward reclassification, this one on a downward one. The value is pinned by the insert policy against the page as it stands in the insert transaction, so a writer cannot understate the classification of a page it names.

The database does not verify the token — the WRITER must

The insert policy checks that a page-backed outcome has a reviewed_state token. It does not check that the token is the one derived from that page state: any non-blank string is accepted, and an integration test pins that fact deliberately.

So the classification pin is not self-sufficient. A writer that skips the staleness comparison can take a token for a page reviewed at SECRET, wait for a legitimate downgrade, and submit that stale token with the page's now-lower classification — which the policy accepts, storing a reason written from classified content at the lower gate.

Closing that is the writer's obligation, not the schema's. Since PLT-680 every decision goes through CandidateOutcomeService, which takes the page's FOR NO KEY UPDATE row lock and only then derives the token — so the classification it pins and the revision that token is built from describe one page state which cannot move between them.

What it does with the token then depends on where the token came from, and the two are not the same act:

  • a stated rejection or dismissal submits a token, which is compared whole-value against the freshly derived one and refuses the write on any mismatch — this is the case the caution above is about;
  • an exclusion submits none, because nothing was displayed to a reviewer, so the token is derived and stored rather than compared. There is no stale submission to catch: the exclusion is itself the decision.

H8a owes the comparison for an approval. A new decision-recording path goes through that service rather than reproducing either half, which is the point of there being one module.

The comparison is deliberately not in the policy: expressing it in SQL means re-implementing H9's canonicalisation and digest in a second language, and a policy cannot take the lock that makes the comparison meaningful. Tracked as PLT-868.

A candidate carrying a target_page_id is refused an outcome, loudly — and that refusal is what the caution above cannot be replaced by for the nominated page. The token fingerprints the nominated page alone, so for a target there is no comparison any writer could perform, however careful: a target reviewed at SECRET could be downgraded before the decision is written, and a classification snapshot taken at write time would record the lowered value with nothing to catch it. Refusing is the only honest answer for a state the ledger cannot faithfully record. Nothing produces such a candidate (a nomination has no "other page"), and the record boundary rejecting targetPageId for this check type is tracked separately.

A null outcome from a read is therefore genuinely ambiguous — undecided, predating the table, hidden by the batch ceiling, or hidden because the decision was taken at a classification above the reader's clearance — and callers must not read it as "undecided".

Deleting is the operator's job, not a cascade. Both foreign keys are NO ACTION: an outcome is the re-nomination suppression key, and cascading would erase a recorded decision behind the operator's back. The cost is that any purge must delete outcomes first — wiki.pages cascades to wiki.lint_findings, and that cascade is refused while an outcome references the finding. The eval-space reset does this explicitly.

No backfill. Findings resolved before this table stay outcome-unknown and are reported as such; their status does not say which decision was taken, and guessing would corrupt the record.

What the resolve response says it recorded (PLT-986)​

PATCH /api/spaces/:spaceId/lint/findings/:findingId returns the flipped finding as data and, in meta.candidateOutcome, what it did with the candidate decision the request carried:

{ "recorded": true, "findingId": "…", "outcome": "rejected", "status": "resolved", "reasonRecorded": true }
{ "recorded": false, "reason": "not_requested" }

outcome is the value as written to the ledger, carried out of the service that wrote it — not a mirror of the request, which the caller can already read back. status comes off the row the flip returned. reasonRecorded says prose was persisted beside the outcome; the prose itself stays in the ledger row, which is gated on the decision-time classification while this response is not.

Read its ABSENCE as "this wiki predates the acknowledgment", never as "nothing was recorded" — recorded: false is what says the latter, and a bare status flip or an ordinary finding decision (PLT-1131, which has its own ledger) reports it. This closes a case that was previously undetectable from the client: a deployment carrying the reviewedState projection but not the H7b write would honour the filter, project the key the capability probe reads, and still discard the outcome — the probe checks the read projection and cannot see what the write path stored.

It rides beside data, never inside it. The row in data is the persisted finding, which the listing surfaces parse with the same envelope; folding a per-request receipt into it would change their wire shape and would read as part of the stored finding.

When the listing answers 503 (PLT-1400)​

GET /api/spaces/:spaceId/lint/findings and GET /api/lint/findings answer 503 with code LINT_FINDINGS_BACKEND_BUSY when the listing's transaction could not acquire a database connection in time. The wiki runs on a single pooled connection per instance, so a concurrent heavy read on the same instance (the KB usage report holds it for up to 4 s) can make a healthy listing fail to start. The read never ran: retry the same request shortly. Any other failure keeps its own status. The /insights anomaly section does exactly that, once per check type.

Closure disclosure and the cutover stamp (PLT-1131 / PLT-1193)​

A human closure normally lands in the same transaction as its row in wiki.finding_decisions, so the closure decoder (wiki.lint_finding_closure) masks its actor, timestamp and kind at that decision's ceiling. A human closure with no decision row is decoded against one deployment-wide instant, wiki.finding_decision_cutover.reached_at:

  • Before the instant, or while it is unset — the closure is treated as pre-ledger history and disclosed at a proxy ceiling: the highest of the finding's captured classification and every page it references.
  • After the instant — the shape contradicts the same-transaction invariant, so the decoder fails closed and withholds the actor, the timestamp and the kind from every reader.

The instant is not set by a migration: migrations run before the code deploys, and the boundary is the moment the rollout completes. It is stamped once per environment, after the wiki deploy, by the post-deploy step db:stamp-finding-decision-cutover (requires CONFIRM_APPLY=1); every release re-runs the read-only db:stamp-finding-decision-cutover:check so an environment provisioned later is stamped too. Until an environment is stamped, the fail-closed branch does not engage there. Once set, the instant is the database's own clock at stamping and a direct statement cannot move it or remove the row. Procedure: the release skill's post-deploy repairs reference.

The decoder is the only read path for closure metadata (PLT-1159). Since migration 075_revoke_lint_finding_closure_select.sql, the runtime role constellation_app holds SELECT on wiki.lint_findings column by column — every column except resolved_at and resolved_by — rather than on the table. A direct read of either column, a SELECT *, or a RETURNING * against the table is refused with permission denied, so the masking above cannot be bypassed by querying the base table. Writes are unaffected: the runtime role still stamps both columns when it closes a finding; it only cannot read them back. A migration that adds a column to this table must also grant SELECT on that column to constellation_app, or every read that projects it fails.

Source anchors​

A finding says what is wrong. Since PLT-560 a page-backed finding may also say where, in source_anchor. That is the data an editor needs to mark the offending text inline rather than only list the finding in a panel; the API stores and serves it, and the inline rendering itself is E12's.

The selector is a hybrid position-plus-quote anchor, the W3C Web Annotation shape reduced to what this module needs:

{
"start": 412,
"end": 435,
"exact": "The disputed claim.",
"prefix": "Lead-in sentence. ",
"suffix": " Trailing sentence."
}

start / end are zero-based, end-exclusive JavaScript (UTF-16) offsets into the raw markdown of pages.body_md — not into rendered text, and not into an editor's own document coordinates.

Because those are code-unit positions, an astral character such as an emoji spans two of them. No boundary is ever left inside such a pair: a derived context window is pulled inward by one unit rather than keeping half a character, and a caller-supplied range that splits one is rejected (400) rather than silently adjusted. A lone surrogate would not survive the jsonb cast — PostgreSQL refuses an unmatched escape — so this is a storage invariant, not a cosmetic one.

They are a hint, not the identity. The identity is the prefix + exact + suffix triple, and every read re-checks it against the body as it is now:

OutcomeanchorStatusWhen
SurvivedresolvedThe quote and both context windows still match — at the stored offsets, or at exactly one other place if the body shifted. resolvedAnchor carries the current range.
InvalidatedinvalidThe text was deleted or rewritten, its surroundings changed, or — once the stored offsets no longer match — more than one place in the body matches the quote and both context windows. Ambiguity invalidates rather than guessing. (A copy appearing elsewhere while the anchored text has not moved is not ambiguity: one candidate is the original, still in place.)
Never anchoredunanchoredsource_anchor is null.

source_anchor is returned only when the anchor resolves. Every invalid outcome answers with source_anchor: null — the finding still surfaces with its detail intact, the excerpt does not.

exact, prefix and suffix are verbatim page text carrying no classification of their own. A resolved anchor is the one case where that text is provably still in the body being returned in the same response, so it discloses nothing new. An unresolved one is a copy of some earlier revision — and revisions are deliberately read-gated on their own classification rather than the page's, so a SECRET page later rewritten and downgraded would otherwise hand its old text to an UNCLASSIFIED reader through the finding, the anchor having stopped resolving precisely because the page was rewritten.

What the editor does with a resolved anchor (PLT-562)​

A finding whose anchorStatus is resolved is marked inline in the page editor, over the passage its anchor quotes. Hovering the mark previews the finding — severity, check type and detail; clicking pins that popover open so it survives the pointer moving away, and Escape or a click outside dismisses it.

Two properties of the mark are worth knowing when reading a finding's anchor:

  • It is drawn over the quoted passage or not at all. The editor holds a parsed document, not the raw markdown, so a quote whose markdown syntax the parser consumed is not found there and the finding is simply not marked. So are an ambiguous quote and one that only matches across a paragraph break. Nothing is lost when that happens — the finding keeps its place in the editor's findings panel with its detail intact, which is also where every invalid and unanchored finding lives.
  • The mark takes nothing from the author. It is not a focus stop and claims no keystrokes, because a focusable element inside the editable body carries the caret with it. The finding is exposed to assistive technology as a description on the marked text rather than as a control, so the popover's own content is reachable by pointer only; the findings panel lists every finding either way.

Resolution is a projection, computed on every response that carries a whole finding — the findings list, the openFindings block of GET /api/pages/:id, and the record / resolve responses themselves — and it never writes back to the finding row. A page save does not touch anchors, deliberately: a metadata-only edit creates no revision, so there is no write event that reliably means "the body moved."

An anchor requires page_id, enforced by the database as well as the API. A page_id IS NULL finding is visible to every tenant member (the RLS policy's page-visibility clauses are vacuous there), and exact / prefix / suffix are verbatim page text — so an anchor on such a row would publish body text to readers the page itself is hidden from.

Who populates it. broken_crossref anchors automatically. The scan keeps one candidate per distinct broken target — its first occurrence in the body — and the finding anchors at the first of those that can be anchored. A link whose [[…]] source span exceeds the quote cap cannot be, so a target that first appears as such a link contributes no candidate even if it recurs in anchorable form later; the finding then anchors at the next broken target, or stays unanchored if there is none. Detection is unaffected in every case: the finding stays one-per-page and its detail still names every broken target. orphan_page, provenance_drift, merge_candidate, the nomination types and the usage-anomaly types describe a page or a pair of pages rather than a passage, so they stay unanchored. A curator may supply sourceAnchor: { start, end, exact } on POST /api/spaces/:spaceId/lint/findings (and on record_lint_finding over MCP) — the server re-reads the body under the caller's own clearance and rejects the request with 400 if body.slice(start, end) is not exactly exact, deriving the context windows itself. Omitting the field records the finding unanchored, which is always allowed.

Check types​

Mechanical — computed by POST /api/spaces/:spaceId/lint; machine-owned (the recording endpoint rejects them). This is the pass the daily Vercel cron (apps/wiki/src/app/api/cron/curator-lint/route.ts, schedule 0 7 * * *) runs automatically over every agent-owned space — no human/agent invocation needed:

Check typeDetects
orphan_pageA page with no parent, no children, and no typed links in either direction (reserved index/log pages excluded).
broken_crossrefAn [[…|id:<uuid>]] wikilink whose target page does not exist or is not visible — the hallucinated-link case. Links inside a fenced or indented code block are examples, not navigation, and are not checked (PLT-1240).
provenance_driftA derived_from link whose target has newer revisions than the link's source_revision_num — the claim may be unsupported.
merge_candidateTwo derived pages (a synthesis + a source_summary, or two summaries) deriving from the SAME single source with heavy text overlap (PLT-270) — deterministic, no LLM needed. Archived pages are not candidates (PLT-1161), so archiving the folded page after a merge ends the condition and the next pass auto-resolves the finding.

The mechanical pass is convergent: re-running it opens findings for new defects, refreshes the detail of persisting ones, and auto-resolves (resolved_by = 'wiki-lint') findings that no longer reproduce.

Curator-judged — the expensive LLM judgement pass, recorded by the wiki-curator subagent via POST /api/spaces/:spaceId/lint/findings after reading the pages. This pass is not on the daily cron — it runs per-release and ad-hoc, agent/human-invoked (PLT-265/PLT-268):

Check typeMeaning
contradictionTwo pages assert incompatible claims — including cross-source (different derived_from).
stale_claimA claim the rest of the space / world shows is no longer true. Recording this finding is how a page is marked stale — the page body is never edited to say so.
missing_conceptAn entity/concept referenced repeatedly but never given a page (space-level).
missing_crossrefTwo clearly related pages with no link between them.
data_gapA question the space should answer but cannot.
near_duplicatePages covering the same topic but NOT sharing a single derived_from source (PLT-270) — needs an LLM to judge "same topic," unlike the deterministic merge_candidate. The repository primitive intended as the curator's escalation set (findCrossSourceNearDuplicateCandidates) skips archived pages (PLT-1161), but it has no caller yet, so nothing about how this type is raised today has changed. Being curator-recorded, an open near_duplicate is never auto-resolved either — archiving a page leaves it open.

Nomination — the KB ingestion-candidacy funnel (PLT-269/PLT-290). Not mechanical (the daily cron reconcile leaves it untouched — a page staying eligible keeps its open nomination until a human acts) and not auto-recordable by the curator surface in the general sense, but curator-recordable as of PLT-290 Layer 2:

Check typeMeaning
ingestion_candidateA nomination — "this page looks worth ingesting into the knowledge base." Machine-recorded by the deterministic candidacy event handler (detected_by = 'wiki-candidacy') or the demand-driven query-miss service (detected_by = 'wiki-demand'), and also curator-recordable by the per-release LLM pass for pages in the ambiguous middle Layer 0/1 skipped (detected_by = 'wiki-curator'). Always a suggestion — auto-NOMINATE, never auto-INGEST; a human reviews and one-click-approves (PLT-257) via ingest_source.

Reader-initiated — the one check type a caller with no wiki:page:write grant can produce (PLT-548). Not mechanical (nothing reconciles it: a request is a point-in-time human act with no condition to re-evaluate, so the daily cron leaves it alone) and not curator-recordable (POST /api/spaces/:spaceId/lint/findings refuses it — this type has exactly one producer):

Check typeMeaning
request_changeA reader asked the curators for an edit — "I can read this page, something is wrong with it, please look." Written only by POST /api/pages/:id/request-change. Severity info, always: a request is a human signal, not evidence of a defect, and error would put it inside the KB promotion gate, letting any reader freeze a page's promotion by asking a question about it. detected_by is the constant wiki-reader-request — the requester's identity is recorded in the audit entry and nowhere else that ties an identity to a request (wiki.request_change_attempts holds an actor id for the per-actor attempt bound, but records only that an actor made attempts — never what was asked or about which page — and only that actor can read its row; PLT-1081), because this row is readable by every tenant member who can read the page. Never mutates the page. Carries captured_classification — the tier the page held when the note was written — because the note is free text about the page and the finding policy otherwise follows the page's current tier, so a later downgrade would publish it to every UNCLASSIFIED member.

System — written only by database-side machinery; listable and resolvable through the lint surface, never recordable through it:

Check typeMeaning
lost_sourceThe page derives (transitively) from a retracted source (PLT-204/PLT-255). Inserted by the SECURITY DEFINER wiki.retract_source_execute; detail_json carries the retracted page id, reason, and depth.
lost_space_referenceThe page links into a space that was decommissioned (PLT-205) — its reference target has been torn down and the referencing claims need re-verifying. Inserted by the SECURITY DEFINER wiki.decommission_space on every external live page that linked into the dead space, anchored to the external page's own space, severity warning. detail_json carries { spaceId, spaceSlug, reason, lostLinkCount }. One open finding per external page (a second decommission touching an already-flagged page de-dupes).
source_summary_classification_inversionA live source_summary page classified strictly below the revision it derived_from (PLT-913). A source_summary is defined as a digest of one source, so an inversion there is close to certainly declassification-by-summary. Severity warning.
derived_page_classification_inversionThe same inversion on any other deriving page type (PLT-913). A generic deriving page may cite a source for one paragraph, so the same rank comparison is weaker evidence — which is why the two are separate types rather than one. Severity warning.

Both classification types are inserted by the SECURITY DEFINER wiki.reconcile_classification_lint (migration 050), which runs inside every space lint — the daily cron's and a manual one alike — immediately after the mechanical reconcile and in the same transaction.

Why a definer function rather than an ordinary collector. The daily curator-lint cron is pinned to UNCLASSIFIED, so it cannot see the classified source that makes an inversion an inversion; and because lint_findings_tenant_rw applies its both-pages-visible predicate to WITH CHECK as well as USING, such a caller could not insert a row naming that source even if handed its id. Detection and write therefore happen together inside the function, which returns void — no counts reach the caller, so LintRunReport and the LINT_RUN audit entry carry no classification numbers.

Every run of the reconcile is audited, whatever it finds (PLT-1416). Because the function reads pages above your clearance, constitution §5 requires an audit entry for the read. Every space lint that reaches the reconcile, and every verification that runs it, files one wiki.lint.classification_scan_performed entry. The entry names the space and the function (context.check: 'wiki.reconcile_classification_lint'), is filed at RESTRICTED, and carries nothing the reconcile found. A space with an inversion and one without leave the same entry. If the lint then fails and rolls back, the findings and the LINT_RUN entry are undone, but the read already happened. So the entry is written again in a fresh transaction. The LINT_RUN entry is not this record: it counts only the mechanical pass. The entry is written by the lint tool, so a direct SQL call to the function, outside the tool, writes no entry. Moving the entry into the function itself is PLT-1430.

They are reconciled, unlike their neighbours here. lost_source and lost_space_reference are written once by a lifecycle event and stand until a human resolves them. The classification pair is re-evaluated on every lint: what still reproduces stays open, what no longer does is auto-resolved — so dismissing one is not an exemption, and it reopens while the inversion stands. The durable exemption is a stored declassification approval — see Sanctioning a classification inversion.

The comparison is against the captured revision, not the source page's current tier. derived_from records the exact revision consulted, and wiki.page_revisions carries its own classification — gated by its own RLS so that reclassifying a page cannot expose the bodies it had under a different one. What a deriving page copied is that revision's content at that revision's tier. Comparing against the current tier would invent an inversion when the source was raised after the derivation, and silently clear one when it was lowered.

The finding carries the tier it was judged at. Detection uses the captured revision, but the standard finding policy authorizes a row through the current visibility of its two pages — and it has no status predicate, so a resolved or dismissed row is listed exactly as readily as an open one. When those facts disagree — a SECRET revision whose source has since been downgraded to UNCLASSIFIED — the row would be readable, and dismissable, by every UNCLASSIFIED member, permanently, since resolving a finding changes a status rather than removing a row. So a classification-inversion finding persists captured_classification, the tier of the revision it was judged against, and the read policy additionally requires the caller's clearance to reach it. The column is NULL for every other check type and constrains nothing there; it is immutable, because a writable ceiling is not a ceiling. This is the same shape the ingestion-candidate ledger uses for reviewed_classification: the page gate closes on an upward reclassification, this one on a downward one, and a row needs both.

Disclosure. page_id is the lower-classified deriving page (the one to fix) and target_page_id the source, so the ordinary RLS gate makes the finding readable only at or above both pages' current classification, and the captured ceiling above keeps it at or above the tier it was judged at. The detail names neither page and no classification value — defence in depth rather than the defence itself.

KB usage anomaly — emitted by the daily curator-lint cron's PLT-393 usage detector (detected_by = 'wiki-usage-anomaly'), not by the wiki-curator and not by record_lint_finding. These are point-in-time operational events computed from a rolling one-day window of wiki.kb_reads; they are listable and resolvable through the lint surface but are never auto-resolved by the mechanical reconcile. Two dedup layers prevent duplicate noise. Open-state suppression keys on (space, checkType, actorId, questionHash) (the actor and question components are null for anomaly types that do not use them): while a finding of that shape remains open, later daily windows are suppressed; after an operator resolves or dismisses it, a later window may re-fire if the anomaly recurs. The separate (space, checkType, actorId, window, questionHash) unique key makes detection idempotent within one daily window, including concurrent sweeps.

Check typeMeaning and detail_json payload
kb_miss_rate_spikeA space's KB miss rate crossed its threshold. Payload: { window, windowDays, totalReads, misses, missRate }.
kb_query_volume_spikeOne actor's KB read volume crossed the baseline multiplier and a single bucket crossed the absolute floor. Payload: { window, windowDays, actorId, readCount, maxSessionReadCount, maxSessionReadDeclared, spaceAverageReadCount }.
kb_degenerate_query_loopOne session asked the same question at least the configured threshold of times in the window (PLT-465) — not one actor across sessions. Payload: { window, windowDays, actorId, questionHash, repeatCount, maxSessionRepeatCount, maxSessionRepeatDeclared, sessionCount }, where repeatCount is the actor's window total and maxSessionRepeatCount is what fired. …Declared: false means the peak came from the collapsed undeclared bucket, which is every undeclared read together and not one session.

The Verifiable payloads (PLT-1195)​

The six Verifiable check types each carry a typed detail_json, because a decision recorded against one of them stores a condition fingerprint projected from that payload — the value SPEC-plt-1098 § D5 uses to decide whether the defect a curator said they fixed is still standing. Before PLT-1195 three of them carried no payload at all and a fourth carried the wrong one, so no such decision could ever settle.

Check typedetail_json payload
orphan_page{ pageId }
broken_crossref{ missingTargetIds } — every unresolved wikilink target on the page, sorted
provenance_drift{ sourceId, targetId, sourceRevisionNum, latestRevisionNum }
merge_candidate{ basis, candidatePageIds, suggestedPrimaryId, suggestedFoldPageIds, overlap }
source_summary_classification_inversion{ pageId, targetPageId, sourceRevisionNum, capturedClassification }
derived_page_classification_inversionsame as above

The payload is richer than the fingerprint, deliberately. The fingerprint is a projection of the payload that answers "which defect is this", so it excludes anything that moves while the defect survives: merge_candidate's overlap is a detection-time coefficient and is dropped (§ D5: identity, never magnitude), as are its role fields, which flip with page type. provenance_drift keeps the revision the edge cites and drops the one the target has since reached. The payload keeps both, because a curator reading the finding wants them.

An already-open finding gains its payload on the next reconcile, not at deploy. The mechanical pass refreshes detail_json alongside detail, and the classification-inversion reconcile updates it on conflict — so a row opened before PLT-1195 carries {} until its space is next linted. A decision taken in that window records no fingerprint and its verification stays pending, which is § D5's fail-safe rather than a defect.

Work this operational queue during the per-release curation pass and ad hoc when a spike appears. Resolve a finding only when an actual cause was fixed; dismiss it when triage proves expected traffic or another false positive. Do not leave either outcome open and do not delete the historical row.

Two false-positive classes that used to dominate this queue are now filtered at detection (PLT-465). The multi-LLM reviewer re-grounds the same PR-title-plus-changed-paths query on every push, and the session-start hook asks the same stable orientation question once per agent session. Both land under the human credential owner — every agent authenticates with a person's token — so (actorId, questionHash) alone could not tell many legitimate sessions from one stuck session, and on 2026-09-03 that shape was 212 of 212 open kb_degenerate_query_loop findings in the knowledge-base space.

Both detectors now judge the busiest single declared session rather than the actor's window total, which reads N sessions asking once as adoption and one session asking N times as a loop. Note what this deliberately does not do:

  • It is not an allowlist on read_purpose. That field is declared by the caller, so exempting it would let a genuinely stuck agent claim the CI purpose and never be flagged — and it would still flag the orientation query, which is a real interactive agent doing what the convention asks.
  • It changes nothing for a caller that declares no session. Those reads share one bucket and are judged exactly as before, so no detection was traded away.

When triaging a surviving finding, read maxSessionRepeatCount against repeatCount and sessionCount: a high total with a low per-session figure is healthy traffic that no longer fires, while a high per-session figure is the signal. The kb_usage top-query report labels each query with its declared purpose and session spread for the same reason — a grounding string is generated text, not a question a person asked.

KB promotion signal — the one check type the usage detector produces that names a page, added by PLT-590 (slice I9). Emitted by the same daily sweep and under the same detected_by = 'wiki-usage-anomaly' producer identity as the three anomalies above, but it is a separate family because its dedup key is a different thing: those key on detail_json — a window plus an actor that is NULL for the space-level kb_miss_rate_spike — and carry no page, so migration 026 gives them their own detail_json-keyed index, while this one's identity is the page column and it dedupes through the ordinary lint_findings_open_dedup_idx — which is why migration 067 widens the check_type CHECK and changes no index at all.

Check typeMeaning and detail_json payload
kb_promotion_signalA synthesis page was cited by at least the configured floor of DISTINCT KB reads inside the window — sustained demand worth a curator's eye. Payload: { window, windowDays, slug, readCount }. Severity info, always: this is demand, not evidence of a defect, and error would put it inside the KB promotion gate and let a popular page block its own curation. Anchored on page_id with target_page_id NULL.

Attribution is strict, and a handle that matches several pages produces nothing. Page slugs are unique per parent, not per space, so one cited slug can name several pages. The detector reuses the PLT-586 resolution rule and tightens it: where the usage report reports the ambiguity and offers no link, the detector emits no row at all. A finding naming one of several candidates would send a curator to a page nothing established, which is worse than saying nothing.

Spokes are excluded. An index_spoke is injected on every hub-backed read, so its count measures how often the index was consulted rather than demand for the page — the same reason the usage report keeps the two citation facets apart under separate limits. Only cited_slugs feeds this detector.

It asserts no defect and starts no ingest. "Promotion" is overloaded in this module — migration 040 uses it for a page-backed ingest into the KB — and this is not that: a page appearing in cited_slugs is already in the KB by construction. The row says the page is carrying load and asks a human to decide what that is worth (refresh it, split it, raise its rank).

Every qualifying page is planned, with no per-sweep cap (PLT-1156). The detector skips pages that already carry an open signal — the partial unique index would refuse the duplicate row anyway, but not the round trip — and plans a signal for every page that remains. The sweep writes its findings in small batches, each in its own transaction, so a busy space no longer risks one transaction expiring and losing every signal it had recorded, and no page falls below a fixed cut. A page the sweep's deadline does not reach is counted as deferred rather than written — see Bounded writes below.

Bounded writes (PLT-1156). Every class above — the three usage anomalies, the promotion signal, and the audit-only over-clearance detections — is detected once, in a read transaction, and then written in batches of ten, each in its own explicitly budgeted transaction. Inside a batch every finding is inserted before any audit entry is written, and each entry commits with the finding it describes. The over-clearance batches run first: that channel has no dedup, so a detection the deadline skips is absent from the record, while a deferred finding is re-detected while its condition holds. Planning also drops anomalies that already carry an open finding, so a space full of persistent ones cannot refill every batch with writes the dedup layer refuses. The daily sweep stops starting batches 40 seconds in, so a busy sweep ends inside the cron's 60-second cap: what it did not reach is counted per check type as deferred, logged with the space and window, and totalled in the sweep summary (usageAnomaliesDeferred, plus usageAnomalySpacesDeferred for spaces reached too late to be read at all). A deferred anomaly whose condition persists is detected again the next day; one whose condition does not is lost — but counted where it happens rather than cut silently.

Bounded passes (PLT-1248). The rule above is no longer the anomaly drain's alone: every per-space transaction the daily sweep opens — for the mechanical lint, the expiry reap, the anomaly detection and each space-log append — is bounded in PostgreSQL as well as in Prisma, so neither a long statement nor a wait for a lock can carry a pass past its deadline. Before this, a space the sweep admitted just before its 30-second work deadline could run that chain unbounded and be killed by the 60-second cron cap, which loses the run summary and the structured log that the ADR-040 § D5 audit waiver rests on — so a sweep that overran left no evidence of what it had done.

There are four deadlines, not one, because some of those transactions carry an obligation the work deadline must not cut off, and one of them can outlive its own window:

  • the work passes — the lint, the reap and the anomaly drain — end 40 seconds into the run. The reaper's floor is the highest of them, because its single transaction carries the archive, the index rebuild and an event plus an audit row per archived page;
  • the reap's own log line ends earlier still, 33 seconds in. It is the one pass that can return after its own window: a log append into a knowledge-base space that rolls back leaves a classification crossing to replay, and that replay is unconditional (see the third deadline below). Ending it 33 seconds in means even the replay behind it is done by 40 seconds, so it cannot reach into the window the lint's entry is promised. The cost is that a space whose reap finishes later than 28 seconds in gets no reap log line — its archive has still committed and is still counted in pagesReaped, and the refusal is counted in spacePassesSkippedBudget;
  • the lint's space-log entry is measured against a later deadline, so a lint that has committed always reaches its entry with at least a floor-sized window rather than being cut off for time. That is a guarantee about the attempt, not about the outcome: the append can still fail, and is then counted in logAppendFailures exactly as it was before. That entry is the space log's durable record of the day's pass; the reap's log line is observability, which is why it is the one that gives way;
  • the classification-crossing audit replay behind a refused append runs under a fixed bound reserved out of the run's audit cutoff, and is never skipped for budget: the crossing happened whether or not the append committed. It is sized as large as the layout allows while still leaving margin, because losing it costs a §5 record. Since PLT-1304 a replay that fails is counted in crossingReplayFailures — across every pass that can open one, the lint, the reap and the anomaly plan included — and the run then reports incomplete, as it does for every other audit entry left unwritten. That is still a narrowing: this path previously had five seconds and no database-side bound, so a wait on the tenant's audit chain head between three and five seconds now loses a record it would once have written.

A pass whose remaining window has fallen below its floor is not begun at all: it opens no transaction, it is counted in the summary as spacePassesSkippedBudget, and the run reports incomplete. A pass that is begun can then end three ways, and they are reported apart.

A transaction cancelled by its own statement_timeout rolls back, and where it is counted depends on what sized its bound (PLT-1304):

  • when the run's remaining window sized it — the run was running out of time — it is counted in spacePassesCutShort, not with its stage's failures, does not count its tenant as failed, and the run reports incomplete. The next daily run begins it with more room and retries it;
  • when the per-pass ceiling sized it — as it does for every pass near the head of a run — it is counted with its stage's failures (spaceLintFailures, reapFailures, usageRollupFailures, logAppendFailures), because a statement too slow for the ceiling will be too slow tomorrow as well.

Only a statement_timeout is read as the clock. An operator's cancel, and a Prisma transaction timeout or connection wait (P2028), stay failures. Nor is spacePassesCutShort the older tenantsCutShort, which counts tenants the work deadline reached with spaces still unvisited. The anomaly drain keeps its own reporting: a begun batch that fails is a usageAnomalyFailures whatever the cause, because its batches share one window and the sweep cannot tell which limit bound the one that failed. The lint and the reap are each a single transaction, so the next daily run retries that work entire — the reaper's archive and its index rebuild share one transaction precisely so neither can land without the other.

The anomaly drain running out of its shared window between batches is not a failure at all: it stops, and the planned writes it did not reach are reported through the deferral aggregates above. Batches that already committed stay committed, and a deferred anomaly is re-detected the next day only while its condition persists.

A log append that is skipped or fails is only counted, never reconstructed. That matters most for the reap log: its archive has already committed in an earlier transaction, so the next sweep finds no newly expired pages and writes no line for the ones it archived today. The lint log is the one guaranteed an attempt, which is why it gets the later deadline.

Lifecycle: open-state suppression only. One open signal stands per page, enforced by the partial unique index rather than by a window key. Once a curator resolves or dismisses it, a later daily window may raise it again while the page is still sustained-read — deliberately, on the reading that demand persisting after yesterday's signal was closed is new evidence. The cost is that a curator repeatedly closing a still-hot page receives one new signal per day; a durable "never signal this page again" exemption is a curator-preference contract rather than dedup, and does not exist.

It is triaged in the needs-review bucket rather than beside the KB nominations, because the candidates queue is wired to the ingestion-candidate outcome ledger and refuses a stated outcome for any other check type. Its rendering and the deep link into the review queue are PLT-591's (slice I10).

It marks the page as needing review NOWHERE, and it is the only check type that does not. A needs-review mark asserts degraded confidence, which every other page-anchored type earns by asserting a defect; flagging a heavily-read page would tell agents to trust it less for being popular. Which types degrade confidence is an explicit, exhaustive map (DEGRADES_PAGE_CONFIDENCE), so adding a check type without answering the question is a typecheck failure rather than an inherited default, and a check type the union does not yet know fails closed — it still flags the page, because a redundant marker is visible while a missing one is not. The page's own findings panel still lists the signal: a curator reading the page should see everything open on it, including what speaks in its favour.

That exemption originally reached the space-index marker alone. Every other needs-review surface still read "any open finding", so a promotion-only page drew a warn triangle in the page tree, a review chip in search results and a needs-review banner on the page itself. PLT-1155 applied the map on all of them. The three page-signals consumers — tree, search, section index — take needsReviewCount, the confidence-degrading subset of openFindingCount carried through that contract; the banner holds the rows rather than a count, so it filters them through the same map at the point of use. The inventory-versus-verdict split at the top of this page is the contract that resulted.

The ingestion review queue (PLT-269 / PLT-290)​

The ingestion_candidate finding type doubles as a human review queue for the knowledge base: a queryable list of "pages someone or something thinks are worth ingesting into the KB", each awaiting a one-click human approval. Nothing is ever ingested automatically — the funnel is auto-NOMINATE, never auto-INGEST.

How a page gets nominated (detected_by records which):

  • wiki-candidacy — the deterministic candidacy event handler nominates a page when it is published or updated and clears the mechanical eligibility bar (well-formed, non-trivial, not already a KB source).
  • wiki-demand — the demand-driven path nominates a page that a knowledge-base query missed: the KB was asked something this page could have answered, so the gap is recorded as a candidate.
  • wiki-curator — the per-release LLM judgement pass nominates pages in the ambiguous middle that the mechanical bar skipped.

How a human works the queue. Nominations surface like any other finding — GET /api/spaces/:spaceId/lint/findings?checkType=ingestion_candidate&status=open (or list_lint_findings over MCP). A reviewer reads the candidate page, and either:

  • Approves — one-click ingest_source (PLT-257) pulls the page into the KB space as a tracked source. Since PLT-682 the approval is part of that same ingest: pass candidateApproval (findingId and reason). For a page-backed candidate add the reviewedState token the surface displayed, plus sourcePageId; an unanchored demand-gap candidate nominates no page, so it carries neither — the first-party tools refuse a token there, because there is no page state to compare it against. The finding is resolved and an approved outcome carrying the batch link is recorded inside the ingest transaction, only after the ingest succeeds. Do not follow it with resolve_lint_finding — the candidate is already resolved, so that call is a failing duplicate resolution. resolve_lint_finding cannot state approved at all: approval runs through the ingest transaction or not at all, because an approved row must name the batch it produced; or

  • Rejects or dismisses the finding (via resolve_lint_finding) if the page isn't KB-worthy — status: resolved pairs with outcome: rejected ("judged and refused"), status: dismissed with outcome: dismissed ("this nomination is noise"). The two are different statements about the page, so the pairing is exact and an inconsistent pair is refused rather than reconciled. A stated outcome carries its reason, and the reviewedState token whenever the candidate is page-backed (an unanchored one carries none). Marking a page excluded from nomination is the one path that auto-resolves the open finding — a terminal exclusion supersedes the pending nomination.

    Since PLT-580 the wiki /review Candidates queue is a first-party caller of this path: a curator selects several nominations, types one shared reason, and the surface issues one PATCH /api/spaces/:spaceId/lint/findings/:findingId per row — over REST, not through the resolve_lint_finding MCP tool — carrying that reason, and the row's own reviewedState for a page-backed nomination. An unanchored one is selectable too and carries none, because the route refuses a token where there is no page to compare it against. It deliberately leaves out a nomination the queue flags changed since nomination, and a page-backed one it holds no readable token for — the first cannot be judged from a detail that no longer describes the page, and the second would be refused for a missing token on every attempt.

A nomination can go stale, and since PLT-1017 the surface says so before offering anything. Eligibility is judged when a page is nominated, and the page can move afterwards — renamed to release notes, re-typed to something the knowledge base does not take, un-approved, or explicitly KB-excluded. CandidateOutcomeService.validateApproval re-runs the Layer-0 rules over the page as it stands and refuses such an approval with a 409, so the finding stays open while the ingest that would close it cannot succeed.

The exact-detail read (GET /api/lint/findings?findingId=) therefore carries that verdict on the finding, as approvalBlockingReasons: an empty array means the rules ran and nothing refuses an approval, a non-empty one lists the reason codes that do, and null means nothing was evaluated — a page-less candidate, a row that is not a nomination, or a page the read could not resolve. A page-backed null is treated as a refusal by the first-party surface, because nobody established that the page is still eligible. The codes are the candidacy policy's own (not_approved, not_durable_page_type, not_ingestable_classification, explicitly_excluded, release_notes, completed_impl_spec_mirror), carried as open strings rather than a closed enum so a client reading a response from a newer server keeps working: a code it cannot name still refuses the approval, and only the sentence it shows degrades. An absent field reads as that same null, so an older server's response parses rather than failing wholesale.

The /review Candidates pane consumes this by withholding the approve control on such a nomination and naming every rule the page now fails — on open, before the curator has typed a reason — and it spends no summary-draft model call on one. Reject stays available, which is usually the right decision for a page that has drifted out of scope. The rules themselves are evaluated only on the server; the surface holds the reasons' names and the words for them, never the policy.

First-party strictness, third-party tolerance. The resolve_lint_finding MCP tool reads the finding before the flip and, once it sees an ingestion_candidate, requires outcome and reason — plus reviewedState for a page-backed candidate, which it refuses for an unanchored one — every first-party surface states its decision. The public REST route deliberately keeps accepting a bare { status } on a candidate and stores it outcome-unknown: a client may legitimately ingest by hand and then resolve, and reading that as rejected would durably record the exact inversion the ledger exists to prevent. Those are different promises, not an inconsistency.

Because nominations are not mechanical, the daily cron's reconcile pass leaves them untouched: a page that stays eligible keeps its open nomination until a human acts, so nothing silently ages out of the queue. Working this queue is a standing step at every release (PLT-301) — the release runner reviews the open ingestion_candidate findings and ingests the approved ones, so nominated knowledge actually reaches the KB on a cadence instead of piling up. See the knowledge-base docs for what the KB is and how it is queried.

The /review queue — what a curator actually sees (PLT-378)​

The findings above are a data model; /wiki/review is the one screen a human works them from. It exists because the counts alone were not actionable: between release curation passes a reader could see how many findings were waiting and not which.

Five queues, and a deep link selects one. The nav carries B9's triage buckets — Needs review, Candidates, Stale, Classification, Closed — each with its count, and ?bucket=<id> picks the current one. The resolver is total: an unknown value falls back to Needs review rather than leaving nothing selected. Rows are grouped under a heading per check type, and each row shows its severity as a word (never colour alone), the producer's own detail sentence, the pages it is about, and who detected it.

The sentence is bounded, never cut (PLT-1088). detail is free text — stored rows reach 4000 characters, though new curator writes are capped at 500 (PLT-1145) — and rendering it whole let one finding push the next off screen on a surface whose rows each demand a decision. A row shows a three-line preview with a Show full detail control that expands it in place inside a height-capped, keyboard-reachable region; the /insights anomaly table does the same in four lines, which is what its wider cell needs to carry the actor, volume and baseline comparison on its own. The editor's own Open findings panel takes four as well (PLT-1153) — not because it is wide but because it is narrow: a 320px sidebar rendering text-xs fits far less on a line than the queue's full-width row, so the same three lines would show markedly less of the sentence. The preview is a line CLAMP over the intact string rather than a truncation of it — the producer puts the decisive clause last, so an ellipsis would drop exactly the part a curator acts on — and the whole sentence stays in the DOM and in the accessibility tree in both states. A finding short enough to fit renders as it always did, with no control and no clamp.

Filters travel in the URL, so a filtered queue is a link a curator can send to a colleague: ?status= takes one status and ?checkType= a comma-separated set. Choosing a new bucket clears them — starting a queue unfiltered rather than carrying an invisible constraint across it.

The applied filters are on the screen, not only in the URL (PLT-1091). They render as removable chips between the controls and the count, so "is a filter applied" is answerable without reading the address bar — the question a reviewer got wrong, concluding none was applied while one was. Removing a chip lifts that filter and keeps the rest; there is one chip per check type, so a two-type link can be cut down to one, which the single-valued picker cannot express. The scope has no chip: it widens rather than narrows, and a strip called "applied filters" that could move it would misdescribe what removing one does. A Clear all filters link sits beside them and keeps the scope.

The check-type picker offers what the queue holds, not the whole vocabulary (PLT-1091). It is derived from the same aggregate the bucket counts come from, for the current bucket and the resolved scope, so a reader can no longer pick a globally valid type their space does not hold and land on an empty queue with nothing explaining why. Three behaviours are worth knowing because they are deliberate:

  • a type you have already selected stays offered even at zero, including one that arrives out of its bucket from a shared link — a control that hid an active filter would leave the rows narrowed by a constraint nothing on screen admits to;
  • the status filter does not narrow the options. Availability is the bucket's. Options that disappeared because you chose dismissed would read as a claim about the queue when they described the intersection of two filters;
  • while the counts are loading or after they fail — and whenever the scope has not resolved — the picker falls back to the full list rather than collapsing. A queue with genuinely no check types says so in words instead, and it says so only when the counts account for what the queue holds, so a check type newer than the running build cannot turn a queue with findings in it into a claim that it has none.

The queue reads the reader's active space by default, which is what its acceptance criteria ask for; ?scope=all widens it to every space the reader can see. The parameter names the widening rather than the narrowing, so the default surface carries no scope parameter at all.

Every count on the surface describes that same scope (PLT-1325). The five bucket pills, the candidate funnel, the count line above the rows and the check-type picker are one aggregate read, keyed on the resolved scope — so the number above the list and the list itself answer the same question. They did not always: the pills were read tenant-wide while the rows were one space's, and the count line carried an across every space clause to say so. That clause survives, inverted: it now appears under ?scope=all, where it names the wide set rather than excusing a mismatch. The separate Active space — N open badge is gone, because the four open pills sum to exactly what it reported — with one documented exception, below.

The scope control sits in the header, above the counts it governs, as two links naming the active space and All spaces; the current one carries aria-current, and each carries a tooltip saying where that scope's findings come from.

Two consequences worth knowing:

  • While the scope has not resolved, the surface shows no counts at all — not a tenant-wide number under a heading that names one space. The bucket nav and the scope control stay, so the reader can still move and still widen.
  • A wait and a settled absence are told apart, and only one of them carries a sentence. While the active space is still being confirmed, the count line and the candidate funnel show loading bones: a read is genuinely in flight. Once it settles to "no servable scope" they show nothing at all, and the explanation and the retry live with the rows, where the queue names the unservable scope. Bones there would claim a read that will never land.
  • The pill sum can fall short of the tenant's open total during a rolling deploy. An open finding whose check type postdates the running build is counted in the aggregate's status facet but dropped from the buckets, deliberately, so a newer type cannot take the review nav down. The open pills therefore account for every check type this build knows, which is not always every check type in the table.

That default rests on a cookie (x-wiki-active-space) that carries no organisation, so the queue treats it as a claim rather than a fact: it narrows only once the space is confirmed to be one this reader can see. When it cannot be confirmed — no space is set, it belongs to an organisation the reader has left, or the check itself failed — the queue reads nothing and says so, offering the retry where there is one and the widening as a link. It does not widen on its own: the scope is a data-scope decision, ?scope=all is how a reader takes it, and a stale cookie or a failed lookup must not take it for them.

A link can point at one finding. ?findingId=<id> selects a single finding: it is pinned above the queue in its own Linked finding region, focus moves to it, and a control beside it returns to the unselected view. The link is resolved by asking the server for that exact finding rather than by looking through the rows already on screen — so it still resolves when the queue holds more than one page, which is the case the surface exists for. While a finding is pinned it is not repeated in its check-type group below, so one finding never carries two sets of controls.

Links produced by other surfaces carry the finding's own queue, derived from its check type and status, and widen the scope to every space the reader can see — otherwise a link would resolve only for a colleague whose active space happened to match, and fail silently for everyone else. The widening is visible in the scope control, which reads All spaces.

The page itself is where most of those links come from. Reading a page that has an open finding shows a warning banner above the body: how many findings the page carries, the sentence of the most pressing one — highest severity, oldest of those — the producer that raised it and how long ago, and a link into the queue selecting that one finding. A page with no open finding shows nothing at all.

The banner counts every open finding on the page, whichever queue it is triaged into and so it agrees with the warn triangle the page tree shows on the same row rather than reporting a narrower number beside it; each link still lands in its own finding's queue. It matches findings by page alone and deliberately ignores the space each one stores: that space is the one the finding was raised in and a page move does not update it, so honouring it would drop every finding older than the move. Once 200 findings come back the count reads 200+ findings. One of them: — the listing returns one page of the newest findings, so at its limit the banner can say neither whether there were more nor that the one it quotes is the most pressing, and it stops claiming either. Below that limit it has the whole page and the finding it names really is the most pressing one. Deciding a finding stays on the review queue, where the reason and the outcome are recorded: the banner warns and hands over, and carries no resolve or dismiss control of its own.

An id that does not resolve simply opens the queue. An unknown id, an id the reader's clearance hides, and an id that is no longer in the selected queue are one case here, and deliberately: the server answers all three with the same empty result, so the surface says nothing that could tell them apart. A reader who cannot see a finding is not told that it exists. Only a failed request is reported, and it says the read failed rather than that the finding is gone — the same distinction the queue draws between a failed active-space check and no active space.

Mark resolved and dismiss happen in place. Both go through the space-scoped PATCH /api/spaces/:spaceId/lint/findings/:findingId, so they carry the same wiki:page:write requirement and the same audit entry as every other status flip. There is no delete: the honesty loop is append-only status state.

The control is called Mark resolved, not "Resolve", and the wording is load-bearing (PLT-1089). The route flips a status and re-runs no check, so nothing verifies the underlying condition is gone — on a merge_candidate it merges nothing. What the act records is a person asserting the finding is handled, which is what the words now say. Dismiss is unchanged: "not a defect" was already an assertion in the reader's own voice.

What the /review queue can and cannot do with a nomination. Stated as an inventory rather than as a rule, because the answer differs per row and three rounds of review found this paragraph claiming affordances that do not exist.

An ingestion_candidate row carries no per-row resolve or dismiss, and that is deliberate: that flip records no reason, so a candidate closed through it lands outcome-unknown — see Candidate outcomes.

Since PLT-580 the queue offers a bulk decision instead, which is a different act: it collects one shared reason for the whole selection and records a real rejected or dismissed outcome per row. What is selectable, and what a row that is not says about itself:

The rowBulk decisionWhat the row says when it is not selectable
Page-backed, page unchanged since nominationyes—
Page-backed, changed since nominationno — its detail no longer describes the pagethat its page changed, so the decision is per-row
Page-backed, no readable reviewedStateno — the route requires a token this reader has none forthat its page cannot be read in a state to decide on
Unanchored (nominates no page)yes, and it submits no token—
Already resolved or dismissedno — terminal—

Every candidate row also carries a Review control, whichever of those it is, and PLT-579 is what it opens.

What a 409 does to the selection (PLT-580 / PLT-1025). A row whose write comes back 409 lost the race — another curator decided it first, or the page moved and the token the row submitted no longer compares. Such a row stays selected while the queue can still hand it a usable snapshot, and leaves the moment a sound read no longer holds it: settled, complete, not a capped first page, and not a failed one. Sound absence is decisive however it happened, a filter included, because neither cause can produce a token that would succeed. Two things end that state instead of a removal: the row succeeding on a retry, and a read handing it back carrying a different token — the one event that makes it retryable again, after which it is an ordinary selected row. Every other failure, and every row a run never reached, simply stays selected with its own note, so a retry costs the curator nothing.

One 409 is deliberately left out of that rule, and it is worth knowing about (PLT-1197). The decision route answers 409 for three different reasons, and only two are about the row: it is no longer open, or it has changed since it was displayed. The third fires when a decision cites a page revision that cannot be read — a revision number that does not exist — and it says nothing about the row, which is still open and still decidable once the evidence is corrected. So every 409 refusing an ordinary decision or a plain status change carries a machine-readable details.reason — finding_not_open, finding_changed or evidence_unreadable — and the queue marks a row only for the first two. An evidence_unreadable refusal records no conflict, and neither does a 409 carrying no reason it recognises: the queue would rather leave such a row selected, and cost a retry, than remove a selection the curator can still act on. Read the reason, never the message; the message is written for a person and may change.

A decision carrying a nomination outcome is refused with the same field (PLT-1245). Such a refusal reuses finding_not_open (the finding was decided first, including when its decision is already in the outcome ledger) and finding_changed (the nominated page moved since the reviewedState token was displayed). It adds four reasons of its own: outcome_not_applicable (the finding is not an ingestion candidate, or names a target page, so it can never carry an outcome), reviewed_state_required (a page-backed candidate was decided without its token), reviewed_state_unexpected (a page-less candidate was sent a token) and nominated_page_unreadable (the nominated page cannot be read in a state a decision can be anchored to). The review queue's per-row control never sends an outcome, so none of the four marks a row. When an ingest approves a candidate, the equivalent refusals it raises while validating that candidate carry candidate_conflict rather than these.

Two ORDINARY check types are also out of the batch (PLT-1089). The table above is about nominations; this rule is not. A merge_candidate and a near_duplicate each say that a change is needed across the pages the finding names, and the queue performs none of it — so a shared decision applied to a page of findings at once would be an unverified attestation made in bulk. Both leave the selection set, and each renders the same kind of stated reason where its checkbox would be. Select-all takes only the selectable rows and the bar counts what it actually took. The single-row Mark resolved on those types is deliberately untouched: a curator who has done the merge still records it. The set is derived from the check type in the model layer, so executable remediation can widen it without touching the queue's renderer.

A row leads with the page it is about (PLT-1093). Under each finding's sentence sits the page it names, as a link titled with that page's own name — the reference that used to sit in the small metadata line beside the detector, now at the weight it needs to be the row's way out. There is no second control pointing at the same place: three were built and rejected in review, each of them a second link to a url the row already linked to. The one row that can also be remediated from the queue — broken_crossref — says so with its own action, described under Removing dead links from the queue.

The destination is derived from the typed fields the row carries (page_id, target_page_id, space_id) and never from producer prose; detail_json is not on the queue's listing envelope to read — only the pinned finding's exact read carries it (below). Where each finding sends the reader:

The check typeWhere the row leads
merge_candidate, near_duplicateboth named pages, labelled Compare — the fix spans them, and no comparison surface exists to open in one go
missing_concept, data_gap, and the three kb_* usage anomaliesthe space, when it names no page; the page when it does — the space is the fallback, not an override
every other typethe pages it names, subject first — a target that is not part of a comparison is the other half of the finding, and stays

A finding that names NEITHER page renders a stated reason where the link would be, rather than a control that goes nowhere. A settled row keeps its reference: it is what the finding was about, which does not change when someone acts on it.

A candidate row keeps Review as its only control — PLT-579 moved its decision into the pane — but carries the same reference every other row does. The reference is evidence, not a decision.

Any other row can be opened for its details (PLT-1183). A quiet Finding details link in a row's metadata line pins that finding in the Linked finding region, keeping the queue's bucket, filters and scope. Under the pinned row the region then shows what the row cannot carry: the check type, when the finding was recorded, and the producer's structured detail_json for the check types that define one:

The check typeWhat the region shows
merge_candidate, near_duplicatethe merge proposal — both pages, the suggested primary, the page to fold, the overlap basis
broken_crossrefthe link targets that do not resolve, as ids — the first 20, then a count
provenance_driftthe source revision cited and the one it is now at
both *_classification_inversion typesthe classification captured from the source revision
lost_space_referencethe decommissioned space and how many links into it were lost

Only declared keys are rendered: any other key, and a payload that does not match its shape, renders nothing rather than raw JSON. A merge proposal is shown as a suggestion — the details section performs nothing and adds no control; the pinned row above keeps the controls it already had, including Remove dead links on a broken cross-reference. The payload comes from the same exact read that resolves the pinned finding, so no request is added, and a nomination is not given the link: its Review control opens the candidate pane instead.

A curator-recorded finding also shows its evidence (PLT-1227). The wiki-curator keeps detail to a short locating sentence and puts the rest in detail_json under five named keys (PLT-1144). For the check types a curator can record — the six judgement types and ingestion_candidate — the Linked finding region, and the candidate pane for a nomination, render them:

KeyWhat is shown
supersedesthe id of the finding this one replaces
evidenceeach quoted claim, with its page slug and section
defectseach sub-defect's summary and its quotes
carriedForwardmaterial restated from the superseded finding, with the finding it came from
rubrican ingestion_candidate's reason per test — durable, reusable, canonical, not already covered

Everything is rendered as plain text — never markdown, HTML or a link — because it is producer-written. Lists show at most 20 entries (5 quotes inside one sub-defect) and then say how many entries were not shown; reading stops there, and in any case after 80 raw entries (20 nested) however few were usable, so a long or malformed list is never read in full. A string past 4,000 characters is cut with the omission stated, and longer text is clamped with Show full detail. A malformed entry is skipped on its own, and a finding of any other check type, or one without these keys, renders exactly as before.

The candidate detail pane (PLT-579)​

The pane opens whether the nomination is open or closed — a closed one is precisely the row whose recorded decision a curator needs to read back, and this is the only surface that shows it. It replaced H3's per-row link to the page — the CONTROL that offered to add the page to the knowledge base, not the reference: the pane carries the page link beside the provenance it belongs with, while the row keeps the plain reference every row carries (above). A nomination that names no page opens too, because an unanchored candidate is decided through the paste path rather than from a page.

The pane re-reads the finding by id. The queue's rows carry what the list renders and no more, so they hold neither the producer's structured payload nor the reviewedState token, and a decision taken from a row would be anchored to nothing. That read is the same GET /api/lint/findings collection with ?findingId= — not a detail route, so a nomination hidden from the reader by clearance and one that does not exist stay the same empty answer — and it is the only path by which a recorded outcome reaches any surface.

One reason serves either decision, and neither control submits without it: both the approval and the stated rejection are refused server-side with a blank one, and the pane applies the same bounds the server does rather than letting a too-long reason reach it.

  • Approve hands the reason, the nomination's id and the token the pane displayed to the add-to-knowledge-base flow, so the candidate is resolved and its approved outcome recorded with its batch link inside the ingest transaction. It targets the tenant's KB space for a page-backed candidate and the finding's own space for an unanchored one, which is what the ingest requires in each case. It is absent — not disabled — where there is no KB space to ingest into, and for a classified page, which the KB cannot hold.
  • Reject states rejected with its reason on the same PATCH route, after a confirmation that restates the reason: it is what the ledger will carry, permanently and unedited.

A decision refused with a 409 — the nomination decided elsewhere, or the page moved under the reader — withdraws both controls rather than re-offering the one just refused; reopening the candidate re-reads it and offers them again against state that exists.

The page shown and the token submitted come from two reads, and the pane checks that they agree (PLT-1031). It reads the finding first and the page second, never the other way round: the reverse order would pair an older body with a newer token, and the server would accept a decision about text nobody saw. GET /api/pages/:id returns the token the server derived alongside that body, so an edit landing between the two reads shows up as two different tokens. The pane then re-reads both once without comment. If they still do not match, it says so and offers Reload in place of the controls, instead of a decision that can only be refused.

Where the page moved after it was nominated, the pane shows the page as it is now instead of the description written then — and it does the same where the nomination carries no recorded state to compare against, which is every candidate raised before that stamp existed. The two say different things: one reports a page that changed, the other reports that nothing can be compared. Neither is reported as unchanged.

The unreadable row is the one the pane cannot help with either, and the reason is worth knowing before you go looking for it. Every stated outcome on a page-backed candidate requires the reviewedState token, the token is derived from the page, and the page is what that reader cannot read — so a fabricated value fails the comparison and there is no other way to obtain a real one. The pane says so and offers neither control. It waits until the page becomes readable to them (a restore, or a clearance that covers it), or is decided by someone for whom it already is.

Three things the screen refuses to claim​

These are worth knowing because each is a place where a plausible-looking screen would be lying:

  • An impossible filter is named, not shown as an empty queue. The listing answers a contradictory pair — ?bucket=stale&checkType=data_gap, say — with an empty result rather than an error, so on screen it is indistinguishable from a genuinely clean queue while meaning the opposite. The surface tells the two apart and says which it is.
  • A full page offers the next one, and the queue says what paging cannot promise. The listing returns one page at a time; when it comes back full the screen offers Load more, which reads the next page and keeps offering until one comes back short — so an exactly-full bucket costs one extra request that returns nothing, and that is the correct answer rather than a wasted call. Completeness is read from the rows and not from the response's total, which is a second statement describing a different instant. What the screen does not claim is snapshot consistency: paging is by offset, so a finding resolved elsewhere between two pages shifts the rows and this queue can miss one. It says so, and offers a Refresh, rather than implying a completeness offset paging cannot provide.
  • The bucket count is the bucket's. No filter reaches the aggregate, so beside a filtered list the count is labelled as the pre-filter total rather than presented as a total for the rows on screen.

Deciding an ordinary finding (PLT-1131)​

Mark resolved and Dismiss no longer close a finding on the first click. They open a decision surface, and it exists because the one-click version could not say what it asserted: resolveFinding flips a status and re-runs nothing, so on a merge_candidate — a finding that says two pages share a source and should become one — Mark resolved merged nothing and the row left the queue as though it had.

The surface collects a reason, typed evidence (a page revision, or a specific explanation of how the remedy was verified), and for Mark resolved an explicit reviewer attestation. The attestation is required rather than optional: a resolution whose author declined to attest is Keep open, not an unattested resolution. Its wording distinguishes a human claim from an automated recheck, because the ledger carries a separate verification column that a re-lint settles and a curator who read the checkbox as "the system verified this" would have attested to something they did not check.

Before confirming, the surface states what the action does — and, more usefully, what it does not: it closes the finding, and it does not edit, merge, archive, delete, ingest or approve any page, nor complete a remediation task elsewhere. There is no undo. Where the finding currently blocks a page's KB promotion it says that closing it retires that block — never that the page is promoted, that no other blocker remains, or that anything about its trust score improves. The check sees only this row, so it cannot know whether others exist.

Decision classes​

What a decision asserts, and what happens to the finding afterwards, follow from the check type's decision class. There are three rules, never one, and the surface renders the right one per row:

ClassMembersMark resolved assertsAfterwards
Verifiableorphan_page, broken_crossref, provenance_drift, merge_candidate, and the two classification inversions"I changed something, and the next reconcile will confirm it or raise the finding again"Provisional. The closed row is never reopened; a new row is raised while the cause stands.
Attestedcontradiction, stale_claim, missing_concept, missing_crossref, data_gap, near_duplicate, lost_source, lost_space_reference, request_change, kb_promotion_signal"I judged this handled"Stands until someone records a new finding. Nothing re-checks it.
Acknowledgedthe three KB usage anomalies"I triaged this event"The event is historical and names no remediation. A later window may open a new episode — a new event, not this one returning.
Adjudicatedingestion_candidatenothing — the type does not use these controlsDecided through its own Approve / Reject / Dismiss flow with its own ledger.

Verification in the same request (PLT-1099)​

A stated Mark resolved on an orphan_page, a broken_crossref or either classification inversion, taken from the single-row decision surface, is checked in the same request: once the decision has committed, the wiki re-runs the space's lint and settles the decision's verification. The first two are checked with the mechanical lint, because every input their detector reads is visible at your clearance, or only ever makes the condition look worse — never better. The inversions are covered below.

  • confirmed — the condition is gone, or the only same-shape finding the pass raised is a genuinely different defect of the same kind (it stays open as its own finding).
  • reproduced — any part of the recorded condition is still there. It is open again as a new finding; the closed row is never reopened. Fixing one of two broken links is reproduced: partial is not resolved.

For a broken_crossref, "the pass raised nothing" is not taken on its own word: the page is read again directly before the decision is confirmed, because the space-wide scan runs across several statements and does not see the space at one instant. (Before PLT-1208 it could also skip a page outright when another was deleted while it ran.) If that read still finds the recorded link broken, the decision stays pending.

It stays pending — never settled on a guess — when the decision's recorded fingerprint cannot be read (including every decision taken before PLT-1195), when a page the finding names is missing or hidden from you, when those pages now sit in different spaces, and when the comparison is inconclusive. A provenance_drift and the two classification inversions are the exception to the space rule: their producer keys on the deriving page, so they are checked in that page's space whatever space the page it derives from sits in — an ingest summary in the knowledge base deriving from a page elsewhere, for example (PLT-1275).

Two Verifiable types depend on more than the pages the finding names, so the check first proves it can see those inputs too (PLT-1206):

  • a merge_candidate also depends on the pair's shared source page and its derived_from links. After the lint, the check reads the pair again: archiving one of the pages, lowering their overlap, or pointing them at different sources confirms the decision. If neither page shows you any source — each may derive from a page classified above you, or from no page at all, and the check cannot tell which — the decision stays pending, and the review queue says the recheck could not tell and will try again.
  • a provenance_drift also depends on the target's latest revision. If that revision is classified above you, the decision stays pending; if the drift is gone and you can see the latest revision, or the derived_from link was removed, it is confirmed.

Every check that lints asks the database about links classified above you. The lint's merge-candidate detector counts them, and the provenance_drift check also asks about revisions. The database answers with a count or a yes/no, never the content. Even so, each such check is recorded in the audit log as wiki.lint.verification_crossing_read_performed, against the space, whatever it found.

A finding the check cannot see into is never reported as fixed. A verification that fails outright also leaves the decision pending, and never fails the decision itself: that committed before the check began. The check also has to finish within the request's time budget; in a space too large to lint in that time it is cut short or skipped, and the decision stays pending.

Three things it deliberately does not do:

  • It never runs in bulk. A batch is one request per row, so checking each would re-lint the whole space once per selected finding. Bulk decisions stay pending.
  • It runs the classification reconcile only for a classification inversion. That pass reads pages above your clearance, so orphan_page and broken_crossref are checked with the mechanical lint instead.
  • It checks where the page is now, not the space the finding was raised in — a moved page leaves its finding behind — and holds the page still while it checks, so it cannot be moved or reclassified halfway through.

A classification inversion is checked with the full lint (PLT-1205). Only the classification reconcile raises these findings, so the check runs it: the same pass, and the same audit entry, as running the space's lint yourself. It runs in the space of the page that derives from the source, which is often not the source's own space. The reconcile sees every page, but a finding it raises is shown to you only if you can read the source revision it cites, so a missing finding is taken as a fix only when that revision is readable to you, or when the derived_from link is gone. Otherwise the decision stays pending. The daily sweep below never runs the reconcile, so an inversion decision is only ever settled by this check, or by a reviewer running it again with Recheck now (below). If the condition still holds, the next lint raises it as a new finding.

A pending decision this path does not settle — other than an inversion — waits for the daily verification sweep below, or for a reviewer's Recheck now.

Rechecking a pending decision (PLT-1259)​

A closed finding whose decision is still pending can offer Recheck now on its record in the Closed bucket. It runs exactly the check above again: the same lint, at your clearance, and the same audit entries, marked trigger: reviewer_recheck where the first run says verify_now. Nothing else about the decision changes. It is how a classification inversion, or any decision classified above the daily sweep, gets a second attempt, because the sweep will never reach either of them.

The control is shown only where your own check could settle the decision:

  • you hold wiki:page:write;
  • the finding is a type this check can see into;
  • every page it names is readable to you, and they sit in one space (an inversion or a provenance_drift is checked in its derived page's space, as above);
  • the decision's recorded condition can be compared.

That means it could settle the decision, not that it will. An inversion whose cited revision you cannot read, or one a declassification approval sanctions, still stays pending. The result is shown as a notice:

  • confirmed or reproduced, as above;
  • still pending, with the reason;
  • did not finish — it ran out of the request's time, met a writer holding the page, or could not get a database connection. Nothing changed, and you can try again.

It is also available as POST /api/spaces/:spaceId/lint/findings/:findingId/verification. A finding you cannot read, or one in another space, answers 404.

The daily verification sweep (PLT-1099)​

Once a day (/wiki/api/cron/verify-decisions, 07:30 UTC) the wiki rechecks the stated Mark resolved decisions still pending — bulk decisions, and ones the same-request check could not settle. For each affected space it re-runs the mechanical lint once and settles, against that lint and with the same rules as above, every decision there it can safely recheck — up to 50 per space per day, so a large backlog drains over several days, the decisions waiting longest first, and at most 1,000 of a tenant's decisions looked at in one run. The rest stay pending and say why (table below): a decision above the lowest classification, a check type no mechanical pass can observe, an input it cannot read — and, best effort, one the run ran out of time for or whose space's pass failed.

It acts under a narrow exception to tenant isolation, recorded in ADR-040 and named in the constitution (§1):

  • It learns only which organisations have pending decisions it could itself read, and nothing else about them, before it enters each one. The question it asks is exactly the one it is allowed to ask: an organisation is discovered only when a reader at the lowest classification, in that organisation, would see the pending decision — every gate the record inherits from its finding and its pages included. An organisation whose only pending work sits above that line is invisible to the sweep, so the act of auditing it cannot signal that the hidden work exists.
  • Inside an organisation it acts as that organisation's own machine account, at the lowest classification, and records an audit entry for every organisation it looked at — including one it then skipped. An organisation it actually entered gets a second entry when the visit ends, saying only whether its work began, so the trail shows how each visit finished and not merely that it started. Both entries carry which enumerator chose the organisation and an identifier unique to that run, so two runs can never be read as one. The run's time budget reserves its last seconds for those entries; if they stall — or far more organisations than today's are still waiting when time runs out — it stops, reports how many went unaudited and how many closing entries it could not write, and that run is recorded as outside the exception rather than covered by it.
  • It settles only decisions whose closure record is unclassified. A classified decision was never delegated to a machine, so it stays pending for a cleared person.

A decision it cannot settle stays pending and says why on the closed record in the review queue, so it never looks like one merely waiting its turn:

Shown on the recordMeaning
only the check when marked resolveda classification inversion; the recheck never settles one
runs at the lowest classificationthe decision is classified; the recheck will not check it
cannot see everything this kind depends onno Verifiable type today; kept for a type added later
no recorded condition to comparedecided before the structural fingerprint (PLT-1195)
no machine account in this organisationthe recheck cannot run here at all
a page is deleted or classified above itthe recheck cannot read what the finding names
pages are in different spacesa merge candidate split across spaces has no single space
could not tell / ran out of time / failedthe last run tried and did not settle it; it will try again

The cron's own response and its completion log line carry the same run-wide totals, and name no organisation unless the run touched at least two — in which case both carry the touched set, because that set is the evidence for a tenant whose discovery audit failed mid-run (ADR-040 § D5).

Bulk is transport, not shared judgement​

A batch issues one request per row with per-row staleness and per-row failure; the shared reason is the anomaly bolted onto that, not the design. So a decision may be taken in bulk only when the reason is a fact about the selection rather than about any row, and the status asserts no unverifiable per-row act.

That leaves bulk Mark resolved admissible for the Acknowledged class alone. Every remediation claim — Verifiable and Attested alike — describes what was done about that finding, so its reason is per-row by construction. The rule is not "the finding names a page": missing_concept and data_gap are page-less Attested types, and a shared reason misdescribes them just as badly. Dismiss survives in bulk everywhere it is offered: "these findings are not defects" can be one shared judgement, and it asserts no remediation.

The queue disables an inadmissible action and says why, rather than hiding it. And because no static rule can check that one sentence is true of every selected row, the confirmation puts that claim in front of the curator in words instead of implying the check has been done.

What is recorded, and who can read it​

The decision is written in the status flip's own transaction, with its actor, its timestamp and a classification ceiling — the high-water mark over the reviewer's clearance, every page the finding references and any cited evidence revision. Free text is not bounded by what its subject is classified at, so stamping a decision at the page's tier would be a down-classification performed by the system.

A reader whose clearance does not reach that ceiling sees nothing — not a row saying a decision exists and is withheld, which would tell them a resource at a higher tier exists. The finding's own closure metadata (who closed it, and when) is withheld at the same ceiling. What stays visible is that the finding is closed, because that is what the queue is for.

A broken_crossref row offers Remove dead links — the first finding type the queue can remediate rather than only navigate to (SPEC-plt-1098 § D7, tier T1). It works in two steps, and only the second changes anything (PLT-1237).

  1. The preview. It shows the exact change: which dead [[Label|id:…]] links are turned into plain text (the label stays, the link goes), how many times each occurs, and the line diff of the page body. Nothing is saved. Next: Mark resolved moves on.
  2. Mark resolved, prefilled. The reason names the links to remove, and the evidence is the page revision the change will save. Tick that you checked the page, then Apply and mark resolved: the change is saved as an ordinary edit — a new revision you can restore from the page history, the routine page UPDATE audit entry, the usual page.updated event — and then your decision is recorded against that revision. Until you confirm, nothing is written: Back to preview returns to step one, and closing the dialog changes nothing.

If the page changed after the preview, the save is refused and nothing is recorded — go back to the preview to see it as it is now. If the save lands and the decision does not, the dialog says so, and confirming again records the decision without editing the page a second time.

The action is offered only on a row that lists its dead links — the mechanical lint's findings. A finding a curator recorded in prose carries no such list, so it keeps its link to the page and its ordinary Mark resolved / Dismiss.

  • Only links that are still broken are touched, and only the ones this finding names. A target that came back since the finding was raised is kept; a link that broke later waits for the next lint run.
  • The words stay exactly as they read (PLT-1240). Each label is escaped against the text on either side of it on its line, so unwrapping never turns it — alone or joined with its neighbours — into a link, a list item, a heading, an HTML tag, an entity or emphasis: a label that is a URL or an email stays plain text, and [[1|…]]. First stays a sentence. Where an escape cannot separate the label from what precedes it (a bare https://… URL, or an open <), the change inserts an empty HTML comment, <!---->, which the page does not show.
  • Links inside code blocks are left alone. A link in a fenced or indented code block is an example: the check does not report it, and the removal does not rewrite it.
  • The decision is still yours. The finding closes only through your attested Mark resolved, and verification settles that decision as usual; the page edit and the decision are two separate writes.
  • A refusal says what you can still do. Each one says that nothing was changed and offers the next step as a button: open the page, or Mark resolved / Dismiss the finding.
  • It needs wiki:page:write, at any clearance (PLT-1237). "Missing" is read under your own clearance, and below SECRET a link to a page classified above you looks broken without being broken. So below SECRET the action also asks a privileged check whether any of the finding's targets is still a live page at any tier — a soft-deleted page counts as gone. If one is, the whole action is refused with 409 target_unconfirmed and nothing is changed; the refusal names no link, so you cannot tell which one, and you can still edit the page by hand. That answer is one bit about pages you cannot see, which is why the check is audited: every time it runs, whatever it answers, it writes one wiki.lint_finding.crossref_target_existence_check_applied_outside_clearance_scope entry naming you, the finding and its page — never a target or the answer. The check writes that entry itself (migration 093), so a direct database call is recorded too. When the check has run and the request then ends without saving — target_unconfirmed, or a later step failing — the entry rolls back with the rest of it, so the queue writes it again straight afterwards; that second write is best effort, and a database failing twice in a row can still lose it. A request that stops before the check, or that the check itself refuses before reading anything, has nothing to record. At SECRET the check is not needed and does not run.
  • A page in an enforced knowledge-base space is refused at the preview, by the same write guard that refuses any ordinary edit there, rather than shown a change the apply would then refuse.
  • Nothing moves under it. The apply sends back the page version and a digest of the previewed change, and is revalidated under the page row lock; anything that moved in between is a 409 and nothing is saved. A page edited in between, or a target restored in between while others are still broken, changes the preview — the dialog offers a fresh one. A finding decided in between, or a restore that leaves nothing to remove, cannot succeed on any retry, so the dialog offers neither Apply nor a new preview.
MethodPathBodyReturns
GET/api/spaces/:spaceId/lint/findings/:findingId/remediation—{ findingId, pageId, expectedVersion, proposalDigest, removed, diff }
POST/api/spaces/:spaceId/lint/findings/:findingId/remediation{ expectedVersion, proposalDigest }{ findingId, pageId, revisionNum, version, removed }

A 409 carries details.reason: stale_version or proposal_changed (preview again), nothing_to_remove, target_unconfirmed (below SECRET: a target still exists), finding_closed, or not_executable.

Merging pages from the queue (PLT-1149)​

A merge_candidate row offers Merge pages — the first structural remediation (SPEC-plt-1098 § D7, tier T2). It changes two pages at once, so it carries more machinery than T1: authorization over both pages, a preview spanning both, one transaction, a critical audit entry, and a revert.

It ships switched off. The server answers 404 on every merge route unless WIKI_MERGE_REMEDIATION_ENABLED is exactly true, and the queue shows the control only when NEXT_PUBLIC_WIKI_MERGE_REMEDIATION_ENABLED is exactly true. Enable it in production only after rehearsing apply, revert and a stale-version refusal in the disposable review fixture (PLT-1102).

  • You write the merged page. The dialog opens on the kept page's text beside the page being folded; edit the kept page until it says what both should. The preview below follows your text — it refreshes shortly after you stop typing, keeping the previous one on screen meanwhile — and lists what will happen: the kept page saved with your text as a new revision, the folded page moved to archived, a supersedes link from the kept page to the folded one (or the existing one kept), with the before/after line diff. Merge pages is enabled once the preview has caught up with your text and you tick the confirmation naming both pages.

  • Only the two pages the finding names change. Links into the folded page, its child pages and wikilinks to it on other pages are left as they are.

  • It needs wiki:page:write, wiki:link:write and SECRET clearance, and both pages readable. A folded page classified above the kept page is refused — copying its text down would declassify it. An archived page on either side is refused.

  • It works in a knowledge-base space, through the trusted operations merge-remediation and merge-remediation-revert: merge candidates arise between derived pages, which approved ingest writes into knowledge-base spaces. In an ordinary space the same merge runs without them.

  • Nothing moves under it. The apply sends back both page versions and a digest of the preview, and is revalidated under the tenant namespace lock and both pages' locks. Anything that moved in between is a 409 and nothing is saved.

  • It closes nothing. The finding stays open; the next lint run sees the archived page and resolves it, or you decide it yourself.

  • What the two revision histories say afterwards is deliberately asymmetric (PLT-1331). The kept page's new revision names the page it absorbed — Merged "<folded page>" into this page (review queue, finding <id>). The folded page's archive revision does not name the kept page at all: it reads Folded into another page and archived (review queue, finding <id>), whatever the two pages' classifications are.

    That is not an oversight, and it is worth knowing before you go looking for the missing name. A revision is readable at its own classification, not its page's — so when a page is folded into a more highly classified one (which is allowed; only the reverse declassifies content), naming the kept page there would put its title in front of readers that row-level security hides it from. And naming it only when it was safe to do so would be its own disclosure: the stand-in would then appear exactly when a page above the reader's clearance was involved, which tells them it exists. So the folded page's summary is the same either way.

    Where to look instead, all readable by anyone cleared for both pages: the kept page's own revision history names the folded page, the supersedes link records the pair, and the operation in wiki.remediation_operations carries both page ids.

  • Revert is the persisted inverse. Each merge is recorded in wiki.remediation_operations, and the revisions it wrote carry its id. Revert merge — offered from the same row, including once the finding is closed — restores the kept page's pre-merge text, brings the folded page back out of the archive and removes the link the merge added (a link that already existed is kept), all in one transaction. It refuses, and changes nothing, if either page was edited after the merge or the link it added was removed or changed. The folded page comes back as draft, not in the status it had before — archived only exits to draft, so that an archived page is never re-approved without a review; the earlier status is kept on the operation and in the audit entry.

MethodPathBodyReturns
GET/api/spaces/:spaceId/lint/findings/:findingId/merge—{ findingId, findingOpen, primary, fold, operation }
POST/api/spaces/:spaceId/lint/findings/:findingId/merge/preview{ mergedBodyMd }{ findingId, primaryPageId, foldPageId, expectedPrimaryVersion, expectedFoldVersion, primaryDiff, foldStatusBefore, foldStatusAfter, supersedesLink, proposalDigest }
POST/api/spaces/:spaceId/lint/findings/:findingId/merge{ mergedBodyMd, expectedPrimaryVersion, expectedFoldVersion, proposalDigest, confirm: { primaryPageId, foldPageId } }{ operationId, primaryPageId, foldPageId, primaryRevisionNum, foldRevisionNum }
POST/api/spaces/:spaceId/lint/findings/:findingId/merge/revert{ operationId }{ operationId, primaryRevisionNum, foldRevisionNum, foldStatus }

A 409 carries details.reason: stale_version, proposal_changed or confirmation_mismatch (preview again), already_applied, classification_inversion, fold_archived, finding_closed, not_executable, and on a revert pages_moved, link_changed or not_applied. The audit actions are wiki.merge.applied and wiki.merge.reverted, on resource type wiki_remediation_operation.

Sanctioning a classification inversion (PLT-1150)​

The two classification-inversion types are standing conditions: every lint run re-evaluates them, so dismissing one is provisional and it comes back while the inversion stands. When a lower-tier derivative is intended — a public summary deliberately derived from a restricted report — the durable answer is a declassification approval: a record saying this derivation, from this source revision, is sanctioned down to a stated tier.

  • It needs its own authority. wiki:declassification:approve (or wiki:declassification:*, the generated wiki:declassification:wiki:declassification:approve, or an admin-equivalent role). wiki:page:write does not confer it: holding edit rights on a page is not a licence to sanction reading it below its source. The approver must also be able to read the exact source revision.
  • It sanctions an inversion that exists. A grant names the deriving page, the source page and the source_revision_num a live derived_from edge cites, and a floor (approvedClassification) at or below where the deriving page sits and strictly below the source revision. The source may live in another space; the approval belongs to the deriving page's space.
  • It is bound to the revision. Repoint the edge at a new source revision and the approval no longer covers it. Lower the deriving page below the floor and the finding comes back too.
  • The next lint run applies it. A covered inversion opens no finding, and an open one closes with resolved_by = wiki-declassification-approval — a machine closure that reads as "sanctioned", never as "the condition is gone". A Mark resolved on that inversion is not confirmed by verification while the approval stands; it stays pending.
  • Amend narrows, revoke withdraws. An amendment may change the reason or raise the floor; lowering it is a revocation plus a new grant, with its own approver. Revocation is soft and final: the record stays as history, and the next run re-raises the inversion.
  • Its visibility follows the high-water rule (SPEC-plt-1098 § D4 property 1): the record is stamped at the highest of the approver's clearance, the source revision and both pages, that ceiling only rises, and the record also disappears for a reader who can no longer see the deriving page or the source revision. A consequence worth knowing: an approval granted by a more-cleared approver is invisible to — and cannot be amended or revoked by — a less-cleared curator who can see the finding it closed.
  • Every grant, amendment and revocation writes auditCritical in the same transaction (wiki.declassification_approval.granted / .amended / .revoked), at the record's ceiling, carrying ids and tiers but never the reason text.
MethodPathBodyReturns
GET/api/spaces/:spaceId/declassification-approvals[?includeRevoked=true]—{ approvals }
POST/api/spaces/:spaceId/declassification-approvals{ pageId, targetPageId, sourceRevisionNum, approvedClassification, reason }the approval, 201
PATCH/api/spaces/:spaceId/declassification-approvals/:approvalId{ approvedClassification?, reason? }the approval
POST/api/spaces/:spaceId/declassification-approvals/:approvalId/revoke{ reason }the approval

Two reads back the review-queue control (PLT-1280). Neither is gated by the latch or the authority, and neither is audited — row security gates everything they return:

MethodPathReturns
GET/api/declassification-approvals/capability{ enabled, canApprove } — the latch and the authority, as a display bit
GET/api/spaces/:spaceId/lint/findings/:findingId/declassification{ pageId, targetPageId, sourceRevisionNum, derivation, approval } for one inversion finding; else 404

derivation is null when no derivation the caller can read exists at the cited revision — the edge is gone, or something it names is above their clearance; the two are one answer on purpose. approval is the live approval for that exact edge and revision the caller can read, or null.

In the review queue​

When the capability reports the feature on and the authority held, /review offers Approve declassification on an open classification-inversion row, and Manage declassification on one closed as sanctioned. The dialog grants with a floor and a reason against the revision the finding cites; with a live approval it shows the floor, the approver and the time, and amends or revokes it. Nothing moves until the next lint run. In Closed, a sanctioned closure is badged Sanctioned and reads "Sanctioned by declassification approval — the inversion still holds", while an inversion the lint closed reads "Remediated automatically — the condition no longer holds".

Over MCP​

list_declassification_approvals and get_finding_declassification_context (reads), grant_declassification_approval, amend_declassification_approval and revoke_declassification_approval are REST clients of the routes above. get_finding_declassification_context returns the sourceRevisionNum a grant needs. There is no pt CLI equivalent, as for the other wiki tools.

Enablement runbook​

The feature ships off. WIKI_DECLASSIFICATION_APPROVALS must be exactly true for any write to succeed and for the reconciler to honour any approval — so switching it off is a complete rollback of the approvals' effect on every application path, not merely a stop on new writes. Reads stay available either way.

  1. Rehearse in the PLT-1102 disposable fixture with the variable set locally. From apps/wiki, run npm run fixture:review:setup -- --declassification-approver: the flag grants the fixture reviewer wiki:declassification:approve through the fixture's audited identity bootstrap (PLT-1287). Then grant an approval on one of the two inversion findings, run lint and confirm it closes as wiki-declassification-approval; revoke and confirm it reopens; unset the variable and confirm approvals are ignored. Tear the fixture down.
  2. Grant wiki:declassification:approve only to the people who should hold it.
  3. Set WIKI_DECLASSIFICATION_APPROVALS=true on the wiki deployment.

Tracked as PLT-1279.

What the PLT-1279 rehearsal established, and what the steps above do not say:

  • Migration 076 must precede the PLT-1150 build, as for any release. An older build ignores the variable. The PLT-1150 build calls the three-argument wiki.reconcile_classification_lint on every lint run whatever the variable says, so deployed against a database without 076 it breaks lint, not only approvals.

  • For a session token, a tenant-specific authority comes from the acting tenant's grants, not from the token. withTenantAuth rebuilds roles (PLT-717, hydrateActingTenantRoles): the names of the user's GLOBAL grants in that tenant with every admin-equivalent spelling stripped, plus TENANT_ADMIN when one of them is the canonical Directory Admin, plus the user baseline. A wiki:declassification:approve minted into a Bearer token is therefore ignored — the fixture reviewer gets 403 until its tenant holds the grant. Two exceptions: the token's platform_admin / PLATFORM_ADMIN claims survive (they are admin-equivalent, so a platform operator can approve in any tenant it is a member of), and an API key keeps the roles it was issued, in its issuing tenant only. That is why step 1 uses --declassification-approver, which writes the grant through the fixture's audited identity bootstrap (PLT-1287). A plain fixture:review:setup leaves the reviewer without it. Do not add the grant with hand-written SQL: identity.* writes belong to Directory (ADR-025), and a permission grant needs its audit entry.

  • Every canonical Directory Admin already holds it, through that synthesised TENANT_ADMIN, so step 2 is about who holds it beyond the tenant's Admins and platform operators. Anyone else needs a GLOBAL role bearing one of the three names checkPermission accepts for this check — wiki:declassification:approve, wiki:declassification:*, or the generated wiki:declassification:wiki:declassification:approve — created and assigned in Directory.

  • Delegated role management can mint it, by more than one path. A role's name is tenant-authored and the authority is granted by name. Directory's circular-grant guard refuses a self-assignment only when the role carries role-management permissions or its name contains admin or role, and a rename runs no such guard. So, without being an Admin:

    • role:assign alone self-assigns a role that already bears one of those names;
    • role:update renames a non-canonical GLOBAL role its holder (or anyone) is already assigned to one of them;
    • role:create and role:assign held by different people combine into the same grant.

    Before enabling, review every holder of role:create, role:assign and role:update, and every existing role already bearing any of the three names, together with who is assigned it.

  • Revoking reopens the inversion as a new finding row. The closed wiki-declassification-approval row stays closed as history, and the next run inserts an open one beside it.

  • Switching the variable off also refuses revocation. Off, every write returns 403, revoke included — which is harmless, because off already ignores every approval, but revoke before switching off if the record itself should read as withdrawn.

  • The audit entries carry field names, not content. A reason-only amendment in the rehearsal recorded changed_fields = {reason} — more when the amendment also raises the floor or the record's ceiling — and the reason text itself never reaches audit.audit_entries.

  • A deployment reads the variable per request, but a hosted deployment only sees a new value after a redeploy. Set it, redeploy the wiki zone, then confirm a grant returns 201 under the runtime role.

REST surface​

All routes are tenant-auth wrapped. The writes in the table below require the wiki:page:write permission. Two surfaces on this page differ: the declassification-approval writes above require wiki:declassification:approve, which wiki:page:write does not confer, and a reader's change request (below) requires no write permission.

RouteBehaviour
POST /api/spaces/:spaceId/lintRun the mechanical pass; returns the run report (counts).
GET /api/spaces/:spaceId/lint/findingsList findings — filter by status, checkType, pageId, findingId; paginated.
POST /api/spaces/:spaceId/lint/findingsRecord a curator-judged finding (409 on duplicate open shape).
PATCH /api/spaces/:spaceId/lint/findings/:findingIdResolve or dismiss an open finding (status flip, attributed), optionally recording the candidate decision it represents — see below.
GET /api/lint/findingsList findings tenant-wide or in one space — filter by bucket, status, a checkType set, pageId, findingId. See below.
GET /api/lint/findings/aggregateGrouped counts — facets and triage buckets. See below.

MCP equivalents (constellation MCP server, REST-only clients): lint_space, list_lint_findings, record_lint_finding, resolve_lint_finding.

The aggregate has no MCP equivalent — it exists for the review UI still to be built. An agent can still get exact counts from list_lint_findings, which reports the total number of matching findings independently of the page it returns: read that total rather than counting the rows you got back, which is what genuinely undercounts once a result exceeds the 200-row page.

Three limits decide how far that gets you:

  • it filters by status, checkType, pageId and findingId only — so a per-status or per-check-type count is one filtered call each, but there is no severity filter: a severity breakdown requires paging the findings and grouping them yourself;
  • it requires a spaceId. GET /api/lint/findings (below) does not, and it also takes a bucket — but it has no MCP equivalent either, so from MCP a tenant-wide number is still one call per space and summing;
  • bucket totals are not exposed anywhere on this surface — apply the bucket table below yourself, including its status-first rule (a resolved or dismissed finding is closed whatever its check type), or your numbers will not match the aggregate's.

Requesting a change as a reader (PLT-548)​

POST /api/pages/:id/request-change requires no write permission, and that is the point of it: a wiki:page:* permission has to be provisioned to each reader individually, so gating on one would exclude exactly the people the affordance exists for. Authorization is instead the check that is already true of anyone who can see the page — it must resolve under RLS at the caller's own clearance. A page that does not (absent, deleted, another tenant's, or above the caller's clearance) answers 404, and lint_findings_tenant_rw re-applies the same predicate at the insert, so the boundary does not rest on the service's lookup alone.

The body carries { "note": "<what looks wrong>" } and nothing else. Check type, severity and producer identity are server-decided; a client able to choose the severity could raise an error finding and freeze the page's KB promotion.

StatusWhen
201A finding was created.
200The request deduped into the open request that already stands on this page; that finding is returned.
400The note is empty or whitespace-only.
404The caller cannot see the page.
409A request already stands on this page in the same space that this caller cannot see — a row hidden by its captured classification. The note is not stored; answering 200 would discard it while reporting success. A request stranded in ANOTHER space is not detected and answers 201; see the dedup paragraph below.
429Either quota refused the attempt — code RATE_LIMIT_EXCEEDED for both, carrying that quota's limit and windowSeconds in details. The per-ACTOR attempt bound is checked first and is the one an ordinary retry loop meets; the per-tenant created-row ceiling is the other. The reader-facing sentence is deliberately the same for both — see the two paragraphs below (PLT-1081).

Dedup is per page, not per reader. The standing request is looked up by page before the insert, as a visible read taken FOR UPDATE so a curator resolving it cannot make the answer stale between the read and the commit. That read is RLS-scoped, so it sees only what the caller sees; the unique index lint_findings_open_dedup_idx catches the rest of the same-space case physically, since an index is enforced outside RLS and a row the caller cannot read still takes the slot. The two together do not cover a page that has both moved space and been downgraded: the index is keyed on space_id so the stranded row does not collide, and the pre-read is blind to it, so a second open request is created. Detecting it needs a read across classification partitions, which constitution §5 obliges auditing at the boundary performing it — PLT-1082 owns that, and PLT-1072 owns the immutable finding space_id underneath it. The consequence is stated rather than hidden: a curator sees that a change was requested, never that three people requested it, and the second reader's note is dropped in favour of the standing one. Dedup per (page, reader) was rejected on two counts — it needs the requester's id in detail_json, which the visibility-gated policy would expose to every tenant member who can read the page, and it needs request_change excluded from that index, which reopens the migrate-before-deploy 42P10 window migration 026's header warns about.

The per-tenant ceiling is derived from the findings themselves — no counter table, and since PLT-1081 it is one of TWO quotas on this route (the per-actor attempt bound below is the other). Inside the writing transaction, serialized by a per-tenant advisory lock, the service counts request_change rows in the trailing hour and refuses past 100. Three properties of that count are deliberate: it ignores status, so a curator clearing the queue cannot hand the tenant its budget back; it ignores space_id, because a flood spread over many spaces is the same flood; and a deduplicated request is not charged, because it created nothing. A refused request never reaches the insert, so a 429 leaves no finding. The window is measured against the database clock, and against STATEMENT time rather than transaction time — created_at is written by the column default, so a cutoff computed on the app host would move the hour by the clock skew between them, and one taken at transaction start would stretch it by however long the request waited on its two advisory locks. The count runs under the caller's own RLS, so it is not exact across visibility partitions: rows above the caller's clearance do not charge their budget, and with k partitions the effective ceiling is k×100. That is a known, deliberate reduction — a tenant-wide count would be a read across classification partitions, which constitution §5 obliges auditing at the boundary performing it; PLT-1082 owns that work.

The per-ACTOR attempt bound is the second quota, and it counts something different (PLT-1081). The ceiling above counts rows a tenant CREATED, so the two paths that create nothing — a dedup hit and the D7 hidden-conflict 409 — never charge it and could be retried for free, each retry still taking both per-tenant advisory locks. So each attempt past the readability check also charges one row in wiki.request_change_attempts, keyed on (tenant_id, actor_id), and the eleventh attempt inside that actor's fixed one-hour window is refused — fixed, not rolling, per the fourth property below. Four properties of it are deliberate:

  • It charges and returns the count in ONE statement — INSERT … ON CONFLICT DO UPDATE … RETURNING, with the comparison taken on the returned value — so there is no path on which the quota is read without being paid, and no advisory lock is needed: the only serialisation is the row-level lock on the acting actor's own counter row.
  • It runs BEFORE both tenant-wide locks, which is the point. A refused attempt therefore buys none of the contention the locks represent. It runs AFTER the readability read, so an attempt on a page that does not exist — or one the caller cannot see — is still the 404 that writes nothing; the consequence, stated, is that hammering invisible page ids is not bounded here.
  • The store carries no page, no note text and no classification. It is a rate counter, not a record of what was asked, so nothing about it is reachable through the page-visibility-gated finding policy — which is why the identity can live there while §D4 keeps it off the finding row. Its SELECT policy admits only the acting actor's own row, and RETURNING re-applies that policy, so an unset app.actor_id fails closed rather than matching every row.
  • It is a FIXED window, anchored at the actor's first charged attempt and not advanced by a refusal. So an actor recovers on their own rather than extending a lockout by continuing to retry — at the cost of admitting up to 2x the limit across a window boundary. The property bought is that a retry loop terminates, not that an exact hourly rate is enforced.

The refusal writes no audit entry: auditCritical advances a tamper-evident chain under a row lock on audit.chain_heads, which is per tenant, so auditing every refused attempt would reintroduce exactly the tenant-wide serialisation this removes. The counter row is the record. A flood updates one row rather than inserting rows, so growth is bounded by the number of distinct requesters, and since PLT-1337 a daily cron — /wiki/api/cron/prune-request-change-attempts — deletes every counter whose window began more than 24 windows (one day) ago. That cannot change a verdict: the next charge would have reset such a row to one attempt anyway. The delete goes through a definer bound to the session tenant, because the runtime role holds no DELETE on the table; tenants are found by a zero-argument enumerator only the wiki_scheduler role may execute (constitution §1, ADR-040), and each batch that deletes rows writes a wiki.request_change.attempts_pruned audit entry carrying the count and the horizon, never an actor id.

The tenant ceiling still has no per-reader component, and that stays deliberate: it counts created rows, so one reader can exhaust the hour's budget across a hundred pages they can read. It is bounded by the per-page dedup — those hundred requests are a hundred distinct pages, each of which needed a curator's attention anyway — and, now, by the per-actor attempt bound above reaching its own limit first.

No SECURITY DEFINER gate is involved, and an earlier revision of this feature had one. That revision answered the dedup and ceiling questions together inside a definer function returning a bare verdict, precisely so neither would leak a count across a classification boundary, and audited every probe under constitution §5's read clause. It was removed rather than mitigated: EXECUTE has to reach the shared constellation_app role for the feature to work at all, so a source-scanning test could not stop another caller invoking it without the audit; and moving the audit into the function is unavailable, because audit.audit_entries carries a tamper-evident hash chain advanced under SELECT FOR UPDATE by packages/platform/db. What ships instead crosses no boundary and therefore owes no §5 read entry — at the cost stated in the two paragraphs above. Migration 064_drop_request_change_gate.sql removes the function where an earlier revision applied it; the runner is keyed on filename, so editing the original migration in place would not have un-applied it.

Resolving one finding by id (PLT-684)​

findingId is an exact filter on this same collection, and deliberately not a dedicated GET /api/spaces/:spaceId/lint/findings/:findingId — the per-finding route stays PATCH-only. The filtered read inherits the collection's RLS scoping, so a finding hidden from the caller by clearance and a finding that does not exist produce the same empty result: same status, same body. A dedicated route would have to reproduce that equivalence by hand, and could leak it as a 403-versus-404 split. Timing equivalence is not claimed — an RLS predicate may do more work for an existing hidden row than an index miss does for an absent id.

It composes with status, checkType and pageId rather than overriding them, so a deep link into a filtered bucket still resolves, and an id that contradicts the active filters is correctly empty instead of escaping them. It also makes a finding past the default page size of 50 reachable without paging, which is the whole point.

offset is forced to zero when findingId is supplied. id is the primary key, so the result is one row or none — but the offset would still apply to it, and a deep link opened from page 2 of the queue carries that page's offset. ?findingId=<id>&offset=50 would otherwise answer total: 1 with an empty findings array. limit needs no such treatment: its minimum is 1, so it can never hide the row.

reviewedState — what a review decision is anchored to (PLT-684)​

Every page-backed finding row carries a reviewedState object, and GET /api/pages/:id carries the page's own current token as a bare string:

{
"reviewedState": "rs1:9f2c…",
"nominationReviewedState": "rs1:41ab…",
"changedSinceNomination": true
}

The page read's token always describes the body it returns (PLT-1216). GET /api/pages/:id — and the MCP get_page tool built on it — derives the body and the token from one database statement, so an edit committing while the page is being read cannot pair one revision's text with another revision's token. A client that pairs a finding's token with the page's (the candidate pane does) can therefore trust a match to mean the body on screen is the state it would decide on.

reviewedState is an opaque versioned digest over the page's current revision plus every attribute KB-ingestion candidacy depends on that the revision does not cover — page type, space, status, slug, title, and whether the page is explicitly KB-excluded — and the page's own id, so that two pages matching on all of those (one space can hold two pages at the same slug under different parents) still get different tokens and one page's decision cannot be replayed against another. A revision number alone is not enough: a revision is written only for a body, frontmatter or classification change (or an explicit edit summary), so a rename, a re-type, a status flip or a move between spaces all leave it standing still.

A reviewing surface displays a row and submits the token back with its decision. Rejection and dismissal now compare it — see Recording a candidate decision: those write paths re-derive the token from the page state they lock and refuse a decision whose token no longer matches. Two consumers are still outstanding — the approval path and the re-nomination suppression key — so a token this endpoint produces is not yet compared on every route that will eventually take one. It is deliberately not an authentication token and carries no signature, so read the comparison for what it is: a value that equals the freshly derived token passes, however the client came by it, and a stale or non-matching one fails. That proves the submitted state agrees with the state being written — it proves nothing about who submitted it, which is what the route's own permission check is for.

nominationReviewedState is the token stored when the finding was raised, and changedSinceNomination is true only when that stamp exists and differs — "the page moved since it was nominated", which means the finding's detail text describes a page that no longer looks like that. A finding with no stamp reports null and false: false means "no proven divergence", not "compared and equal", so a consumer that needs the distinction reads nominationReviewedState for null.

detailJson.reviewedState is reserved: POST /api/spaces/:spaceId/lint/findings rejects a caller-supplied value for that key with a 400. A client that could stamp its own finding would make the queue report changedSinceNomination: false against a value nobody read under a lock.

Since PLT-683 the stamp is written server-side on every page-backed nomination. Every candidate producer — the candidacy event pass, the demand-driven anchored match, and the curator record path — creates through one shared boundary that locks the page, derives the token from that locked state, and writes it under this key. So a nomination created from now on reports a real nominationReviewedState; ones created before it still report null, and changedSinceNomination stays false for them, which is why that flag means "no proven divergence" rather than "compared and equal". A page-less demand-gap nomination is stamped with nothing, deliberately: it has no page state to fingerprint. The read path assumes none of this — a stored stamp, however it got there, is returned and compared.

Recording a nomination — the reviewedState field and the suppression refusal (PLT-683)​

POST /api/spaces/:spaceId/lint/findings accepts an optional first-class reviewedState on an ingestion_candidate — the token the surface displayed, echoed back. When supplied it is compared whole-value against the state the server locks, and a mismatch is a 409; either way the stamp persisted is the server-derived token, never the submitted string. It is refused on any other check type and on a finding that names no page, since the token fingerprints a page. Optional rather than required only until the MCP proxy (PLT-685) ships.

The same endpoint gains a second 409: the page has already been reviewed as a candidate in exactly this state. Re-nomination is suppressed when a rejected, dismissed or excluded decision was recorded against this exact token, or when a committed, un-reverted ingest names this page as its origin at its current revision. The two arms expire differently, and the difference is the point. A REVIEW hold is a "not this state" hold: any change to an eligibility input expires it, including the metadata-only edits (page_type, slug, title, status, space_id) that leave revision_num standing still. An INGEST hold ignores the token entirely — a rename does not undo an ingest that happened — and is withdrawn instead by the ingest ceasing to be current: the batch reverted, the source page edited so source_revision_num falls behind, the source anchor retracted, or the ingested content leaving the knowledge base. Both Layer-1 dedup checks step aside for a page's own ingest so that withdrawal can be observed at all — a raw-source ingest copies the origin, so the ≥80% overlap gate and the slug/title lookup would otherwise both keep an edited origin silenced on provenance that is already obsolete. Coverage by anyone else's KB content still dedups normally. That last one turns on two spaces, not one — the batch's destination and the anchor's own current space must BOTH be live KB spaces — because a source page can be moved after the ingest. Moving it from one live KB space to another therefore changes nothing: the content has not left the knowledge base, and re-nominating would ask a curator to ingest what is already there. Moving it into a space that is not a KB space, or unmarking either space, withdraws the hold. Permanent exclusion remains the page-exclusion workflow. The refusal deliberately does not say which decision caused it: the check answers a boolean, because the outcome category is exactly what the ledger's read policy withholds from a caller below the batch ceiling.

Every caller gets the same verdict, and that is deliberate. The check gates the subject — a page you cannot read answers "not suppressed" — but it never gates the answer by the batch's classification_ceiling, so an already-ingested page is not re-nominated whenever a sibling page in its batch is reclassified upward. The cost is a narrow, recorded disclosure: a curator who can read an origin page learns from the 409 that some ingest exists, even where the ceiling would hide the batch itself. The refusal still names no outcome category, no batch and no space.

Every nomination writes one audited read, whatever the check answers (PLT-943, PLT-1415). The check reads the outcome ledger, the page's findings and all of its revisions past your clearance, so constitution §5 requires an audit entry. Each nomination that reaches the check files one wiki.ingestion_candidate.suppression_read_outside_clearance_scope entry naming the page at the page's own classification, with no verdict, outcome or batch. It is recorded before the check runs and is written again in a fresh transaction if the nomination rolls back, so it exists on the 409, on a created finding and on a nomination that fails afterwards. Neither its presence nor the number of entries says what the check answered.

The object is null on a space-level finding (there is no page to fingerprint, and such a row is visible to every tenant member so it must carry no page-derived data) and on a finding whose page is soft-deleted or unreadable. Treat null as "no token available here", never as "unchanged".

Recording a candidate decision (PLT-680)​

The same PATCH optionally records which decision closed an ingestion candidate. Without it the flip lands on status alone — where an approval, a rejection and a terminal exclusion are all resolved, and a dismissal says only that the finding was closed, not that a decision was taken. Recovering which one is what the outcome ledger exists for.

{
"status": "resolved",
"outcome": "rejected",
"reason": "Too narrow to be reusable knowledge.",
"reviewedState": "rs1:9f2c…"
}
FieldRule
statusresolved or dismissed, as before.
outcomeOptional. Only rejected or dismissed.
reasonRequired with an outcome, refused without one. Non-blank.
reviewedStateRequired with an outcome on a page-backed candidate; refused on a page-less one.

A bare { "status": … } records no outcome and is never mapped to one. resolved here documents a finding that was fixed, superseded, re-derived or re-linked — and a client may legitimately ingest a page by hand and then send it, so reading it as rejected would durably record the exact inversion the ledger exists to prevent. Such a call succeeds and stores nothing; no existing client breaks. A reviewedState sent alongside a bare status is accepted and discarded — not compared, not stored, not an error — because a surface echoes back the token it displayed with whatever it submits. A reason without an outcome is refused instead: it is prose a human typed meaning it to be recorded, and a bare flip records nothing.

Status and outcome must agree exactly, resolved → rejected and dismissed → dismissed. They are two different statements about the page — dismissed says the nomination is noise, rejected says the page was judged and refused — and letting them disagree would desynchronise the queue's bucket from the ledger's record of why.

approved and excluded cannot be stated here at all. approved is written solely by the ingest transaction (it must name the batch it produced — a biconditional the database enforces) and excluded solely by the exclusion workflow. Each asserts a state a plain PATCH does not create, so accepting them would let any REST client mint a false approval.

What a stale decision looks like​

409 with code: "CONFLICT". The submitted reviewedState is compared whole-value against a token re-derived from the page the writer locks — and it is the token that is compared, never the revision number, because page_type, slug, title, status, space_id and kb_excluded_at all change with revision_num standing still. Re-read the candidate (?findingId=<id>) and decide again.

The same 409 covers three further shapes, all refused before any write so the finding is left open rather than resolved without the decision the caller asked for:

  • the candidate carries a target_page_id — the token binds the nominated page alone, so no writer can honestly bind the target;
  • the finding is not an ingestion_candidate — "approved" means nothing about a contradiction, and the bare status path still resolves it normally;
  • the nominated page has no state a decision can be anchored to (no revision this caller may read).

The audit entry​

A stated outcome emits exactly one auditCritical entry, wiki.ingestion_candidate.outcome_recorded, and the routine UPDATE entry does not also fire — auditCritical writes its row through auditAction, so pairing them would double-count one transition. A bare status flip keeps the routine entry. The reason text is not copied into the audit entry: it is prose written about a possibly-classified page, and only the ledger row is gated on the decision-time classification.

The entry inherits the nominated page's classification, per the audit rule that an entry carries the classification of the resource it describes — a decision on a SECRET page is audited SECRET. It never falls below RESTRICTED, which is forwardToSiem's default filter: inheriting UNCLASSIFIED would drop the entry from external monitoring altogether. Omitting the reason text does not lower it, because the rule is about the resource rather than about the payload.

A curator detail is at most 500 characters (PLT-1145). POST /api/spaces/:spaceId/lint/findings refuses a longer one with a 400 (the message under error.details.issues.detail), and record_lint_finding with an input-validation error before any request is sent; both name detailJson as the destination: detail is the short sentence the review queue renders — what is wrong and where — and quoted evidence, enumerated sub-defects and material carried forward from an earlier pass go in detailJson under named keys. The bound was 4000; PLT-1101 sampled every template-composed row under 400 characters and curator rows from 571 to 2839. 500 is a ceiling above the 280-character, two-sentence target the wiki-curator contract sets (PLT-1144), so a small overshoot is not refused. It applies to the write path only: rows already stored above it are not rewritten and still read and render, and the mechanical, usage-anomaly, candidacy and system producers do not pass through this boundary.

Free text carrying a NUL byte is refused at the boundary, on every declared string field and anywhere inside detailJson. PostgreSQL stores it in neither text nor jsonb, so such a request was a 500 at the INSERT; it is now a 400.

Excluding a page records its own outcome​

excludeFromNomination closes every open nomination for the page and records an excluded outcome for each, deriving the token server-side — after the exclusion is stamped, so it carries kb_excluded_at and therefore differs from the token a later re-included page would present. If no token can be derived, the whole exclusion rolls back rather than closing a nomination outcome-unknown: that is a visibility condition, so refusing asks for a retry a better-cleared caller can satisfy.

A target-backed nomination is the exception — it is skipped and closes outcome-unknown. No writer can ever record an outcome for that shape (the token binds the nominated page alone), and a target reference is a permanent property of the row rather than a visibility condition, so rolling back would make the page permanently un-excludable and take a governance control offline because a malformed nomination exists.

The inherited nomination-policy exclusion (PLT-606) deliberately records no outcome. excluded denotes the terminal, page-local control; an inherited policy is reversible, and an immutable row saying excluded would permanently misstate why the finding closed. A distinct value for that case is a follow-up.

Approving a candidate through ingest (PLT-682)​

approved is not written by the findings PATCH at all — it is written by the ingest that fulfils the candidate, because migration 043 makes approved and a non-null ingest_batch_id a biconditional and only the ingest transaction has a batch to name. POST /api/spaces/:spaceId/ingest therefore accepts an optional candidateApproval:

{
"sourceRef": "Tenancy model",
"sourcePageId": "…",
"candidateApproval": {
"findingId": "…",
"reason": "Durable, reusable, and the KB has no coverage of it.",
"reviewedState": "rs1:9f2c…"
}
}
FieldRule
findingIdAn open ingestion_candidate this ingest closes.
reasonRequired with the object, non-blank.
reviewedStateRequired for a page-backed candidate; refused for a page-less (demand-gap) one.

An ingest that omits the object behaves exactly as before and touches no finding. The object is refused as a unit — an unknown key is a 400, not a silently ignored field.

Everything is validated before the ingest writes anything, and the outcome is written only after it succeeds. Both happen in the ingest's one transaction, holding the candidate's row lock throughout, so a refusal leaves the nomination open and a failed ingest records no approval.

A 409 covers each of these:

  • the submitted reviewedState does not match a token re-derived from the page the transaction locks — the page moved since it was displayed, so re-read the candidate and approve again;
  • the candidate is not open, is not an ingestion_candidate, or carries a target_page_id;
  • the candidate is not about what this ingest is ingesting: an anchored candidate must name the page being ingested, and an unanchored one must belong to the destination space;
  • the nominated page is no longer eligible on the safety half of the candidacy rules — it is not approved, it carries a classification the knowledge base may not hold, or it has been explicitly kb-excluded;
  • the page's resolved nomination policy now forbids it (PLT-814) — the page's own ingest_policy or a folder above it carries an exclude, or the space it sits in is no longer watched. See The policy is re-resolved at consumption below. This one is evaluated first, ahead of the coverage checks below it: a page that is both excluded by policy and already covered is refused for the policy reason, because "may this be in the knowledge base at all" outranks "is it already there".

The policy is re-resolved at consumption (PLT-814)​

The eligibility revalidation above is page-local: it reads the page's own type, status, classification, slug, body and kb_excluded_at. The inherited folder policy and the space's nominateFrom gate are a different question, answered by wiki.resolve_nomination_policy and the space row — and before PLT-814 they were asked when a page was nominated and nowhere else.

That left the queue acting as the authorization. A candidate recorded before an ancestor folder was excluded stayed approvable, because no wiki.page.updated event fires for a descendant when a folder's policy changes, so nothing re-evaluated it. PLT-606's review found five ways to reach that state — a descendant of a newly excluded folder, a nomination landing just after the exclusion's retirement, a nominate cleared to inherit in an unwatched space, a re-parent, and a restore that re-homes the page — and they are one gap with five doors rather than five defects.

So the approval now re-resolves the policy, under the same page and finding locks it already holds, and before the coverage gate: "may this be in the knowledge base at all" outranks "is it already there". Two refusals can come out of it, and they are gated differently:

RefusalHalfWhat it means
excluded_by_policyhard_safetyThe resolved policy is exclude — set on the page's own ingest_policy, or on a folder above it. Binds whoever nominated it.
space_not_watchedcurator_overridableThe page's space is no longer one the knowledge base takes pages from.

excluded_by_policy is hard safety for the reason the page-local explicitly_excluded is: it is the same "never", and it binds even when the row that supplied it sits above the caller's clearance — which is what the SECURITY DEFINER walk is for. It is not only a folder's. Resolution is nearest-wins over page > folder > space, so a page carrying its own ingest_policy: { nomination: 'exclude' } — a different column from kb_excluded_at, and one the page-local rules never read — resolves here too. The refusal message therefore names the state and neither the row nor its level: naming the folder would disclose what the resolver withholds, and on that branch would simply be false.

space_not_watched takes the overridable half because nominateFrom governs automatic candidacy — the folder-run path already declines to apply it to an explicit, human-approved bulk ingest. Note what "overridable" means here, since it is a property of the row's ORIGIN and not of the approver: a wiki-candidacy or wiki-demand candidate is still refused once its space is unwatched, and so is one from an origin nobody has classified. Only wiki-curator, which never ran the gate, keeps its override.

Write-time retirement is an optimisation, not the control. Setting a folder to exclude still retires that folder page's own entry, and a move made through PATCH /api/pages/:id still retires the moved page's — both keep the review queue tidy. Neither is what stops the ingest, and a stale entry surviving in the queue is no longer a governance hole.

The candidate detail pane asks the same question, so it renders no Approve control for a candidate the ingest would refuse. It does so in its own transaction rather than the detail read's, because the resolver crosses a clearance boundary on every call and owes a wiki.nomination_policy.resolution_applied_outside_clearance_scope entry — and an auditCritical write inside that RepeatableRead read would degrade the entry's hash chain. Since PLT-1301 that entry is unconditional and verdict-free, so the separate transaction is paid on every detail view rather than only where the resolution came from a hidden ancestor. The pane's verdict takes no locks and remains advice about the affordance; the ingest re-decides everything under its own.

A curator's override stays approvable. The candidacy funnel's Layer-0 precision rules — the durable-type allowlist, the release-notes slug, the completed impl-spec mirror — exist to make the automatic layer under-nominate, and a curator recording a candidate by hand never ran them. They are therefore re-applied only to a candidate whose origin did run them, where they catch a page that drifted into one of those classes after it was nominated. The token comparison cannot catch that drift: it proves the reviewer saw the state being decided about, not that the state was the one at nomination.

The test is "did evaluateEligibility run before the row was recorded?", not "was it automatic?" — so it covers both detected_by = wiki-candidacy (the gate itself) and detected_by = wiki-demand (which calls that same gate before anchoring a nomination, and so only ever nominates a page Layer 0 would also accept). wiki-curator is the only exempt value, and it is recognised by name rather than inferred. detected_by is free text on record_lint_finding, so an unrecognised origin — a legacy row, a typo, a detector added later — is gated rather than granted the override.

The destination must be a marked knowledge-base space, which is not a rule of this path — every ingest is already refused otherwise.

The candidate's page_revision_num is re-anchored, not consulted. It gates nothing — a candidate stamped at revision N whose page has since reached N+1 is reviewed in place, because the open-finding dedup key carries no revision and no replacement row could ever be created. What the resolution does is replace the stamp with the revision of the page it locked, in the same statement as the status flip, so the row does not end up recording a decision against a state nobody decided about. A candidate with no page submits none, and a caller that submits none leaves the existing stamp untouched.

The audit entry is the same wiki.ingestion_candidate.outcome_recorded a stated decision emits, written beside the ingest's own ingest.batch.committed and sharing its correlation id. Its context names the space, the outcome, the resulting status and the reason's length; it carries neither the reason text, the nominated page id, nor the batch id, because an append-only audit entry has a fixed classification and cannot follow a later reclassification of that page or a raised batch ceiling.

Approving a candidate the knowledge base already covers (PLT-990)​

An approval whose page the knowledge base already holds is refused whole — 409, no batch, no pages, no outcome row, and the finding is left open. The reason is a retrieval property rather than a tidiness one: the index-first read ranks spokes per question, so two pages on one subject split the citations and neither carries the whole answer.

Three predicates run in phase A, before any write, and none subsumes another:

PredicateWhat it seesRecovery
identityA committed, un-reverted batch naming this page as its origin, at any revision, with both spaces still live knowledge bases and the source anchor live.Revert the batch and re-ingest. Slug reclaim makes that a single step. Refreshing a stale KB copy goes this way, never through a second batch.
similarityA served KB page whose body covers this page at or above the same containment threshold the candidacy funnel uses, in any live knowledge base — not the ingest destination alone.None — merging is terminal. Folding the candidate's material into the covering page raises the overlap, so a re-approval is refused more firmly. Merge, then dismiss the candidate.
membershipThe candidate's own space is already a live knowledge base.None needed — a page already in the knowledge base is not material to add. Dismiss it.

The predicates are independent, so clearing one is not enough: an approval succeeds only when all three come back clear, and a curator working from one refusal can hit another on the retry. A revert clears identity and, for anything that ingest touched, similarity too — but an unrelated covering page still refuses.

Identity is answered by a trusted, ceiling-blind check (wiki.ingest_batch_is_live_kb_ingest_for_page_resolved, which takes no slug and resolves the transitional fallback space from wiki.kb_fallback_slug() itself — PLT-1033), so a batch above the caller's classification ceiling produces a byte-identical refusal to one they can see. Distinguishable refusals would be the oracle that check exists to close; one refusal is not. This is the residual H8b already priced — a curator who can read an origin learns that some ingest exists — and the refusal names neither the batch, its ceiling, nor who ingested it. A similarity refusal may name the covering page, which discloses nothing new: that page is read through ordinary RLS at the ingest transaction's UNCLASSIFIED pin, so it is one the same read would have shown the caller anyway.

"Covered" means content the reader actually SERVES, for similarity and membership. An archived page and one past its expires_at ground no answer, so neither refuses — a page the reader never returns splits no citations. On the membership side that carve-out buys the expired case only: an archived candidate never reaches this check at all, because eligibility revalidation refuses any status other than approved as hard safety. The identity predicate is deliberately not narrowed the same way — it is a shipped shared predicate the nomination boundary also uses — so an approval can still be refused on identity for a batch whose pages the reader has retired; the recovery is the documented revert-and-re-ingest.

No origin is exempt, the curator included. The curator override exempts a hand-recorded nomination from the eligibility precision rules — "does this page belong in the KB" — and coverage is the different question "is it already there", which no override answers.

A page-less (demand-gap) approval is an explicit no-op. The trusted check takes a page id and the similarity derivation needs a page's search vector; a raw paste has neither, so it proceeds unchanged and pays for no check.

What a refusal writes. No ingest, no batch, no page, no outcome row and no suppression hold — so nothing is armed that could later fail to expire, and the verdict is re-derived per request from predicates that lapse on their own. It does commit an audit entry for every trusted read it actually performed, and each of those is two durable writes rather than one. That count is not fixed, because the predicates short-circuit in the order above: a membership refusal reaches no trusted read and files nothing, an identity refusal files one entry, and only a similarity refusal — or an approval that clears every predicate — files both.

The audit entries, and why they survive. Both coverage reads cross a classification boundary and owe a constitution §5 entry: the approval-time identity read files wiki.ingestion_candidate.approval_identity_check_applied_outside_clearance_scope — its own action, because the nomination boundary's read is a different call site — and the similarity read files the same wiki.ingestion_candidate.provenance_check_applied_outside_clearance_scope the candidacy funnel does, one entry per candidate evaluated. Neither carries the verdict: an entry that appeared only on a refusal would let anyone with audit access recover, for a page whose batch they cannot read, exactly what the bare boolean withholds. The refusal is therefore returned out of the transaction and raised as a 409 after it commits — a throw from inside would roll the entries back on precisely the paths that disclose. auditCritical also writes an audit.entry.created outbox row in the same transaction, so a refused approval becomes observable to subscribers of that type once the dispatcher ships it. What the commit guarantees is the enqueueing: the row is durable and eligible for delivery. Whether it is ever delivered is the dispatcher's and the subscriber's business, not this transaction's.

What this cannot see, and the list is open. An approval refuses only what the check can see. A covering KB page reclassified above the ingest transaction's UNCLASSIFIED pin is invisible to the similarity read; a page-less candidate has nothing to check; a candidate whose title and body reduce to an empty search vector is never scored; an ordinary KB page created between the check and the commit introduces coverage this approval never saw (the page and finding locks do not hold the KB corpus); and a pre-migration-036 batch carries no origin column for identity to match. Each is a duplicate that can still be written. Treat that as an open list rather than a closed count — every round of review of this decision found another instance.

Dismissal is not liveness-preserving, and that is recorded rather than papered over. Suppression is keyed on the candidate page's review state, and reverting or editing the covering page does not move it — so a candidate dismissed as "already covered" stays suppressed until its own page changes, even after the coverage disappears. That is why the automatic refusal above writes no outcome and no hold: it never arms a hold it cannot expire. A curator dismissing by hand is recording a judgement about this state and carries that limitation knowingly.

The tenant-wide findings listing (PLT-699)​

GET /api/lint/findings is the drill-down the bucket counts point at. It answers "show me the findings in this queue" in one call, across every live space in the tenant — the scope a /review deep link needs, since needs_review alone spans 16 of the 18 check types and they are spread over every space.

ParameterMeaning
spaceIdOptional. Omitted, the listing spans every live space in the tenant; supplied, it restricts to that space and a space you cannot see is a 404. Same contract as the aggregate.
bucketOne triage bucket — needs_review, candidates, stale, closed.
checkTypeA comma-separated set: ?checkType=contradiction,data_gap. A single value is a set of one.
statusOne of open / resolved / dismissed.
pageId, findingIdAs on the space-scoped list. findingId forces offset to 0.
limit, offsetDefault 50, maximum 200; offset ≥ 0.

The shipped GET /api/spaces/:spaceId/lint/findings is unchanged — same query contract (a single checkType, no bucket), same body, same 404 — and now reads through this same path, so the two URLs cannot answer one query differently.

How a bucket becomes a filter. The translation is derived from the one check-type-to-bucket map, never restated, which is what makes this listing and the counts above it partition the same rows:

  • an open bucket → status = 'open' and the check types that map to it;
  • closed → any non-open status, with no check-type restriction. Status decides first, exactly as it does in the bucket table above.

Filters intersect; a contradictory pair is empty, not an error. ?bucket=needs_review&checkType=stale_claim returns nothing — stale_claim belongs to stale — and so does ?bucket=closed&status=open. That is deliberate: a client carrying a stale filter forward from a previous view has asked a question with a well-defined answer, and refusing it would be a worse failure than answering it truthfully with nothing. It is the same judgement that makes findingId ignore a carried-forward offset instead of rejecting it.

Sets are comma-separated, and the repeated form is refused. ?checkType=a&checkType=b returns 400 naming the right syntax. It is not merely unsupported: the shared query parser folds repeated parameters to their last value, so accepting it would silently drop a and hand back a narrower result the caller could not tell from a smaller queue. Members are trimmed and de-duplicated; an empty member (a trailing or doubled comma) or an unknown check type is a 400 as well, for the same reason — a set filter must never quietly lose an entry.

Ordering and pagination. One tenant-wide sequence, created_at DESC, id DESC, with the primary key as tiebreak. Four properties are worth stating because none of them is guessable from the response:

  • No per-space grouping. Page 2 is the next slice of one queue, not the first page of the next space.
  • offset counts rows currently visible to you in live spaces. It is relative to your clearance and to space liveness, not to the table — the same join that keeps a decommissioned space's findings out of the aggregate's counts keeps them out of these rows.
  • Determinism holds for a fixed visible dataset. A concurrent insert, status flip, clearance change or decommission between two requests can shift offsets, so a row may be returned twice or missed while paging a queue that is being worked.
  • total is not a snapshot of the page beside it. The rows and the count are two statements, so a queue mutating under a paging client can report a total its page does not reconcile to. Read it as the size of the queue, not as an invariant over the array.

The endpoint has no MCP equivalent — like the aggregate, it exists for the review UI. From MCP, list_lint_findings still requires a spaceId.

The findings aggregate (PLT-510)​

GET /api/lint/findings/aggregate answers "how much is in the queue, and of what" in one grouped query per source table — deliberately not one COUNT(*) per category. Four UI surfaces are planned on top of it. The review shell's triage nav (PLT-576) and the sidebar badge (PLT-530) now ship, and both read the endpoint through one coalescing client hook (useLintFindingAggregate); the KB insights summary (PLT-585) and the findings list's space badge (PLT-378) do not exist yet and are to arrive through that same hook. The endpoint shipped ahead of them precisely so each does not arrive with its own counting path — that is how four screens come to disagree about one queue.

One request is not by itself one answer, and PLT-1039 is what closed the gap. Coalescing the requests was only half of it: each mounted consumer kept its own settled outcome, and the shared layer announced itself on writes alone — an invalidation told every consumer to re-read, while a successful read told nobody. So a failed read that the reader retried from /review, which is where the product routes that failure, brought that page current and left the sidebar badge blank until a remount. Since PLT-1039 a settled value is also announced on its own channel, and every mounted consumer of the same scope adopts it without issuing a request of its own. Scope is unchanged by that: a value read under one principal, or for one space, is never adopted by a consumer rendering under another.

spaceId is optional and that is the contract, not an oversight:

  • omitted — aggregates every live space in the tenant;
  • supplied — restricts to that space, and 404s if the space does not exist or the caller cannot see it. A zero-filled body would be indistinguishable from a genuinely empty space.

The response carries the raw facets and the pre-computed buckets side by side: total, byStatus, byCheckType and bySeverity (each split by status), buckets, and — since PLT-577 — byCandidateOutcome. Every map is zero-filled across its full key space, so a consumer never needs an absent-key fallback.

Five triage buckets, and they partition every finding — status is consulted first, so each row is counted exactly once:

BucketContents
needs_reviewevery open finding not in the three buckets below
candidatesopen ingestion_candidate — the KB nomination queue
staleopen stale_claim
classificationthe open *_classification_inversion pair (PLT-913)
closedevery resolved or dismissed finding, of any check type

closed is not called resolved on purpose: it holds dismissals too, while byStatus.resolved means the status alone. A UI is free to label it "Resolved".

classification arrived last, and could not have arrived earlier (PLT-914). Until PLT-913 no check type produced a classification finding, so the queue would have been an always-zero row reading "checked and clean" where the truth was "not checked at all". PLT-913 shipped the two producing types into needs_review as a truthful interim; PLT-914 moved them here, because the question they pose is not "does this want a curator" but "is something readable by people who must not read it".

Changing this key set is a coordinated cross-layer change, and it is not atomic at runtime. The bucket map is closed and strictly parsed by the /review client, so a server reporting a key the client does not know — or a client expecting one an older server does not send — fails the parse and takes the review surface to its error state until versions converge. Both halves read one shared constant, so they cannot drift in git; a rolling deploy is the window that remains. Note the contrast with byCheckType, which strips unknown keys precisely because that universe grows on every routine lint rule.

Space-level findings are not clearance-gated. RLS gates a finding through the page it references, so a finding carrying no page (every usage-anomaly type, plus space-level missing_concept / data_gap and demand-gap nominations) is visible to every tenant member — and therefore included in their bucket totals. The clearance caveat below applies to page-backed findings.

byCandidateOutcome — the one facet from another table (PLT-577)​

Every other map above folds wiki.lint_findings columns. byCandidateOutcome counts decision rows in wiki.ingestion_candidate_outcomes (H7a's ledger), one per approved / rejected / dismissed / excluded, and it exists because a finding's status cannot carry a decision: an approval, a curator's stated rejection and an explicit page exclusion all leave resolved behind. Reading "approved" off byStatus would report the other two as approvals.

Two consequences a consumer must carry rather than work around:

  • Its clearance gate is not the finding facets' gate. These counts additionally apply the decision-time classification and, for approved, the linked batch's classification_ceiling — so a decision the caller may not know about is excluded before the COUNT(*), and approved can be missing entirely while the finding that carries it is still counted in byCheckType.ingestion_candidate. That is the concealment working, not a discrepancy to reconcile.
  • The visible outcomes therefore need not account for every closed candidate. The guarantee is Σ visible outcomes ≤ detected − still-open, where detected is byCheckType.ingestion_candidate summed over its statuses. What is left over is exactly the closed candidates whose decision this caller cannot see — hidden by the ceiling — or that never had one recorded, because it was taken before the ledger existed (migration 043 is additive and deliberately not backfilled) or resolved without stating a decision. Those three are indistinguishable by contract, so a UI must render the remainder as its own outcome-unknown stage, named for what the reader knows rather than for a cause, and must not attribute it to any decision — least of all to approved, which would undo the concealment by arithmetic. The /review funnel strip is the reference consumer; where the two halves disagree it reports the inconsistency rather than clamping it away.

Because the two halves come from different tables, the endpoint's transaction runs at RepeatableRead: at READ COMMITTED each statement takes its own snapshot, and the remainder above is their difference, so a decision committed between the two reads would make it describe a state that never existed.

Non-destructive autonomy​

The entire lint surface is non-destructive by construction (design review R6):

  • Findings are inserted or status-flipped — there is no delete path.
  • The lint pass never mutates linted pages. Marking stale = an open stale_claim finding.
  • Page deletes and synthesis-rewrites stay human-gated even in spaces whose ingest_policy.autonomy is autonomous. Because lint performs no gated write, it runs in both human_approved and autonomous spaces.

Visibility caveat​

The mechanical scan runs under the caller's RLS context (tenant + clearance). A wikilink target that exists but is classified above the linting caller's clearance is reported as broken_crossref (a false positive); a reference to a soft-deleted page is a true positive. Run lint with a clearance that covers the space's content, or have the curator flag suspected classification false positives in its verdict instead of resolving them.

The wiki-curator subagent​

.claude/agents/wiki-curator.md drives the curator-judged loop: run lint_space → judge drift candidates → sweep for semantic defects → record findings → re-check open findings → append_space_log (action: "lint") → emit the standard verdict bar. Its only page writes are the reserved maintenance surfaces (space log and index).

Cadence is split by cost (PLT-265/PLT-268): the cheap mechanical pass (orphan_page / broken_crossref / provenance_drift / merge_candidate) is fully automated on a daily Vercel cron and needs no subagent invocation. The classification reconcile rides along with it — it is SQL, not an LLM pass, so it costs the same cron nothing extra. The token-bearing curator-judged pass (contradictions, stale claims, missing concepts/crossrefs, data gaps, near-duplicates) plus the ingestion-candidate review stays agent/human-run, per-release and ad-hoc — it is a standing step at every release cut (PLT-301), not a scheduled cron.