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:
| Column | Meaning |
|---|---|
space_id | The linted space. |
page_id | The page the finding is about (nullable — space-level findings have no subject page). |
target_page_id | The other page involved: the contradicting page, or the drifted provenance target. |
check_type | One of the check types below. |
severity | info / warning / error. |
status | open → resolved / dismissed. A status flip with attribution — never deleted. |
detail | Actionable description quoting/locating the offending claims. |
detail_json | Machine-readable payload (PLT-255). For lost_source: { retractedPageId, reason, depth, retypedFrom? }; for lost_space_reference: { spaceId, spaceSlug, reason, lostLinkCount }; {} otherwise. |
detected_by | wiki-lint for the mechanical pass; an agent principal (e.g. wiki-curator) otherwise. |
page_revision_num | Revision 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):
| Check type | Detects |
|---|---|
orphan_page | A page with no parent, no children, and no typed links in either direction (reserved index/log pages excluded). |
broken_crossref | An [[…|id:<uuid>]] wikilink whose target page does not exist or is not visible — the hallucinated-link case. |
provenance_drift | A derived_from link whose target has newer revisions than the link's source_revision_num — the claim may be unsupported. |
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 — recorded by the wiki-curator subagent via POST /api/spaces/:spaceId/lint/findings after reading the pages:
| Check type | Meaning |
|---|---|
contradiction | Two pages assert incompatible claims — including cross-source (different derived_from). |
stale_claim | A 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_concept | An entity/concept referenced repeatedly but never given a page (space-level). |
missing_crossref | Two clearly related pages with no link between them. |
data_gap | A question the space should answer but cannot. |
System — written only by database-side machinery; listable and resolvable through the lint surface, never recordable through it:
| Check type | Meaning |
|---|---|
lost_source | The 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_reference | The 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). |
REST surface
All routes are tenant-auth wrapped; write paths require the wiki:page:write permission.
| Route | Behaviour |
|---|---|
POST /api/spaces/:spaceId/lint | Run the mechanical pass; returns the run report (counts). |
GET /api/spaces/:spaceId/lint/findings | List findings — filter by status, checkType, pageId; paginated. |
POST /api/spaces/:spaceId/lint/findings | Record a curator-judged finding (409 on duplicate open shape). |
PATCH /api/spaces/:spaceId/lint/findings/:findingId | Resolve or dismiss an open finding (status flip, attributed). |
MCP equivalents (constellation MCP server, REST-only clients): lint_space, list_lint_findings, record_lint_finding, resolve_lint_finding.
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_claimfinding. - Page deletes and synthesis-rewrites stay human-gated even in spaces whose
ingest_policy.autonomyisautonomous. Because lint performs no gated write, it runs in bothhuman_approvedandautonomousspaces.
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 loop on a documented cadence (weekly, per-release, and ad-hoc — mirroring memory-curator): 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).