openvibers/openvibe.wiki Commits

Generated seed text stays noindex until a person reviews it: AI-assisted imports count as AI output for the gate; reviewRevision (history form and POST /api/v1/pages/:id/revisions/:n/review) re-evaluates the gate and re-sends the Search document with wiki.page.updated; boot-time index reconciliation for rows seeded earlier

OP OpenVibers committed 23 Sept 2026 · 1042138abb · 11 files, +325 −34

- seed: importedFrom.aiAssisted: true; the gate gets authorship { mode: 'ai', reviewed } for such revisions (also rows recognised by an "AI assistance" import label), so seeded pages are published and readable but noindex (ai_generated_unreviewed) and out of sitemaps, feeds and Search
- non-listable decisions are tombstones in Search
- disclosure says "Not yet reviewed by a person" / "Reviewed by a person"
- reviews by owners/editors who are people only (capability wiki.revision.publish for services acting for one); pending AI proposals keep their own review
- reconcileIndex() at boot re-sends only documents whose indexability changed (idempotent)
- tests: seed noindex and absent from sitemap/feed/Search until reviewed, refusals, approval flips to indexable with exactly one index event, needs-changes takes it out again, reconciliation

Changes

ddiff --git a/README.md b/README.md
iindex 1ce32de..858c450 100644
---- a/README.md
++++ b/README.md
@@@ -97,14 +97,23 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
`/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes);
`/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.
- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons
-- (policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text only
-- after review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and
++ (policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text and
++ AI-assisted imports only after a person's review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and
the Search index; members/private/deleted pages are tombstones and never appear in sitemaps,
feeds or search, and are served `Cache-Control: private, no-store`.
- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pages
about the network's repositories, summarised from their README/STATUS files at pinned commits,
-- each cited with a GitHub permalink and retrieval time, authorship recorded as `imported`
-- ("summarised with AI assistance"). Idempotent.
++ each cited with a GitHub permalink and retrieval time, authorship recorded as `imported` with
++ `importedFrom.aiAssisted: true` ("summarised with AI assistance"). Idempotent. Being generated
++ text, the pages are published and readable with that disclosure ("Not yet reviewed by a person")
++ but the gate keeps them `noindex` (`ai_generated_unreviewed`) — out of sitemaps, feeds and Search —
++ until a person reviews each one.
++- **Reviewing revisions**: owners and editors (people) review an existing AI-assisted revision from
++ the history view ("Reviewed — correct" / "Needs changes") or `POST /api/v1/pages/:id/revisions/:n/review`.
++ The review goes into the append-only review log; for the published revision the gate is
++ re-evaluated and a changed Search document goes out (with `wiki.page.updated`). Imported rows
++ written before the flag existed are recognised by their "AI assistance" label, and at boot Wiki
++ re-sends Search any document whose indexability changed (`reconcileIndex`, idempotent).
Not built yet: page-level talk moderation, media upload from Wiki (attach existing Media objects
only), consuming Media/Sources change events (Media does not emit deletion events yet; checks are
@@@ -140,16 +149,16 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of
| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` |
| `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` |
| `wiki.revision.propose` | `POST /proposals` |
--| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /proposals/:id/review` |
++| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /pages/:id/revisions/:n/review`, `POST /proposals/:id/review` |
| `wiki.revision.revert` | `POST /pages/:id/revert` |
| `wiki.citation.attach` | `POST /pages/:id/revisions/:n/citations` |
| `wiki.search.query` | `GET /search` |
--The ids are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's
--`wiki.search` becomes the three-segment `wiki.search.query`), with the service manifest in
--[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Until a contracts
--release defines them, [server/auth/capabilities.js](server/auth/capabilities.js) decides grants with
--the contracts library's own matching rule.
++The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's
++`wiki.search` becomes the three-segment `wiki.search.query`) and are released in openvibe-contracts
++v0.19.0 with the service manifest ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)).
++The proposal for `wiki.revision.publish` now also lists the revision review route, which the
++released manifest does not name yet.
## Events
@@@ -158,7 +167,7 @@ transaction as the change; the relay publishes to OpenVibe.Events when `EVENTS_U
- `wiki.space.updated` — created, settings, visibility, roles, renamed, deleted
- `wiki.revision.created` — every revision (internal: a revision is a draft until published)
--- `wiki.page.published | updated | unpublished | deleted` — publication changes (public only for public, listable pages)
++- `wiki.page.published | updated | unpublished | deleted` — publication changes (public only for public, listable pages); `updated` also when a review changes what Search holds
- `wiki.watch.triggered` — recipients who may read the page
- `wiki.index_document.upserted | deleted` — `search.index-document@1` documents and tombstones for
OpenVibe.Search, with a monotonic index revision (`createIndexSequencer`)
ddiff --git a/STATUS.json b/STATUS.json
iindex 64ba3b4..5ab6b31 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,12 +7,12 @@
"runtime": "node 22, express, better-sqlite3",
"port": 4800,
"domain": "openvibe.wiki",
-- "contracts": "openvibe-contracts v0.13.0",
++ "contracts": "openvibe-contracts v0.19.0",
"packages": ["openvibe-publishing v0.2.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],
"capabilities": ["wiki.space.create", "wiki.page.create", "wiki.page.read", "wiki.revision.propose", "wiki.revision.publish", "wiki.revision.revert", "wiki.citation.attach", "wiki.search.query"],
-- "capabilitiesRegistered": false,
++ "capabilitiesRegistered": true,
"deployed": false,
"onOpenVibeSites": true,
-- "seed": "official space \"openvibe\" (10 pages summarised from the repositories' README/STATUS files, cited)",
++ "seed": "official space \"openvibe\" (10 pages summarised with AI assistance from the repositories' README/STATUS files, cited; noindex until a person reviews each)",
"updated": "2026-09-22"
}
ddiff --git a/docs/capabilities-proposal/wiki.revision.publish.json b/docs/capabilities-proposal/wiki.revision.publish.json
iindex f30cd16..a4b97b9 100644
---- a/docs/capabilities-proposal/wiki.revision.publish.json
++++ b/docs/capabilities-proposal/wiki.revision.publish.json
@@@ -4,7 +4,7 @@
"owner": "wiki",
"status": "active",
"visibility": "first-party",
-- "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's approval or rejection of an AI proposal, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",
++ "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's review (approved or needs changes) of an AI proposal or of an existing AI-assisted revision, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",
"permissions": [
"wiki:revision:publish"
],
@@@ -23,6 +23,7 @@
"POST /api/v1/pages/:id/publish",
"POST /api/v1/pages/:id/schedule",
"POST /api/v1/pages/:id/unpublish",
-- "POST /api/v1/proposals/:id/review"
++ "POST /api/v1/proposals/:id/review",
++ "POST /api/v1/pages/:id/revisions/:n/review"
]
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 711501a..93aabc8 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -21,6 +21,7 @@
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
* POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)
++ * POST /pages/:id/revisions/:n/review wiki.revision.publish { decision: approved|rejected, note? } a person (owner/editor)
* POST /pages/:id/publish wiki.revision.publish { revision? }
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
* POST /pages/:id/unpublish wiki.revision.publish
@@@ -176,6 +177,12 @@ function createApi({ svc, viewers, platform, config, log = console }) {
const cites = await resolveCitations((req.body || {}).citations, platform);
return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };
}, 201));
++ router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), R((req) => {
++ const { page, space } = pageAndSpace(req.params.id);
++ const b = req.body || {};
++ const out = svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
++ return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };
++ }, 201));
router.post('/pages/:id/publish', guard('wiki.revision.publish'), R((req) => {
const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex bf3e92c..572cff2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -18,7 +18,7 @@
* GET|POST /w/:space/:slug/revert revert as a new revision
* GET /w/:space/:slug/publish?rev= confirm publishing an older revision
* GET|POST /w/:space/:slug/settings publish, schedule, unpublish, move, media, visibility, delete
-- * POST /w/:space/:slug/watch, /discuss
++ * POST /w/:space/:slug/watch, /discuss, /review (a person reviews an AI-assisted revision)
*
* Caching: an anonymous view of a public, published page is `public, max-age=60`; everything
* else (signed-in views, members/private pages, editing surfaces, errors) is `private, no-store`.
@@@ -438,6 +438,16 @@ function createPages({ svc, viewers, platform, config, log = console }) {
}
}));
++ router.post('/w/:space/:slug/review', form, wrap(async (req, res) => {
++ const found = locate(req, res, '/review');
++ if (!found) return;
++ const { space, page } = found;
++ if (!editorsOnly(req, res, space)) return;
++ const b = req.body || {};
++ svc.reviewRevision(space.id, page.slug, Number(b.revision), { decision: b.decision, note: b.note ? String(b.note).slice(0, 2000) : null }, req.actor);
++ res.redirect(303, `${views.wpath(space, page)}/history`);
++ }));
++
router.post('/w/:space/:slug/watch', form, wrap(async (req, res) => {
const found = locate(req, res, '/watch');
if (!found) return;
ddiff --git a/server/index.js b/server/index.js
iindex 366e448..e8ab258 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -22,6 +22,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const svc = createWikiService({ db, stores, outbox: platform.outbox, config, now, log });
++ const reconciled = svc.reconcileIndex();
++ if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 609ee38..90ad029 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -134,11 +134,17 @@ ${list.map((r, i) => html`<tr${r.published ? raw(' class="wk-live"') : ''}><td><
<td>${t(r.createdAt)}</td><td>${r.author || ''}</td>
<td>${r.kind}${r.meta && r.meta.authorship && r.meta.authorship.mode !== 'human' ? html` · ${r.meta.authorship.mode === 'ai' ? 'AI-generated' : r.meta.authorship.mode}` : ''}${r.proposal ? html` · proposal ${r.proposal.status}` : ''}</td>
<td>${r.message || ''} <span class="wk-muted">${String(r.citationCount)} source(s)</span>
++${r.aiAssisted ? html` <span class="wk-tag">${r.needsReview ? 'AI-assisted, not yet reviewed' : (r.review && r.review.decision === 'approved' ? 'reviewed by a person' : 'AI-assisted')}</span>` : ''}
${canEdit && !r.published ? html` <a href="${base}/revert?to=${String(r.number)}">revert to this</a>` : ''}
${canEdit && !r.published && (!r.proposal || r.proposal.status === 'approved') ? html` <a href="${base}/publish?rev=${String(r.number)}">publish this</a>` : ''}</td></tr>`)}
</tbody></table>
${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</button></p>` : ''}
--</form></section>`;
++</form>
++${canEdit && list.some((r) => r.needsReview) ? html`<h2>Waiting for a person's review</h2>
++<p class="wk-muted">These revisions were written with AI assistance. Search engines are told not to index them, and they stay out of sitemaps, feeds and search, until a person checks them against their sources.</p>
++<ul class="wk-review">${list.filter((r) => r.needsReview).map((r) => html`<li><a href="${base}?rev=${String(r.number)}">Revision ${String(r.number)}</a>${r.published ? ' (published)' : ''}${r.review ? html` · last review: ${r.review.decision === 'rejected' ? 'needs changes' : r.review.decision}` : ''}
++<form method="post" action="${base}/review" class="wk-inline"><input type="hidden" name="revision" value="${String(r.number)}"><label class="wk-sr" for="wk-note-${String(r.number)}">Review note</label><input id="wk-note-${String(r.number)}" type="text" name="note" maxlength="2000" placeholder="Note (optional)"> <button type="submit" name="decision" value="approved">Reviewed — correct</button> <button type="submit" name="decision" value="rejected">Needs changes</button></form></li>`)}</ul>` : ''}
++</section>`;
}
function diffPage({ space, page, diff }) {
ddiff --git a/server/wiki/seed.js b/server/wiki/seed.js
iindex 766e9e2..421c046 100644
---- a/server/wiki/seed.js
++++ b/server/wiki/seed.js
@@@ -5,7 +5,9 @@
* Idempotent: an existing space is reused and a page whose slug exists is left alone (never
* overwritten), so running it twice changes nothing. Authorship is recorded as `imported` with the
* truth about where the text comes from: summaries of the cited repository READMEs/STATUS files,
-- * written with AI assistance. Every page carries URL citations (permalinks at the commit read, with
++ * written with AI assistance (`importedFrom.aiAssisted`). Pages are published and readable with that
++ * disclosure, but the gate keeps them noindex (out of sitemaps, feeds and Search) until a person
++ * reviews each revision (service.reviewRevision). Every page carries URL citations (permalinks at the commit read, with
* the retrieval time) on the revision that uses them.
*/
const { pageSlug } = require('./content');
@@@ -16,6 +18,8 @@ const IMPORTED = {
importedFrom: {
label: 'the OpenVibe repository README and STATUS files at the cited commits, summarised with AI assistance',
originalAuthor: 'OpenVibers',
++ // Generated text: held noindex (the gate's ai_generated_unreviewed) until a person reviews it.
++ aiAssisted: true,
},
};
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex a177bab..66648b0 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -34,6 +34,35 @@ function proposalIdNew(t) { return `prp_${ulid(t)}`; }
function toIso(ms) { return ms == null ? null : new Date(ms).toISOString(); }
++/**
++ * An imported revision whose text was produced with AI assistance (the seed) is generated content:
++ * it needs a person's review before it may be indexed, exactly like AI output. Rows written before
++ * the explicit flag existed are recognised by their import label.
++ */
++function aiAssistedImport(rec) {
++ if (!rec || rec.mode !== 'imported' || !rec.importedFrom) return false;
++ return rec.importedFrom.aiAssisted === true || /AI assistance/i.test(String(rec.importedFrom.label || ''));
++}
++
++/** Gate facts for a revision's authorship: AI-assisted imports count as AI output until reviewed. */
++function authorshipGateFacts(rec, review) {
++ if (aiAssistedImport(rec)) return { authorship: { mode: 'ai', reviewed: authorship.isReviewed(rec, review) } };
++ return authorship.gateFacts(rec, review);
++}
++
++/** The disclosure shown at the item; AI-assisted imports say whether a person has reviewed them. */
++function disclosureFor(rec, review) {
++ const d = authorship.disclosure(rec, review);
++ if (!d || !aiAssistedImport(rec)) return d;
++ const reviewed = authorship.isReviewed(rec, review);
++ return {
++ ...d,
++ short: 'Imported, written with AI assistance',
++ long: `${d.long.replace(/\.\s*$/, '')}. ${reviewed ? 'Reviewed by a person.' : 'Not yet reviewed by a person.'}`,
++ reviewed,
++ };
++}
++
function createWikiService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {
const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
const access = createAccess(db);
@@@ -207,7 +236,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
text,
citationCount: citations.forRevision(page.id, rev.number).length,
noindex: !!page.noindex,
-- ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
++ ...(rec ? authorshipGateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
};
}
@@@ -226,7 +255,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
* from the last one sent (members/private pages are always tombstones: never in search), and the
* product event when the publication state moved. Both go to the outbox in the caller's transaction.
*/
-- function sync(before, pageId, actor) {
++ function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
const page = q.pageById.get(pageId);
const space = q.spaceById.get(page.space_id);
const eff = access.effectiveVisibility(space, page);
@@@ -236,7 +265,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
const cites = rev ? citations.forRevision(page.id, rev.number) : [];
const doc = sequencer.stamp(hooks.buildIndexDocument({
owner: 'wiki', type: 'page', id: page.id, revision: 0, state, visibility: GATE_VIS[eff],
-- deleted: eff !== 'public' || !decision,
++ // Not listable (e.g. unreviewed generated text): never in Search, like members/private pages.
++ deleted: eff !== 'public' || !decision || !decision.listable,
canonicalUrl: pageUrl(space, page), title: rev ? rev.fields.title : page.title,
summary: rev ? (rev.fields.summary || ssr.markdownToText(rev.content, 300)) : null,
body: rev ? ssr.markdownToText(rev.content) : '',
@@@ -248,7 +278,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
const after = { state, visibility: eff, revision: page.published_revision };
-- const action = hooks.actionFor(before && before.state ? before : null, after);
++ let action = hooks.actionFor(before && before.state ? before : null, after);
++ // A review changes only the gate decision: announce it when what Search holds changed.
++ if (!action && updatedIfIndexChanged && state === 'published' && doc.revision !== sentBefore) action = 'updated';
if (action) {
let evDoc = doc;
if (doc.deleted && (action === 'published' || action === 'updated')) {
@@@ -459,6 +491,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');
// Only this service's own import path (the seed) may state another authorship; people are human authors.
const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);
++ // authorship.record() keeps label and original author only; the AI-assistance flag is ours.
++ if (importRecord && rec.mode === 'imported' && importRecord.importedFrom && importRecord.importedFrom.aiAssisted === true) rec.importedFrom.aiAssisted = true;
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const text = checkBody(body == null ? '' : body);
@@@ -692,7 +726,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
return {
space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),
isPublishedRevision: n === page.published_revision && page.state === 'published',
-- authorship: rec, review, disclosure: rec ? authorship.disclosure(rec, review) : null,
++ authorship: rec, review, disclosure: rec ? disclosureFor(rec, review) : null,
++ needsReview: !!rec && aiAssistedImport(rec) && !authorship.isReviewed(rec, review),
proposal: q.proposalByRev.get(page.id, n) || null,
infobox: infoboxOf(page.id, n),
citations: citations.forRevision(page.id, n),
@@@ -739,6 +774,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
const space = q.spaceById.get(page.space_id);
return revisions.list(page.id, { limit, before: cursor }).filter((r) => revisionVisible(space, page, r.number, actor)).map((r) => ({
...r, proposal: props.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
++ review: reviews.latest(page.id, r.number),
++ aiAssisted: aiAssistedImport(r.meta && r.meta.authorship),
++ needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, reviews.latest(page.id, r.number)),
citationCount: citations.forRevision(page.id, r.number).length,
}));
},
@@@ -854,6 +892,54 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
return { watching: on };
},
++ /**
++ * Re-send Search whatever differs from what it was last sent, for every page (idempotent: the
++ * sequencer sends nothing for an unchanged document). Run at boot, so a rule change — e.g.
++ * AI-assisted imports becoming noindex until reviewed — reaches Search for rows written earlier.
++ */
++ reconcileIndex() {
++ return tx(() => {
++ let sent = 0;
++ for (const { id } of db.prepare('SELECT id FROM wiki_pages ORDER BY id').all()) {
++ const b = before(q.pageById.get(id));
++ if (b.indexRevision == null) continue; // never sent: nothing in Search to correct
++ const out = sync(b, id, { kind: 'system', service: 'svc:wiki' });
++ if (out.indexRevision !== b.indexRevision) sent++;
++ }
++ return { sent };
++ });
++ },
++
++ /**
++ * A person reviews an existing revision ("reviewed, correct" = approved / "needs changes" =
++ * rejected). Recorded in the append-only review log; when it is the published revision the
++ * gate is re-evaluated, and a changed Search document goes out with wiki.page.updated.
++ * Pending AI proposals are reviewed through reviewProposal instead.
++ */
++ reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
++ const space = spaceOrFail(spaceIdOrSlug);
++ const page = q.pageBySlug.get(space.id, String(slug));
++ if (!page || page.state === 'deleted') fail(404, 'page.not_found', 'No such page');
++ if (!access.canEdit(space, actor)) fail(403, 'review.forbidden', 'Only owners and editors of this space review revisions');
++ const who = requirePerson(actor);
++ if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
++ const n = Number(revisionNumber);
++ if (!Number.isInteger(n) || !revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
++ const prop = q.proposalByRev.get(page.id, n);
++ if (prop && prop.status === 'pending') fail(409, 'review.proposal_pending', 'This revision is a pending AI proposal: approve or reject the proposal', { proposal_id: prop.id });
++ return tx(() => {
++ const b = before(page);
++ const review = wrapContentError(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));
++ const out = page.state === 'published' && page.published_revision === n
++ ? sync(b, page.id, actor, { updatedIfIndexChanged: true })
++ : { action: null, indexRevision: b ? b.indexRevision : null };
++ const fresh = q.pageById.get(page.id);
++ const rev = revisions.get(page.id, n);
++ const decisionNow = fresh.state === 'published' && fresh.published_revision === n ? decide(space, fresh, rev) : null;
++ return { review, page: fresh, action: out.action, indexable: decisionNow ? decisionNow.indexable : null, reasons: decisionNow ? decisionNow.codes : null };
++ });
++ },
++
// AI proposals (a seam for OpenVibe.AI: Wiki never calls a model) -------------------
/**
* An AI workflow proposes a revision. It becomes an immutable revision with ai authorship
@@@ -929,4 +1015,4 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
return svc;
}
--module.exports = { createWikiService, WikiError, GATE_VIS };
++module.exports = { createWikiService, WikiError, GATE_VIS, aiAssistedImport };
ddiff --git a/test/review.test.js b/test/review.test.js
nnew file mode 100644
iindex 0000000..fca0aef
---- /dev/null
++++ b/test/review.test.js
@@@ -0,0 +1,151 @@
++'use strict';
++/**
++ * A person reviews an existing AI-assisted revision (the seed's imported pages, including rows
++ * seeded before the explicit aiAssisted flag existed): only people with the owner or editor role
++ * may; approval flips the page to indexable, re-sends the Search document once (with
++ * wiki.page.updated), and puts it in sitemaps and feeds; "needs changes" takes it out again.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const H = require('./helpers');
++const { seed, SYSTEM } = require('../server/wiki/seed');
++
++const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'openvibe.json'), 'utf8'));
++
++(async () => {
++ const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
++ const staff = H.subject(), editor = H.subject(), viewer = H.subject(), stranger = H.subject();
++ const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });
++ try {
++ seed(h.svc, data, { log: H.quiet });
++ const space = h.svc.findSpace('openvibe');
++ // A row seeded before the flag existed: recognised by its label alone.
++ const legacy = h.svc.createPage(space.id, {
++ title: 'Legacy import', body: `${data.pages[1].body}`, citations: [{ url: data.pages[1].citations[0].url, retrievedAt: '2026-09-22T12:00:00Z' }],
++ authorship: { mode: 'imported', importedFrom: { label: 'the READMEs, summarised with AI assistance', originalAuthor: 'OpenVibers' } },
++ }, SYSTEM);
++ h.svc.publish(legacy.page.id, {}, SYSTEM);
++ assert.strictEqual(h.stores.revisions.get(legacy.page.id, 1).meta.authorship.importedFrom.aiAssisted, undefined);
++ assert.deepStrictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.codes, ['ai_generated_unreviewed']);
++
++ h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });
++ h.svc.setRole(space.id, viewer, 'viewer', { kind: 'user', subject: staff, staff: true });
++ const page = h.svc.findPage(space.id, 'openvibe-search');
++ const n = page.published_revision;
++ const url = `/api/v1/pages/${page.id}/revisions/${n}/review`;
++ const indexEvents = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.'));
++ const updates = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type === 'wiki.page.updated');
++ const before = { index: indexEvents().length, updates: updates().length };
++
++ // Refused: anonymous, a signed-in stranger, a viewer, a service acting as itself, a service without the capability.
++ assert.strictEqual((await H.req(h, 'POST', url, { body: { decision: 'approved' } })).status, 403);
++ assert.strictEqual((await H.req(h, 'POST', url, { token: tok(stranger), body: { decision: 'approved' } })).status, 403);
++ assert.strictEqual((await H.req(h, 'POST', url, { token: tok(viewer), body: { decision: 'approved' } })).status, 403);
++ assert.strictEqual((await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), body: { decision: 'approved' } })).status, 403);
++ let r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.page.read'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json.code, 'capability.denied');
++ assert.strictEqual((await H.req(h, 'POST', url, { token: tok(editor), body: { decision: 'maybe' } })).status, 422);
++ assert.strictEqual((await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/99/review`, { token: tok(editor), body: { decision: 'approved' } })).status, 404);
++ // Nothing changed.
++ assert.strictEqual(h.stores.reviews.history(page.id).length, 0);
++ assert.deepStrictEqual({ index: indexEvents().length, updates: updates().length }, before);
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
++
++ // The history view offers the review form to editors only.
++ let hist = await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(editor)) });
++ assert.ok(hist.text.includes('Reviewed — correct') && hist.text.includes('Needs changes'));
++ assert.ok(!(await H.req(h, 'GET', '/w/openvibe/openvibe-search/history')).text.includes('Reviewed — correct'));
++ assert.ok(!(await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(viewer)) })).text.includes('Reviewed — correct'));
++ // A viewer posting the form is refused.
++ r = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(viewer)), form: { revision: String(n), decision: 'approved' } });
++ assert.strictEqual(r.status, 403);
++
++ // An editor approves through the form (a service acting for the editor would work the same way).
++ r = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(editor)), form: { revision: String(n), decision: 'approved', note: 'Checked against the README' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.headers.get('location'), '/w/openvibe/openvibe-search/history');
++ const reviews = h.stores.reviews.history(page.id);
++ assert.strictEqual(reviews.length, 1);
++ assert.deepStrictEqual([reviews[0].reviewer, reviews[0].decision, reviews[0].note], [editor, 'approved', 'Checked against the README']);
++
++ // Indexable now; the Search document went out exactly once, with wiki.page.updated.
++ const idx = indexEvents().slice(before.index);
++ assert.strictEqual(idx.length, 1);
++ assert.strictEqual(idx[0].event_type, 'wiki.index_document.upserted');
++ assert.strictEqual(idx[0].payload.indexability.decision, 'index');
++ assert.ok(idx[0].subject.revision > indexEvents()[before.index - 1].subject.revision, 'the sequencer bumped the index revision');
++ assert.ok(contracts.validate('search.index-document@1', idx[0].payload).valid);
++ const upd = updates().slice(before.updates);
++ assert.strictEqual(upd.length, 1);
++ assert.strictEqual(upd[0].visibility, 'public');
++ assert.strictEqual(upd[0].payload.indexability.decision, 'index');
++ assert.ok(contracts.validate('events.event-envelope@1', upd[0]).valid);
++ r = await H.req(h, 'GET', '/w/openvibe/openvibe-search');
++ assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
++ assert.ok(r.text.includes('Reviewed by a person.'));
++ assert.ok(!r.text.includes('Not yet reviewed by a person.'));
++ assert.ok((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('http://wiki.test/w/openvibe/openvibe-search'));
++ assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/openvibe/openvibe-search'));
++ assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/openvibe/openvibe-search'));
++ // The form disappears for a reviewed revision; other pages still wait.
++ hist = await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(editor)) });
++ assert.ok(!hist.text.includes('Reviewed — correct'));
++ assert.ok(hist.text.includes('reviewed by a person'));
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-events'));
++
++ // A second identical approval records the review but sends nothing new (same document).
++ const count = indexEvents().length;
++ r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.indexable, true);
++ assert.strictEqual(r.json.action, null);
++ assert.strictEqual(indexEvents().length, count);
++
++ // "Needs changes" takes it out again: tombstone + wiki.page.updated, gone from the sitemap.
++ r = await H.req(h, 'POST', url, { token: tok(staff, 'admin'), body: { decision: 'rejected', note: 'Status is out of date' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.indexable, false);
++ assert.deepStrictEqual(r.json.reasons, ['ai_generated_unreviewed']);
++ assert.strictEqual(indexEvents().pop().event_type, 'wiki.index_document.deleted');
++ assert.strictEqual(updates().length, before.updates + 2);
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
++ assert.ok((await H.req(h, 'GET', '/w/openvibe/openvibe-search')).text.includes('Not yet reviewed by a person.'));
++
++ // The legacy-labelled row is reviewable the same way.
++ h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });
++ assert.strictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.indexable, true);
++
++ // Human revisions and AI proposals are unaffected: a pending proposal goes through its own review.
++ const prop = h.svc.propose({ space: 'openvibe', pageId: page.id, body: `${data.pages[6].body} x`, workflow: { id: 'wiki.generate_page', runId: 'run_9' } }, { kind: 'service', service: 'svc:ai', subject: null });
++ assert.throws(() => h.svc.reviewRevision(space.id, page.slug, prop.revision.number, { decision: 'approved' }, { kind: 'user', subject: editor }), (e) => e.code === 'review.proposal_pending');
++ console.log('review ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
++
++// Rows seeded under the old rule (indexed before AI-assisted imports needed review) are corrected at boot.
++(async () => {
++ const { seed: seedFn } = require('../server/wiki/seed');
++ const h1 = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
++ const dbPath = h1.dbPath;
++ seedFn(h1.svc, data, { log: H.quiet });
++ const page = h1.svc.findPage(h1.svc.findSpace('openvibe').id, 'openvibe-network');
++ // Simulate the host: Search was last sent an indexable document for this page.
++ const old = h1.db.prepare("SELECT revision FROM wiki_index_revisions WHERE id = ?").get(page.id).revision;
++ h1.db.prepare("UPDATE wiki_index_revisions SET hash = 'sent-under-the-old-rule' WHERE id = ?").run(page.id);
++ await h1.stop();
++ const h2 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });
++ const ev = H.outbox(h2).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.')).pop();
++ assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
++ assert.ok(ev.subject.revision > old);
++ const n = H.outbox(h2).length;
++ await h2.stop();
++ const h3 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });
++ assert.strictEqual(H.outbox(h3).length, n, 'a second boot sends nothing');
++ await h3.stop();
++ console.log('reconcile ok');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/seed.test.js b/test/seed.test.js
iindex cd8fbce..3249135 100644
---- a/test/seed.test.js
++++ b/test/seed.test.js
@@@ -1,8 +1,10 @@
'use strict';
/**
* The official "OpenVibe" seed: idempotent, every page published, cited (GitHub permalinks at a
-- * pinned commit, with a retrieval time), indexable under the production gate policy, internally
-- * linked without red links, labelled with its real authorship, and free of pricing copy.
++ * pinned commit, with a retrieval time), internally linked without red links, labelled with its real
++ * authorship (imported, written with AI assistance), free of pricing copy, and — being generated
++ * text — noindex and out of sitemaps, feeds and Search until a person reviews it; the only gate
++ * reason left is that review (the production word and source policy passes).
*/
const assert = require('assert');
const fs = require('fs');
@@@ -43,27 +45,40 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
const published = h.svc.publishedPublic();
assert.strictEqual(published.length, data.pages.length);
++ // Generated (AI-assisted) text: published and readable, but noindex until a person reviews it.
for (const { page, decision } of published) {
-- assert.ok(decision.indexable, `${page.title}: indexable (${decision.codes.join(', ')})`);
++ assert.strictEqual(page.state, 'published');
++ assert.strictEqual(decision.indexable, false, page.title);
++ assert.strictEqual(decision.listable, false, page.title);
++ assert.deepStrictEqual(decision.codes, ['ai_generated_unreviewed'], `${page.title}: only the review is missing (${decision.codes.join(', ')})`);
++ assert.strictEqual(h.stores.revisions.get(page.id, page.published_revision).meta.authorship.importedFrom.aiAssisted, true);
}
-- // The pages as a reader sees them: no red links, imported authorship disclosed, sitemap lists them all.
for (const p of data.pages) {
const r = await H.req(h, 'GET', `/w/openvibe/${content.pageSlug(p.title)}`);
assert.strictEqual(r.status, 200, p.title);
assert.ok(!r.text.includes('ov-redlink'), `${p.title}: no red links`);
assert.ok(r.text.includes('summarised with AI assistance'), `${p.title}: authorship disclosed`);
-- assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
++ assert.ok(r.text.includes('Not yet reviewed by a person.'), `${p.title}: review state disclosed`);
++ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'), `${p.title}: noindex`);
}
-- const sm = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
-- assert.strictEqual((sm.text.match(/<loc>/g) || []).length, data.pages.length);
++ // Absent from sitemaps, feeds and Search until reviewed.
++ assert.strictEqual(((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.match(/<loc>/g) || []).length, 0);
++ assert.strictEqual((await H.req(h, 'GET', '/feed.atom')).status, 404);
++ assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
++ const idx = H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.'));
++ assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');
// The official space is not editable by an ordinary signed-in person, but is by staff.
const person = { kind: 'user', subject: H.subject(), staff: false };
const staff = { kind: 'user', subject: H.subject(), staff: true };
assert.throws(() => h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);
assert.ok(h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff).page);
-- // Search index documents record the imported authorship.
-- const doc = H.outbox(h).find((e) => e.event_type === 'wiki.index_document.upserted').payload;
++ // After a person's review the page is indexable; its Search document records the imported authorship.
++ const first1 = published[0].page;
++ h.svc.reviewRevision('openvibe', first1.slug, first1.published_revision, { decision: 'approved' }, staff);
++ const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted').pop().payload;
++ assert.strictEqual(doc.id, first1.id);
assert.strictEqual(doc.authorship, 'imported');
++ assert.strictEqual(doc.indexability.decision, 'index');
console.log('seed ok');
} finally {
await h.stop();