openvibers/openvibe.wiki Commits
Wiki: the AI editor's review is a second kind, labelled "Checked by OpenVibe's AI editor" (#226)
Changes
nnew file mode 100644@@@ -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"++ ]++}@@@ -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"nnew file mode 100644@@@ -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'));@@@ -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',@@@ -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 }),@@@ -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, vibeconst 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);@@@ -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 };@@@ -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() },});@@@ -393,7 +393,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vipublished_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 })),@@@ -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>`;}nnew file mode 100644@@@ -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,++};@@@ -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 indexableitems.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 };@@@ -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 },@@@ -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'));nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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"
+ ]
+}
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"wiki.page.read",
"wiki.page.read",
"wiki.revision.propose",
"wiki.revision.propose",
"wiki.revision.publish",
"wiki.revision.publish",
+ "wiki.review.ai_editor",
"wiki.revision.revert",
"wiki.revision.revert",
"wiki.citation.attach",
"wiki.citation.attach",
"wiki.search.query"
"wiki.search.query"
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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'));
@@ -17,6 +17,7 @@ const CAPS = Object.freeze({
@@ -17,6 +17,7 @@ const CAPS = Object.freeze({
PAGE_READ: 'wiki.page.read',
PAGE_READ: 'wiki.page.read',
REVISION_PROPOSE: 'wiki.revision.propose',
REVISION_PROPOSE: 'wiki.revision.propose',
REVISION_PUBLISH: 'wiki.revision.publish',
REVISION_PUBLISH: 'wiki.revision.publish',
+ REVISION_REVIEW_AI_EDITOR: 'wiki.review.ai_editor',
REVISION_REVERT: 'wiki.revision.revert',
REVISION_REVERT: 'wiki.revision.revert',
CITATION_ATTACH: 'wiki.citation.attach',
CITATION_ATTACH: 'wiki.citation.attach',
SEARCH_QUERY: 'wiki.search.query',
SEARCH_QUERY: 'wiki.search.query',
@@ -34,6 +34,7 @@ const { createAttachmentStore } = require('openvibe-publishing/media');
@@ -34,6 +34,7 @@ const { createAttachmentStore } = require('openvibe-publishing/media');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createScheduler } = require('openvibe-publishing/schedule');
const { createScheduler } = require('openvibe-publishing/schedule');
const { createReviewLog } = require('openvibe-publishing/authorship');
const { createReviewLog } = require('openvibe-publishing/authorship');
+const { createReviewKindLog } = require('./wiki/review-kind');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
@@ -66,7 +67,8 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
@@ -66,7 +67,8 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
revisions,
revisions,
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
redirects: seo.createRedirectStore(db, { prefix: 'wiki_page', now }),
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 }),
attachments: createAttachmentStore(db, { prefix: 'wiki_page', now }),
discussions: createDiscussionRefs(db, { prefix: 'wiki', now }),
discussions: createDiscussionRefs(db, { prefix: 'wiki', now }),
scheduler: createScheduler(db, { prefix: 'wiki', now, leaseMs: scheduleLeaseMs }),
scheduler: createScheduler(db, { prefix: 'wiki', now, leaseMs: scheduleLeaseMs }),
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
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 importer = require('../wiki/import');
const { http } = contracts;
const { http } = contracts;
@@ -248,7 +248,7 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
@@ -248,7 +248,7 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
const cites = await resolveCitations((req.body || {}).citations, platform);
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) };
return { citations: (await svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor)).map(serializeCitation) };
}, 201));
}, 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 { page, space } = await pageAndSpace(req.params.id);
const b = req.body || {};
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);
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);
@@ -36,6 +36,20 @@ function guard(capabilityId) {
@@ -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 —
* 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,
* the error's status/code/detail, `extra` spread into the body, 'internal.error'/'Internal error' at 500,
@@ -120,4 +134,4 @@ function citationsFromForm(body) {
@@ -120,4 +134,4 @@ function citationsFromForm(body) {
return out;
return out;
}
}
-module.exports = { actorMiddleware, guard, sendError, run, resolveCitations, resolveBundleCitations, citationsFromForm };
+module.exports = { actorMiddleware, guard, guardReview, sendError, run, resolveCitations, resolveBundleCitations, citationsFromForm };
@@ -179,12 +179,12 @@ route('post', '/pages/{id}/revisions/{n}/citations', {
@@ -179,12 +179,12 @@ route('post', '/pages/{id}/revisions/{n}/citations', {
});
});
route('get', '/review-queue', {
route('get', '/review-queue', {
operationId: 'getReviewQueue', summary: 'The reviewer queue: published AI-assisted pages waiting for a person', tags: ['revisions'], security: PERSON,
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]) },
responses: { 200: json('The waiting pages', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },
});
});
route('post', '/pages/{id}/revisions/{n}/review', {
route('post', '/pages/{id}/revisions/{n}/review', {
operationId: 'reviewRevision', summary: 'Approve or reject a revision', tags: ['revisions'], security: PERSON, parameters: [PAGE, REVISION],
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') } }),
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() },
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() },
});
});
@@ -393,7 +393,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -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,
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_published_at: page.revision_published_at ? new Date(page.revision_published_at).toISOString() : null,
revision_created_at: v.revision.createdAt,
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,
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 })),
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 })),
attachments: v.attachments.map((a) => ({ media_id: a.mediaId, state: a.state, broken_reason: a.brokenReason, alt: a.alt, caption: a.caption })),
@@ -10,6 +10,7 @@ const showcase = require('openvibe-shared/showcase');
@@ -10,6 +10,7 @@ const showcase = require('openvibe-shared/showcase');
const { figureHtml } = require('openvibe-publishing/media');
const { figureHtml } = require('openvibe-publishing/media');
const { infoboxToText, mediaRefs } = require('../wiki/content');
const { infoboxToText, mediaRefs } = require('../wiki/content');
const { pickBlock, foldBlock } = require('./short');
const { pickBlock, foldBlock } = require('./short');
+const reviewKind = require('../wiki/review-kind');
const { html, raw } = ssr;
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
const t = (v) => raw(ssr.timeTag(v));
@@ -401,12 +402,18 @@ function pageFacts(v, base) {
@@ -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)}`,
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>`);
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 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>`;
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><
@@ -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>${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.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>
<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 ? 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>`)}
${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>
</tbody></table>
@@ -464,7 +471,7 @@ ${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</bu
@@ -464,7 +471,7 @@ ${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</bu
</form>
</form>
${canEdit && list.some((r) => r.needsReview) ? html`<h2>Waiting for a person's review</h2>
${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>
<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>` : ''}
<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>`;
</section>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -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,
+};
@@ -17,6 +17,7 @@ const { AsyncLocalStorage } = require('node:async_hooks');
@@ -17,6 +17,7 @@ const { AsyncLocalStorage } = require('node:async_hooks');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
+const reviewKind = require('./review-kind');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
/** An infobox entry's text value by key (a revision's fields.infobox), or null. */
/** An infobox entry's text value by key (a revision's fields.infobox), or null. */
@@ -34,6 +35,7 @@ function firstSentence(text) {
@@ -34,6 +35,7 @@ function firstSentence(text) {
}
}
const { isMediaId, figureHtml } = require('openvibe-publishing/media');
const { isMediaId, figureHtml } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
const { ulid } = require('openvibe-contracts').ids;
+const { checkCapability, CAPS } = require('../auth/capabilities');
const content = require('./content');
const content = require('./content');
const importer = require('./import');
const importer = require('./import');
const { createAccess, VISIBILITIES } = require('./access');
const { createAccess, VISIBILITIES } = require('./access');
@@ -67,21 +69,35 @@ function aiAssistedImport(rec) {
@@ -67,21 +69,35 @@ function aiAssistedImport(rec) {
/** Gate facts for a revision's authorship: AI-assisted imports count as AI output until reviewed. */
/** Gate facts for a revision's authorship: AI-assisted imports count as AI output until reviewed. */
function authorshipGateFacts(rec, review) {
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 }) {
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 =
@@ -240,6 +256,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return actor.subject;
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) {
async function spaceOrFail(idOrSlug) {
const s = /^spc_/.test(String(idOrSlug)) ? await q.spaceById.get(idOrSlug) : await q.spaceBySlug.get(String(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');
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 =
@@ -523,8 +548,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
const rec = rev.meta.authorship;
const rec = rev.meta.authorship;
if (rec) {
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);
const b = await before(page);
await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });
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 =
@@ -557,12 +584,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!pages.length) return [];
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 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 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 = [];
const out = [];
pages.forEach((page, i) => {
pages.forEach((page, i) => {
const space = spaces.get(page.space_id);
const space = spaces.get(page.space_id);
if (!space || !revs[i]) return;
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;
return out;
}
}
@@ -1048,8 +1075,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1048,8 +1075,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const rev = await revisions.get(page.id, n);
const rev = await revisions.get(page.id, n);
if (!rev) fail(404, 'revision.not_found', 'No such revision');
if (!rev) fail(404, 'revision.not_found', 'No such revision');
n = rev.number;
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),
citations.forRevision(page.id, n),
infoboxOf(page.id, n),
infoboxOf(page.id, n),
q.linksOf.all(page.id, n),
q.linksOf.all(page.id, n),
@@ -1066,6 +1093,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -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 offered = headNumber && headNumber > n ? await revisions.get(page.id, headNumber) : null;
const rec = rev.meta.authorship || null;
const rec = rev.meta.authorship || null;
+ const review = reviewKind.latestOf(kindws);
const facts = gateFactsWith(space, page, rev, cites.length, review);
const facts = gateFactsWith(space, page, rev, cites.length, review);
const decision = page.state === 'published' && n === page.published_revision
const decision = page.state === 'published' && n === page.published_revision
? seo.evaluate(facts, { policy, now: now() })
? seo.evaluate(facts, { policy, now: now() })
@@ -1073,8 +1101,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1073,8 +1101,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return {
return {
space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),
space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),
isPublishedRevision: n === page.published_revision && page.state === 'published',
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,
proposal: proposal || null,
infobox,
infobox,
citations: cites,
citations: cites,
@@ -1152,12 +1181,14 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1152,12 +1181,14 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const visible = [];
const visible = [];
for (const r of list) if (await revisionVisible(space, page, r.number, actor, byRev.get(r.number) || null)) visible.push(r);
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 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) => ({
return visible.map((r, i) => ({
...r, proposal: byRev.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
...r, proposal: byRev.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
review: revws[i],
review: revws[i],
+ reviews: kindws[i],
+ labels: reviewKind.labelsFor(kindws[i]),
aiAssisted: aiAssistedImport(r.meta && r.meta.authorship),
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,
citationCount: (cites[i] || []).length,
}));
}));
},
},
@@ -1202,6 +1233,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -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
* 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.
* 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.
* 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) {
async pendingReviews(actor) {
await roles(actor);
await roles(actor);
@@ -1210,7 +1245,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1210,7 +1245,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!access.canEdit(x.space, actor)) continue;
if (!access.canEdit(x.space, actor)) continue;
const rec = x.rev.meta && x.rev.meta.authorship;
const rec = x.rev.meta && x.rev.meta.authorship;
if (!rec || !(aiAssistedImport(rec) || authorship.needsReview(rec))) continue;
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({
items.push({
site: 'wiki',
site: 'wiki',
kind: 'wiki_page',
kind: 'wiki_page',
@@ -1220,6 +1257,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1220,6 +1257,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
url: pageUrl(x.space, x.page),
url: pageUrl(x.space, x.page),
createdAt: new Date(x.page.revision_published_at || x.page.published_at || x.rev.createdAt).toISOString(),
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 })),
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` },
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 =
@@ -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.
* Pending AI proposals are reviewed through reviewProposal instead.
*/
*/
async reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
async reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
@@ -1430,8 +1470,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1430,8 +1470,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const space = await spaceOrFail(spaceIdOrSlug);
const space = await spaceOrFail(spaceIdOrSlug);
const page = await q.pageBySlug.get(space.id, String(slug));
const page = await q.pageBySlug.get(space.id, String(slug));
if (!page || page.state === 'deleted') fail(404, 'page.not_found', 'No such page');
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');
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
const n = Number(revisionNumber);
const n = Number(revisionNumber);
if (!Number.isInteger(n) || !await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${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 =
@@ -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 });
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 () => {
return await tx(async () => {
const b = await before(page);
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
const out = page.state === 'published' && page.published_revision === n
? await sync(b, page.id, actor, { updatedIfIndexChanged: true })
? await sync(b, page.id, actor, { updatedIfIndexChanged: true })
: { action: null, indexRevision: b ? b.indexRevision : null };
: { action: null, indexRevision: b ? b.indexRevision : null };
@@ -8,16 +8,22 @@
@@ -8,16 +8,22 @@
const assert = require('assert');
const assert = require('assert');
const H = require('./helpers');
const H = require('./helpers');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { CAPS } = require('../server/auth/capabilities');
+const { CAPS, PROPOSED } = require('../server/auth/capabilities');
(async () => {
(async () => {
// The capabilities the routes enforce are the released ones, so capabilities.check() decides them.
// 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)) {
for (const id of Object.values(CAPS)) {
const cap = contracts.capabilities.get(id);
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.strictEqual(cap.owner, 'wiki', `${id} is owned by wiki`);
assert.notStrictEqual(cap.status, 'retired', `${id} is not retired`);
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(
assert.deepStrictEqual(
contracts.capabilities.check({ cap: ['wiki.*'] }, CAPS.PAGE_READ),
contracts.capabilities.check({ cap: ['wiki.*'] }, CAPS.PAGE_READ),
{ allowed: true, code: null, reason: null },
{ allowed: true, code: null, reason: null },
@@ -27,6 +27,9 @@ assert.deepStrictEqual([...ids].sort(), [...PROPOSED].sort());
@@ -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.
// 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 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]));
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());
assert.deepStrictEqual([...guarded].sort(), [...PROPOSED].sort());
const manifest = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
const manifest = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -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); });