openvibers/openvibe.news Commits
News: the AI editor's review is a second kind, labelled "Checked by OpenVibe's AI editor" (#220)
Changes
nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "news.review.ai_editor",++ "version": "1.0",++ "owner": "news",++ "status": "active",++ "visibility": "first-party",++ "description": "Record the editorial queue's AI editor decision (approved or rejected) on a story revision or an AI brief, 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 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": [++ "news:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "news-write",++ "events": [++ "news.story.updated",++ "news.index_document.upserted",++ "news.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/stories/:id/reviews",++ "POST /api/v1/briefs/:id/reviews"++ ]++}@@@ -24,7 +24,8 @@"news.source.attach","news.timeline.update","news.perspective.update",-- "news.topic.manage"++ "news.topic.manage",++ "news.review.ai_editor"],"eventsProduced": ["news.source.ingested",nnew file mode 100644@@@ -0,0 +1,18 @@++-- phase: expand++-- Two reviewer kinds in both append-only review logs (server/domain/review-kind.js and++-- server/domain/briefs.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 news.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 for stories, older processes during a rolling deploy) keeps working.++ALTER TABLE news_story_reviews++ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'++ CHECK (reviewer_kind IN ('person', 'ai_editor'));++++ALTER TABLE news_brief_reviews++ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'++ CHECK (reviewer_kind IN ('person', 'ai_editor'));@@@ -24,11 +24,22 @@ const CAPABILITIES = Object.freeze({TIMELINE_UPDATE: 'news.timeline.update',PERSPECTIVE_UPDATE: 'news.perspective.update',TOPIC_MANAGE: 'news.topic.manage',++ REVIEW_AI_EDITOR: 'news.review.ai_editor',});++// The ids above as a local fallback while openvibe-contracts does not define one yet (the same++// rule the library uses: the exact id, or a `prefix.*` grant covering it). An id the library knows++// always goes through the library, so the day a release lands nothing changes here.++const PROPOSED = new Set(Object.values(CAPABILITIES));++/** → { allowed, code, reason } like capabilities.check(). */function checkCapability(claims, capabilityId) {++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {++ return capabilities.grants(claims && claims.cap, capabilityId)++ ? { allowed: true, code: null, reason: null }++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };++ }return capabilities.check(claims, capabilityId);}--module.exports = { CAPABILITIES, checkCapability };++module.exports = { CAPABILITIES, PROPOSED, checkCapability };@@@ -21,7 +21,7 @@const contracts = require('openvibe-contracts');const { verifyServiceToken } = require('openvibe-sdk/auth');const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');--const { checkCapability } = require('./capabilities');++const { checkCapability, CAPABILITIES } = require('./capabilities');const { ids, http, staff: staffMap } = contracts;const PRINCIPAL_SUB = /^(svc|app|mod):/;@@@ -123,4 +123,16 @@ function guard(cap) {};}--module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError };++/**++ * The review routes' guard: a service acting as itself (no person named) is the AI editor and++ * needs news.review.ai_editor; a person, or a service acting for one, is judged as before.++ */++function guardReview(req, res, next) {++ const v = req.viewer;++ if (!v || v.kind !== 'service' || v.subject) return next();++ const c = checkCapability(v.claims, CAPABILITIES.REVIEW_AI_EDITOR);++ if (c.allowed) return next();++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });++}++++module.exports = { createViewerResolver, guard, guardReview, ANONYMOUS, ViewerError };@@@ -33,6 +33,7 @@ const { createDb } = require('openvibe-sdk/db');const { createRevisionStore } = require('openvibe-publishing/revisions');const { createCitationStore } = require('openvibe-publishing/citations');const { createReviewLog } = require('openvibe-publishing/authorship');++const { createReviewKindLog } = require('./domain/review-kind');const { createIndexSequencer } = require('openvibe-publishing/index-hooks');const { createDiscussionRefs } = require('openvibe-publishing/discussion');@@@ -70,7 +71,8 @@ function createStore(db, { now = () => Date.now() } = {}) {now,revisions,citations: createCitationStore(db, { prefix: 'news_story', now, revisions }),-- reviews: createReviewLog(db, { prefix: 'news_story', 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: 'news_story', now }), { db, prefix: 'news_story', now }),sequencer: createIndexSequencer(db, { prefix: 'news', now }),discussion: createDiscussionRefs(db, { prefix: 'news_story', now }),tx: async (fn) => await db.tx(() => fn()),@@@ -21,6 +21,8 @@*/const { draftFromOutput } = require('../clients/ai');++const reviewKind = require('./review-kind');++const { checkCapability, CAPABILITIES } = require('../auth/capabilities');const TONES = ['brief', 'neutral', 'explained'];const TONE_TEXT = { brief: 'In brief', neutral: 'Summary', explained: 'Explained' };@@@ -44,8 +46,9 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {gaps = excluded.gaps, run_id = excluded.run_id, model = excluded.model, stub = excluded.stub, item_count = excluded.item_count,cluster_updated_at = excluded.cluster_updated_at, created_at = excluded.created_at`),reviewLatest: db.prepare('SELECT * FROM news_brief_reviews WHERE cluster_id = ? AND kind = ? AND tone = ? ORDER BY id DESC LIMIT 1'),-- reviewInsert: db.prepare(`INSERT INTO news_brief_reviews (cluster_id, kind, tone, brief_created_at, reviewer, decision, note, reviewed_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),++ reviewByKind: db.prepare('SELECT DISTINCT ON (reviewer_kind) * FROM news_brief_reviews WHERE cluster_id = ? AND kind = ? AND tone = ? ORDER BY reviewer_kind, id DESC'),++ reviewInsert: db.prepare(`INSERT INTO news_brief_reviews (cluster_id, kind, tone, brief_created_at, reviewer, reviewer_kind, decision, note, reviewed_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),pending: db.prepare(`SELECT b.*, c.label, c.window_end, c.updated_at AS cluster_updated_at_now,(SELECT count(DISTINCT i.outlet) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS outlets,(SELECT count(*) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS item_count_now@@@ -83,39 +86,70 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {};}-- const reviewShape = (r) => (r ? { id: r.id, clusterId: r.cluster_id, kind: r.kind, tone: r.tone, briefCreatedAt: Number(r.brief_created_at), reviewer: r.reviewer, decision: r.decision, note: r.note, reviewedAt: Number(r.reviewed_at) } : null);++ const reviewShape = (r) => (r ? { id: Number(r.id), clusterId: r.cluster_id, kind: r.kind, tone: r.tone, briefCreatedAt: Number(r.brief_created_at), reviewer: r.reviewer, reviewerKind: r.reviewer_kind === reviewKind.AI_EDITOR ? reviewKind.AI_EDITOR : reviewKind.PERSON, decision: r.decision, note: r.note, reviewedAt: Number(r.reviewed_at) } : null);++++ /**++ * The AI editor records a review as itself: a first-party service principal (svc:…) with no++ * person named, holding news.review.ai_editor. A service acting for a person is that person.++ */++ function isAiEditor(viewer) {++ if (!viewer || viewer.kind !== 'service' || viewer.subject || !/^svc:/.test(String(viewer.service))) return false;++ return checkCapability(viewer.claims, CAPABILITIES.REVIEW_AI_EDITOR).allowed;++ }/** The latest review of one brief (any decision), or null. */async function reviewOf(clusterId, { kind = 'summary', tone = 'neutral' } = {}) {return reviewShape(await q.reviewLatest.get(clusterId, kind, tone));}++ /** The review of each kind for one brief: { person, ai_editor }. */++ async function reviewsOf(clusterId, { kind = 'summary', tone = 'neutral' } = {}) {++ const out = { person: null, ai_editor: null };++ for (const row of await q.reviewByKind.all(clusterId, kind, tone)) {++ out[row.reviewer_kind === reviewKind.AI_EDITOR ? reviewKind.AI_EDITOR : reviewKind.PERSON] = reviewShape(row);++ }++ return out;++ }++/** An approval counts only for the exact brief it reviewed (a remake changes created_at). */function isApproved(brief, review) {return Boolean(brief && review && review.decision === 'approved' && Number(review.briefCreatedAt) === Number(brief.created_at));}-- /** A person's review of a brief. Only a person (usr_…) can review; the review log is the audit. */++ /**++ * A review of a brief: a signed-in person's, or the AI editor's (a svc:… principal acting as++ * itself with news.review.ai_editor). The review log is the audit.++ */async function review(viewer, clusterId, { kind = 'summary', tone = 'neutral', decision, note = null } = {}) {-- if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw Object.assign(new Error('Only a signed-in person can review a brief'), { code: 'review.person_required', status: 403 });++ const aiEditor = isAiEditor(viewer);++ if (!aiEditor && (!viewer || viewer.kind !== 'user' || !viewer.subject)) throw Object.assign(new Error('Only a signed-in person (or the AI editor) can review a brief'), { code: 'review.person_required', status: 403 });if (!KINDS.includes(kind) || !TONES.includes(tone)) throw Object.assign(new Error('kind or tone not known'), { code: 'brief.invalid', status: 422 });if (decision !== 'approved' && decision !== 'rejected') throw Object.assign(new Error('decision is approved or rejected'), { code: 'review.invalid_decision', status: 422 });const got = await get(clusterId, { kind, tone });if (!got || !got.brief) throw Object.assign(new Error('No such brief'), { code: 'brief.not_found', status: 404 });if (got.brief.stub || !got.enough) throw Object.assign(new Error('This brief is not shown to readers'), { code: 'brief.not_public', status: 422 });-- const row = await q.reviewInsert.get(String(clusterId), kind, tone, Number(got.brief.created_at), viewer.subject, decision, note == null ? null : String(note).slice(0, 2000), store.now());++ const row = await q.reviewInsert.get(String(clusterId), kind, tone, Number(got.brief.created_at),++ aiEditor ? viewer.service : viewer.subject, aiEditor ? reviewKind.AI_EDITOR : reviewKind.PERSON,++ decision, note == null ? null : String(note).slice(0, 2000), store.now());return reviewShape(row);}-- /** Every current brief nobody has reviewed yet, oldest first: what the reviewer queue lists. */++ /**++ * Every current brief nobody has reviewed yet, oldest first: what the reviewer queue lists. An++ * ai_editor decision is named in `reviewed`: its approval publishes and indexes the brief and++ * leaves the queue; its rejection stays until a person decides.++ */async function pendingReviews() {const items = [];for (const row of await q.pending.all()) {if (Number(row.outlets) < MIN_OUTLETS) continue;if (Number(row.item_count) !== Number(row.item_count_now) || Number(row.cluster_updated_at) !== Number(row.cluster_updated_at_now)) continue;const brief = shape(row);-- const review = await reviewOf(row.cluster_id);-- if (review && Number(review.briefCreatedAt) === Number(brief.created_at)) continue; // a decision for this exact text++ const kinds = await reviewsOf(row.cluster_id);++ const forThisText = (r) => Boolean(r && Number(r.briefCreatedAt) === Number(brief.created_at));++ if (forThisText(kinds.person)) continue; // a person's decision, approved or rejected, for this exact text++ const ai = forThisText(kinds.ai_editor) ? kinds.ai_editor : null;++ if (ai && ai.decision === 'approved') continue; // the AI editor approved it: published and indexableitems.push({site: 'news', kind: 'ai_brief', id: row.cluster_id,title: brief.headline || row.label, revision: null,@@@ -125,6 +159,7 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {{ code: 'ai_generated_unreviewed', effect: 'noindex', detail: 'a person has not reviewed this brief', text: 'AI-written and not reviewed by a person.' },{ code: 'noindex_requested', effect: 'noindex', detail: 'briefs are noindex until reviewed', text: 'An editor asked for it to stay out of search engines until a person reviews it.' },],++ 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/briefs/${encodeURIComponent(row.cluster_id)}/reviews` },});}@@@ -191,7 +226,7 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {const latest = async (limit = 12) => (await q.latest.all(MIN_OUTLETS, limit)).map((r) => ({ ...shape(r), label: r.label, window_end: Number(r.window_end), outlets: Number(r.outlets) }));-- return { TONES, TONE_TEXT, KINDS, MIN_REPORTS, MIN_OUTLETS, get, make, sweep, latest, review, reviewOf, isApproved, pendingReviews, reviewed };++ return { TONES, TONE_TEXT, KINDS, MIN_REPORTS, MIN_OUTLETS, get, make, sweep, latest, review, reviewOf, reviewsOf, isApproved, pendingReviews, reviewed };}module.exports = { createBriefs, TONES, TONE_TEXT, MIN_REPORTS };@@@ -22,6 +22,7 @@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 { createPublication: createPublicationGlue } = require('openvibe-publishing/publication');const { normalize } = require('openvibe-publishing/ingest');@@@ -31,21 +32,37 @@ const OWNER = 'news';/*** AI-assisted text (hybrid: a person edited an AI draft) is held like AI-generated text: hidden-- * from indexes and not publishable until a person records an approving review of that revision.++ * from indexes and not publishable until a person or the AI editor records an approving review of++ * that revision.*/function gateAuthorship(rec, review) {-- if (rec.mode === 'hybrid') return { authorship: { mode: 'ai', reviewed: authorship.isReviewed(rec, review) }, ...(rec.stubProvider ? { stubProvider: true } : {}) };-- return authorship.gateFacts(rec, review);++ const reviewed = reviewKind.approved(review);++ return { authorship: { mode: rec.mode === 'hybrid' ? 'ai' : rec.mode, reviewed }, ...(rec.stubProvider ? { stubProvider: true } : {}) };}function canPublish(rec, review) {-- if (rec.mode === 'hybrid' && !authorship.isReviewed(rec, review)) return { ok: false, reason: 'ai_assisted_unreviewed' };-- return authorship.canPublish(rec, review);++ if (rec.mode === 'hybrid' && !reviewKind.approved(review)) return { ok: false, reason: 'ai_assisted_unreviewed' };++ if (authorship.needsReview(rec) && !reviewKind.approved(review)) return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };++ return { ok: true, reason: null };}--function disclosure(rec, review) {-- const d = rec ? authorship.disclosure(rec, review) : null;-- if (!d || rec.mode !== 'hybrid') return d;-- const reviewed = authorship.isReviewed(rec, review);-- return { ...d, long: `${d.long.replace(/\.\s*$/, '')}. ${reviewed ? 'Reviewed by a person.' : 'Not yet reviewed by a person.'}` };++/**++ * The disclosure at the item, with the review of each kind (a person's, the AI editor's, both):++ * "Checked by OpenVibe's AI editor on 2026-11-06" is never "Reviewed by a person".++ */++function disclosure(rec, kinds = { person: null, ai_editor: null }) {++ const d = rec ? authorship.disclosure(rec, null) : null;++ if (!d) return null;++ const person = reviewKind.isPersonReview(kinds.person);++ const ai = reviewKind.aiEditorLabel(kinds.ai_editor);++ const hybrid = rec.mode === 'hybrid';++ if (!person && !ai) {++ if (!hybrid) return { ...d, reviewed: false }; // the library says "not yet reviewed by a person"++ return { ...d, long: `${d.long.replace(/\.\s*$/, '')}. Not yet reviewed by a person.`, reviewed: false };++ }++ const base = d.long.replace(/,\s*(?:not yet )?reviewed by a person\.?\s*$/, '').replace(/\.\s*$/, '');++ let long = base;++ if (person) long += hybrid ? '. Reviewed by a person.' : ', reviewed by a person.';++ if (ai) long += `${long.endsWith('.') ? ' ' : '. '}${ai}.`;++ return { ...d, long, reviewed: true };}function createPublication({ store, config, outbox, indexnow }) {@@@ -67,6 +84,8 @@ function createPublication({ store, config, outbox, indexnow }) {const authorshipOf = (rev) => (rev && rev.meta && rev.meta.authorship) || null;const reviewOf = async (story, rev) => (rev ? await store.reviews.latest(story.id, rev.number) : null);++ /** The review of each kind for one revision: { person, ai_editor }. */++ const reviewsOf = async (story, rev) => (rev ? await store.reviews.latestByKind(story.id, rev.number) : { person: null, ai_editor: null });const paragraphsOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.paragraphs) ? rev.fields.paragraphs : []);const snapshotOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.sources) ? rev.fields.sources : []);@@@ -208,7 +227,7 @@ function createPublication({ store, config, outbox, indexnow }) {return {POLICY: policy, OWNER, abs, storyPath, storyUrl, topicPath, feedId,-- authorshipOf, reviewOf, paragraphsOf, snapshotOf, liveSources, support, decide, topicOf,++ authorshipOf, reviewOf, reviewsOf, paragraphsOf, snapshotOf, liveSources, support, decide, topicOf,documentFor, syncIndex, snapshot, afterChange, plain,};}@@@ -12,6 +12,7 @@*/const seo = require('openvibe-publishing/seo');const { disclosure } = require('./publication');++const reviewKind = require('./review-kind');const iso = (v) => (v == null ? null : new Date(v).toISOString());@@@ -48,6 +49,7 @@ function createReading({ store, config, stories, publication, people, topics })}const rec = publication.authorshipOf(r);const review = await publication.reviewOf(story, r);++ const kinds = await publication.reviewsOf(story, r);const authorSubjects = rec && Array.isArray(rec.authors) ? rec.authors : [];const who = authorSubjects.length ? await people.many(authorSubjects) : new Map();const authors = authorSubjects.map((s) => who.get(s)).filter((p) => p && p.known).map((p) => ({ name: p.name, username: p.username }));@@@ -68,7 +70,7 @@ function createReading({ store, config, stories, publication, people, topics })const s = snap.find((x) => x.id === f.source_item_id);return { kind: f.kind, n: s ? s.n : null, outlet: s ? s.outlet : null, at: iso(f.created_at) };}),-- authorship: rec, review, disclosure: rec ? disclosure(rec, review) : null, authors,++ authorship: rec, review, reviews: kinds, labels: reviewKind.labelsFor(kinds), disclosure: rec ? disclosure(rec, kinds) : null, authors,decision: await publication.decide(story, r),first_published_at: iso(story.first_published_at), updated_at: r.createdAt, published_at: iso(story.published_at),};@@@ -81,7 +83,17 @@ function createReading({ store, config, stories, publication, people, topics })headline: m.headline, headline_sources: m.headline_sources,topic: m.topic ? { slug: m.topic.slug, name: m.topic.name, url: publication.abs(publication.topicPath(m.topic)) } : null,first_published_at: m.first_published_at, updated_at: m.updated_at,-- authorship: m.authorship ? { mode: m.authorship.mode, workflow: m.authorship.workflow ? m.authorship.workflow.id : null, reviewed_by_person: Boolean(m.review && m.review.decision === 'approved'), disclosure: m.disclosure ? m.disclosure.long : null } : null,++ authorship: m.authorship ? {++ mode: m.authorship.mode, workflow: m.authorship.workflow ? m.authorship.workflow.id : null,++ reviewed: Boolean(m.labels && m.labels.length),++ labels: m.labels || [],++ reviewed_by_person: Boolean(m.reviews && m.reviews.person && m.reviews.person.decision === 'approved'),++ disclosure: m.disclosure ? m.disclosure.long : null,++ reviews: {++ person: m.reviews && m.reviews.person ? { decision: m.reviews.person.decision, at: m.reviews.person.reviewedAt } : null,++ ai_editor: m.reviews && m.reviews.ai_editor ? { decision: m.reviews.ai_editor.decision, at: m.reviews.ai_editor.reviewedAt } : null,++ },++ } : null,authors: m.authors,claims: m.paragraphs.map((p) => ({ text: p.text, sources: p.sources, supported: p.supported })),sources: m.sources,nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++/**++ * Two reviewer kinds in one append-only log (the `news_story_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++ * news.review.ai_editor — recorded with its principal, the time and a note.++ * migrations/0009_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. 'news_story' → news_story_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,++};@@@ -26,6 +26,8 @@ const authorship = require('openvibe-publishing/authorship');const seo = require('openvibe-publishing/seo');const { ApiError } = require('../http/errors');const { actorRef, canPublish } = require('./publication');++const reviewKind = require('./review-kind');++const { checkCapability, CAPABILITIES } = require('../auth/capabilities');const access = require('./access');const MAX_PARAGRAPHS = 60;@@@ -108,6 +110,15 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullfunction actingEditor(viewer) { return access.requireEditor(config, viewer); }++ /**++ * The AI editor records a review as itself: a first-party service principal (svc:…) with no++ * person named, holding news.review.ai_editor. A service acting for a person is that person.++ */++ function isAiEditor(viewer) {++ if (!viewer || viewer.kind !== 'service' || viewer.subject || !/^svc:/.test(String(viewer.service))) return false;++ return checkCapability(viewer.claims, CAPABILITIES.REVIEW_AI_EDITOR).allowed;++ }++// ── The working source table, timeline and perspectives ─/** Attached sources with their items, in citation order: [{ n, item, perspective_id, detached_at }]. */@@@ -582,8 +593,10 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullconst rev = story.state === 'published' && story.published_revision ? await store.revisions.get(story.id, story.published_revision) : head;const rec = rev && rev.meta && rev.meta.authorship;if (!rec || !(rec.mode === 'ai' || rec.mode === 'hybrid' || rec.stubProvider)) continue;-- const review = await store.reviews.latest(story.id, rev.number);-- if (review) continue;++ const kinds = await store.reviews.latestByKind(story.id, rev.number);++ if (kinds.person) continue; // a person's decision, approved or rejected, has been made++ if (kinds.ai_editor && kinds.ai_editor.decision === 'approved') continue; // the AI editor approved it++ const ai = kinds.ai_editor;const decision = await publication.decide(story, rev, { state: story.state });items.push({site: 'news', kind: 'news_story', id: story.id,@@@ -592,6 +605,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullurl: publication.storyUrl(story), editUrl: `/edit/stories/${story.id}`,createdAt: new Date(rev.createdAt).toISOString(),reasons: 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/stories/${encodeURIComponent(story.id)}/reviews` },...(story.state !== 'published' ? { publish: { method: 'POST', path: `/api/v1/stories/${encodeURIComponent(story.id)}/publish` } } : {}),});@@@ -599,14 +613,21 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullreturn items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));},-- /** A person's review of one revision (what lets an AI draft be published and indexed). */++ /**++ * A review of one revision: a signed-in editor's (what lets an AI draft be published and++ * indexed), or the AI editor's — a svc:… service principal acting as itself with++ * news.review.ai_editor — recorded with kind `ai_editor`, never as a person's review.++ */async review(viewer, story, { revision, decision, note } = {}, { traceparent } = {}) {-- if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');-- actingEditor(viewer);++ const aiEditor = isAiEditor(viewer);++ if (!aiEditor && (!viewer || viewer.kind !== 'user' || !viewer.subject)) throw new ApiError(403, 'review.person_required', 'Only a signed-in person (or the AI editor) can review a revision');++ if (!aiEditor) actingEditor(viewer);const n = parseInt(revision, 10);if (!await store.revisions.get(story.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);return await store.tx(async () => {-- const row = await store.reviews.record({ entityId: story.id, revision: n, reviewer: viewer.subject, decision, note });++ const row = aiEditor++ ? await store.reviews.recordAiEditor({ entityId: story.id, revision: n, principal: viewer.service, decision, note })++ : await store.reviews.record({ entityId: story.id, revision: n, reviewer: viewer.subject, decision, note });if (story.state === 'published') await publication.syncIndex(await q.byId.get(story.id), { traceparent });return row;});@@@ -38,7 +38,7 @@const express = require('express');const contracts = require('openvibe-contracts');const { run, jsonBody, ApiError, privateNoStore } = require('./errors');--const { guard } = require('../auth/viewer');++const { guard, guardReview } = require('../auth/viewer');const access = require('../domain/access');function cors(origins) {@@@ -96,8 +96,10 @@ function createApi(ctx) {// A person reviews an AI brief. The review log is append-only; the brief page leaves noindex only// while the approval still matches the brief (a remake waits for a person again).-- router.post('/briefs/:id/reviews', B('news.story.publish'), jsonBody, run(async (req) => {-- access.requireEditor(config, req.viewer);++ router.post('/briefs/:id/reviews', guardReview, B('news.story.publish'), jsonBody, run(async (req) => {++ // A service acting as itself is the AI editor (guardReview checked news.review.ai_editor); a++ // person, or a service acting for one, must still be an editor.++ if (req.viewer.kind !== 'service' || req.viewer.subject) access.requireEditor(config, req.viewer);if (!briefs) throw new ApiError(503, 'briefs.unavailable', 'AI briefs are not configured');const b = req.body || {};return { review: await briefs.review(req.viewer, req.params.id, { kind: b.kind, tone: b.tone, decision: b.decision, note: b.note }) };@@@ -227,7 +229,7 @@ function createApi(ctx) {const out = await stories.retract(req.viewer, story, { note: b.note }, tp(req));return { changed: out.changed, story: await storyDto(out.story) };});-- router.post('/stories/:id/reviews', B('news.story.publish'), jsonBody, run(async (req) => {++ router.post('/stories/:id/reviews', guardReview, B('news.story.publish'), jsonBody, run(async (req) => {const story = await mustStory(req);const b = req.body || {};return { review: await stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)) };@@@ -186,6 +186,11 @@ function createPublicRoutes(ctx) {// reviewed: a remake changes created_at, so an old approval no longer applies.const review = await briefs.reviewOf(id, { kind: 'summary', tone });const reviewed = tone === 'neutral' && got.enough && got.fresh && briefs.isApproved(got.brief, review);++ // A brief can carry both kinds of approval (a person's, the AI editor's): the page names each,++ // but only a review of this exact text — a remake changes created_at and waits again.++ const kindReviews = reviewed && got.brief ? await briefs.reviewsOf(id, { kind: 'summary', tone }) : { person: null, ai_editor: null };++ const forText = (r) => (r && Number(r.briefCreatedAt) === Number(got.brief.created_at) ? r : null);++ const reviews = { person: forText(kindReviews.person), ai_editor: forText(kindReviews.ai_editor) };const canonical = `${config.baseUrl}/briefs/${id}`;const decision = reviewed? seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: canonical, wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() })@@@ -194,7 +199,7 @@ function createPublicRoutes(ctx) {title: got.enough ? `${got.brief && got.brief.headline ? got.brief.headline : got.cluster.label} (AI brief)` : `${got.cluster.label} (reports)`,description: got.enough ? 'An AI summary of the reports on this subject, every paragraph linked to its reports.' : 'The reports on this subject so far. Too few outlets have covered it for an AI summary.',decision, canonical, localStyles: ['css/briefs.css'],-- body: briefPages.briefPage({ ...got, tone, perspectives: persp ? persp.brief : null, canMake: canMake(), notice, review: reviewed ? review : null }),++ body: briefPages.briefPage({ ...got, tone, perspectives: persp ? persp.brief : null, canMake: canMake(), notice, review: reviewed ? review : null, reviews }),}, { cacheable: !notice && (reviewed || !got.brief) });}));@@@ -49,9 +49,19 @@ function reportsOnlyPage({ cluster, reports, outlets, minOutlets }) {}/** /briefs/:id */--function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake, notice = null, enough = true, minOutlets = 2, review = null }) {++function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake, notice = null, enough = true, minOutlets = 2, review = null, reviews = null }) {const outlets = [...new Set(reports.map((r) => r.outlet))];if (!enough) return reportsOnlyPage({ cluster, reports, outlets, minOutlets });++ // Two kinds of approval can stand behind a brief: a person's and the AI editor's. Each is named.++ const kinds = reviews || { person: review, ai_editor: null };++ const reviewLabels = [];++ if (kinds.person && kinds.person.decision === 'approved') {++ reviewLabels.push(h`Reviewed by a person <time datetime="${new Date(kinds.person.reviewedAt).toISOString()}">${time(kinds.person.reviewedAt)}</time>.`);++ }++ if (kinds.ai_editor && kinds.ai_editor.decision === 'approved') {++ reviewLabels.push(h`Checked by OpenVibe's AI editor <time datetime="${new Date(kinds.ai_editor.reviewedAt).toISOString()}">${time(kinds.ai_editor.reviewedAt)}</time>.`);++ }++ const reviewed = reviewLabels.map((l) => `${l} `).join('');const shown = brief && !brief.stub ? brief : null;const tabs = h`<nav class="brief-tones" aria-label="How to tell it">${TONES.map(([t, label]) => (t === tone? h`<span class="brief-tone on" aria-current="page">${label}</span>`@@@ -59,7 +69,7 @@ function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMakeconst persp = perspectives && !perspectives.stub ? perspectives : null;return String(h`<header class="site-header"><p class="eyebrow"><span class="badge badge-ai">AI brief</span> from ${coverage(reports, outlets)}</p><h1>${shown && shown.headline ? shown.headline : cluster.label}</h1>--<p class="meta">${review ? h`Reviewed by a person <time datetime="${new Date(review.reviewedAt).toISOString()}">${time(review.reviewedAt)}</time>. ` : ''}${AI_NOTE}</p></header>++<p class="meta">${reviewed ? raw(reviewed) : ''}${AI_NOTE}</p></header>${notice ? h`<p class="notice">${notice}</p>` : ''}${tabs}<section class="brief" aria-label="The brief">@@@ -1,26 +1,34 @@'use strict';/*** The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the-- * library's grant rule rather than deciding a proposed id locally (the fallback that existed while-- * the ids were only proposals). These pin that: every guarded id is defined by the installed-- * contracts and by the news service manifest, and checkCapability agrees with capabilities.check for-- * an exact grant, a prefix grant, no grant, and an id the contracts do not define.++ * library's grant rule rather than deciding a proposed id locally. One id is still a proposal and++ * waits for a Contracts release — news.review.ai_editor (the AI editor's review kind) — so it is++ * decided by the same grant rule locally until then (server/auth/capabilities.js PROPOSED). These++ * pin that: every guarded id is defined by the installed contracts and the news service manifest,++ * except the proposal, which is tracked as one; and checkCapability agrees with capabilities.check++ * for an exact grant, a prefix grant, no grant, and an id nobody defines.*/const assert = require('assert');const { capabilities, services } = require('openvibe-contracts');--const { checkCapability, CAPABILITIES } = require('../server/auth/capabilities');++const { checkCapability, CAPABILITIES, PROPOSED } = require('../server/auth/capabilities');const { check, done } = require('./helpers/boot');(async () => {await check('every guarded capability is defined by the installed contracts and the news manifest', () => {const manifest = services.get('news');assert.ok(manifest, 'openvibe-contracts defines the news service manifest');-- assert.deepStrictEqual([...manifest.capabilities].sort(), Object.values(CAPABILITIES).sort());++ const released = Object.values(CAPABILITIES).filter((id) => capabilities.get(id));++ assert.deepStrictEqual([...manifest.capabilities].sort(), released.sort());for (const id of Object.values(CAPABILITIES)) {const cap = capabilities.get(id);-- assert.ok(cap, `${id} is defined 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, 'news', `${id} is owned by news`);}++ // The AI editor's id is the one proposal; when Contracts releases it, this line goes.++ assert.strictEqual(capabilities.get('news.review.ai_editor'), undefined);});await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {@@@ -30,8 +38,7 @@ const { check, done } = require('./helpers/boot');// A grant of a sibling capability, or no cap at all, is denied with the library's code.assert.strictEqual(checkCapability({ cap: ['news.story.read'] }, 'news.story.create').code, 'capability.denied');assert.strictEqual(checkCapability(null, 'news.story.create').code, 'capability.denied');-- // An id the contracts do not define still answers capability.unknown — the guard delegates,-- // it never decides an unknown id on its own.++ // An id nobody defines still answers capability.unknown — the guard only falls back for a proposal.assert.strictEqual(checkCapability({ cap: ['news.*'] }, 'news.not.a.capability').code, 'capability.unknown');});nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * The AI editor (plan 2026-10-22 §"The editorial queue"): a second reviewer kind, `ai_editor`,++ * recorded on a story's review and on an AI brief's review by the orchestrator's service principal.++ * Its approval publishes and indexes the text exactly as a person's 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 news.review.ai_editor may record one; a token without++ * it is refused and writes nothing.++ */++const assert = require('assert');++const { boot, check, done, launchReports } = require('./helpers/boot');++++const AI_ON_PAGE = /Checked by OpenVibe(?:'|')s AI editor/;++const wf = { id: 'news.summarize_story', runId: 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8', version: 1 };++++async function storyPart() {++ const t = await boot({ ai: true });++ try {++ const reports = launchReports(t.sources, { a: { fields: { body: 'AI editor story source' } } });++ await t.pull();++ const clusterId = (await t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id)).cluster_id;++ const story = (await t.api('/stories', { json: { cluster: clusterId, headline: 'Europa Clipper launch', topic: 'space' } })).json().story;++ const aiToken = t.network.serviceToken('ai', ['news.story.revise']);++ const editor = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.news'], cap: ['news.review.ai_editor'] });++ const paragraphs = [++ { text: 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, beginning a cruise of several years to Jupiter and its moon Europa.', sources: [reports.a.id, reports.b.id] },++ { text: 'According to NASA the spacecraft should arrive at Jupiter in 2030, after which it will make repeated close passes of Europa to study its ice shell and the ocean beneath.', sources: [reports.b.id] },++ ];++ const rev = (await t.get(`/api/v1/stories/${story.id}/revisions`, { as: aiToken, headers: { 'x-ov-origin': 'ai' }, json: { headline: 'Europa Clipper is on its way to Jupiter', paragraphs, expected_revision: 0, authorship: { workflow: wf } } })).json().revision;++++ await check('a token without news.review.ai_editor is refused and writes nothing', async () => {++ const r = await t.get(`/api/v1/stories/${story.id}/reviews`, { as: aiToken, json: { revision: rev, decision: 'approved' } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'capability.denied');++ assert.strictEqual((await t.ctx.store.reviews.history(story.id)).length, 0);++ });++++ await check('the AI editor approves: the story publishes and reads "Checked by OpenVibe\'s AI editor"', async () => {++ const r = await t.get(`/api/v1/stories/${story.id}/reviews`, { as: editor, json: { revision: rev, decision: 'approved', note: 'Checked every claim' } });++ 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']);++ const pub = await t.api(`/stories/${story.id}/publish`, { json: { revision: rev } });++ assert.strictEqual(pub.status, 200, pub.text);++ const page = await t.get(`/stories/${story.slug}`);++ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'indexable');++ assert.ok(AI_ON_PAGE.test(page.text), 'the page says who checked it');++ assert.ok(!/reviewed by a person/i.test(page.text), 'never as a person\'s review');++ const json = (await t.get(`/stories/${story.slug}.json`)).json();++ assert.strictEqual(json.authorship.reviewed, true);++ assert.ok(json.authorship.labels.some((l) => l.startsWith("Checked by OpenVibe's AI editor")), JSON.stringify(json.authorship));++ assert.deepStrictEqual([json.authorship.reviews.ai_editor.decision, json.authorship.reviews.person], ['approved', null]);++ });++++ await check('a person\'s review still says "Reviewed by a person"; both can show', async () => {++ const r = await t.api(`/stories/${story.id}/reviews`, { json: { revision: rev, decision: 'approved' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().review.reviewerKind, 'person');++ const page = await t.get(`/stories/${story.slug}`);++ assert.ok(/reviewed by a person/i.test(page.text), 'the person\'s label is unchanged');++ assert.ok(AI_ON_PAGE.test(page.text), 'and both are shown');++ });++ } finally {++ await t.close();++ }++}++++async function briefPart() {++ const t = await boot({ ai: true });++ try {++ const run = (id) => ({ status: 201, run: { id, status: 'succeeded', workflow: { key: 'news.summarize_story', version: 3 }, synthetic: false, provenance: { model: 'test-model' }, output: { headline: 'Europa Clipper is on its way', summary: 'NASA launched Europa Clipper toward Jupiter.', key_points: [{ text: 'It reaches Jupiter in 2030.', citations: [1] }], timeline: [], citations: [0, 1], gaps: [] } } });++ launchReports(t.sources);++ await t.pull();++ const clusterId = (await t.db().prepare("SELECT cluster_id, count(*) AS n FROM news_source_items WHERE status = 'active' GROUP BY cluster_id ORDER BY n DESC").get()).cluster_id;++ t.ai.setNext(run('run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E1'));++ await t.ctx.briefs.sweep();++ const editor = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.news'], cap: ['news.review.ai_editor'] });++ const noCap = t.network.serviceToken('ai', ['news.story.publish']);++++ await check('a brief reviewed by the AI editor is indexable and labelled, never as a person\'s review', async () => {++ const before = await t.get(`/briefs/${clusterId}`);++ assert.ok(before.text.includes('noindex'));++ const no = await t.api(`/briefs/${clusterId}/reviews`, { as: noCap, json: { kind: 'summary', tone: 'neutral', decision: 'approved' } });++ assert.strictEqual(no.status, 403, no.text);++ const r = await t.get(`/api/v1/briefs/${clusterId}/reviews`, { as: editor, json: { kind: 'summary', tone: 'neutral', decision: 'approved', note: 'Checked against the reports' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.json().review.reviewerKind, r.json().review.reviewer], ['ai_editor', 'svc:orchestrator']);++ const page = await t.get(`/briefs/${clusterId}`);++ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'reviewed: indexable');++ assert.ok(AI_ON_PAGE.test(page.text), 'the brief says who checked it');++ assert.ok(!/reviewed by a person/i.test(page.text));++ assert.ok((await t.get('/sitemaps/briefs.xml')).text.includes(`/briefs/${clusterId}`));++ });++++ await check('a person\'s approval of a brief still says "Reviewed by a person"', async () => {++ t.clock.advance(60 * 60 * 1000);++ t.ai.setNext(run('run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E2'));++ await t.ctx.briefs.make(clusterId, { kind: 'summary', tone: 'neutral' });++ const r = await t.api(`/briefs/${clusterId}/reviews`, { json: { kind: 'summary', tone: 'neutral', decision: 'approved' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().review.reviewerKind, 'person');++ const page = await t.get(`/briefs/${clusterId}`);++ assert.ok(/Reviewed by a person <time/.test(page.text), 'the person\'s label is unchanged');++ assert.ok(!AI_ON_PAGE.test(page.text));++ });++++ await check('an AI editor rejection stays in the queue and names its kind', async () => {++ t.clock.advance(60 * 60 * 1000);++ t.ai.setNext(run('run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E3'));++ await t.ctx.briefs.make(clusterId, { kind: 'summary', tone: 'neutral' });++ const r = await t.get(`/api/v1/briefs/${clusterId}/reviews`, { as: editor, json: { kind: 'summary', tone: 'neutral', decision: 'rejected', note: 'Not accurate' } });++ assert.strictEqual(r.status, 201, r.text);++ const q = await t.api('/review-queue');++ const item = q.json().items.find((i) => i.id === clusterId);++ assert.ok(item, 'the AI editor\'s rejection leaves the brief in the person\'s queue');++ assert.deepStrictEqual([item.reviewed.kind, item.reviewed.decision, item.reviewed.by], ['ai_editor', 'rejected', 'svc:orchestrator']);++ const page = await t.get(`/briefs/${clusterId}`);++ assert.ok(page.text.includes('noindex'), 'rejected: still noindex');++ });++ } finally {++ await t.close();++ }++}++++(async () => {++ await storyPart();++ await briefPart();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "news.review.ai_editor",
+ "version": "1.0",
+ "owner": "news",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Record the editorial queue's AI editor decision (approved or rejected) on a story revision or an AI brief, 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 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": [
+ "news:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "news-write",
+ "events": [
+ "news.story.updated",
+ "news.index_document.upserted",
+ "news.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/stories/:id/reviews",
+ "POST /api/v1/briefs/:id/reviews"
+ ]
+}
@@ -24,7 +24,8 @@
@@ -24,7 +24,8 @@
"news.source.attach",
"news.source.attach",
"news.timeline.update",
"news.timeline.update",
"news.perspective.update",
"news.perspective.update",
- "news.topic.manage"
+ "news.topic.manage",
+ "news.review.ai_editor"
],
],
"eventsProduced": [
"eventsProduced": [
"news.source.ingested",
"news.source.ingested",
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+-- phase: expand
+-- Two reviewer kinds in both append-only review logs (server/domain/review-kind.js and
+-- server/domain/briefs.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 news.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 for stories, older processes during a rolling deploy) keeps working.
+ALTER TABLE news_story_reviews
+ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'
+ CHECK (reviewer_kind IN ('person', 'ai_editor'));
+
+ALTER TABLE news_brief_reviews
+ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'
+ CHECK (reviewer_kind IN ('person', 'ai_editor'));
@@ -24,11 +24,22 @@ const CAPABILITIES = Object.freeze({
@@ -24,11 +24,22 @@ const CAPABILITIES = Object.freeze({
TIMELINE_UPDATE: 'news.timeline.update',
TIMELINE_UPDATE: 'news.timeline.update',
PERSPECTIVE_UPDATE: 'news.perspective.update',
PERSPECTIVE_UPDATE: 'news.perspective.update',
TOPIC_MANAGE: 'news.topic.manage',
TOPIC_MANAGE: 'news.topic.manage',
+ REVIEW_AI_EDITOR: 'news.review.ai_editor',
});
});
+// The ids above as a local fallback while openvibe-contracts does not define one yet (the same
+// rule the library uses: the exact id, or a `prefix.*` grant covering it). An id the library knows
+// always goes through the library, so the day a release lands nothing changes here.
+const PROPOSED = new Set(Object.values(CAPABILITIES));
+
/** → { allowed, code, reason } like capabilities.check(). */
/** → { allowed, code, reason } like capabilities.check(). */
function checkCapability(claims, capabilityId) {
function checkCapability(claims, capabilityId) {
+ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
+ return capabilities.grants(claims && claims.cap, capabilityId)
+ ? { allowed: true, code: null, reason: null }
+ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
+ }
return capabilities.check(claims, capabilityId);
return capabilities.check(claims, capabilityId);
}
}
-module.exports = { CAPABILITIES, checkCapability };
+module.exports = { CAPABILITIES, PROPOSED, checkCapability };
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { verifyServiceToken } = require('openvibe-sdk/auth');
const { verifyServiceToken } = require('openvibe-sdk/auth');
const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
-const { checkCapability } = require('./capabilities');
+const { checkCapability, CAPABILITIES } = require('./capabilities');
const { ids, http, staff: staffMap } = contracts;
const { ids, http, staff: staffMap } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
@@ -123,4 +123,16 @@ function guard(cap) {
@@ -123,4 +123,16 @@ function guard(cap) {
};
};
}
}
-module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError };
+/**
+ * The review routes' guard: a service acting as itself (no person named) is the AI editor and
+ * needs news.review.ai_editor; a person, or a service acting for one, is judged as before.
+ */
+function guardReview(req, res, next) {
+ const v = req.viewer;
+ if (!v || v.kind !== 'service' || v.subject) return next();
+ const c = checkCapability(v.claims, CAPABILITIES.REVIEW_AI_EDITOR);
+ if (c.allowed) return next();
+ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
+}
+
+module.exports = { createViewerResolver, guard, guardReview, ANONYMOUS, ViewerError };
@@ -33,6 +33,7 @@ const { createDb } = require('openvibe-sdk/db');
@@ -33,6 +33,7 @@ const { createDb } = require('openvibe-sdk/db');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createReviewLog } = require('openvibe-publishing/authorship');
const { createReviewLog } = require('openvibe-publishing/authorship');
+const { createReviewKindLog } = require('./domain/review-kind');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
@@ -70,7 +71,8 @@ function createStore(db, { now = () => Date.now() } = {}) {
@@ -70,7 +71,8 @@ function createStore(db, { now = () => Date.now() } = {}) {
now,
now,
revisions,
revisions,
citations: createCitationStore(db, { prefix: 'news_story', now, revisions }),
citations: createCitationStore(db, { prefix: 'news_story', now, revisions }),
- reviews: createReviewLog(db, { prefix: 'news_story', 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: 'news_story', now }), { db, prefix: 'news_story', now }),
sequencer: createIndexSequencer(db, { prefix: 'news', now }),
sequencer: createIndexSequencer(db, { prefix: 'news', now }),
discussion: createDiscussionRefs(db, { prefix: 'news_story', now }),
discussion: createDiscussionRefs(db, { prefix: 'news_story', now }),
tx: async (fn) => await db.tx(() => fn()),
tx: async (fn) => await db.tx(() => fn()),
@@ -21,6 +21,8 @@
@@ -21,6 +21,8 @@
*/
*/
const { draftFromOutput } = require('../clients/ai');
const { draftFromOutput } = require('../clients/ai');
+const reviewKind = require('./review-kind');
+const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
const TONES = ['brief', 'neutral', 'explained'];
const TONES = ['brief', 'neutral', 'explained'];
const TONE_TEXT = { brief: 'In brief', neutral: 'Summary', explained: 'Explained' };
const TONE_TEXT = { brief: 'In brief', neutral: 'Summary', explained: 'Explained' };
@@ -44,8 +46,9 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
@@ -44,8 +46,9 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
gaps = excluded.gaps, run_id = excluded.run_id, model = excluded.model, stub = excluded.stub, item_count = excluded.item_count,
gaps = excluded.gaps, run_id = excluded.run_id, model = excluded.model, stub = excluded.stub, item_count = excluded.item_count,
cluster_updated_at = excluded.cluster_updated_at, created_at = excluded.created_at`),
cluster_updated_at = excluded.cluster_updated_at, created_at = excluded.created_at`),
reviewLatest: db.prepare('SELECT * FROM news_brief_reviews WHERE cluster_id = ? AND kind = ? AND tone = ? ORDER BY id DESC LIMIT 1'),
reviewLatest: db.prepare('SELECT * FROM news_brief_reviews WHERE cluster_id = ? AND kind = ? AND tone = ? ORDER BY id DESC LIMIT 1'),
- reviewInsert: db.prepare(`INSERT INTO news_brief_reviews (cluster_id, kind, tone, brief_created_at, reviewer, decision, note, reviewed_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
+ reviewByKind: db.prepare('SELECT DISTINCT ON (reviewer_kind) * FROM news_brief_reviews WHERE cluster_id = ? AND kind = ? AND tone = ? ORDER BY reviewer_kind, id DESC'),
+ reviewInsert: db.prepare(`INSERT INTO news_brief_reviews (cluster_id, kind, tone, brief_created_at, reviewer, reviewer_kind, decision, note, reviewed_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
pending: db.prepare(`SELECT b.*, c.label, c.window_end, c.updated_at AS cluster_updated_at_now,
pending: db.prepare(`SELECT b.*, c.label, c.window_end, c.updated_at AS cluster_updated_at_now,
(SELECT count(DISTINCT i.outlet) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS outlets,
(SELECT count(DISTINCT i.outlet) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS outlets,
(SELECT count(*) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS item_count_now
(SELECT count(*) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS item_count_now
@@ -83,39 +86,70 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
@@ -83,39 +86,70 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
};
};
}
}
- const reviewShape = (r) => (r ? { id: r.id, clusterId: r.cluster_id, kind: r.kind, tone: r.tone, briefCreatedAt: Number(r.brief_created_at), reviewer: r.reviewer, decision: r.decision, note: r.note, reviewedAt: Number(r.reviewed_at) } : null);
+ const reviewShape = (r) => (r ? { id: Number(r.id), clusterId: r.cluster_id, kind: r.kind, tone: r.tone, briefCreatedAt: Number(r.brief_created_at), reviewer: r.reviewer, reviewerKind: r.reviewer_kind === reviewKind.AI_EDITOR ? reviewKind.AI_EDITOR : reviewKind.PERSON, decision: r.decision, note: r.note, reviewedAt: Number(r.reviewed_at) } : null);
+
+ /**
+ * The AI editor records a review as itself: a first-party service principal (svc:…) with no
+ * person named, holding news.review.ai_editor. A service acting for a person is that person.
+ */
+ function isAiEditor(viewer) {
+ if (!viewer || viewer.kind !== 'service' || viewer.subject || !/^svc:/.test(String(viewer.service))) return false;
+ return checkCapability(viewer.claims, CAPABILITIES.REVIEW_AI_EDITOR).allowed;
+ }
/** The latest review of one brief (any decision), or null. */
/** The latest review of one brief (any decision), or null. */
async function reviewOf(clusterId, { kind = 'summary', tone = 'neutral' } = {}) {
async function reviewOf(clusterId, { kind = 'summary', tone = 'neutral' } = {}) {
return reviewShape(await q.reviewLatest.get(clusterId, kind, tone));
return reviewShape(await q.reviewLatest.get(clusterId, kind, tone));
}
}
+ /** The review of each kind for one brief: { person, ai_editor }. */
+ async function reviewsOf(clusterId, { kind = 'summary', tone = 'neutral' } = {}) {
+ const out = { person: null, ai_editor: null };
+ for (const row of await q.reviewByKind.all(clusterId, kind, tone)) {
+ out[row.reviewer_kind === reviewKind.AI_EDITOR ? reviewKind.AI_EDITOR : reviewKind.PERSON] = reviewShape(row);
+ }
+ return out;
+ }
+
/** An approval counts only for the exact brief it reviewed (a remake changes created_at). */
/** An approval counts only for the exact brief it reviewed (a remake changes created_at). */
function isApproved(brief, review) {
function isApproved(brief, review) {
return Boolean(brief && review && review.decision === 'approved' && Number(review.briefCreatedAt) === Number(brief.created_at));
return Boolean(brief && review && review.decision === 'approved' && Number(review.briefCreatedAt) === Number(brief.created_at));
}
}
- /** A person's review of a brief. Only a person (usr_…) can review; the review log is the audit. */
+ /**
+ * A review of a brief: a signed-in person's, or the AI editor's (a svc:… principal acting as
+ * itself with news.review.ai_editor). The review log is the audit.
+ */
async function review(viewer, clusterId, { kind = 'summary', tone = 'neutral', decision, note = null } = {}) {
async function review(viewer, clusterId, { kind = 'summary', tone = 'neutral', decision, note = null } = {}) {
- if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw Object.assign(new Error('Only a signed-in person can review a brief'), { code: 'review.person_required', status: 403 });
+ const aiEditor = isAiEditor(viewer);
+ if (!aiEditor && (!viewer || viewer.kind !== 'user' || !viewer.subject)) throw Object.assign(new Error('Only a signed-in person (or the AI editor) can review a brief'), { code: 'review.person_required', status: 403 });
if (!KINDS.includes(kind) || !TONES.includes(tone)) throw Object.assign(new Error('kind or tone not known'), { code: 'brief.invalid', status: 422 });
if (!KINDS.includes(kind) || !TONES.includes(tone)) throw Object.assign(new Error('kind or tone not known'), { code: 'brief.invalid', status: 422 });
if (decision !== 'approved' && decision !== 'rejected') throw Object.assign(new Error('decision is approved or rejected'), { code: 'review.invalid_decision', status: 422 });
if (decision !== 'approved' && decision !== 'rejected') throw Object.assign(new Error('decision is approved or rejected'), { code: 'review.invalid_decision', status: 422 });
const got = await get(clusterId, { kind, tone });
const got = await get(clusterId, { kind, tone });
if (!got || !got.brief) throw Object.assign(new Error('No such brief'), { code: 'brief.not_found', status: 404 });
if (!got || !got.brief) throw Object.assign(new Error('No such brief'), { code: 'brief.not_found', status: 404 });
if (got.brief.stub || !got.enough) throw Object.assign(new Error('This brief is not shown to readers'), { code: 'brief.not_public', status: 422 });
if (got.brief.stub || !got.enough) throw Object.assign(new Error('This brief is not shown to readers'), { code: 'brief.not_public', status: 422 });
- const row = await q.reviewInsert.get(String(clusterId), kind, tone, Number(got.brief.created_at), viewer.subject, decision, note == null ? null : String(note).slice(0, 2000), store.now());
+ const row = await q.reviewInsert.get(String(clusterId), kind, tone, Number(got.brief.created_at),
+ aiEditor ? viewer.service : viewer.subject, aiEditor ? reviewKind.AI_EDITOR : reviewKind.PERSON,
+ decision, note == null ? null : String(note).slice(0, 2000), store.now());
return reviewShape(row);
return reviewShape(row);
}
}
- /** Every current brief nobody has reviewed yet, oldest first: what the reviewer queue lists. */
+ /**
+ * Every current brief nobody has reviewed yet, oldest first: what the reviewer queue lists. An
+ * ai_editor decision is named in `reviewed`: its approval publishes and indexes the brief and
+ * leaves the queue; its rejection stays until a person decides.
+ */
async function pendingReviews() {
async function pendingReviews() {
const items = [];
const items = [];
for (const row of await q.pending.all()) {
for (const row of await q.pending.all()) {
if (Number(row.outlets) < MIN_OUTLETS) continue;
if (Number(row.outlets) < MIN_OUTLETS) continue;
if (Number(row.item_count) !== Number(row.item_count_now) || Number(row.cluster_updated_at) !== Number(row.cluster_updated_at_now)) continue;
if (Number(row.item_count) !== Number(row.item_count_now) || Number(row.cluster_updated_at) !== Number(row.cluster_updated_at_now)) continue;
const brief = shape(row);
const brief = shape(row);
- const review = await reviewOf(row.cluster_id);
- if (review && Number(review.briefCreatedAt) === Number(brief.created_at)) continue; // a decision for this exact text
+ const kinds = await reviewsOf(row.cluster_id);
+ const forThisText = (r) => Boolean(r && Number(r.briefCreatedAt) === Number(brief.created_at));
+ if (forThisText(kinds.person)) continue; // a person's decision, approved or rejected, for this exact text
+ const ai = forThisText(kinds.ai_editor) ? kinds.ai_editor : null;
+ if (ai && ai.decision === 'approved') continue; // the AI editor approved it: published and indexable
items.push({
items.push({
site: 'news', kind: 'ai_brief', id: row.cluster_id,
site: 'news', kind: 'ai_brief', id: row.cluster_id,
title: brief.headline || row.label, revision: null,
title: brief.headline || row.label, revision: null,
@@ -125,6 +159,7 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
@@ -125,6 +159,7 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
{ code: 'ai_generated_unreviewed', effect: 'noindex', detail: 'a person has not reviewed this brief', text: 'AI-written and not reviewed by a person.' },
{ code: 'ai_generated_unreviewed', effect: 'noindex', detail: 'a person has not reviewed this brief', text: 'AI-written and not reviewed by a person.' },
{ code: 'noindex_requested', effect: 'noindex', detail: 'briefs are noindex until reviewed', text: 'An editor asked for it to stay out of search engines until a person reviews it.' },
{ code: 'noindex_requested', effect: 'noindex', detail: 'briefs are noindex until reviewed', text: 'An editor asked for it to stay out of search engines until a person reviews it.' },
],
],
+ 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/briefs/${encodeURIComponent(row.cluster_id)}/reviews` },
review: { method: 'POST', path: `/api/v1/briefs/${encodeURIComponent(row.cluster_id)}/reviews` },
});
});
}
}
@@ -191,7 +226,7 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
@@ -191,7 +226,7 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
const latest = async (limit = 12) => (await q.latest.all(MIN_OUTLETS, limit)).map((r) => ({ ...shape(r), label: r.label, window_end: Number(r.window_end), outlets: Number(r.outlets) }));
const latest = async (limit = 12) => (await q.latest.all(MIN_OUTLETS, limit)).map((r) => ({ ...shape(r), label: r.label, window_end: Number(r.window_end), outlets: Number(r.outlets) }));
- return { TONES, TONE_TEXT, KINDS, MIN_REPORTS, MIN_OUTLETS, get, make, sweep, latest, review, reviewOf, isApproved, pendingReviews, reviewed };
+ return { TONES, TONE_TEXT, KINDS, MIN_REPORTS, MIN_OUTLETS, get, make, sweep, latest, review, reviewOf, reviewsOf, isApproved, pendingReviews, reviewed };
}
}
module.exports = { createBriefs, TONES, TONE_TEXT, MIN_REPORTS };
module.exports = { createBriefs, TONES, TONE_TEXT, MIN_REPORTS };
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
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 { createPublication: createPublicationGlue } = require('openvibe-publishing/publication');
const { createPublication: createPublicationGlue } = require('openvibe-publishing/publication');
const { normalize } = require('openvibe-publishing/ingest');
const { normalize } = require('openvibe-publishing/ingest');
@@ -31,21 +32,37 @@ const OWNER = 'news';
@@ -31,21 +32,37 @@ const OWNER = 'news';
/**
/**
* AI-assisted text (hybrid: a person edited an AI draft) is held like AI-generated text: hidden
* AI-assisted text (hybrid: a person edited an AI draft) is held like AI-generated text: hidden
- * from indexes and not publishable until a person records an approving review of that revision.
+ * from indexes and not publishable until a person or the AI editor records an approving review of
+ * that revision.
*/
*/
function gateAuthorship(rec, review) {
function gateAuthorship(rec, review) {
- if (rec.mode === 'hybrid') return { authorship: { mode: 'ai', reviewed: authorship.isReviewed(rec, review) }, ...(rec.stubProvider ? { stubProvider: true } : {}) };
- return authorship.gateFacts(rec, review);
+ const reviewed = reviewKind.approved(review);
+ return { authorship: { mode: rec.mode === 'hybrid' ? 'ai' : rec.mode, reviewed }, ...(rec.stubProvider ? { stubProvider: true } : {}) };
}
}
function canPublish(rec, review) {
function canPublish(rec, review) {
- if (rec.mode === 'hybrid' && !authorship.isReviewed(rec, review)) return { ok: false, reason: 'ai_assisted_unreviewed' };
- return authorship.canPublish(rec, review);
+ if (rec.mode === 'hybrid' && !reviewKind.approved(review)) return { ok: false, reason: 'ai_assisted_unreviewed' };
+ if (authorship.needsReview(rec) && !reviewKind.approved(review)) return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };
+ return { ok: true, reason: null };
}
}
-function disclosure(rec, review) {
- const d = rec ? authorship.disclosure(rec, review) : null;
- if (!d || rec.mode !== 'hybrid') return d;
- const reviewed = authorship.isReviewed(rec, review);
- return { ...d, long: `${d.long.replace(/\.\s*$/, '')}. ${reviewed ? 'Reviewed by a person.' : 'Not yet reviewed by a person.'}` };
+/**
+ * The disclosure at the item, with the review of each kind (a person's, the AI editor's, both):
+ * "Checked by OpenVibe's AI editor on 2026-11-06" is never "Reviewed by a person".
+ */
+function disclosure(rec, kinds = { person: null, ai_editor: null }) {
+ const d = rec ? authorship.disclosure(rec, null) : null;
+ if (!d) return null;
+ const person = reviewKind.isPersonReview(kinds.person);
+ const ai = reviewKind.aiEditorLabel(kinds.ai_editor);
+ const hybrid = rec.mode === 'hybrid';
+ if (!person && !ai) {
+ if (!hybrid) return { ...d, reviewed: false }; // the library says "not yet reviewed by a person"
+ return { ...d, long: `${d.long.replace(/\.\s*$/, '')}. Not yet reviewed by a person.`, reviewed: false };
+ }
+ const base = d.long.replace(/,\s*(?:not yet )?reviewed by a person\.?\s*$/, '').replace(/\.\s*$/, '');
+ let long = base;
+ if (person) long += hybrid ? '. Reviewed by a person.' : ', reviewed by a person.';
+ if (ai) long += `${long.endsWith('.') ? ' ' : '. '}${ai}.`;
+ return { ...d, long, reviewed: true };
}
}
function createPublication({ store, config, outbox, indexnow }) {
function createPublication({ store, config, outbox, indexnow }) {
@@ -67,6 +84,8 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -67,6 +84,8 @@ function createPublication({ store, config, outbox, indexnow }) {
const authorshipOf = (rev) => (rev && rev.meta && rev.meta.authorship) || null;
const authorshipOf = (rev) => (rev && rev.meta && rev.meta.authorship) || null;
const reviewOf = async (story, rev) => (rev ? await store.reviews.latest(story.id, rev.number) : null);
const reviewOf = async (story, rev) => (rev ? await store.reviews.latest(story.id, rev.number) : null);
+ /** The review of each kind for one revision: { person, ai_editor }. */
+ const reviewsOf = async (story, rev) => (rev ? await store.reviews.latestByKind(story.id, rev.number) : { person: null, ai_editor: null });
const paragraphsOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.paragraphs) ? rev.fields.paragraphs : []);
const paragraphsOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.paragraphs) ? rev.fields.paragraphs : []);
const snapshotOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.sources) ? rev.fields.sources : []);
const snapshotOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.sources) ? rev.fields.sources : []);
@@ -208,7 +227,7 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -208,7 +227,7 @@ function createPublication({ store, config, outbox, indexnow }) {
return {
return {
POLICY: policy, OWNER, abs, storyPath, storyUrl, topicPath, feedId,
POLICY: policy, OWNER, abs, storyPath, storyUrl, topicPath, feedId,
- authorshipOf, reviewOf, paragraphsOf, snapshotOf, liveSources, support, decide, topicOf,
+ authorshipOf, reviewOf, reviewsOf, paragraphsOf, snapshotOf, liveSources, support, decide, topicOf,
documentFor, syncIndex, snapshot, afterChange, plain,
documentFor, syncIndex, snapshot, afterChange, plain,
};
};
}
}
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
*/
*/
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const { disclosure } = require('./publication');
const { disclosure } = require('./publication');
+const reviewKind = require('./review-kind');
const iso = (v) => (v == null ? null : new Date(v).toISOString());
const iso = (v) => (v == null ? null : new Date(v).toISOString());
@@ -48,6 +49,7 @@ function createReading({ store, config, stories, publication, people, topics })
@@ -48,6 +49,7 @@ function createReading({ store, config, stories, publication, people, topics })
}
}
const rec = publication.authorshipOf(r);
const rec = publication.authorshipOf(r);
const review = await publication.reviewOf(story, r);
const review = await publication.reviewOf(story, r);
+ const kinds = await publication.reviewsOf(story, r);
const authorSubjects = rec && Array.isArray(rec.authors) ? rec.authors : [];
const authorSubjects = rec && Array.isArray(rec.authors) ? rec.authors : [];
const who = authorSubjects.length ? await people.many(authorSubjects) : new Map();
const who = authorSubjects.length ? await people.many(authorSubjects) : new Map();
const authors = authorSubjects.map((s) => who.get(s)).filter((p) => p && p.known).map((p) => ({ name: p.name, username: p.username }));
const authors = authorSubjects.map((s) => who.get(s)).filter((p) => p && p.known).map((p) => ({ name: p.name, username: p.username }));
@@ -68,7 +70,7 @@ function createReading({ store, config, stories, publication, people, topics })
@@ -68,7 +70,7 @@ function createReading({ store, config, stories, publication, people, topics })
const s = snap.find((x) => x.id === f.source_item_id);
const s = snap.find((x) => x.id === f.source_item_id);
return { kind: f.kind, n: s ? s.n : null, outlet: s ? s.outlet : null, at: iso(f.created_at) };
return { kind: f.kind, n: s ? s.n : null, outlet: s ? s.outlet : null, at: iso(f.created_at) };
}),
}),
- authorship: rec, review, disclosure: rec ? disclosure(rec, review) : null, authors,
+ authorship: rec, review, reviews: kinds, labels: reviewKind.labelsFor(kinds), disclosure: rec ? disclosure(rec, kinds) : null, authors,
decision: await publication.decide(story, r),
decision: await publication.decide(story, r),
first_published_at: iso(story.first_published_at), updated_at: r.createdAt, published_at: iso(story.published_at),
first_published_at: iso(story.first_published_at), updated_at: r.createdAt, published_at: iso(story.published_at),
};
};
@@ -81,7 +83,17 @@ function createReading({ store, config, stories, publication, people, topics })
@@ -81,7 +83,17 @@ function createReading({ store, config, stories, publication, people, topics })
headline: m.headline, headline_sources: m.headline_sources,
headline: m.headline, headline_sources: m.headline_sources,
topic: m.topic ? { slug: m.topic.slug, name: m.topic.name, url: publication.abs(publication.topicPath(m.topic)) } : null,
topic: m.topic ? { slug: m.topic.slug, name: m.topic.name, url: publication.abs(publication.topicPath(m.topic)) } : null,
first_published_at: m.first_published_at, updated_at: m.updated_at,
first_published_at: m.first_published_at, updated_at: m.updated_at,
- authorship: m.authorship ? { mode: m.authorship.mode, workflow: m.authorship.workflow ? m.authorship.workflow.id : null, reviewed_by_person: Boolean(m.review && m.review.decision === 'approved'), disclosure: m.disclosure ? m.disclosure.long : null } : null,
+ authorship: m.authorship ? {
+ mode: m.authorship.mode, workflow: m.authorship.workflow ? m.authorship.workflow.id : null,
+ reviewed: Boolean(m.labels && m.labels.length),
+ labels: m.labels || [],
+ reviewed_by_person: Boolean(m.reviews && m.reviews.person && m.reviews.person.decision === 'approved'),
+ disclosure: m.disclosure ? m.disclosure.long : null,
+ reviews: {
+ person: m.reviews && m.reviews.person ? { decision: m.reviews.person.decision, at: m.reviews.person.reviewedAt } : null,
+ ai_editor: m.reviews && m.reviews.ai_editor ? { decision: m.reviews.ai_editor.decision, at: m.reviews.ai_editor.reviewedAt } : null,
+ },
+ } : null,
authors: m.authors,
authors: m.authors,
claims: m.paragraphs.map((p) => ({ text: p.text, sources: p.sources, supported: p.supported })),
claims: m.paragraphs.map((p) => ({ text: p.text, sources: p.sources, supported: p.supported })),
sources: m.sources,
sources: m.sources,
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 `news_story_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
+ * news.review.ai_editor — recorded with its principal, the time and a note.
+ * migrations/0009_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. 'news_story' → news_story_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,
+};
@@ -26,6 +26,8 @@ const authorship = require('openvibe-publishing/authorship');
@@ -26,6 +26,8 @@ const authorship = require('openvibe-publishing/authorship');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const { actorRef, canPublish } = require('./publication');
const { actorRef, canPublish } = require('./publication');
+const reviewKind = require('./review-kind');
+const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
const access = require('./access');
const access = require('./access');
const MAX_PARAGRAPHS = 60;
const MAX_PARAGRAPHS = 60;
@@ -108,6 +110,15 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -108,6 +110,15 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
function actingEditor(viewer) { return access.requireEditor(config, viewer); }
function actingEditor(viewer) { return access.requireEditor(config, viewer); }
+ /**
+ * The AI editor records a review as itself: a first-party service principal (svc:…) with no
+ * person named, holding news.review.ai_editor. A service acting for a person is that person.
+ */
+ function isAiEditor(viewer) {
+ if (!viewer || viewer.kind !== 'service' || viewer.subject || !/^svc:/.test(String(viewer.service))) return false;
+ return checkCapability(viewer.claims, CAPABILITIES.REVIEW_AI_EDITOR).allowed;
+ }
+
// ── The working source table, timeline and perspectives ─
// ── The working source table, timeline and perspectives ─
/** Attached sources with their items, in citation order: [{ n, item, perspective_id, detached_at }]. */
/** Attached sources with their items, in citation order: [{ n, item, perspective_id, detached_at }]. */
@@ -582,8 +593,10 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -582,8 +593,10 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
const rev = story.state === 'published' && story.published_revision ? await store.revisions.get(story.id, story.published_revision) : head;
const rev = story.state === 'published' && story.published_revision ? await store.revisions.get(story.id, story.published_revision) : head;
const rec = rev && rev.meta && rev.meta.authorship;
const rec = rev && rev.meta && rev.meta.authorship;
if (!rec || !(rec.mode === 'ai' || rec.mode === 'hybrid' || rec.stubProvider)) continue;
if (!rec || !(rec.mode === 'ai' || rec.mode === 'hybrid' || rec.stubProvider)) continue;
- const review = await store.reviews.latest(story.id, rev.number);
- if (review) continue;
+ const kinds = await store.reviews.latestByKind(story.id, rev.number);
+ if (kinds.person) continue; // a person's decision, approved or rejected, has been made
+ if (kinds.ai_editor && kinds.ai_editor.decision === 'approved') continue; // the AI editor approved it
+ const ai = kinds.ai_editor;
const decision = await publication.decide(story, rev, { state: story.state });
const decision = await publication.decide(story, rev, { state: story.state });
items.push({
items.push({
site: 'news', kind: 'news_story', id: story.id,
site: 'news', kind: 'news_story', id: story.id,
@@ -592,6 +605,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -592,6 +605,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
url: publication.storyUrl(story), editUrl: `/edit/stories/${story.id}`,
url: publication.storyUrl(story), editUrl: `/edit/stories/${story.id}`,
createdAt: new Date(rev.createdAt).toISOString(),
createdAt: new Date(rev.createdAt).toISOString(),
reasons: decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),
reasons: 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/stories/${encodeURIComponent(story.id)}/reviews` },
review: { method: 'POST', path: `/api/v1/stories/${encodeURIComponent(story.id)}/reviews` },
...(story.state !== 'published' ? { publish: { method: 'POST', path: `/api/v1/stories/${encodeURIComponent(story.id)}/publish` } } : {}),
...(story.state !== 'published' ? { publish: { method: 'POST', path: `/api/v1/stories/${encodeURIComponent(story.id)}/publish` } } : {}),
});
});
@@ -599,14 +613,21 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -599,14 +613,21 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
},
},
- /** A person's review of one revision (what lets an AI draft be published and indexed). */
+ /**
+ * A review of one revision: a signed-in editor's (what lets an AI draft be published and
+ * indexed), or the AI editor's — a svc:… service principal acting as itself with
+ * news.review.ai_editor — recorded with kind `ai_editor`, never as a person's review.
+ */
async review(viewer, story, { revision, decision, note } = {}, { traceparent } = {}) {
async review(viewer, story, { revision, decision, note } = {}, { traceparent } = {}) {
- if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
- actingEditor(viewer);
+ const aiEditor = isAiEditor(viewer);
+ if (!aiEditor && (!viewer || viewer.kind !== 'user' || !viewer.subject)) throw new ApiError(403, 'review.person_required', 'Only a signed-in person (or the AI editor) can review a revision');
+ if (!aiEditor) actingEditor(viewer);
const n = parseInt(revision, 10);
const n = parseInt(revision, 10);
if (!await store.revisions.get(story.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
if (!await store.revisions.get(story.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
return await store.tx(async () => {
return await store.tx(async () => {
- const row = await store.reviews.record({ entityId: story.id, revision: n, reviewer: viewer.subject, decision, note });
+ const row = aiEditor
+ ? await store.reviews.recordAiEditor({ entityId: story.id, revision: n, principal: viewer.service, decision, note })
+ : await store.reviews.record({ entityId: story.id, revision: n, reviewer: viewer.subject, decision, note });
if (story.state === 'published') await publication.syncIndex(await q.byId.get(story.id), { traceparent });
if (story.state === 'published') await publication.syncIndex(await q.byId.get(story.id), { traceparent });
return row;
return row;
});
});
@@ -38,7 +38,7 @@
@@ -38,7 +38,7 @@
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
-const { guard } = require('../auth/viewer');
+const { guard, guardReview } = require('../auth/viewer');
const access = require('../domain/access');
const access = require('../domain/access');
function cors(origins) {
function cors(origins) {
@@ -96,8 +96,10 @@ function createApi(ctx) {
@@ -96,8 +96,10 @@ function createApi(ctx) {
// A person reviews an AI brief. The review log is append-only; the brief page leaves noindex only
// A person reviews an AI brief. The review log is append-only; the brief page leaves noindex only
// while the approval still matches the brief (a remake waits for a person again).
// while the approval still matches the brief (a remake waits for a person again).
- router.post('/briefs/:id/reviews', B('news.story.publish'), jsonBody, run(async (req) => {
- access.requireEditor(config, req.viewer);
+ router.post('/briefs/:id/reviews', guardReview, B('news.story.publish'), jsonBody, run(async (req) => {
+ // A service acting as itself is the AI editor (guardReview checked news.review.ai_editor); a
+ // person, or a service acting for one, must still be an editor.
+ if (req.viewer.kind !== 'service' || req.viewer.subject) access.requireEditor(config, req.viewer);
if (!briefs) throw new ApiError(503, 'briefs.unavailable', 'AI briefs are not configured');
if (!briefs) throw new ApiError(503, 'briefs.unavailable', 'AI briefs are not configured');
const b = req.body || {};
const b = req.body || {};
return { review: await briefs.review(req.viewer, req.params.id, { kind: b.kind, tone: b.tone, decision: b.decision, note: b.note }) };
return { review: await briefs.review(req.viewer, req.params.id, { kind: b.kind, tone: b.tone, decision: b.decision, note: b.note }) };
@@ -227,7 +229,7 @@ function createApi(ctx) {
@@ -227,7 +229,7 @@ function createApi(ctx) {
const out = await stories.retract(req.viewer, story, { note: b.note }, tp(req));
const out = await stories.retract(req.viewer, story, { note: b.note }, tp(req));
return { changed: out.changed, story: await storyDto(out.story) };
return { changed: out.changed, story: await storyDto(out.story) };
});
});
- router.post('/stories/:id/reviews', B('news.story.publish'), jsonBody, run(async (req) => {
+ router.post('/stories/:id/reviews', guardReview, B('news.story.publish'), jsonBody, run(async (req) => {
const story = await mustStory(req);
const story = await mustStory(req);
const b = req.body || {};
const b = req.body || {};
return { review: await stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)) };
return { review: await stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)) };
@@ -186,6 +186,11 @@ function createPublicRoutes(ctx) {
@@ -186,6 +186,11 @@ function createPublicRoutes(ctx) {
// reviewed: a remake changes created_at, so an old approval no longer applies.
// reviewed: a remake changes created_at, so an old approval no longer applies.
const review = await briefs.reviewOf(id, { kind: 'summary', tone });
const review = await briefs.reviewOf(id, { kind: 'summary', tone });
const reviewed = tone === 'neutral' && got.enough && got.fresh && briefs.isApproved(got.brief, review);
const reviewed = tone === 'neutral' && got.enough && got.fresh && briefs.isApproved(got.brief, review);
+ // A brief can carry both kinds of approval (a person's, the AI editor's): the page names each,
+ // but only a review of this exact text — a remake changes created_at and waits again.
+ const kindReviews = reviewed && got.brief ? await briefs.reviewsOf(id, { kind: 'summary', tone }) : { person: null, ai_editor: null };
+ const forText = (r) => (r && Number(r.briefCreatedAt) === Number(got.brief.created_at) ? r : null);
+ const reviews = { person: forText(kindReviews.person), ai_editor: forText(kindReviews.ai_editor) };
const canonical = `${config.baseUrl}/briefs/${id}`;
const canonical = `${config.baseUrl}/briefs/${id}`;
const decision = reviewed
const decision = reviewed
? seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: canonical, wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() })
? seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: canonical, wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() })
@@ -194,7 +199,7 @@ function createPublicRoutes(ctx) {
@@ -194,7 +199,7 @@ function createPublicRoutes(ctx) {
title: got.enough ? `${got.brief && got.brief.headline ? got.brief.headline : got.cluster.label} (AI brief)` : `${got.cluster.label} (reports)`,
title: got.enough ? `${got.brief && got.brief.headline ? got.brief.headline : got.cluster.label} (AI brief)` : `${got.cluster.label} (reports)`,
description: got.enough ? 'An AI summary of the reports on this subject, every paragraph linked to its reports.' : 'The reports on this subject so far. Too few outlets have covered it for an AI summary.',
description: got.enough ? 'An AI summary of the reports on this subject, every paragraph linked to its reports.' : 'The reports on this subject so far. Too few outlets have covered it for an AI summary.',
decision, canonical, localStyles: ['css/briefs.css'],
decision, canonical, localStyles: ['css/briefs.css'],
- body: briefPages.briefPage({ ...got, tone, perspectives: persp ? persp.brief : null, canMake: canMake(), notice, review: reviewed ? review : null }),
+ body: briefPages.briefPage({ ...got, tone, perspectives: persp ? persp.brief : null, canMake: canMake(), notice, review: reviewed ? review : null, reviews }),
}, { cacheable: !notice && (reviewed || !got.brief) });
}, { cacheable: !notice && (reviewed || !got.brief) });
}));
}));
@@ -49,9 +49,19 @@ function reportsOnlyPage({ cluster, reports, outlets, minOutlets }) {
@@ -49,9 +49,19 @@ function reportsOnlyPage({ cluster, reports, outlets, minOutlets }) {
}
}
/** /briefs/:id */
/** /briefs/:id */
-function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake, notice = null, enough = true, minOutlets = 2, review = null }) {
+function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake, notice = null, enough = true, minOutlets = 2, review = null, reviews = null }) {
const outlets = [...new Set(reports.map((r) => r.outlet))];
const outlets = [...new Set(reports.map((r) => r.outlet))];
if (!enough) return reportsOnlyPage({ cluster, reports, outlets, minOutlets });
if (!enough) return reportsOnlyPage({ cluster, reports, outlets, minOutlets });
+ // Two kinds of approval can stand behind a brief: a person's and the AI editor's. Each is named.
+ const kinds = reviews || { person: review, ai_editor: null };
+ const reviewLabels = [];
+ if (kinds.person && kinds.person.decision === 'approved') {
+ reviewLabels.push(h`Reviewed by a person <time datetime="${new Date(kinds.person.reviewedAt).toISOString()}">${time(kinds.person.reviewedAt)}</time>.`);
+ }
+ if (kinds.ai_editor && kinds.ai_editor.decision === 'approved') {
+ reviewLabels.push(h`Checked by OpenVibe's AI editor <time datetime="${new Date(kinds.ai_editor.reviewedAt).toISOString()}">${time(kinds.ai_editor.reviewedAt)}</time>.`);
+ }
+ const reviewed = reviewLabels.map((l) => `${l} `).join('');
const shown = brief && !brief.stub ? brief : null;
const shown = brief && !brief.stub ? brief : null;
const tabs = h`<nav class="brief-tones" aria-label="How to tell it">${TONES.map(([t, label]) => (t === tone
const tabs = h`<nav class="brief-tones" aria-label="How to tell it">${TONES.map(([t, label]) => (t === tone
? h`<span class="brief-tone on" aria-current="page">${label}</span>`
? h`<span class="brief-tone on" aria-current="page">${label}</span>`
@@ -59,7 +69,7 @@ function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake
@@ -59,7 +69,7 @@ function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake
const persp = perspectives && !perspectives.stub ? perspectives : null;
const persp = perspectives && !perspectives.stub ? perspectives : null;
return String(h`<header class="site-header"><p class="eyebrow"><span class="badge badge-ai">AI brief</span> from ${coverage(reports, outlets)}</p>
return String(h`<header class="site-header"><p class="eyebrow"><span class="badge badge-ai">AI brief</span> from ${coverage(reports, outlets)}</p>
<h1>${shown && shown.headline ? shown.headline : cluster.label}</h1>
<h1>${shown && shown.headline ? shown.headline : cluster.label}</h1>
-<p class="meta">${review ? h`Reviewed by a person <time datetime="${new Date(review.reviewedAt).toISOString()}">${time(review.reviewedAt)}</time>. ` : ''}${AI_NOTE}</p></header>
+<p class="meta">${reviewed ? raw(reviewed) : ''}${AI_NOTE}</p></header>
${notice ? h`<p class="notice">${notice}</p>` : ''}
${notice ? h`<p class="notice">${notice}</p>` : ''}
${tabs}
${tabs}
<section class="brief" aria-label="The brief">
<section class="brief" aria-label="The brief">
@@ -1,26 +1,34 @@
@@ -1,26 +1,34 @@
'use strict';
'use strict';
/**
/**
* The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the
* The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the
- * library's grant rule rather than deciding a proposed id locally (the fallback that existed while
- * the ids were only proposals). These pin that: every guarded id is defined by the installed
- * contracts and by the news service manifest, and checkCapability agrees with capabilities.check for
- * an exact grant, a prefix grant, no grant, and an id the contracts do not define.
+ * library's grant rule rather than deciding a proposed id locally. One id is still a proposal and
+ * waits for a Contracts release — news.review.ai_editor (the AI editor's review kind) — so it is
+ * decided by the same grant rule locally until then (server/auth/capabilities.js PROPOSED). These
+ * pin that: every guarded id is defined by the installed contracts and the news service manifest,
+ * except the proposal, which is tracked as one; and checkCapability agrees with capabilities.check
+ * for an exact grant, a prefix grant, no grant, and an id nobody defines.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { capabilities, services } = require('openvibe-contracts');
const { capabilities, services } = require('openvibe-contracts');
-const { checkCapability, CAPABILITIES } = require('../server/auth/capabilities');
+const { checkCapability, CAPABILITIES, PROPOSED } = require('../server/auth/capabilities');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
(async () => {
(async () => {
await check('every guarded capability is defined by the installed contracts and the news manifest', () => {
await check('every guarded capability is defined by the installed contracts and the news manifest', () => {
const manifest = services.get('news');
const manifest = services.get('news');
assert.ok(manifest, 'openvibe-contracts defines the news service manifest');
assert.ok(manifest, 'openvibe-contracts defines the news service manifest');
- assert.deepStrictEqual([...manifest.capabilities].sort(), Object.values(CAPABILITIES).sort());
+ const released = Object.values(CAPABILITIES).filter((id) => capabilities.get(id));
+ assert.deepStrictEqual([...manifest.capabilities].sort(), released.sort());
for (const id of Object.values(CAPABILITIES)) {
for (const id of Object.values(CAPABILITIES)) {
const cap = capabilities.get(id);
const cap = capabilities.get(id);
- assert.ok(cap, `${id} is defined 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, 'news', `${id} is owned by news`);
assert.strictEqual(cap.owner, 'news', `${id} is owned by news`);
}
}
+ // The AI editor's id is the one proposal; when Contracts releases it, this line goes.
+ assert.strictEqual(capabilities.get('news.review.ai_editor'), undefined);
});
});
await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {
await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {
@@ -30,8 +38,7 @@ const { check, done } = require('./helpers/boot');
@@ -30,8 +38,7 @@ const { check, done } = require('./helpers/boot');
// A grant of a sibling capability, or no cap at all, is denied with the library's code.
// A grant of a sibling capability, or no cap at all, is denied with the library's code.
assert.strictEqual(checkCapability({ cap: ['news.story.read'] }, 'news.story.create').code, 'capability.denied');
assert.strictEqual(checkCapability({ cap: ['news.story.read'] }, 'news.story.create').code, 'capability.denied');
assert.strictEqual(checkCapability(null, 'news.story.create').code, 'capability.denied');
assert.strictEqual(checkCapability(null, 'news.story.create').code, 'capability.denied');
- // An id the contracts do not define still answers capability.unknown — the guard delegates,
- // it never decides an unknown id on its own.
+ // An id nobody defines still answers capability.unknown — the guard only falls back for a proposal.
assert.strictEqual(checkCapability({ cap: ['news.*'] }, 'news.not.a.capability').code, 'capability.unknown');
assert.strictEqual(checkCapability({ cap: ['news.*'] }, 'news.not.a.capability').code, 'capability.unknown');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * The AI editor (plan 2026-10-22 §"The editorial queue"): a second reviewer kind, `ai_editor`,
+ * recorded on a story's review and on an AI brief's review by the orchestrator's service principal.
+ * Its approval publishes and indexes the text exactly as a person's 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 news.review.ai_editor may record one; a token without
+ * it is refused and writes nothing.
+ */
+const assert = require('assert');
+const { boot, check, done, launchReports } = require('./helpers/boot');
+
+const AI_ON_PAGE = /Checked by OpenVibe(?:'|')s AI editor/;
+const wf = { id: 'news.summarize_story', runId: 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8', version: 1 };
+
+async function storyPart() {
+ const t = await boot({ ai: true });
+ try {
+ const reports = launchReports(t.sources, { a: { fields: { body: 'AI editor story source' } } });
+ await t.pull();
+ const clusterId = (await t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id)).cluster_id;
+ const story = (await t.api('/stories', { json: { cluster: clusterId, headline: 'Europa Clipper launch', topic: 'space' } })).json().story;
+ const aiToken = t.network.serviceToken('ai', ['news.story.revise']);
+ const editor = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.news'], cap: ['news.review.ai_editor'] });
+ const paragraphs = [
+ { text: 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, beginning a cruise of several years to Jupiter and its moon Europa.', sources: [reports.a.id, reports.b.id] },
+ { text: 'According to NASA the spacecraft should arrive at Jupiter in 2030, after which it will make repeated close passes of Europa to study its ice shell and the ocean beneath.', sources: [reports.b.id] },
+ ];
+ const rev = (await t.get(`/api/v1/stories/${story.id}/revisions`, { as: aiToken, headers: { 'x-ov-origin': 'ai' }, json: { headline: 'Europa Clipper is on its way to Jupiter', paragraphs, expected_revision: 0, authorship: { workflow: wf } } })).json().revision;
+
+ await check('a token without news.review.ai_editor is refused and writes nothing', async () => {
+ const r = await t.get(`/api/v1/stories/${story.id}/reviews`, { as: aiToken, json: { revision: rev, decision: 'approved' } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ assert.strictEqual((await t.ctx.store.reviews.history(story.id)).length, 0);
+ });
+
+ await check('the AI editor approves: the story publishes and reads "Checked by OpenVibe\'s AI editor"', async () => {
+ const r = await t.get(`/api/v1/stories/${story.id}/reviews`, { as: editor, json: { revision: rev, decision: 'approved', note: 'Checked every claim' } });
+ 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']);
+ const pub = await t.api(`/stories/${story.id}/publish`, { json: { revision: rev } });
+ assert.strictEqual(pub.status, 200, pub.text);
+ const page = await t.get(`/stories/${story.slug}`);
+ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'indexable');
+ assert.ok(AI_ON_PAGE.test(page.text), 'the page says who checked it');
+ assert.ok(!/reviewed by a person/i.test(page.text), 'never as a person\'s review');
+ const json = (await t.get(`/stories/${story.slug}.json`)).json();
+ assert.strictEqual(json.authorship.reviewed, true);
+ assert.ok(json.authorship.labels.some((l) => l.startsWith("Checked by OpenVibe's AI editor")), JSON.stringify(json.authorship));
+ assert.deepStrictEqual([json.authorship.reviews.ai_editor.decision, json.authorship.reviews.person], ['approved', null]);
+ });
+
+ await check('a person\'s review still says "Reviewed by a person"; both can show', async () => {
+ const r = await t.api(`/stories/${story.id}/reviews`, { json: { revision: rev, decision: 'approved' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().review.reviewerKind, 'person');
+ const page = await t.get(`/stories/${story.slug}`);
+ assert.ok(/reviewed by a person/i.test(page.text), 'the person\'s label is unchanged');
+ assert.ok(AI_ON_PAGE.test(page.text), 'and both are shown');
+ });
+ } finally {
+ await t.close();
+ }
+}
+
+async function briefPart() {
+ const t = await boot({ ai: true });
+ try {
+ const run = (id) => ({ status: 201, run: { id, status: 'succeeded', workflow: { key: 'news.summarize_story', version: 3 }, synthetic: false, provenance: { model: 'test-model' }, output: { headline: 'Europa Clipper is on its way', summary: 'NASA launched Europa Clipper toward Jupiter.', key_points: [{ text: 'It reaches Jupiter in 2030.', citations: [1] }], timeline: [], citations: [0, 1], gaps: [] } } });
+ launchReports(t.sources);
+ await t.pull();
+ const clusterId = (await t.db().prepare("SELECT cluster_id, count(*) AS n FROM news_source_items WHERE status = 'active' GROUP BY cluster_id ORDER BY n DESC").get()).cluster_id;
+ t.ai.setNext(run('run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E1'));
+ await t.ctx.briefs.sweep();
+ const editor = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.news'], cap: ['news.review.ai_editor'] });
+ const noCap = t.network.serviceToken('ai', ['news.story.publish']);
+
+ await check('a brief reviewed by the AI editor is indexable and labelled, never as a person\'s review', async () => {
+ const before = await t.get(`/briefs/${clusterId}`);
+ assert.ok(before.text.includes('noindex'));
+ const no = await t.api(`/briefs/${clusterId}/reviews`, { as: noCap, json: { kind: 'summary', tone: 'neutral', decision: 'approved' } });
+ assert.strictEqual(no.status, 403, no.text);
+ const r = await t.get(`/api/v1/briefs/${clusterId}/reviews`, { as: editor, json: { kind: 'summary', tone: 'neutral', decision: 'approved', note: 'Checked against the reports' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.json().review.reviewerKind, r.json().review.reviewer], ['ai_editor', 'svc:orchestrator']);
+ const page = await t.get(`/briefs/${clusterId}`);
+ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'reviewed: indexable');
+ assert.ok(AI_ON_PAGE.test(page.text), 'the brief says who checked it');
+ assert.ok(!/reviewed by a person/i.test(page.text));
+ assert.ok((await t.get('/sitemaps/briefs.xml')).text.includes(`/briefs/${clusterId}`));
+ });
+
+ await check('a person\'s approval of a brief still says "Reviewed by a person"', async () => {
+ t.clock.advance(60 * 60 * 1000);
+ t.ai.setNext(run('run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E2'));
+ await t.ctx.briefs.make(clusterId, { kind: 'summary', tone: 'neutral' });
+ const r = await t.api(`/briefs/${clusterId}/reviews`, { json: { kind: 'summary', tone: 'neutral', decision: 'approved' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().review.reviewerKind, 'person');
+ const page = await t.get(`/briefs/${clusterId}`);
+ assert.ok(/Reviewed by a person <time/.test(page.text), 'the person\'s label is unchanged');
+ assert.ok(!AI_ON_PAGE.test(page.text));
+ });
+
+ await check('an AI editor rejection stays in the queue and names its kind', async () => {
+ t.clock.advance(60 * 60 * 1000);
+ t.ai.setNext(run('run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E3'));
+ await t.ctx.briefs.make(clusterId, { kind: 'summary', tone: 'neutral' });
+ const r = await t.get(`/api/v1/briefs/${clusterId}/reviews`, { as: editor, json: { kind: 'summary', tone: 'neutral', decision: 'rejected', note: 'Not accurate' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const q = await t.api('/review-queue');
+ const item = q.json().items.find((i) => i.id === clusterId);
+ assert.ok(item, 'the AI editor\'s rejection leaves the brief in the person\'s queue');
+ assert.deepStrictEqual([item.reviewed.kind, item.reviewed.decision, item.reviewed.by], ['ai_editor', 'rejected', 'svc:orchestrator']);
+ const page = await t.get(`/briefs/${clusterId}`);
+ assert.ok(page.text.includes('noindex'), 'rejected: still noindex');
+ });
+ } finally {
+ await t.close();
+ }
+}
+
+(async () => {
+ await storyPart();
+ await briefPart();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });