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. The canonical needs-review contract is one query — a page needs review iff wiki.lint_findings has at least one row with page_id = <page> and status = 'open' — surfaced as openFindings on GET /api/pages/:id and as the ⚠ needs review (<check types>) annotation in the rebuilt space index.

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.
statusopenresolved / dismissed. A status flip with attribution — never deleted.
detailActionable description quoting/locating the offending claims.
detail_jsonMachine-readable payload (PLT-255). For lost_source: { retractedPageId, reason, depth, retypedFrom? }; for lost_space_reference: { spaceId, spaceSlug, reason, lostLinkCount }; {} 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).

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.

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

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.

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.

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

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 absolute/baseline threshold. Payload: { window, windowDays, actorId, readCount, spaceAverageReadCount }.
kb_degenerate_query_loopOne (actorId, questionHash) pair repeated at least the configured threshold in the window. Payload: { window, windowDays, actorId, questionHash, repeatCount }. It means “possible loop,” not proof that one agent session was stuck.

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 known false-positive classes are tracked by PLT-465 and PLT-466: the multi-LLM reviewer re-grounds the same PR-title-plus-changed-paths query on each push, and the session-start hook intentionally asks the same stable orientation question once per agent session. Both currently land under the human credential owner, so actor id plus question hash cannot distinguish many legitimate sessions from one stuck session. Until purpose/session attribution lands, compare the hash with its classification-safe sample when that hash is still present in the bounded kb_usage top-query report. Dismiss a match to either known automation shape and investigate unfamiliar interactive questions before choosing resolved or dismissed; if the hash has aged out of the report, record that attribution is unavailable rather than inferring a session from the actor id alone.

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. Ingesting does not itself close the ingestion_candidate finding (the ingest path writes the source batch; finding resolution is a separate lint operation), so the nomination is cleared afterwards with resolve_lint_finding; or
  • Dismisses the finding (via resolve_lint_finding) if the page isn't KB-worthy. Marking a page excluded from nomination is the one path that auto-resolves the open finding — a terminal exclusion supersedes the pending nomination.

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.

REST surface

All routes are tenant-auth wrapped; write paths require the wiki:page: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; 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).
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 and pageId 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, so a tenant-wide number means 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.

The findings aggregate (PLT-510)

GET /api/lint/findings/aggregate answers "how much is in the queue, and of what" in one grouped query — deliberately not one COUNT(*) per category. Four UI surfaces are planned on top of it and none of them exists yet: the review shell's triage nav (PLT-576), the sidebar badge (PLT-530), the KB insights summary (PLT-585) and the findings list's space badge (PLT-378). The endpoint ships 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.

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), and buckets. Every map is zero-filled across its full key space, so a consumer never needs an absent-key fallback.

Four 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 two buckets below
candidatesopen ingestion_candidate — the KB nomination queue
staleopen stale_claim
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".

There is no classification bucket. No check type produces one — the check-type universe has nothing about classification — and an always-zero entry would read as "checked and clean" when the truth is "not checked at all". Adding one later is an additive change to this response.

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.

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