openvibers/openvibe.blog Commits

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

OP OpenVibers committed 6 days ago · 24fbd099a6 · 15 files, +488 −40

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

* Blog: the AI editor capability proposal and service manifest entry

Changes

ddiff --git a/docs/capabilities-proposal/blog.review.ai_editor.json b/docs/capabilities-proposal/blog.review.ai_editor.json
nnew file mode 100644
iindex 0000000..a802134
---- /dev/null
++++ b/docs/capabilities-proposal/blog.review.ai_editor.json
@@@ -0,0 +1,23 @@
++{
++ "id": "blog.review.ai_editor",
++ "version": "1.0",
++ "owner": "blog",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Record the editorial queue's AI editor decision (approved or rejected) on a post revision, as the first-party service principal acting as itself (a svc: principal with no X-OV-Subject). This is never a person's review: an approval lets an AI draft publish through the existing publish action and the post says \"Checked by OpenVibe's AI editor\" with its date, never \"Reviewed by a person\". Granted only to the orchestrator's service principal.",
++ "permissions": [
++ "blog:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "blog-write",
++ "events": [
++ "blog.post.updated",
++ "blog.index_document.upserted",
++ "blog.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/posts/:id/reviews"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex 74d8cd6..95f3826 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -25,7 +25,8 @@
"blog.post.schedule",
"blog.post.unpublish",
"blog.post.delete",
-- "blog.feed.read"
++ "blog.feed.read",
++ "blog.review.ai_editor"
],
"eventsProduced": [
"blog.post.created",
ddiff --git a/migrations/0008_review_kinds.sql b/migrations/0008_review_kinds.sql
nnew file mode 100644
iindex 0000000..1da6703
---- /dev/null
++++ b/migrations/0008_review_kinds.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- Two reviewer kinds in the append-only review log (server/domain/review-kind.js; the AI editor
++-- review of the editorial queue, plan 2026-10-22 §"The editorial queue"):
++--
++-- person a signed-in person's approval or rejection (the default; every row written before
++-- this migration, and every row openvibe-publishing/authorship reviews.record() writes);
++-- ai_editor the orchestrator's service principal, which holds blog.review.ai_editor.
++--
++-- Expand only: existing rows keep their meaning through the default, and code that does not know
++-- the column (the shared log's INSERT, older processes during a rolling deploy) keeps working.
++ALTER TABLE blog_post_reviews
++ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'
++ CHECK (reviewer_kind IN ('person', 'ai_editor'));
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex fc2c239..80cce04 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -24,12 +24,23 @@ const CAPABILITIES = Object.freeze({
POST_SCHEDULE: 'blog.post.schedule',
POST_UNPUBLISH: 'blog.post.unpublish',
POST_DELETE: 'blog.post.delete',
++ REVIEW_AI_EDITOR: 'blog.review.ai_editor',
FEED_READ: 'blog.feed.read',
});
++// 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 };
ddiff --git a/server/db.js b/server/db.js
iindex bda90dd..000e6b2 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -35,6 +35,7 @@ const { createScheduler } = require('openvibe-publishing/schedule');
const { createAttachmentStore } = require('openvibe-publishing/media');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createReviewLog } = require('openvibe-publishing/authorship');
++const { createReviewKindLog } = require('./domain/review-kind');
const { createRedirectStore } = require('openvibe-publishing/seo');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
@@@ -78,7 +79,8 @@ function createStore(db, { now = () => Date.now(), leaseMs = 60000 } = {}) {
scheduler: createScheduler(db, { prefix: 'blog', now, leaseMs, maxAttempts: 3, backoffMs: [30000, 120000] }),
attachments: createAttachmentStore(db, { prefix: 'blog_post', now }),
discussion: createDiscussionRefs(db, { prefix: 'blog_post', now }),
-- reviews: createReviewLog(db, { prefix: 'blog_post', 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: 'blog_post', now }), { db, prefix: 'blog_post', now }),
redirects: createRedirectStore(db, { prefix: 'blog', now }),
sequencer: createIndexSequencer(db, { prefix: 'blog', now }),
tx: async (fn) => await db.tx(() => fn()),
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex d46f29a..ec7962b 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -22,7 +22,9 @@ const seo = require('openvibe-publishing/seo');
const authorship = require('openvibe-publishing/authorship');
const { isMediaId } = require('openvibe-publishing/media');
const { ApiError } = require('../http/errors');
--const { actorRef } = require('./publication');
++const { actorRef, canPublish } = require('./publication');
++const reviewKind = require('./review-kind');
++const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
const VISIBILITIES = ['public', 'unlisted', 'members', 'private'];
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
@@@ -35,6 +37,15 @@ const newPostId = (now) => `pst_${ids.ulid(now)}`;
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';
++/**
++ * The AI editor records a review as itself: a first-party service principal (svc:…) with no
++ * person named, holding blog.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;
++}
++
function listOf(v) {
if (v == null || v === '') return [];
const arr = Array.isArray(v) ? v : String(v).split(',');
@@@ -352,8 +363,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
-- if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);
++ const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
++ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's or the AI editor's approval before it can be published (${ok.reason})`);
if (publishGate) {
const gate = await publishGate(post, rev);
if (gate && gate.blocking && gate.blocking.length) {
@@@ -429,8 +440,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;
if (action === 'publish') {
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
-- if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);
++ const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
++ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's or the AI editor's approval before it can be scheduled (${ok.reason})`);
}
return await store.tx(async () => {
const { job, created } = await store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
@@@ -516,7 +527,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
/**
* Posts whose head revision is AI-generated and nobody has approved it yet: what a person
* still has to review before the post can be published or indexed. Only posts this viewer
-- * may review, oldest first. The gate's own reasons and words come with each item.
++ * may review, oldest first. The gate's own reasons and words come with each item, and an
++ * ai_editor decision is named in `reviewed` (an approval publishes and leaves the queue; a
++ * rejection stays until a person decides).
*/
async pendingReviews(viewer) {
const rows = await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
@@@ -529,8 +542,10 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const head = await store.revisions.head(post.id);
const rec = publication.authorshipOf(head);
if (!head || !rec || !authorship.needsReview(rec)) continue;
-- const review = await store.reviews.latest(post.id, head.number);
-- if (review) continue; // a decision, approved or rejected, has been made for this revision
++ const kinds = await store.reviews.latestByKind(post.id, head.number);
++ if (kinds.person) continue; // a person's decision, approved or rejected, has been made for this revision
++ if (kinds.ai_editor && kinds.ai_editor.decision === 'approved') continue; // the AI editor approved it: published and indexable
++ const ai = kinds.ai_editor;
const decision = await publication.decide(blog, post, head);
items.push({
site: 'blog',
@@@ -543,6 +558,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
editUrl: `/write/posts/${post.id}`,
createdAt: new Date(head.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/posts/${encodeURIComponent(post.id)}/reviews` },
...(post.state === 'draft' ? { publish: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/publish` } } : {}),
});
@@@ -550,16 +566,23 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
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 person's (what lets an AI draft be published and
++ * indexed), or the AI editor's — a svc:… service principal acting as itself with
++ * blog.review.ai_editor — recorded with kind `ai_editor`, never as a person's review.
++ */
async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
-- if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
++ 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');
const blog = await blogOf(post);
-- if (!await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
++ if (!aiEditor && !await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
const n = parseInt(revision, 10);
if (!await store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
return await store.tx(async () => {
const before = publication.snapshot(blog, post);
-- const row = await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
++ const row = aiEditor
++ ? await store.reviews.recordAiEditor({ entityId: post.id, revision: n, principal: viewer.service, decision, note })
++ : await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
await publication.afterChange(before, post.id, { actor: viewer, ...ctx });
return row;
});
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex e731416..2cc698f 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
++const reviewKind = require('./review-kind');
// Blog's editorial policy for the gate: sources are optional on a blog; a very short post is
// served and listed in feeds but not offered to search engines (reason "thin").
@@@ -25,6 +26,44 @@ const OWNER = 'blog';
/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */
const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };
++/** The gate's authorship facts: an approving review of either kind (a person's or the AI editor's). */
++function gateFacts(rec, review) {
++ const out = { authorship: { mode: rec.mode, reviewed: reviewKind.approved(review) } };
++ if (rec.stubProvider) out.stubProvider = true;
++ return out;
++}
++
++/** May this revision be published? AI-generated text needs an approval from a person or the AI editor. */
++function canPublish(rec, review) {
++ if (authorship.needsReview(rec) && !reviewKind.approved(review)) {
++ return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };
++ }
++ return { ok: true, reason: null };
++}
++
++/** Does either kind of review approve this revision? */
++function approvedReview(kinds = { person: null, ai_editor: null }) {
++ return reviewKind.approved(kinds.person) || reviewKind.approved(kinds.ai_editor);
++}
++
++/**
++ * 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". With no review
++ * it is the library's own text ("not yet reviewed by a person"), unchanged.
++ */
++function disclosure(rec, kinds = { person: null, ai_editor: null }) {
++ const d = authorship.disclosure(rec, null);
++ if (!d) return null;
++ const person = reviewKind.isPersonReview(kinds.person);
++ const ai = reviewKind.aiEditorLabel(kinds.ai_editor);
++ if (!person && !ai) return { ...d, reviewed: false };
++ const base = d.long.replace(/,\s*(?:not yet )?reviewed by a person\.?\s*$/, '').replace(/\.\s*$/, '');
++ const tail = person && ai ? `, reviewed by a person. ${ai}.`
++ : person ? ', reviewed by a person.'
++ : `. ${ai}.`;
++ return { ...d, long: `${base}${tail}`, reviewed: true };
++}
++
function createPublication({ store, config, outbox, indexnow }) {
const { db } = store;
const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');
@@@ -44,6 +83,11 @@ function createPublication({ store, config, outbox, indexnow }) {
return rev ? await store.reviews.latest(post.id, rev.number) : null;
}
++ /** The review of each kind for one revision: { person, ai_editor }. */
++ async function reviewsOf(post, rev) {
++ return rev ? await store.reviews.latestByKind(post.id, rev.number) : { person: null, ai_editor: null };
++ }
++
/**
* The gate's decision for a post at one revision (the published one by default). `review` lets a
* caller that already fetched reviews in one batch (sitemaps, llms-full) skip the per-post lookup;
@@@ -58,7 +102,8 @@ function createPublication({ store, config, outbox, indexnow }) {
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
noindex: Boolean(post.noindex),
};
-- if (rec) Object.assign(facts, authorship.gateFacts(rec, review !== undefined ? review : await reviewOf(post, rev)));
++ const rv = review !== undefined ? review : await reviewOf(post, rev);
++ if (rec) Object.assign(facts, gateFacts(rec, rv));
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
}
@@@ -168,7 +213,8 @@ function createPublication({ store, config, outbox, indexnow }) {
return {
POLICY, OWNER, GATE_VISIBILITY,
blogPath, postPath, postUrl, abs, feedId,
-- decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, termsOf,
++ gateFacts, canPublish, approvedReview, disclosure,
++ decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, reviewsOf, termsOf,
};
}
@@@ -185,4 +231,4 @@ function actorRef(actor) {
return { type: 'service', id: 'blog' };
}
--module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY };
++module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY, gateFacts, canPublish, approvedReview, disclosure };
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
iindex 14b79ff..370e9ff 100644
---- a/server/domain/reading.js
++++ b/server/domain/reading.js
@@@ -7,7 +7,7 @@
*/
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
--const authorship = require('openvibe-publishing/authorship');
++const reviewKind = require('./review-kind');
const { renderBody } = require('../render/pages');
function createReading({ store, blogs, posts: postsApi, publication, people, media, vip = null }) {
@@@ -105,7 +105,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
/** The machine-readable representation of a post (same content as the page). */
async function postJson({ blog, post, rev, person, decision }) {
const rec = publication.authorshipOf(rev);
-- const review = await publication.reviewOf(post, rev);
++ const kinds = await publication.reviewsOf(post, rev);
const { tags, categories } = await termsView(post, blog);
const series = await blogs.seriesById(post.series_id);
return {
@@@ -121,7 +121,16 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
revised_at: rev.createdAt,
author: person ? { subject: post.author_subject, name: person.known ? person.name : null, username: person.username } : null,
-- authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, reviewed: authorship.isReviewed(rec, review), disclosure: authorship.disclosure(rec, review) } : null,
++ authorship: rec ? {
++ mode: rec.mode, workflow: rec.workflow || null,
++ reviewed: publication.approvedReview(kinds),
++ labels: reviewKind.labelsFor(kinds),
++ disclosure: publication.disclosure(rec, kinds),
++ reviews: {
++ person: kinds.person ? { decision: kinds.person.decision, at: kinds.person.reviewedAt } : null,
++ ai_editor: kinds.ai_editor ? { decision: kinds.ai_editor.decision, at: kinds.ai_editor.reviewedAt } : null,
++ },
++ } : null,
tags: tags.map((t) => t.name),
categories: categories.map((c) => c.name),
series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,
ddiff --git a/server/domain/review-kind.js b/server/domain/review-kind.js
nnew file mode 100644
iindex 0000000..80157a6
---- /dev/null
++++ b/server/domain/review-kind.js
@@@ -0,0 +1,197 @@
++'use strict';
++/**
++ * Two reviewer kinds in one append-only log (the `blog_post_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
++ * blog.review.ai_editor — recorded with its principal, the time and a note.
++ * migrations/0008_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. 'blog_post' → blog_post_reviews).
++ */
++function createReviewKindLog(log, { db, prefix, now = () => Date.now() } = {}) {
++ const R = `${prefix}_reviews`;
++ const latestStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`);
++ const latestByKindStmt = db.prepare(`SELECT DISTINCT ON (reviewer_kind) * FROM ${R}
++ WHERE entity_id = ? AND revision = ? ORDER BY reviewer_kind, id DESC`);
++ const latestManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision) r.*
++ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)
++ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision
++ ORDER BY r.entity_id, r.revision, r.id DESC`);
++ const latestByKindManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision, r.reviewer_kind) r.*
++ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)
++ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision
++ ORDER BY r.entity_id, r.revision, r.reviewer_kind, r.id DESC`);
++ const insertEditor = db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, reviewer_kind, decision, note, reviewed_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`);
++ const historyStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id LIMIT ?`);
++
++ const number = (v) => {
++ const n = Number(v);
++ return Number.isInteger(n) && n >= 0 && n <= 2147483647 ? n : null;
++ };
++
++ async function latest(entityId, revision) {
++ const n = number(revision);
++ if (n == null) return null;
++ return shape(await latestStmt.get(entityId, n));
++ }
++
++ async function latestByKind(entityId, revision) {
++ const n = number(revision);
++ if (n == null) return { person: null, ai_editor: null };
++ return byKind(await latestByKindStmt.all(entityId, n));
++ }
++
++ async function latestMany(refs = []) {
++ const keys = refs.filter((r) => r && number(r.revision) != null);
++ if (!keys.length) return refs.map(() => null);
++ const rows = await latestManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));
++ const by = new Map(rows.map((r) => [`${r.entity_id}\u0000${r.revision}`, shape(r)]));
++ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || null);
++ }
++
++ async function latestByKindMany(refs = []) {
++ const keys = refs.filter((r) => r && number(r.revision) != null);
++ if (!keys.length) return refs.map(() => ({ person: null, ai_editor: null }));
++ const rows = await latestByKindManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));
++ const by = new Map();
++ for (const row of rows) {
++ const key = `${row.entity_id}\u0000${row.revision}`;
++ const out = by.get(key) || { person: null, ai_editor: null };
++ out[row.reviewer_kind === AI_EDITOR ? AI_EDITOR : PERSON] = shape(row);
++ by.set(key, out);
++ }
++ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || { person: null, ai_editor: null });
++ }
++
++ /** An ai_editor review: the service principal that made it, never a person's subject. */
++ async function recordAiEditor({ entityId, revision, principal, decision, note = null } = {}) {
++ const n = number(revision);
++ if (n == null) throw new TypeError('revision must be a non-negative integer');
++ if (!PRINCIPAL_RE.test(String(principal))) throw new TypeError('principal must be a svc:… principal');
++ if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');
++ return shape(await insertEditor.get(String(entityId), n, String(principal), AI_EDITOR, decision, note == null ? null : String(note).slice(0, 2000), now()));
++ }
++
++ /** The shared log's person write, with the kind its row has by default. */
++ async function record(args) {
++ return shape(await log.record(args));
++ }
++
++ async function history(entityId, { limit = 1000 } = {}) {
++ const n = Math.max(1, Math.min(10000, Math.floor(Number(limit)) || 1000));
++ return (await historyStmt.all(entityId, n)).map(shape);
++ }
++
++ return {
++ ...log,
++ record,
++ history,
++ latest,
++ latestMany,
++ latestByKind,
++ latestByKindMany,
++ recordAiEditor,
++ };
++}
++
++module.exports = {
++ AI_EDITOR, PERSON, AI_EDITOR_LABEL, PRINCIPAL_RE,
++ reviewerKind, isPersonReview, isAiEditorReview, approved, reviewDate, aiEditorLabel, aiEditorVerdict, labelsFor, latestOf,
++ createReviewKindLog,
++};
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 5f3af36..ff98265 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -35,7 +35,7 @@ const seo = require('openvibe-publishing/seo');
const cache = require('openvibe-shared/cache-policy');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { guard } = require('../auth/viewer');
--const { checkCapability } = require('../auth/capabilities');
++const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
function cors(origins) {
const allowed = new Set(origins);
@@@ -290,7 +290,16 @@ function createApi(ctx) {
const out = await posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
return { revision: out.revision.number, post: await postDto(out.post, { full: true }) };
}, 201);
-- router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {
++ // A review by the AI editor (a svc:… principal acting as itself) needs blog.review.ai_editor.
++ // People, and services acting for a person, are judged by posts.review as before.
++ const reviewGuard = (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 contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
++ };
++ router.post('/posts/:id/reviews', reviewGuard, B('blog.post.publish'), jsonBody, run(async (req) => {
const { post } = await mustPost(req);
const b = req.body || {};
const review = await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex b50f099..81cc05a 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -209,14 +209,14 @@ function createEditorRoutes(ctx) {
const head = await posts.head(post);
const published = post.published_revision ? await posts.revision(post, post.published_revision) : null;
const rec = publication.authorshipOf(head);
-- const review = await publication.reviewOf(post, head);
++ const kinds = await publication.reviewsOf(post, head);
const terms = await publication.termsOf(post);
const decision = await publication.decide(blog, { ...post, state: 'published' }, head);
const patchNotes = ctx.patchNotes ? await ctx.patchNotes.load(post.id) : null;
const body = editor.editPage({
blog, post, head, published, decision, patchNotes, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),
-- disclosure: rec ? authorship.disclosure(rec, review) : null,
-- needsReview: rec ? !authorship.canPublish(rec, review).ok : false,
++ disclosure: rec ? publication.disclosure(rec, kinds) : null,
++ needsReview: rec ? authorship.needsReview(rec) && !publication.approvedReview(kinds) : false,
publicUrl: publication.postPath(blog, post),
canDelete: await access.canWrite(store, req.viewer, blog, 'delete', post),
form: editor.postForm({ blog, post, head, terms, series: await blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex dcba7d8..ae21df4 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -205,12 +205,12 @@ route('post', '/posts/{id}/revert', {
});
route('get', '/review-queue', {
operationId: 'getReviewQueue', summary: 'The reviewer queue: posts whose AI-written head waits for a person', tags: ['revisions'], security: PERSON,
-- description: 'Editorial staff only. Oldest first, with the gate\'s reason codes and words and the review path for each post.',
++ description: 'Editorial staff only. Oldest first, with the gate\'s reason codes and words and the review path for each post. An item the AI editor rejected carries `reviewed` (kind, decision, principal, time, label); the AI editor\'s approvals publish the post and leave the queue.',
responses: { 200: json('The waiting posts', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },
});
route('post', '/posts/{id}/reviews', {
operationId: 'reviewPostRevision', summary: 'Approve or reject an AI-authored revision', tags: ['revisions'], security: PERSON, parameters: [POST],
-- description: 'People only. An approved revision may then publish.',
++ description: 'A person who may publish the post (blog.post.publish), or the AI editor — a svc:… service principal acting as itself with blog.review.ai_editor — whose review is recorded with kind `ai_editor` and labelled "Checked by OpenVibe\'s AI editor", never "Reviewed by a person". An approved revision may then publish.',
requestBody: body({ type: 'object', required: ['revision', 'decision'], properties: {
revision: { type: 'integer', minimum: 1 }, decision: { type: 'string', enum: ['approved', 'rejected'] }, note: nullable('string'),
} }),
ddiff --git a/server/http/public.js b/server/http/public.js
iindex c5168bb..107698d 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -22,7 +22,6 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
--const authorship = require('openvibe-publishing/authorship');
const cache = require('openvibe-shared/cache-policy');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const pages = require('../render/pages');
@@@ -347,7 +346,7 @@ function createPublicRoutes(ctx) {
const attachments = await store.attachments.list(post.id);
const citations = await store.citations.forRevision(post.id, rev.number);
const rec = publication.authorshipOf(rev);
-- const review = await publication.reviewOf(post, rev);
++ const kinds = await publication.reviewsOf(post, rev);
const path = publication.postPath(blog, post);
const firstCategory = categories[0];
const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
@@@ -373,7 +372,7 @@ function createPublicRoutes(ctx) {
blog, blogUrl: publication.blogPath(blog), post, rev,
author: { name: person.name, url: reading.urls.author(person, blog) },
tags, categories, series: await seriesView(blog, post), attachments, citations,
-- disclosure: rec ? authorship.disclosure(rec, review) : null,
++ disclosure: rec ? publication.disclosure(rec, kinds) : null,
decisionNote: preview ? `preview · ${decision.robots}` : null,
comments, canEdit: await access.canWrite(store, req.viewer, blog, 'edit', post),
urlFor: media.urlFor, shareUrl: publication.postUrl(blog, post), breadcrumbs: crumbs, jsonUrl: `${path}.json`,
ddiff --git a/test/capabilities.test.js b/test/capabilities.test.js
iindex ff34338..86dd331 100644
---- a/test/capabilities.test.js
++++ b/test/capabilities.test.js
@@@ -1,14 +1,16 @@
'use strict';
/**
* The blog.* 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 blog 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 — blog.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 blog 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');
const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };
@@@ -17,23 +19,32 @@ const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };
await check('every guarded capability is defined by the installed contracts and the blog manifest', async () => {
const manifest = services.get('blog');
assert.ok(manifest, 'openvibe-contracts defines the blog 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, 'blog', `${id} is owned by blog`);
}
++ // The AI editor's id is the one proposal; when Contracts releases it, this line goes.
++ assert.strictEqual(capabilities.get('blog.review.ai_editor'), undefined);
});
await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', async () => {
// An exact grant and a `prefix.*` grant the library's matching rule accepts.
assert.deepStrictEqual(checkCapability(GRANTED, 'blog.post.read'), { allowed: true, code: null, reason: null });
assert.deepStrictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.post.read'), { allowed: true, code: null, reason: null });
++ // The proposal is decided with the same rule until Contracts defines it.
++ assert.deepStrictEqual(checkCapability({ cap: ['blog.review.ai_editor'] }, 'blog.review.ai_editor'), { allowed: true, code: null, reason: null });
++ assert.deepStrictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.review.ai_editor'), { allowed: true, code: null, reason: null });
++ assert.strictEqual(checkCapability({ cap: ['blog.post.publish'] }, 'blog.review.ai_editor').code, 'capability.denied');
// A grant of a sibling capability, or no cap at all, is denied with the library's code.
assert.strictEqual(checkCapability({ cap: ['blog.post.create'] }, 'blog.post.read').code, 'capability.denied');
assert.strictEqual(checkCapability(null, 'blog.post.read').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: ['blog.*'] }, 'blog.not.a.capability').code, 'capability.unknown');
});
ddiff --git a/test/review-ai-editor.test.js b/test/review-ai-editor.test.js
nnew file mode 100644
iindex 0000000..d7f0275
---- /dev/null
++++ b/test/review-ai-editor.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++/**
++ * The AI editor (plan 2026-10-22 §"The editorial queue"): a second reviewer kind, `ai_editor`,
++ * recorded on the review by the orchestrator's service principal. Its approval lets an AI draft be
++ * published and indexed exactly as a person's does, but the post 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 blog.review.ai_editor may record one; a token without
++ * it is refused and writes nothing.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7Q8R9S0T1V2W3' };
++const AI_LABEL = "Checked by OpenVibe's AI editor";
++// The page escapes the apostrophe; the rendered text still reads "Checked by OpenVibe's AI editor".
++const AI_ON_PAGE = /Checked by OpenVibe(?:&#39;|')s AI editor/;
++
++(async () => {
++ const t = await boot();
++ const lead = t.official;
++ const admin = t.network.addUser('admin', { role: 'admin' });
++ const ai = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.read', 'blog.post.publish']);
++ const editor = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.blog'], cap: ['blog.review.ai_editor'] });
++ const noCap = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.blog'], cap: ['blog.post.publish'] });
++ const makeDraft = async (title) => {
++ const r = await t.get('/api/v1/blogs/openvibe/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': lead.subject }, json: { title, body: LONG, authorship: { workflow: WORKFLOW } } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json().post;
++ };
++ try {
++ const post = await makeDraft('An AI draft');
++
++ await check('a token without blog.review.ai_editor is refused and writes nothing', async () => {
++ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: noCap, json: { revision: 1, decision: 'approved' } });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.json().code, 'capability.denied');
++ assert.strictEqual((await t.ctx.store.reviews.history(post.id)).length, 0, 'nothing was recorded');
++ });
++
++ await check('the AI editor approves: the draft publishes and reads "Checked by OpenVibe\'s AI editor"', async () => {
++ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: editor, json: { revision: 1, decision: 'approved', note: 'Checked against the sources' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual([r.json().review.reviewerKind, r.json().review.reviewer, r.json().review.decision], ['ai_editor', 'svc:orchestrator', 'approved']);
++
++ const pub = await t.get(`/api/v1/posts/${post.id}/publish`, { as: admin, json: { revision: 1 } });
++ assert.strictEqual(pub.status, 200, pub.text);
++ assert.strictEqual(pub.json().post.state, 'published');
++
++ const path = new URL(pub.json().post.url).pathname;
++ const page = await t.get(path);
++ assert.strictEqual(page.status, 200);
++ 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');
++ assert.ok(!page.text.includes('not yet reviewed by a person'));
++
++ const json = await t.get(`${path}.json`);
++ assert.strictEqual(json.status, 200, json.text);
++ assert.strictEqual(json.json().authorship.reviewed, true);
++ assert.ok(json.json().authorship.labels.some((l) => l.startsWith(AI_LABEL)), JSON.stringify(json.json().authorship));
++ assert.deepStrictEqual([json.json().authorship.reviews.ai_editor.decision, json.json().authorship.reviews.person], ['approved', null]);
++ });
++
++ await check('a person\'s review still says "Reviewed by a person"', async () => {
++ const other = await makeDraft('A person-checked draft');
++ const r = await t.get(`/api/v1/posts/${other.id}/reviews`, { as: admin, json: { revision: 1, decision: 'approved' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().review.reviewerKind, 'person');
++ const pub = await t.get(`/api/v1/posts/${other.id}/publish`, { as: admin, json: { revision: 1 } });
++ assert.strictEqual(pub.status, 200, pub.text);
++ const page = await t.get(new URL(pub.json().post.url).pathname);
++ assert.ok(/reviewed by a person/i.test(page.text), 'the person\'s label is unchanged');
++ assert.ok(!AI_ON_PAGE.test(page.text));
++ });
++
++ await check('both reviews on one post show both labels', async () => {
++ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: admin, json: { revision: 1, decision: 'approved' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const page = await t.get(new URL((await t.get(`/api/v1/posts/${post.id}`, { as: admin })).json().post.url).pathname);
++ assert.ok(/reviewed by a person/i.test(page.text));
++ assert.ok(AI_ON_PAGE.test(page.text));
++ assert.ok(page.text.toLowerCase().indexOf('reviewed by a person') < page.text.indexOf('Checked by OpenVibe'), 'the person\'s sentence, then the AI editor\'s');
++ });
++
++ await check('an AI editor rejection keeps the draft out of publish and names its kind in the queue', async () => {
++ const third = await makeDraft('Kept hidden');
++ const r = await t.get(`/api/v1/posts/${third.id}/reviews`, { as: editor, json: { revision: 1, decision: 'rejected', note: 'Not accurate' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const pub = await t.get(`/api/v1/posts/${third.id}/publish`, { as: admin, json: { revision: 1 } });
++ assert.notStrictEqual(pub.status, 200, 'a rejected revision cannot be published');
++ const q = await t.get('/api/v1/review-queue', { as: admin });
++ const item = q.json().items.find((i) => i.id === third.id);
++ assert.ok(item, 'the AI editor\'s rejection leaves the draft in the person\'s queue');
++ assert.deepStrictEqual([item.reviewed.kind, item.reviewed.decision, item.reviewed.by], ['ai_editor', 'rejected', 'svc:orchestrator']);
++ assert.ok(item.reviewed.label.startsWith('Kept hidden by OpenVibe\'s AI editor'), item.reviewed.label);
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });