openvibers/openvibe.wiki Commits

Wiki: the AI editor's review is a second kind, labelled "Checked by OpenVibe's AI editor" (#226)

OP OpenVibers committed 6 days ago · fe01aff929 · 15 files, +485 −46

* Wiki: the AI editor's review is a second kind, labelled "Checked by OpenVibe's AI editor"

* Wiki: the review route's own guard and capability proposal, and the unreviewed disclosure text kept

Changes

ddiff --git a/docs/capabilities-proposal/wiki.review.ai_editor.json b/docs/capabilities-proposal/wiki.review.ai_editor.json
nnew file mode 100644
iindex 0000000..eb0e176
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.review.ai_editor.json
@@@ -0,0 +1,21 @@
++{
++ "id": "wiki.review.ai_editor",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Record the editorial queue's AI editor decision (approved or needs changes) on an existing revision, as the first-party service principal acting as itself (a svc: principal with no X-OV-Subject). This is never a person's review: an approval lets AI-written text publish and index, and the page says \"Checked by OpenVibe's AI editor\" with its date, never \"Reviewed by a person\". Granted only to the orchestrator's service principal.",
++ "permissions": [
++ "wiki:review:ai_editor"
++ ],
++ "resourceConstraints": [],
++ "quotaClass": "wiki-write",
++ "events": [
++ "wiki.page.updated",
++ "wiki.index_document.upserted",
++ "wiki.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/pages/:id/revisions/:n/review"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex a51020d..10ce117 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -19,6 +19,7 @@
"wiki.page.read",
"wiki.revision.propose",
"wiki.revision.publish",
++ "wiki.review.ai_editor",
"wiki.revision.revert",
"wiki.citation.attach",
"wiki.search.query"
ddiff --git a/migrations/0007_review_kinds.sql b/migrations/0007_review_kinds.sql
nnew file mode 100644
iindex 0000000..d1a9de4
---- /dev/null
++++ b/migrations/0007_review_kinds.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- Two reviewer kinds in the append-only review log (server/wiki/review-kind.js; the AI editor
++-- review of the editorial queue, plan 2026-10-22 §"The editorial queue"):
++--
++-- person a signed-in person's approval or rejection (the default; every row written before
++-- this migration, and every row openvibe-publishing/authorship reviews.record() writes);
++-- ai_editor the orchestrator's service principal, which holds wiki.review.ai_editor.
++--
++-- Expand only: existing rows keep their meaning through the default, and code that does not know
++-- the column (the shared log's INSERT, older processes during a rolling deploy) keeps working.
++ALTER TABLE wiki_page_reviews
++ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'
++ CHECK (reviewer_kind IN ('person', 'ai_editor'));
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex bf0b13a..3ab9fc1 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -17,6 +17,7 @@ const CAPS = Object.freeze({
PAGE_READ: 'wiki.page.read',
REVISION_PROPOSE: 'wiki.revision.propose',
REVISION_PUBLISH: 'wiki.revision.publish',
++ REVISION_REVIEW_AI_EDITOR: 'wiki.review.ai_editor',
REVISION_REVERT: 'wiki.revision.revert',
CITATION_ATTACH: 'wiki.citation.attach',
SEARCH_QUERY: 'wiki.search.query',
ddiff --git a/server/db.js b/server/db.js
iindex c49b07a..b165184 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -34,6 +34,7 @@ const { createAttachmentStore } = require('openvibe-publishing/media');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createScheduler } = require('openvibe-publishing/schedule');
const { createReviewLog } = require('openvibe-publishing/authorship');
++const { createReviewKindLog } = require('./wiki/review-kind');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const seo = require('openvibe-publishing/seo');
@@@ -66,7 +67,8 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
revisions,
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
redirects: seo.createRedirectStore(db, { prefix: 'wiki_page', now }),
-- reviews: createReviewLog(db, { prefix: 'wiki_page', now }),
++ // The shared log records a person's review; the product adds the ai_editor kind and keeps the kinds of both.
++ reviews: createReviewKindLog(createReviewLog(db, { prefix: 'wiki_page', now }), { db, prefix: 'wiki_page', now }),
attachments: createAttachmentStore(db, { prefix: 'wiki_page', now }),
discussions: createDiscussionRefs(db, { prefix: 'wiki', now }),
scheduler: createScheduler(db, { prefix: 'wiki', now, leaseMs: scheduleLeaseMs }),
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 0cb688c..102be39 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -42,7 +42,7 @@
*/
const express = require('express');
const contracts = require('openvibe-contracts');
--const { actorMiddleware, guard, run, resolveCitations, resolveBundleCitations } = require('./common');
++const { actorMiddleware, guard, guardReview, run, resolveCitations, resolveBundleCitations } = require('./common');
const importer = require('../wiki/import');
const { http } = contracts;
@@@ -248,7 +248,7 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
const cites = await resolveCitations((req.body || {}).citations, platform);
return { citations: (await 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'), B('wiki.revision.publish'), R(async (req) => {
++ router.post('/pages/:id/revisions/:n/review', guardReview, B('wiki.revision.publish'), R(async (req) => {
const { page, space } = await pageAndSpace(req.params.id);
const b = req.body || {};
const out = await svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
ddiff --git a/server/http/common.js b/server/http/common.js
iindex b4902be..14ece70 100644
---- a/server/http/common.js
++++ b/server/http/common.js
@@@ -36,6 +36,20 @@ function guard(capabilityId) {
};
}
++/**
++ * The review route's guard. A person passes (the service judges their role); a service acting for
++ * a person keeps needing wiki.revision.publish; the AI editor, acting as itself, needs
++ * wiki.review.ai_editor — the capability granted only to the orchestrator's service principal.
++ */
++function guardReview(req, res, next) {
++ const a = req.actor;
++ if (!a || a.kind !== 'service') return next();
++ const capabilityId = a.subject ? 'wiki.revision.publish' : 'wiki.review.ai_editor';
++ const c = checkCapability(a.claims, capabilityId);
++ if (c.allowed) return next();
++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
++}
++
/**
* Service errors → problem+json (openvibe-sdk/service, plan T1): the kit's defaults are this module's —
* the error's status/code/detail, `extra` spread into the body, 'internal.error'/'Internal error' at 500,
@@@ -120,4 +134,4 @@ function citationsFromForm(body) {
return out;
}
--module.exports = { actorMiddleware, guard, sendError, run, resolveCitations, resolveBundleCitations, citationsFromForm };
++module.exports = { actorMiddleware, guard, guardReview, sendError, run, resolveCitations, resolveBundleCitations, citationsFromForm };
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex b282bdc..56c3038 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -179,12 +179,12 @@ route('post', '/pages/{id}/revisions/{n}/citations', {
});
route('get', '/review-queue', {
operationId: 'getReviewQueue', summary: 'The reviewer queue: published AI-assisted pages waiting for a person', tags: ['revisions'], security: PERSON,
-- description: 'Staff only. Oldest first, with the gate\'s reason codes and words and the review path for each page.',
++ description: 'Staff only. Oldest first, with the gate\'s reason codes and words and the review path for each page. An item the AI editor rejected carries `reviewed` (kind, decision, principal, time, label); the AI editor\'s approvals publish the page and leave the queue.',
responses: { 200: json('The waiting pages', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },
});
route('post', '/pages/{id}/revisions/{n}/review', {
operationId: 'reviewRevision', summary: 'Approve or reject a revision', tags: ['revisions'], security: PERSON, parameters: [PAGE, REVISION],
-- description: 'wiki.revision.publish. A person (owner or editor); an approved AI-assisted revision becomes indexable when it publishes.',
++ description: 'A person (owner or editor) with wiki.revision.publish; or the AI editor — a svc:… service principal acting as itself with wiki.review.ai_editor — whose review is recorded with kind `ai_editor` and labelled "Checked by OpenVibe\'s AI editor", never "Reviewed by a person". An approved AI-assisted revision becomes indexable when it publishes.',
requestBody: body({ type: 'object', required: ['decision'], properties: { decision: { type: 'string', enum: ['approved', 'rejected'] }, note: nullable('string') } }),
responses: { 201: json('The review and what it changed', { type: 'object', properties: { review: { type: 'object' }, page: ref('Page'), action: nullable('string'), indexable: { type: 'boolean' }, reasons: arrayOf(str()) } }), ...errors() },
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 661a822..57b4440 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -393,7 +393,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
published_at: page.published_at ? new Date(page.published_at).toISOString() : null,
revision_published_at: page.revision_published_at ? new Date(page.revision_published_at).toISOString() : null,
revision_created_at: v.revision.createdAt,
-- authorship: v.authorship ? { mode: v.authorship.mode, workflow: v.authorship.workflow || null, reviewed: !!(v.review && v.review.decision === 'approved') } : null,
++ authorship: v.authorship ? {
++ mode: v.authorship.mode, workflow: v.authorship.workflow || null,
++ reviewed: Boolean(v.labels && v.labels.length),
++ labels: v.labels || [],
++ reviews: {
++ person: v.reviews && v.reviews.person ? { decision: v.reviews.person.decision, at: v.reviews.person.reviewedAt } : null,
++ ai_editor: v.reviews && v.reviews.ai_editor ? { decision: v.reviews.ai_editor.decision, at: v.reviews.ai_editor.reviewedAt } : null,
++ },
++ } : null,
infobox: v.infobox,
citations: v.citations.map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null, license_note: c.licenseNote })),
attachments: v.attachments.map((a) => ({ media_id: a.mediaId, state: a.state, broken_reason: a.brokenReason, alt: a.alt, caption: a.caption })),
ddiff --git a/server/render/views.js b/server/render/views.js
iindex b2e1e5f..e21118d 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -10,6 +10,7 @@ const showcase = require('openvibe-shared/showcase');
const { figureHtml } = require('openvibe-publishing/media');
const { infoboxToText, mediaRefs } = require('../wiki/content');
const { pickBlock, foldBlock } = require('./short');
++const reviewKind = require('../wiki/review-kind');
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
@@@ -401,12 +402,18 @@ function pageFacts(v, base) {
v.isPublishedRevision ? html`this text accepted ${t(rev.createdAt)}` : html`revision ${String(rev.number)}, saved ${t(rev.createdAt)}`,
];
if (v.offeredAt && v.isPublishedRevision) facts.push(html`<span class="wk-fact-wait">a newer edit, offered ${t(v.offeredAt)}, is waiting</span>`);
-- const r = v.review;
-- const reviewed = !!(r && /approv|accept/i.test(String(r.decision || '')));
++ const kinds = v.reviews || { person: v.review || null, ai_editor: null };
const ai = !!(v.authorship && v.authorship.mode && v.authorship.mode !== 'human');
-- const badge = reviewed
-- ? html`<span class="wk-review wk-review--ok">Reviewed by a person${r.reviewed_at || r.reviewedAt ? html` ${t(r.reviewed_at || r.reviewedAt)}` : ''}</span>`
-- : ai ? html`<span class="wk-review wk-review--no">Not yet reviewed by a person</span>` : '';
++ const badges = [];
++ if (reviewKind.isPersonReview(kinds.person)) {
++ const r = kinds.person;
++ badges.push(html`<span class="wk-review wk-review--ok">Reviewed by a person${r.reviewedAt ? html` ${t(r.reviewedAt)}` : ''}</span>`);
++ }
++ if (reviewKind.isAiEditorReview(kinds.ai_editor)) {
++ badges.push(html`<span class="wk-review wk-review--ai">Checked by OpenVibe's AI editor ${t(kinds.ai_editor.reviewedAt)}</span>`);
++ }
++ if (!badges.length && ai) badges.push(html`<span class="wk-review wk-review--no">Not yet reviewed by a person</span>`);
++ const badge = badges.length ? html`<span class="wk-review-labels">${badges.map((b, i) => html`${i ? ' ' : ''}${b}`)}</span>` : '';
return html`<p class="wk-facts">${facts.map((f, i) => html`${i ? html`<span aria-hidden="true"> · </span>` : ''}<span>${f}</span>`)}${badge ? html` ${badge}` : ''}</p>`;
}
@@@ -456,7 +463,7 @@ ${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 || ''} <a class="wk-muted" href="${base}/sources?rev=${String(r.number)}">${String(r.citationCount)} source(s)</a>
--${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>` : ''}
++${r.aiAssisted ? html` <span class="wk-tag">${r.needsReview ? 'AI-assisted, not yet reviewed' : (r.labels && r.labels.length ? r.labels.map((l) => l.charAt(0).toLowerCase() + l.slice(1)).join(', ') : '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>
@@@ -464,7 +471,7 @@ ${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</bu
</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}` : ''}
++<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}${r.review.reviewerKind === 'ai_editor' ? ' (AI editor)' : ' (a person)'}` : ''}
<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>`;
}
ddiff --git a/server/wiki/review-kind.js b/server/wiki/review-kind.js
nnew file mode 100644
iindex 0000000..23e7761
---- /dev/null
++++ b/server/wiki/review-kind.js
@@@ -0,0 +1,197 @@
++'use strict';
++/**
++ * Two reviewer kinds in one append-only log (the `wiki_page_reviews` table
++ * openvibe-publishing/authorship creates):
++ *
++ * person a signed-in person's approval or rejection (a usr_… subject). The library's
++ * reviews.record() writes these, unchanged.
++ * ai_editor the orchestrator's service principal — a svc:… principal whose token holds
++ * wiki.review.ai_editor — recorded with its principal, the time and a note.
++ * migrations/0007_review_kinds.sql adds the reviewer_kind column (default 'person').
++ *
++ * An ai_editor approval publishes and indexes AI-written text exactly as a person's approval
++ * does, but it never reads as a person's review: the label is "Checked by OpenVibe's AI editor".
++ * Authorship stays the library's: this module only adds the kind to the log's rows and the words
++ * that follow from it.
++ */
++const { ids } = require('openvibe-contracts');
++
++const AI_EDITOR = 'ai_editor';
++const PERSON = 'person';
++const AI_EDITOR_LABEL = "Checked by OpenVibe's AI editor";
++const PRINCIPAL_RE = /^svc:[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
++
++const isPersonSubject = (v) => ids.isSubjectId('user', v);
++
++/** 'person' for anything the shared log wrote before the kind column existed. */
++function reviewerKind(review) {
++ return review && review.reviewerKind === AI_EDITOR ? AI_EDITOR : PERSON;
++}
++
++function isPersonReview(review) {
++ return Boolean(review && reviewerKind(review) === PERSON && review.decision === 'approved' && isPersonSubject(review.reviewer));
++}
++
++function isAiEditorReview(review) {
++ return Boolean(review && reviewerKind(review) === AI_EDITOR && review.decision === 'approved' && PRINCIPAL_RE.test(String(review.reviewer)));
++}
++
++/** An approving review of either kind: what lets AI-written text be published and indexed. */
++function approved(review) {
++ return isPersonReview(review) || isAiEditorReview(review);
++}
++
++/** The ISO date a review was recorded (YYYY-MM-DD), or null. */
++function reviewDate(review) {
++ if (!review || !review.reviewedAt) return null;
++ const d = new Date(review.reviewedAt);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
++}
++
++/** "Checked by OpenVibe's AI editor on 2026-11-06", or null when the AI editor did not approve. */
++function aiEditorLabel(review) {
++ if (!isAiEditorReview(review)) return null;
++ const d = reviewDate(review);
++ return d ? `${AI_EDITOR_LABEL} on ${d}` : AI_EDITOR_LABEL;
++}
++
++/** What the AI editor decided, for the staff queue: the approval label or "Kept hidden by …". */
++function aiEditorVerdict(review) {
++ if (!review || reviewerKind(review) !== AI_EDITOR) return null;
++ const d = reviewDate(review);
++ const what = review.decision === 'approved' ? AI_EDITOR_LABEL : "Kept hidden by OpenVibe's AI editor";
++ return d ? `${what} on ${d}` : what;
++}
++
++/** The public labels for a revision, from the review of each kind: [] when nobody approved it. */
++function labelsFor({ person = null, ai_editor = null } = {}) {
++ const labels = [];
++ if (isPersonReview(person)) labels.push('Reviewed by a person');
++ const ai = aiEditorLabel(ai_editor);
++ if (ai) labels.push(ai);
++ return labels;
++}
++
++function shape(row) {
++ if (!row) return null;
++ return {
++ id: Number(row.id),
++ entityId: row.entityId !== undefined ? row.entityId : row.entity_id,
++ revision: Number(row.revision),
++ reviewer: row.reviewer,
++ reviewerKind: (row.reviewerKind || row.reviewer_kind) === AI_EDITOR ? AI_EDITOR : PERSON,
++ decision: row.decision,
++ note: row.note,
++ reviewedAt: typeof row.reviewedAt === 'string' ? row.reviewedAt : new Date(Number(row.reviewed_at)).toISOString(),
++ };
++}
++
++function byKind(rows) {
++ const out = { person: null, ai_editor: null };
++ for (const row of rows) out[row.reviewer_kind === AI_EDITOR ? AI_EDITOR : PERSON] = shape(row);
++ return out;
++}
++
++/** The newest decision of either kind (the log's latest row decides whether the text is reviewed). */
++function latestOf({ person = null, ai_editor = null } = {}) {
++ if (!person) return ai_editor || null;
++ if (!ai_editor) return person;
++ return Number(ai_editor.id) > Number(person.id) ? ai_editor : person;
++}
++
++/**
++ * The product's review store: the shared log plus kind-aware reads and the ai_editor write.
++ * `prefix` names the product's own table (e.g. 'wiki_page' → wiki_page_reviews).
++ */
++function createReviewKindLog(log, { db, prefix, now = () => Date.now() } = {}) {
++ const R = `${prefix}_reviews`;
++ const latestStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`);
++ const latestByKindStmt = db.prepare(`SELECT DISTINCT ON (reviewer_kind) * FROM ${R}
++ WHERE entity_id = ? AND revision = ? ORDER BY reviewer_kind, id DESC`);
++ const latestManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision) r.*
++ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)
++ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision
++ ORDER BY r.entity_id, r.revision, r.id DESC`);
++ const latestByKindManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision, r.reviewer_kind) r.*
++ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)
++ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision
++ ORDER BY r.entity_id, r.revision, r.reviewer_kind, r.id DESC`);
++ const insertEditor = db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, reviewer_kind, decision, note, reviewed_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`);
++ const historyStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id LIMIT ?`);
++
++ const number = (v) => {
++ const n = Number(v);
++ return Number.isInteger(n) && n >= 0 && n <= 2147483647 ? n : null;
++ };
++
++ async function latest(entityId, revision) {
++ const n = number(revision);
++ if (n == null) return null;
++ return shape(await latestStmt.get(entityId, n));
++ }
++
++ async function latestByKind(entityId, revision) {
++ const n = number(revision);
++ if (n == null) return { person: null, ai_editor: null };
++ return byKind(await latestByKindStmt.all(entityId, n));
++ }
++
++ async function latestMany(refs = []) {
++ const keys = refs.filter((r) => r && number(r.revision) != null);
++ if (!keys.length) return refs.map(() => null);
++ const rows = await latestManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));
++ const by = new Map(rows.map((r) => [`${r.entity_id}\u0000${r.revision}`, shape(r)]));
++ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || null);
++ }
++
++ async function latestByKindMany(refs = []) {
++ const keys = refs.filter((r) => r && number(r.revision) != null);
++ if (!keys.length) return refs.map(() => ({ person: null, ai_editor: null }));
++ const rows = await latestByKindManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));
++ const by = new Map();
++ for (const row of rows) {
++ const key = `${row.entity_id}\u0000${row.revision}`;
++ const out = by.get(key) || { person: null, ai_editor: null };
++ out[row.reviewer_kind === AI_EDITOR ? AI_EDITOR : PERSON] = shape(row);
++ by.set(key, out);
++ }
++ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || { person: null, ai_editor: null });
++ }
++
++ /** An ai_editor review: the service principal that made it, never a person's subject. */
++ async function recordAiEditor({ entityId, revision, principal, decision, note = null } = {}) {
++ const n = number(revision);
++ if (n == null) throw new TypeError('revision must be a non-negative integer');
++ if (!PRINCIPAL_RE.test(String(principal))) throw new TypeError('principal must be a svc:… principal');
++ if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');
++ return shape(await insertEditor.get(String(entityId), n, String(principal), AI_EDITOR, decision, note == null ? null : String(note).slice(0, 2000), now()));
++ }
++
++ /** The shared log's person write, with the kind its row has by default. */
++ async function record(args) {
++ return shape(await log.record(args));
++ }
++
++ async function history(entityId, { limit = 1000 } = {}) {
++ const n = Math.max(1, Math.min(10000, Math.floor(Number(limit)) || 1000));
++ return (await historyStmt.all(entityId, n)).map(shape);
++ }
++
++ return {
++ ...log,
++ record,
++ history,
++ latest,
++ latestMany,
++ latestByKind,
++ latestByKindMany,
++ recordAiEditor,
++ };
++}
++
++module.exports = {
++ AI_EDITOR, PERSON, AI_EDITOR_LABEL, PRINCIPAL_RE,
++ reviewerKind, isPersonReview, isAiEditorReview, approved, reviewDate, aiEditorLabel, aiEditorVerdict, labelsFor, latestOf,
++ createReviewKindLog,
++};
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex 24ce4d1..f0d25e2 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -17,6 +17,7 @@ const { AsyncLocalStorage } = require('node:async_hooks');
const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
++const reviewKind = require('./review-kind');
const ssr = require('openvibe-publishing/ssr');
/** An infobox entry's text value by key (a revision's fields.infobox), or null. */
@@@ -34,6 +35,7 @@ function firstSentence(text) {
}
const { isMediaId, figureHtml } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
++const { checkCapability, CAPS } = require('../auth/capabilities');
const content = require('./content');
const importer = require('./import');
const { createAccess, VISIBILITIES } = require('./access');
@@@ -67,21 +69,35 @@ function aiAssistedImport(rec) {
/** 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);
++ const out = { authorship: { mode: aiAssistedImport(rec) ? 'ai' : rec.mode, reviewed: reviewKind.approved(review) } };
++ if (rec.stubProvider) out.stubProvider = true;
++ return out;
}
--/** 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,
-- };
++/** The disclosure shown at the item, with the review of each kind (a person's, the AI editor's, both). */
++function disclosureFor(rec, kinds = { person: null, ai_editor: null }) {
++ const d = authorship.disclosure(rec, null);
++ if (!d) return null;
++ const labels = reviewKind.labelsFor(kinds);
++ const imported = aiAssistedImport(rec);
++ if (!labels.length) {
++ // Nobody approved it: the library's own sentence stays ("not yet reviewed by a person").
++ const out = { ...d, reviewed: false };
++ if (imported) out.long = `${d.long.replace(/\.\s*$/, '')}. Not yet reviewed by a person.`;
++ if (imported) out.short = 'Imported, written with AI assistance';
++ return out;
++ }
++ const base = d.long.replace(/,\s*(?:not yet )?reviewed by a person\.?\s*$/, '').replace(/\.\s*$/, '');
++ // An imported page has always said "Reviewed by a person." on its own; AI-generated text keeps the
++ // library's ", reviewed by a person." clause.
++ const person = reviewKind.isPersonReview(kinds.person);
++ const ai = reviewKind.aiEditorLabel(kinds.ai_editor);
++ let long = base;
++ if (person) long += imported ? '. Reviewed by a person.' : ', reviewed by a person.';
++ if (ai) long += `${long.endsWith('.') ? ' ' : '. '}${ai}.`;
++ const out = { ...d, long, reviewed: true };
++ if (imported) out.short = 'Imported, written with AI assistance';
++ return out;
}
function createWikiService({ db, stores, outbox, config, community = null, vip = null, now = () => Date.now(), log = console, indexnow = null, sharing = null }) {
@@@ -240,6 +256,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return actor.subject;
}
++ /**
++ * The AI editor records a review as itself: a first-party service principal (svc:…) with no
++ * person named, holding wiki.review.ai_editor. A service acting for a person is that person.
++ */
++ function isAiEditorActor(actor) {
++ if (!actor || actor.kind !== 'service' || actor.subject || !/^svc:/.test(String(actor.service))) return false;
++ return checkCapability(actor.claims, CAPS.REVISION_REVIEW_AI_EDITOR).allowed;
++ }
++
async function spaceOrFail(idOrSlug) {
const s = /^spc_/.test(String(idOrSlug)) ? await q.spaceById.get(idOrSlug) : await q.spaceBySlug.get(String(idOrSlug));
if (!s || s.deleted_at) fail(404, 'space.not_found', 'No such space');
@@@ -523,8 +548,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
const rec = rev.meta.authorship;
if (rec) {
-- const ok = authorship.canPublish(rec, await reviews.latest(page.id, revisionNumber));
-- if (!ok.ok) fail(409, ok.reason, 'AI-generated revisions are published only after a person approves them');
++ const review = await reviews.latest(page.id, revisionNumber);
++ if (authorship.needsReview(rec) && !reviewKind.approved(review)) {
++ fail(409, rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider', 'AI-generated revisions are published only after a person or the AI editor approves them');
++ }
}
const b = await before(page);
await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });
@@@ -557,12 +584,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!pages.length) return [];
const spaces = new Map((await q.spacesByIds.all([...new Set(pages.map((p) => p.space_id))])).map((s) => [s.id, s]));
const refs = pages.map((p) => ({ entityId: p.id, revision: p.published_revision }));
-- const [revs, cites, revws] = await Promise.all([revisions.getMany(refs), citations.forRevisions(refs), reviews.latestMany(refs)]);
++ const [revs, cites, revws, kindws] = await Promise.all([revisions.getMany(refs), citations.forRevisions(refs), reviews.latestMany(refs), reviews.latestByKindMany(refs)]);
const out = [];
pages.forEach((page, i) => {
const space = spaces.get(page.space_id);
if (!space || !revs[i]) return;
-- out.push({ space, page, rev: revs[i], review: revws[i] || null, decision: decideWith(space, page, revs[i], cites[i] || [], revws[i]) });
++ out.push({ space, page, rev: revs[i], review: revws[i] || null, reviews: kindws[i], labels: reviewKind.labelsFor(kindws[i]), decision: decideWith(space, page, revs[i], cites[i] || [], revws[i]) });
});
return out;
}
@@@ -1048,8 +1075,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const rev = await revisions.get(page.id, n);
if (!rev) fail(404, 'revision.not_found', 'No such revision');
n = rev.number;
-- const [review, cites, infobox, links, atts, children, parentRow, backlinks, discussion, watching, proposals, jobs, headNumber] = await Promise.all([
-- reviews.latest(page.id, n),
++ const [kindws, cites, infobox, links, atts, children, parentRow, backlinks, discussion, watching, proposals, jobs, headNumber] = await Promise.all([
++ reviews.latestByKind(page.id, n),
citations.forRevision(page.id, n),
infoboxOf(page.id, n),
q.linksOf.all(page.id, n),
@@@ -1066,6 +1093,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
]);
const offered = headNumber && headNumber > n ? await revisions.get(page.id, headNumber) : null;
const rec = rev.meta.authorship || null;
++ const review = reviewKind.latestOf(kindws);
const facts = gateFactsWith(space, page, rev, cites.length, review);
const decision = page.state === 'published' && n === page.published_revision
? seo.evaluate(facts, { policy, now: now() })
@@@ -1073,8 +1101,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
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 ? disclosureFor(rec, review) : null,
-- needsReview: !!rec && aiAssistedImport(rec) && !authorship.isReviewed(rec, review),
++ authorship: rec, review, reviews: kindws, labels: reviewKind.labelsFor(kindws),
++ disclosure: rec ? disclosureFor(rec, kindws) : null,
++ needsReview: !!rec && aiAssistedImport(rec) && !reviewKind.isPersonReview(kindws.person),
proposal: proposal || null,
infobox,
citations: cites,
@@@ -1152,12 +1181,14 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const visible = [];
for (const r of list) if (await revisionVisible(space, page, r.number, actor, byRev.get(r.number) || null)) visible.push(r);
const refs = visible.map((r) => ({ entityId: page.id, revision: r.number }));
-- const [revws, cites] = await Promise.all([reviews.latestMany(refs), citations.forRevisions(refs)]);
++ const [revws, kindws, cites] = await Promise.all([reviews.latestMany(refs), reviews.latestByKindMany(refs), citations.forRevisions(refs)]);
return visible.map((r, i) => ({
...r, proposal: byRev.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
review: revws[i],
++ reviews: kindws[i],
++ labels: reviewKind.labelsFor(kindws[i]),
aiAssisted: aiAssistedImport(r.meta && r.meta.authorship),
-- needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, revws[i]),
++ needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !reviewKind.isPersonReview(kindws[i].person),
citationCount: (cites[i] || []).length,
}));
},
@@@ -1202,6 +1233,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* Published, public pages whose text nobody has approved yet: AI-assisted imports and
* AI-generated revisions held noindex by the gate. Only pages this actor may review.
* Oldest first, with the gate's own reason codes and words for each.
++ *
++ * An ai_editor approval publishes the page, so it leaves the queue; an ai_editor rejection
++ * stays, with `reviewed` naming the kind, until a person decides (approve to publish, or
++ * reject to keep it hidden and clear the item).
*/
async pendingReviews(actor) {
await roles(actor);
@@@ -1210,7 +1245,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!access.canEdit(x.space, actor)) continue;
const rec = x.rev.meta && x.rev.meta.authorship;
if (!rec || !(aiAssistedImport(rec) || authorship.needsReview(rec))) continue;
-- if (x.review) continue; // a decision, approved or rejected, has been made for this revision
++ if (x.reviews.person) continue; // a person's decision, approved or rejected, has been made for this revision
++ const ai = x.reviews.ai_editor;
++ if (ai && ai.decision === 'approved') continue; // the AI editor approved it: published and indexable
items.push({
site: 'wiki',
kind: 'wiki_page',
@@@ -1220,6 +1257,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
url: pageUrl(x.space, x.page),
createdAt: new Date(x.page.revision_published_at || x.page.published_at || x.rev.createdAt).toISOString(),
reasons: x.decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),
++ reviewed: ai ? { kind: reviewKind.AI_EDITOR, decision: ai.decision, by: ai.reviewer, at: ai.reviewedAt, label: reviewKind.aiEditorVerdict(ai) } : null,
review: { method: 'POST', path: `/api/v1/pages/${encodeURIComponent(x.page.id)}/revisions/${x.page.published_revision}/review` },
});
}
@@@ -1420,9 +1458,11 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
},
/**
-- * 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.
++ * A review of an existing revision ("reviewed, correct" = approved / "needs changes" =
++ * rejected), recorded in the append-only review log as a person's review or, when the actor
++ * is the AI editor principal acting as itself (a svc:… token holding wiki.review.ai_editor,
++ * checked by the route), as an ai_editor review. 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.
*/
async reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
@@@ -1430,8 +1470,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const space = await spaceOrFail(spaceIdOrSlug);
const page = await 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);
++ const aiEditor = isAiEditorActor(actor);
++ if (!aiEditor && !access.canEdit(space, actor)) fail(403, 'review.forbidden', 'Only owners and editors of this space review revisions');
++ const who = aiEditor ? null : 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) || !await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
@@@ -1439,7 +1480,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
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 await tx(async () => {
const b = await before(page);
-- const review = await wrapStore(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));
++ const review = aiEditor
++ ? await reviews.recordAiEditor({ entityId: page.id, revision: n, principal: actor.service, decision, note })
++ : await wrapStore(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));
const out = page.state === 'published' && page.published_revision === n
? await sync(b, page.id, actor, { updatedIfIndexChanged: true })
: { action: null, indexRevision: b ? b.indexRevision : null };
ddiff --git a/test/event-contracts.test.js b/test/event-contracts.test.js
iindex 50f8f5c..b6baa73 100644
---- a/test/event-contracts.test.js
++++ b/test/event-contracts.test.js
@@@ -8,16 +8,22 @@
const assert = require('assert');
const H = require('./helpers');
const contracts = require('openvibe-contracts');
--const { CAPS } = require('../server/auth/capabilities');
++const { CAPS, PROPOSED } = require('../server/auth/capabilities');
(async () => {
// The capabilities the routes enforce are the released ones, so capabilities.check() decides them.
++ // One id waits for a Contracts release — wiki.review.ai_editor — and is decided locally until then.
for (const id of Object.values(CAPS)) {
const cap = contracts.capabilities.get(id);
-- assert.ok(cap, `${id} is released by openvibe-contracts`);
++ if (!cap) {
++ assert.ok(PROPOSED.has(id), `${id} is not released yet, so it must be a local proposal`);
++ continue;
++ }
assert.strictEqual(cap.owner, 'wiki', `${id} is owned by wiki`);
assert.notStrictEqual(cap.status, 'retired', `${id} is not retired`);
}
++ // The AI editor's id is the one proposal; when Contracts releases it, this line goes.
++ assert.strictEqual(contracts.capabilities.get('wiki.review.ai_editor'), undefined);
assert.deepStrictEqual(
contracts.capabilities.check({ cap: ['wiki.*'] }, CAPS.PAGE_READ),
{ allowed: true, code: null, reason: null },
ddiff --git a/test/proposals.test.js b/test/proposals.test.js
iindex f0f9341..b70f6ce 100644
---- a/test/proposals.test.js
++++ b/test/proposals.test.js
@@@ -27,6 +27,9 @@ assert.deepStrictEqual([...ids].sort(), [...PROPOSED].sort());
// Every guard('…') in the API is a proposed capability, and every proposed capability is guarded.
const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
const guarded = new Set([...api.matchAll(/guard\('([a-z.]+)'\)/g)].map((m) => m[1]));
++// The review route has its own guard: a service acting for a person keeps wiki.revision.publish;
++// the AI editor acting as itself needs wiki.review.ai_editor.
++if (/\/pages\/:id\/revisions\/:n\/review', guardReview/.test(api)) guarded.add('wiki.review.ai_editor');
assert.deepStrictEqual([...guarded].sort(), [...PROPOSED].sort());
const manifest = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
ddiff --git a/test/review-ai-editor.test.js b/test/review-ai-editor.test.js
nnew file mode 100644
iindex 0000000..ca26295
---- /dev/null
++++ b/test/review-ai-editor.test.js
@@@ -0,0 +1,123 @@
++'use strict';
++/**
++ * The AI editor (plan 2026-10-22 §"The editorial queue"): a second reviewer kind, `ai_editor`,
++ * recorded on the review by the orchestrator's service principal. Its approval publishes and
++ * indexes AI-written text exactly as a person's approval does, but the page says
++ * "Checked by OpenVibe's AI editor" (with the date) — never "Reviewed by a person". A person's
++ * review path and label do not change, and a page can show both.
++ *
++ * Only a service token whose principal holds wiki.review.ai_editor may record one; a token
++ * without it is refused and writes nothing.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const H = require('./helpers');
++const { seed, SYSTEM } = require('../server/wiki/seed');
++
++const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'openvibe.json'), 'utf8'));
++const AI_LABEL = "Checked by OpenVibe's AI editor";
++
++(async () => {
++ const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
++ const staff = H.subject(), editor = H.subject();
++ const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });
++ const aiEditor = H.serviceToken({ client: 'orchestrator', sub: 'svc:orchestrator', cap: ['wiki.review.ai_editor'] });
++ try {
++ await seed(h.svc, data, { log: H.quiet });
++ const space = await h.svc.findSpace('openvibe');
++ await h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });
++
++ const page = await h.svc.findPage(space.id, 'openvibe-search');
++ const n = page.published_revision;
++ const url = `/api/v1/pages/${page.id}/revisions/${n}/review`;
++
++ // Refused: a service token without the capability writes nothing; a person-shaped service
++ // (X-OV-Subject) is judged as that person, not as the AI editor.
++ let r = await H.req(h, 'POST', url, { token: H.serviceToken({ client: 'orchestrator', sub: 'svc:orchestrator', cap: ['wiki.revision.publish'] }), body: { decision: 'approved' } });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.json.code, 'capability.denied');
++ assert.strictEqual((await h.stores.reviews.history(page.id)).length, 0, 'nothing was recorded');
++
++ // The AI editor approves: the page publishes (it already was) and becomes indexable, labelled as checked.
++ r = await H.req(h, 'POST', url, { token: aiEditor, body: { decision: 'approved', note: 'Checked against the sources' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual([r.json.review.reviewerKind, r.json.review.reviewer, r.json.review.decision], ['ai_editor', 'svc:orchestrator', 'approved']);
++ assert.strictEqual(r.json.indexable, true);
++ assert.deepStrictEqual(r.json.reasons, []);
++ let revs = await h.stores.reviews.history(page.id);
++ assert.deepStrictEqual([revs[0].reviewerKind, revs[0].reviewer, revs[0].note], ['ai_editor', 'svc:orchestrator', 'Checked against the sources']);
++
++ let html = await H.req(h, 'GET', '/w/openvibe/openvibe-search');
++ assert.ok(html.text.includes('<meta name="robots" content="index, follow">'), 'the AI editor\'s approval indexes the page');
++ assert.ok(html.text.includes(AI_LABEL), 'the page says who checked it');
++ assert.ok(html.text.includes('wk-review--ai'), 'in its own badge');
++ assert.ok(!html.text.includes('Reviewed by a person'), 'never as a person\'s review');
++ assert.ok(!html.text.includes('Not yet reviewed by a person'), 'and not as unreviewed');
++ assert.ok((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
++
++ // The JSON twin carries the same label and the review kinds.
++ const json = await H.req(h, 'GET', '/w/openvibe/openvibe-search.json');
++ assert.strictEqual(json.status, 200, json.text);
++ assert.strictEqual(json.json.authorship.reviewed, true);
++ assert.ok(json.json.authorship.labels.some((l) => l.startsWith(AI_LABEL)), JSON.stringify(json.json.authorship));
++ assert.deepStrictEqual([json.json.authorship.reviews.ai_editor.decision, json.json.authorship.reviews.person], ['approved', null]);
++
++ // A person reviews the same page after the AI editor: both labels show, the person's path unchanged.
++ r = await H.req(h, 'POST', url, { token: tok(editor), body: { decision: 'approved', note: 'I checked it too' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.review.reviewerKind, 'person');
++ html = await H.req(h, 'GET', '/w/openvibe/openvibe-search');
++ assert.ok(html.text.includes('Reviewed by a person'), 'the person\'s label is unchanged');
++ assert.ok(html.text.includes(AI_LABEL), 'and both are shown');
++ assert.ok(html.text.indexOf('Reviewed by a person') < html.text.indexOf(AI_LABEL) || html.text.includes('wk-review--ai'), 'both badges are in the markup');
++
++ // A rejection by the AI editor keeps a page hidden and stays in the staff queue, which names the kind.
++ const other = await h.svc.findPage(space.id, 'openvibe-network');
++ const otherUrl = `/api/v1/pages/${other.id}/revisions/${other.published_revision}/review`;
++ r = await H.req(h, 'POST', otherUrl, { token: aiEditor, body: { decision: 'rejected', note: 'Numbers out of date' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.indexable, false);
++ const staffTok = tok(staff, 'admin');
++ const q = await H.req(h, 'GET', '/api/v1/review-queue', { token: staffTok });
++ const item = q.json.items.find((i) => i.id === other.id);
++ assert.ok(item, 'the AI editor\'s rejection leaves the page in the person\'s queue');
++ assert.deepStrictEqual([item.reviewed.kind, item.reviewed.decision, item.reviewed.by], ['ai_editor', 'rejected', 'svc:orchestrator']);
++ assert.ok(item.reviewed.label.startsWith('Kept hidden by OpenVibe\'s AI editor'), item.reviewed.label);
++
++ // A person's rejection clears it, as before.
++ r = await H.req(h, 'POST', otherUrl, { token: tok(editor), body: { decision: 'rejected' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const after = await H.req(h, 'GET', '/api/v1/review-queue', { token: staffTok });
++ assert.ok(!after.json.items.some((i) => i.id === other.id), 'a person\'s decision clears the queue');
++
++ // The AI editor cannot publish a page never reviewed... and a person still can (unchanged path).
++ const legacy = await h.svc.createPage(space.id, {
++ title: 'Legacy import', body: data.pages[1].body,
++ authorship: { mode: 'imported', importedFrom: { label: 'the READMEs, summarised with AI assistance', originalAuthor: 'OpenVibers' } },
++ }, SYSTEM);
++ await h.svc.publish(legacy.page.id, {}, SYSTEM);
++ assert.strictEqual((await H.req(h, 'GET', '/w/openvibe/legacy-import')).text.includes(AI_LABEL), false);
++ await h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });
++ const legacyHtml = await H.req(h, 'GET', '/w/openvibe/legacy-import');
++ assert.ok(legacyHtml.text.includes('Reviewed by a person'));
++ assert.ok(!legacyHtml.text.includes(AI_LABEL));
++ // A plain AI-generated page (not an import) is gated the same way: the AI editor's approval
++ // is what lets it publish, and the page names the AI editor.
++ const gen = await h.svc.createPage(space.id, {
++ title: 'Generated page', body: `${data.pages[2].body} ${'more words '.repeat(30)}`,
++ authorship: { mode: 'ai', workflow: { id: 'wiki.generate_page', runId: 'run_ai_1' } },
++ }, SYSTEM);
++ const aiActor = { kind: 'service', service: 'svc:orchestrator', subject: null, claims: { cap: ['wiki.review.ai_editor'] } };
++ await assert.rejects(async () => await h.svc.publish(gen.page.id, {}, SYSTEM), (e) => e.code === 'ai_generated_unreviewed');
++ await h.svc.reviewRevision(space.id, 'generated-page', 1, { decision: 'approved' }, aiActor);
++ await h.svc.publish(gen.page.id, {}, SYSTEM);
++ const genHtml = await H.req(h, 'GET', '/w/openvibe/generated-page');
++ assert.ok(genHtml.text.includes(AI_LABEL), 'an AI-generated page carries the AI editor label');
++ assert.ok(!genHtml.text.includes('Reviewed by a person'));
++
++ console.log('ai editor ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });