openvibers/openvibe.reviews Commits

A correction yields a public summary revision

OP OpenVibers committed 23 Sept 2026 · 91e613d393 · 7 files, +462 −51

Accepting a reader's correction changed only the queue row: no revision, no public history. Now a
correction of a published summary is a new immutable revision whose meta carries the public
correction note (who: its author, the editor; when: its time; which revision it corrects), approved
by that editor and published at once through the normal publish path.

- PATCH /api/v1/corrections/:id { status: accepted, correction_note, summary? } on an entity with a
  published summary creates it (text carried forward unless summary is given) and accepts the
  request in the same transaction; without a note it is 422 correction.note_required. A service
  also needs reviews.summary.publish for that. Rejecting creates nothing.
- POST …/summary/revisions with correction_note (and optionally correction_id) is an editor's own
  correction; the edit page's summary form and the editor desk do both with plain form posts.
- The entity page shows the corrected text, a "Corrected" notice and the summary's revision history
  with every correction note (no JavaScript); earlier revisions stay readable at /e/:slug/summary/:n,
  the history page lists the notes.
- reviews.summary.updated carries correction { note, corrects }; the Search document, the Review
  JSON-LD (now datePublished = first publication, dateModified = this revision) and the feeds (a new
  entry that starts with the note) follow the corrected revision. No reviews.summary.corrected: it
  is not registered in openvibe-contracts.
- Corrected AI-drafted text is AI-assisted (hybrid), never "written by a person".
- Readers never see a request's text, its sender, the editors' note or its id: correction.* audit
  rows lose target and detail in the public history.

test/corrections.test.js covers the round trip, the refusals and the leak scan.

Changes

ddiff --git a/README.md b/README.md
iindex b59a317..cf5ea82 100644
---- a/README.md
++++ b/README.md
@@@ -91,7 +91,16 @@ revision (citations moved to replacements, unsupported points dropped) waits for
**Corrections.** Signed-in people send corrections (entity, alias, signal, summary, aggregate, optional
evidence URL) from the entity page or `reviews.correction.submit`; editors accept or reject them on the
--desk. Correction text is not published.
++desk. What a request says and who sent it are never published. A correction of a published summary is
++a new immutable summary revision carrying a public correction note (who: the editor who wrote it; when:
++its time), approved by that editor and published at once: accepting a request about an entity whose
++summary is published needs that note (the text is carried forward unless the editor changes it, from
++the desk or the edit page), and an editor can correct on their own with `correction_note` on a new
++revision. The entity page shows the corrected text, a "Corrected" notice and the summary's revision
++history with every correction note; earlier revisions stay readable at `/e/:slug/summary/:n`. Rejecting
++creates nothing. AI-drafted text a person corrected becomes AI-assisted (hybrid), never "written by a
++person". JSON-LD (`dateModified`), the feeds (a new entry that starts with the note) and the Search
++document follow the corrected revision.
**Pages (SSR, useful without JavaScript).** `/`, `/about`, `/search`, `/e/:slug` (aggregate with inputs
and computation, summary with citations, signal table with provenance and timestamps, sources and
@@@ -135,11 +144,11 @@ writes need an editor who is a person (staff or `REVIEWS_EDITORS`). Errors are R
| Capability | Routes |
|---|---|
| `reviews.entity.resolve` | `POST /resolve`, `GET /entities`, `GET /entities/:ref`, `/history`, `/aggregate`, `/summary/revisions/:n`, `GET /items`, `GET /items/:id`, `POST /items/:id/resolution` |
--| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` |
++| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` `{ status, note?, correction_note?, summary? }` (accepting with a correction also needs `reviews.summary.publish`) |
| `reviews.entity.merge` / `reviews.entity.split` | `POST /entities/:ref/merge` `{ into, note }`, `POST /entities/:ref/split` `{ note }` |
| `reviews.signal.import` | `POST /signals/import` `{ source_item_id }`, `POST /sources/sync` |
| `reviews.summary.propose` | `POST /entities/:ref/summary/proposals` (OpenVibe.AI) |
--| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions`, `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` |
++| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions` (`correction_note`, `correction_id`: a correction, published at once), `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` |
| `reviews.correction.submit` | `POST /entities/:ref/corrections` |
The ids and the service manifest are released in openvibe-contracts v0.20.0 (from the proposals in
@@@ -152,7 +161,9 @@ decides them with the contracts grant rule. `reviews.entity.manage` and
Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
`reviews.entity.split`, `reviews.signal.added` (with `replaces`), `reviews.signal.removed` (status,
--reason, `replaced_by`), `reviews.summary.published|updated|unpublished`, and the Search index events
++reason, `replaced_by`), `reviews.summary.published|updated|unpublished` (a correction is
++`reviews.summary.updated` with `correction: { note, corrects }`; a dedicated `reviews.summary.corrected`
++would first need registering in OpenVibe.Contracts), and the Search index events
`reviews.index_document.upserted|deleted`. Consumed: `sources.item.created|updated|removed`
(subscription created with `scripts/subscribe.js`).
@@@ -191,6 +202,7 @@ by editors; signals arrive only from Sources.
- **provenance on every signal**, enforced by the schema, immutable by trigger; review text never stored (`signals.test.js`)
- **no-JS**: every public page and every editor workflow works with plain HTML and form posts; Community comments are shown, never copied (`nojs.test.js`)
- a withdrawn cited signal flags the summary and prepares a pending revision that only a person can publish (`summaries.test.js`)
++- **a correction yields a public revision**: accepting a request (or an editor correcting on their own) creates a new immutable revision with the correction note, the page shows the corrected text and the revision history with the note without JavaScript, the old revision stays readable, Search/JSON-LD/feeds/events follow it, the request's text and sender never reach a public surface, a rejection creates nothing, and only an editor who is a person decides (`corrections.test.js`)
- deterministic resolution; a name alone never resolves; permissions and capabilities enforced (`resolution.test.js`)
- every event validates as `events.event-envelope@1` and every index document as `search.index-document@1`; proposals validate against the contracts schemas; nothing seeded (`signals.test.js`, `proposals.test.js`)
ddiff --git a/server/http/api.js b/server/http/api.js
iindex abc72eb..fc30474 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -25,7 +25,8 @@
* POST /items/:id/resolution reviews.entity.resolve { entity, add_alias? } | { ignore: true, note? } editor
* POST /signals/import reviews.signal.import { source_item_id } fetch the item from Sources and apply it
* POST /sources/sync reviews.signal.import pull the next pages of review items from Sources
-- * POST /entities/:ref/summary/revisions reviews.summary.publish { overview?, overview_signals?, pros?, cons?, expected_revision?, message?, publish? }
++ * POST /entities/:ref/summary/revisions reviews.summary.publish { overview?, overview_signals?, pros?, cons?, expected_revision?, message?, publish?, correction_note?, correction_id? }
++ * with correction_note: a correction of the published summary (published at once)
* POST /entities/:ref/summary/proposals reviews.summary.propose { overview?, pros?, cons?, workflow: { id, run_id }, stub_provider?, note? }
* GET /entities/:ref/summary/revisions/:n reviews.entity.resolve
* POST /entities/:ref/summary/revisions/:n/review reviews.summary.publish { decision: approved|rejected, note?, publish? }
@@@ -33,11 +34,13 @@
* POST /entities/:ref/summary/unpublish reviews.summary.publish
* POST /entities/:ref/corrections reviews.correction.submit { target_type, target_id?, body, evidence_url? } a person
* GET /corrections reviews.entity.manage open corrections (editors)
-- * PATCH /corrections/:id reviews.entity.manage { status: accepted|rejected, note? }
++ * PATCH /corrections/:id reviews.entity.manage { status: accepted|rejected, note?, correction_note?, summary? }
++ * accepting with a published summary corrects it (+ reviews.summary.publish)
*/
const express = require('express');
const contracts = require('openvibe-contracts');
const { actorMiddleware, guard, run } = require('./common');
++const { checkCapability } = require('../auth/capabilities');
const { http } = contracts;
@@@ -142,7 +145,15 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');
return { corrections: svc.openCorrections() };
}));
-- router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => ({ correction: svc.resolveCorrection(req.params.id, body(req), req.actor) })));
++ router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => {
++ const b = body(req);
++ // Accepting with a correction publishes a summary revision: a service needs that grant too.
++ if (req.actor.kind === 'service' && b.status === 'accepted' && (b.correction_note || b.summary)) {
++ const c = checkCapability(req.actor.claims, 'reviews.summary.publish');
++ if (!c.allowed) throw new svc.ReviewsError(403, c.code, c.reason);
++ }
++ return svc.resolveCorrection(req.params.id, b, req.actor);
++ }));
return router;
}
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex a61ea1f..64232a1 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -5,7 +5,8 @@
*
* /robots.txt crawl rules + sitemap location + explicit automated-consumer policy
* /sitemap.xml sitemap index → /sitemaps/pages.xml, /sitemaps/entities-<n>.xml
-- * /feed.atom published summaries (Atom), /feed.json (JSON Feed 1.1)
++ * /feed.atom published summaries (Atom), /feed.json (JSON Feed 1.1); one entry per published
++ * revision, so a correction is a new entry that starts with its note
* /llms.txt orientation for language models
*/
const express = require('express');
@@@ -53,7 +54,8 @@ function createMachine({ svc, config }) {
id: `tag:openvibe.reviews,2026:summary/${summary.id}/revision/${summary.published_revision}`,
url: `${svc.entityUrl(entity)}#summary`,
title: `${entity.name}: summary`,
-- summary: [rev.content || '', ...(rev.fields.pros || []).map((p) => `Pro: ${p.text}`), ...(rev.fields.cons || []).map((p) => `Con: ${p.text}`)].join('\n').slice(0, 1000),
++ summary: [rev.meta && rev.meta.correction ? `Correction: ${rev.meta.correction.note}` : null, rev.content || '', ...(rev.fields.pros || []).map((p) => `Pro: ${p.text}`), ...(rev.fields.cons || []).map((p) => `Con: ${p.text}`)]
++ .filter((x) => x != null).join('\n').slice(0, 1000),
published: summary.revision_published_at,
updated: summary.revision_published_at,
tags: [entity.kind],
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 3f36dec..7cdd8b1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -9,7 +9,7 @@
* GET /e/:slug entity page: aggregate with inputs, summary, signals with provenance, discussion
* GET /e/:slug.json the same data as JSON
* GET /e/:slug/history aggregate revisions, summary revisions, merges and splits, every signal, editorial log
-- * GET /e/:slug/summary/:n one summary revision (editors: approve / reject)
++ * GET /e/:slug/summary/:n one summary revision (editors: approve / reject); corrections carry their note
* GET|POST /e/:slug/correct a signed-in person sends a correction to the editors
* POST /e/:slug/discuss comment through OpenVibe.Community
* Editors: GET /editor, POST /editor/sync, GET|POST /editor/entities/new, GET|POST /e/:slug/edit,
@@@ -47,12 +47,15 @@ function structuredData(p, config) {
const pub = p.summary && p.summary.published;
if (pub && pub.points.some((x) => x.supported)) {
const text = [pub.overview || '', ...pub.points.filter((x) => x.kind !== 'overview').map((x) => `${x.kind === 'pro' ? 'Pro' : 'Con'}: ${x.text}`)].join('\n').trim();
-- out.push(seo.structuredData.review({
++ const review = seo.structuredData.review({
url: `${en.url}#summary`, itemReviewed: { type, name: en.name, url: en.url },
author: { type: 'Organization', name: 'OpenVibe.Reviews editors', url: `${origin}/about` },
-- datePublished: p.summary.revision_published_at, body: text.slice(0, 5000),
++ datePublished: p.summary.published_at || p.summary.revision_published_at, body: text.slice(0, 5000),
publisher: { name: 'OpenVibe', url: 'https://openvibe.network' },
-- }));
++ });
++ // A later revision (an update or a correction) is the same review, modified.
++ if (review && p.summary.published_at && p.summary.revision_published_at !== p.summary.published_at) review.dateModified = p.summary.revision_published_at;
++ out.push(review);
}
return out.filter(Boolean);
}
@@@ -75,6 +78,7 @@ function summaryFromForm(b) {
pros: points('pro'), cons: points('con'),
expected_revision: b.expected_revision != null && b.expected_revision !== '' ? Number(b.expected_revision) : undefined,
message: b.message || null, publish: b.publish === '1',
++ correction_note: b.correction_note || null, correction_id: b.correction_id || null,
};
}
@@@ -266,15 +270,21 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
}
}));
-- function editPage(req, res, e, { status = 200, error = null, flash = null } = {}) {
-- send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });
++ /** An open correction request about `e` (the summary form then answers it). */
++ function openCorrectionFor(e, id) {
++ const c = id ? svc.correction(String(id)) : null;
++ return c && c.status === 'open' && svc.canonicalOf(c.entity_id) === e.id ? c : null;
++ }
++
++ function editPage(req, res, e, { status = 200, error = null, flash = null, correcting = null } = {}) {
++ send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, correcting, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });
}
router.get('/e/:slug/edit', (req, res) => {
if (!editorOnly(req, res)) return;
const e = locate(req, res, '/edit');
if (!e) return;
-- editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null });
++ editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null, correcting: openCorrectionFor(e, req.query.correction) });
});
router.post('/e/:slug/edit', form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
@@@ -311,7 +321,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, `${svc.entityPath(e)}/edit?saved=1`);
} catch (err) {
if (!err.status || err.status >= 500) throw err;
-- editPage(req, res, e, { status: err.status, error: err.message });
++ editPage(req, res, e, { status: err.status, error: err.message, correcting: openCorrectionFor(e, (req.body || {}).correction_id) });
}
}));
@@@ -364,7 +374,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
router.post('/editor/corrections/:id', form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const b = req.body || {};
-- svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null }, req.actor);
++ svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);
res.redirect(303, '/editor#corrections');
}));
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 285c27d..a207123 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -99,7 +99,7 @@ function aboutPage() {
<h2 id="entities">Entities, merges and splits</h2>
<p>Items are matched to entities by deterministic rules: a source bound to one entity, the item's URL, a GTIN, SKU or MPN. A name alone is never enough: an editor confirms it. A merge never rewrites anything, so a split restores the earlier attribution exactly; both are recorded with who, when and why.</p>
<h2 id="corrections">Corrections and discussion</h2>
--<p>Anyone signed in can send a correction to the editors from an entity page. Discussion lives on <a href="https://openvibe.community">OpenVibe.Community</a> and is shown here, never copied.</p>
++<p>Anyone signed in can send a correction to the editors from an entity page. What you send, and who you are, stays with the editors. When an editor corrects a published summary, the correction is a new revision published with a note saying what was corrected; the earlier revisions stay readable in the summary's revision history. Discussion lives on <a href="https://openvibe.community">OpenVibe.Community</a> and is shown here, never copied.</p>
</section>`;
}
@@@ -133,6 +133,20 @@ function citeLinks(cites) {
return html`${cites.map((c, i) => html`${i ? ' ' : ''}<a href="#${c.signal_id}" class="${c.ok ? 'rv-cite' : 'rv-cite rv-cite-gone'}" title="${c.ok ? 'cited signal' : `cited signal is ${c.status}`}">[${c.signal_id.slice(-6)}${c.ok ? '' : ` ${c.status}`}]</a>`)}`;
}
++/** The summary's public revision history: every revision readers may see, with each correction note. */
++function summaryHistory(en, s) {
++ const list = s.history || [];
++ if (!list.length) return '';
++ const corrections = list.filter((r) => r.correction).length;
++ return html`<h3 id="summary-history">Revision history</h3>
++<p class="rv-muted">${corrections ? `${corrections} correction${corrections === 1 ? '' : 's'}. ` : ''}Earlier revisions stay readable; a correction is a new revision, never an edit of an old one.</p>
++<ul class="rv-revisions">${list.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${tl(r.created_at)}${r.disclosure ? html` · ${r.disclosure}` : ''}${r.correction ? html`<br><strong>Correction:</strong> ${r.correction.note}` : ''}</li>`)}</ul>`;
++}
++
++function correctionNotice(r, at) {
++ return html`<p class="rv-notice rv-correction" role="status"><strong>Corrected</strong> ${t(at)}${r.correction.corrects ? ` (revision ${r.correction.corrects} corrected by revision ${r.number})` : ''}: ${r.correction.note}</p>`;
++}
++
function summarySection(p) {
const s = p.summary;
const head = html`<h2 id="summary">Summary</h2>`;
@@@ -146,13 +160,15 @@ function summarySection(p) {
const ov = r.points.find((x) => x.kind === 'overview');
return html`<section class="rv-summary">${head}
${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by OpenVibe.Reviews editors. Revision ${String(r.number)}, published ${t(s.revision_published_at)}.</p>`}
++${r.correction ? correctionNotice(r, s.revision_published_at) : ''}
${s.flagged ? notice('warn', `Under review: ${s.flag_reason || 'a cited signal changed'} (${s.flagged_at ? s.flagged_at.slice(0, 10) : ''}). Points whose citations are marked below may no longer be supported.`) : ''}
${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 2 }))}${ov ? html`<p class="rv-muted">Cites ${citeLinks(ov.citations)}</p>` : ''}</div>` : ''}
<div class="rv-proscons">
<div><h3>Pros</h3>${pros.length ? html`<ul>${pros.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
<div><h3>Cons</h3>${cons.length ? html`<ul>${cons.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
</div>
--<p class="rv-muted">A summary never carries a rating: the only numbers are the signals above and the aggregate computed from them.</p></section>`;
++<p class="rv-muted">A summary never carries a rating: the only numbers are the signals above and the aggregate computed from them.</p>
++${summaryHistory(p.entity, s)}</section>`;
}
function signalRow(s) {
@@@ -233,7 +249,7 @@ function historyPage(h) {
<h2 id="aggregates">Aggregate revisions</h2>
${h.aggregates.length ? html`<div class="rv-scroll"><table class="rv-history"><thead><tr><th>Revision</th><th>Computed</th><th>Why</th><th>Result</th></tr></thead><tbody>${h.aggregates.map((a) => html`<tr><td>${String(a.revision)}</td><td>${tl(a.computed_at)}</td><td>${a.trigger.replace(/_/g, ' ')}</td><td>${aggregateLine(a)}${a.result ? html` <span class="rv-muted">(${String(a.result.inputs.length)} inputs)</span>` : ''}</td></tr>`)}</tbody></table></div>` : html`<p class="rv-muted">No aggregate has ever been computed: no qualifying signal yet.</p>`}
<h2 id="summary">Summary revisions</h2>
--${h.summary_revisions.length ? html`<ul>${h.summary_revisions.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${t(r.created_at)}${r.disclosure ? html` · ${r.disclosure.short}` : ''}${r.system ? html` · prepared after: ${r.system.reason}` : ''}${r.message ? html` <span class="rv-muted">${r.message}</span>` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No summary revisions.</p>`}
++${h.summary_revisions.length ? html`<ul>${h.summary_revisions.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${t(r.created_at)}${r.disclosure ? html` · ${r.disclosure.short}` : ''}${r.system ? html` · prepared after: ${r.system.reason}` : ''}${r.correction ? html`<br><strong>Correction:</strong> ${r.correction.note}` : (r.message ? html` <span class="rv-muted">${r.message}</span>` : '')}</li>`)}</ul>` : html`<p class="rv-muted">No summary revisions.</p>`}
<h2 id="merges">Merges and splits</h2>
${h.merges.length ? html`<ul>${h.merges.map((m) => html`<li><a href="${epath(m.from)}">${m.from.name}</a> merged into <a href="${epath(m.to)}">${m.to.name}</a> ${tl(m.merged_at)}${m.note ? html` — ${m.note}` : ''}${m.split_at ? html`; split again ${tl(m.split_at)}${m.split_note ? html` — ${m.split_note}` : ''}` : html` <span class="rv-tag">active</span>`}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
<h2 id="signals">Every signal, including replaced and withdrawn ones</h2>
@@@ -247,6 +263,7 @@ function summaryRevisionPage(en, r, { editor }) {
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'History', url: `${epath(en)}/history` }, { name: `Summary revision ${r.number}` }])}
<section><h1>Summary revision ${String(r.number)} <span class="rv-tag">${r.status}</span></h1>
${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by an editor ${t(r.created_at)}.</p>`}
++${r.correction ? correctionNotice(r, r.created_at) : ''}
${r.system ? notice('warn', `Prepared automatically after: ${r.system.reason}. ${r.system.dropped_points && r.system.dropped_points.length ? `${r.system.dropped_points.length} point(s) were dropped because every signal they cited is gone.` : ''}`) : ''}
${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 1 }))}</div>` : ''}
<ul>${r.points.filter((p) => p.kind !== 'overview').map((p) => html`<li><strong>${p.kind === 'pro' ? 'Pro' : 'Con'}:</strong> ${p.text} ${citeLinks(p.citations)}</li>`)}</ul>
@@@ -261,7 +278,7 @@ function correctionPage(en, { values = {}, error = null, done = false } = {}) {
<section><h1>Suggest a correction for ${en.name}</h1>
${done ? notice('ok', 'Thank you. Your correction is in the editors\' queue.') : ''}
${error ? notice('error', error) : ''}
--<p>Tell the editors what is wrong: a signal attributed to the wrong thing, a source that should not count, a summary point the cited signals do not support. Corrections are read by editors; they are not published.</p>
++<p>Tell the editors what is wrong: a signal attributed to the wrong thing, a source that should not count, a summary point the cited signals do not support. What you write, and who you are, stays with the editors. If they correct the summary, they publish a correction note saying what changed, next to the earlier revision.</p>
<form method="post" action="${epath(en)}/correct" class="rv-form">
<label>About <select name="target_type">${['entity', 'signal', 'summary', 'aggregate', 'alias'].map((x) => html`<option value="${x}"${values.target_type === x ? raw(' selected') : ''}>${x}</option>`)}</select></label>
<label>Which one (optional: a signal id such as sig_…) <input type="text" name="target_id" maxlength="100" value="${values.target_id || ''}"></label>
@@@ -284,7 +301,11 @@ ${pending.length ? html`<ul>${pending.map((x) => html`<li><a href="${epath(x.ent
${flagged.length ? html`<ul>${flagged.map((s) => html`<li><a href="${epath(s.entity)}">${s.entity.name}</a>: ${s.flag_reason}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
<h2 id="corrections">Open corrections</h2>
${corrections.length ? html`<ul>${corrections.map((c) => html`<li><a href="${epath(c.entity)}">${c.entity.name}</a> · ${c.target_type}${c.target_id ? ` ${c.target_id}` : ''} · ${t(c.created_at)}<blockquote>${c.body}</blockquote>${safeUrl(c.evidence_url) ? html`<a href="${safeUrl(c.evidence_url)}" rel="nofollow noopener">evidence</a>` : ''}
--<form method="post" action="/editor/corrections/${e(c.id)}" class="rv-inline"><input type="text" name="note" placeholder="Note" maxlength="500"> <button type="submit" name="status" value="accepted">Accepted</button> <button type="submit" name="status" value="rejected">Rejected</button></form></li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
++<form method="post" action="/editor/corrections/${e(c.id)}" class="rv-form">
++${c.summary_published ? html`<label>Correction note, published with a new summary revision (required to accept) <input type="text" name="correction_note" maxlength="2000"></label>
++<p class="rv-muted">Accepting publishes the current summary text again as a correction revision with this note. To change the text as well, <a href="${epath(c.entity)}/edit?correction=${e(c.id)}#summary-form">correct the summary</a>.</p>` : ''}
++<label>Note for editors (not published) <input type="text" name="note" maxlength="500"></label>
++<button type="submit" name="status" value="accepted">Accepted</button> <button type="submit" name="status" value="rejected">Rejected</button></form></li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
</section>`;
}
@@@ -308,7 +329,7 @@ function newEntityPage({ values = {}, error = null } = {}) {
<button type="submit">Create</button></form></section>`;
}
--function editEntityPage({ page: p, error = null, flash = null, entities = [] }) {
++function editEntityPage({ page: p, error = null, flash = null, correcting = null, entities = [] }) {
const en = p.entity;
const allSignals = p.signals;
const sel = (name, chosen = []) => html`<select name="${name}" multiple size="4">${allSignals.map((s) => html`<option value="${s.signal_id}"${chosen.includes(s.signal_id) ? raw(' selected') : ''}>${s.signal_id.slice(-8)} · ${s.source_key} · ${signalValue(s)}</option>`)}</select>`;
@@@ -336,12 +357,14 @@ function editEntityPage({ page: p, error = null, flash = null, entities = [] })
<h2>Summary</h2>
<p class="rv-muted">Every pro and con cites the signals it rests on. A summary carries no rating. Saving creates a new revision; "Save and publish" publishes it.</p>
--<form method="post" action="${epath(en)}/summary" class="rv-form">
++<form method="post" action="${epath(en)}/summary" class="rv-form" id="summary-form">
++${correcting ? html`<div class="rv-notice rv-correction"><p>Correcting the summary in answer to a reader's correction request (${correcting.target_type}${correcting.target_id ? ` ${correcting.target_id}` : ''}):</p><blockquote>${correcting.body}</blockquote><p class="rv-muted">Publishing accepts the request. The request stays with the editors; the correction note below is what readers see.</p></div><input type="hidden" name="correction_id" value="${correcting.id}">` : ''}
<label>Overview (Markdown) <textarea name="overview" rows="6" maxlength="6000">${cur ? cur.overview : ''}</textarea></label>
<label>The overview cites ${sel('overview_signals', ov ? ov.citations.filter((c) => c.ok).map((c) => c.signal_id) : [])}</label>
<fieldset><legend>Pros</legend>${rows('pro', pros)}</fieldset>
<fieldset><legend>Cons</legend>${rows('con', cons)}</fieldset>
<label>Revision note <input type="text" name="message" maxlength="500"></label>
++${cur ? html`<label>Correction note (public; fill it in only to correct the published summary: the revision is published at once and the note stays in its history) <input type="text" name="correction_note" maxlength="2000"${correcting ? raw(' required minlength="10"') : ''}></label>` : ''}
<input type="hidden" name="expected_revision" value="${String(p.summary ? p.summary.head_revision : 0)}">
<button type="submit" name="publish" value="0">Save</button> <button type="submit" name="publish" value="1">Save and publish</button></form>
${p.summary && p.summary.pending && p.summary.pending.length ? html`<p>Waiting for review: ${p.summary.pending.map((r, i) => html`${i ? ', ' : ''}<a href="${epath(en)}/summary/${String(r.number)}">revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span>`)}</p>` : ''}
ddiff --git a/server/reviews/service.js b/server/reviews/service.js
iindex b2def36..28fc5c6 100644
---- a/server/reviews/service.js
++++ b/server/reviews/service.js
@@@ -7,8 +7,8 @@
*
* Every write runs in one SQLite transaction together with the events it causes (transactional
* outbox): reviews.entity.merged|split, reviews.signal.added|removed,
-- * reviews.summary.published|updated|unpublished, and the Search index events
-- * reviews.index_document.upserted|deleted.
++ * reviews.summary.published|updated|unpublished (a correction is an `updated` carrying
++ * `correction`), and the Search index events reviews.index_document.upserted|deleted.
*
* Methods take an actor (server/reviews/access.js) and throw ReviewsError (status + stable code).
*/
@@@ -34,8 +34,11 @@ const SUMMARY_WORKFLOW = 'reviews.summarize_entity';
const MAX_POINTS = 12;
const MAX_POINT = 600;
const MAX_OVERVIEW = 6000;
++const MIN_CORRECTION_NOTE = 10;
++const MAX_CORRECTION_NOTE = 2000;
// A summary is text with citations. Nothing in it can carry a score.
--const SUMMARY_KEYS = new Set(['overview', 'pros', 'cons', 'overview_signals', 'expected_revision', 'expectedRevision', 'message', 'publish', 'workflow', 'stub_provider', 'stubProvider', 'note']);
++const SUMMARY_KEYS = new Set(['overview', 'pros', 'cons', 'overview_signals', 'expected_revision', 'expectedRevision', 'message', 'publish', 'workflow', 'stub_provider', 'stubProvider', 'note', 'correction_note', 'correction_id']);
++const BODY_KEYS = ['overview', 'overview_signals', 'pros', 'cons'];
const RATING_KEY_RE = /(rating|stars?|score|grade|verdict_value)/i;
// Numeric ratings in AI text ("4.5/5", "8 out of 10", "★★★★"): AI output never states a rating.
const RATING_TEXT_RE = /(\b\d+(?:[.,]\d+)?\s*(?:\/|out of)\s*(?:5|10|100)\b)|[★☆⭐]|\b\d+(?:[.,]\d+)?\s*stars?\b/i;
@@@ -332,6 +335,90 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
for (const p of summaryPoints(rev)) for (const id of p.signals) q.insertCitation.run(summary.id, rev.number, p.key, id);
}
++ function expectedOf(input, head) {
++ if (input.expected_revision != null && input.expected_revision !== '') return Number(input.expected_revision);
++ if (input.expectedRevision != null && input.expectedRevision !== '') return Number(input.expectedRevision);
++ return head;
++ }
++
++ function createRevision(args) {
++ try {
++ return revisions.create(args).revision;
++ } catch (err) {
++ if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
++ throw err;
++ }
++ }
++
++ // ── Corrections of a published summary ───────────────────
++ const wantsCorrection = (input) => [input.correction_note, input.correction_id].some((v) => v != null && String(v).trim() !== '');
++
++ /** The correction note is public text: what was wrong and what changed. */
++ function checkCorrectionNote(v) {
++ const s = String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
++ if (s.length < MIN_CORRECTION_NOTE) fail(422, 'correction.note_required', `A correction says what was corrected, in a note readers will see (at least ${MIN_CORRECTION_NOTE} characters)`);
++ if (s.length > MAX_CORRECTION_NOTE) fail(422, 'correction.invalid', `A correction note is at most ${MAX_CORRECTION_NOTE} characters`);
++ return s;
++ }
++
++ /**
++ * Who answers for a correction revision: the correcting editor joins the published revision's
++ * authors. AI-drafted text a person corrected is AI-assisted (hybrid), never "written by a person".
++ */
++ function correctionAuthorship(base, who) {
++ const authors = [...new Set([...((base && base.authors) || []), who])];
++ if (base && (base.mode === 'ai' || base.mode === 'hybrid') && base.workflow) {
++ return authorship.record({ mode: 'hybrid', authors, workflow: base.workflow, stubProvider: !!base.stubProvider });
++ }
++ return authorship.record({ mode: 'human', authors });
++ }
++
++ /** The published text as summary input (a correction that changes no words, e.g. after a signal fix). */
++ function carryForward(rev) {
++ const f = rev.fields || {};
++ return { overview: rev.content || '', overview_signals: f.overview_signals || [], pros: f.pros || [], cons: f.cons || [] };
++ }
++
++ /**
++ * Corrects a published summary: a new immutable revision carrying the public correction note
++ * (who: its author, the editor; when: its time), approved by that editor and published at once.
++ * Earlier revisions stay in the public history. Without new text the published text is carried
++ * forward. An open correction request it answers is accepted in the same transaction; what the
++ * request said and who sent it stay with the editors.
++ */
++ function correctSummary(e, input, { note, requestId = null, resolutionNote = null }, actor, who) {
++ const text = checkCorrectionNote(note);
++ const summary = q.summary.get(e.id);
++ if (!summary || summary.state !== 'published' || !summary.published_revision) fail(409, 'summary.not_published', 'Only a published summary is corrected; save and publish a revision instead');
++ let request = null;
++ if (requestId != null && String(requestId).trim() !== '') {
++ request = q.correction.get(String(requestId).trim());
++ if (!request) fail(404, 'correction.not_found', 'No such correction');
++ if (canonicalOf(request.entity_id) !== e.id) fail(409, 'correction.other_entity', 'That correction is about another entity');
++ if (request.status !== 'open') fail(409, 'correction.closed', `Already ${request.status}`);
++ }
++ const pub = revisions.get(summary.id, summary.published_revision);
++ const body = checkSummaryInput(e.id, BODY_KEYS.some((k) => input[k] !== undefined) ? input : carryForward(pub));
++ const revision = createRevision({
++ entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
++ meta: {
++ authorship: correctionAuthorship(pub.meta && pub.meta.authorship, who),
++ correction: { note: text, request: request ? request.id : null, corrects: pub.number },
++ },
++ author: who, message: input.message ? String(input.message).slice(0, 500) : `Correction: ${text.slice(0, 200)}`, allowUnchanged: true,
++ });
++ writeCitations(summary, revision);
++ reviews.record({ entityId: summary.id, revision: revision.number, reviewer: who, decision: 'approved', note: 'correction' });
++ if (request) {
++ q.resolveCorrection.run('accepted', who, resolutionNote, now(), request.id);
++ audit(actor, 'correction.accepted', e.id, request.id, { note: resolutionNote, summary_revision: revision.number });
++ }
++ audit(actor, 'summary.corrected', e.id, summary.id, { revision: revision.number, corrects: pub.number, correction_id: request ? request.id : null });
++ svc.publishSummary(e.id, { revision: revision.number }, actor);
++ const fresh = q.summary.get(e.id);
++ return { summary: fresh, revision: summaryRevisionView(fresh, revisions.get(summary.id, revision.number), e.id), published: true, correction: request ? q.correction.get(request.id) : null };
++ }
++
function revisionStatus(summary, rev) {
const review = reviews.latest(summary.id, rev.number);
const rec = rev.meta && rev.meta.authorship;
@@@ -829,10 +916,35 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
disclosure: rec ? authorship.disclosure(rec, review) : null,
review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,
system: rev.meta && rev.meta.system ? rev.meta.system : null,
++ correction: correctionView(rev),
author: rev.author, message: rev.message, created_at: rev.createdAt,
};
}
++ /** The public side of a correction: the note, which revision it corrects, and whether a reader asked for it. */
++ function correctionView(rev) {
++ const c = rev && rev.meta && rev.meta.correction;
++ return c ? { note: c.note, corrects: c.corrects, requested: !!c.request } : null;
++ }
++
++ /** The summary's history as readers may see it, newest first (entity page). */
++ function publicHistory(summary) {
++ if (!summary) return [];
++ return revisions.list(summary.id, { limit: 200 }).filter((r) => publicRevision(summary, r)).map((r) => {
++ const d = r.meta && r.meta.authorship ? authorship.disclosure(r.meta.authorship, reviews.latest(summary.id, r.number)) : null;
++ return { number: r.number, status: revisionStatus(summary, r), created_at: r.createdAt, disclosure: d ? d.short : null, correction: correctionView(r) };
++ });
++ }
++
++ // Audit detail readers never see: which correction request it was, what it said, how editors resolved it.
++ function auditDetail(r, editor) {
++ const d = parse(r.detail, {});
++ if (editor) return d;
++ if (/^correction\./.test(r.action)) return {};
++ const { correction_id: _hidden, ...rest } = d;
++ return rest;
++ }
++
// ── Public API ───────────────────────────────────────────
const svc = {
access, ReviewsError, entityPath, entityUrl, canonicalOf, closure,
@@@ -889,6 +1001,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
published_at: toIso(summary.published_at), revision_published_at: toIso(summary.revision_published_at),
published: pub ? summaryRevisionView(summary, pub, e.id) : null,
pending: editor ? pendingRevisions(summary).map((p) => summaryRevisionView(summary, p.rev, e.id)) : pendingRevisions(summary).length,
++ history: publicHistory(summary),
} : null,
open_corrections: q.openCorrectionsOf.get(e.id).n,
decision: decide(e),
@@@ -912,8 +1025,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
})),
signals: allSignalsIn(closure(e.id)).map(signalView),
audit: q.auditOf.all(e.id, e.id).map((r) => ({
-- id: r.id, at: toIso(r.at), action: r.action, target: r.target,
-- actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: parse(r.detail, {}),
++ id: r.id, at: toIso(r.at), action: r.action, target: editor || !/^correction\./.test(r.action) ? r.target : null,
++ actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: auditDetail(r, editor),
})),
};
},
@@@ -1245,27 +1358,24 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
trustMap,
// Summaries (reviews.summary.publish | reviews.summary.propose) ----------------------
-- /** An editor writes a summary revision; publish: true publishes it at once. */
++ /**
++ * An editor writes a summary revision; publish: true publishes it at once. With a
++ * correction_note (and optionally the correction_id of a reader's request it answers) the
++ * revision corrects the published summary: it carries the note and is published at once.
++ */
writeSummary(ref, input = {}, actor) {
const who = requireEditorPerson(actor, 'Writing a summary');
return tx(() => {
const e = entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
++ if (wantsCorrection(input)) return correctSummary(e, input, { note: input.correction_note, requestId: input.correction_id }, actor, who);
const body = checkSummaryInput(e.id, input);
const summary = ensureSummary(e.id);
-- const head = revisions.headNumber(summary.id);
-- const expected = input.expected_revision != null ? Number(input.expected_revision) : input.expectedRevision != null ? Number(input.expectedRevision) : head;
-- let revision;
-- try {
-- ({ revision } = revisions.create({
-- entityId: summary.id, expectedRevision: expected, content: body.content, fields: body.fields,
-- meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,
-- message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,
-- }));
-- } catch (err) {
-- if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
-- throw err;
-- }
++ const revision = createRevision({
++ entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
++ meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,
++ message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,
++ });
writeCitations(summary, revision);
audit(actor, 'summary.revision', e.id, summary.id, { revision: revision.number });
let published = null;
@@@ -1353,7 +1463,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
product: 'reviews', type: 'summary', action, id: summary.id, revision: n,
actor: hooks.subjectRef(actorId(actor)), decision, now: now(),
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: false, visibility: 'public', canonical_url: entityUrl(fresh), publication_state: 'published', indexability: hooks.searchIndexability(decision) },
-- extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null },
++ extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null, ...(rev.meta && rev.meta.correction ? { correction: { note: rev.meta.correction.note, corrects: rev.meta.correction.corrects } } : {}) },
}));
}
return q.summary.get(e.id);
@@@ -1431,18 +1541,40 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return q.correction.get(id);
});
},
-- openCorrections() { return q.openCorrections.all().map((c) => ({ ...c, entity: q.entity.get(c.entity_id) })); },
++ openCorrections() {
++ return q.openCorrections.all().map((c) => {
++ const entity = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);
++ const s = entity && entity.state === 'active' ? q.summary.get(entity.id) : null;
++ return { ...c, entity, summary_published: !!(s && s.state === 'published') };
++ });
++ },
correction(id) { return q.correction.get(String(id)) || null; },
-- resolveCorrection(id, { status, note = null } = {}, actor) {
-- requireEditorPerson(actor, 'Resolving a correction');
++ /**
++ * An editor accepts or rejects a correction request. `note` stays with the editors. Accepting
++ * a request about an entity whose summary is published corrects that summary: a new revision
++ * with the public `correction_note` (and the new text in `summary`, or the published text
++ * carried forward). Rejecting creates nothing.
++ */
++ resolveCorrection(id, { status, note = null, correction_note: correctionNote = null, summary: body = null } = {}, actor) {
++ const who = requireEditorPerson(actor, 'Resolving a correction');
if (!['accepted', 'rejected'].includes(status)) fail(422, 'correction.invalid', 'status is accepted or rejected');
++ if (body != null && (typeof body !== 'object' || Array.isArray(body))) fail(422, 'summary.invalid', 'summary is { overview, overview_signals, pros, cons }');
return tx(() => {
const c = q.correction.get(String(id));
if (!c) fail(404, 'correction.not_found', 'No such correction');
if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);
-- q.resolveCorrection.run(status, actorId(actor), note ? String(note).slice(0, 2000) : null, now(), c.id);
-- audit(actor, `correction.${status}`, c.entity_id, c.id, { note });
-- return q.correction.get(c.id);
++ const internal = note ? String(note).slice(0, 2000) : null;
++ if (status === 'accepted') {
++ const e = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);
++ const s = e && e.state === 'active' ? q.summary.get(e.id) : null;
++ if (s && s.state === 'published') {
++ const out = correctSummary(e, { ...(body || {}) }, { note: correctionNote, requestId: c.id, resolutionNote: internal }, actor, who);
++ return { correction: out.correction, revision: out.revision };
++ }
++ }
++ q.resolveCorrection.run(status, who, internal, now(), c.id);
++ audit(actor, `correction.${status}`, c.entity_id, c.id, { note: internal });
++ return { correction: q.correction.get(c.id), revision: null };
});
},
ddiff --git a/test/corrections.test.js b/test/corrections.test.js
nnew file mode 100644
iindex 0000000..2127603
---- /dev/null
++++ b/test/corrections.test.js
@@@ -0,0 +1,221 @@
++'use strict';
++/**
++ * Corrections: accepting a reader's correction of a published summary (or an editor correcting it
++ * on their own) creates a new immutable summary revision that carries the public correction note,
++ * is published at once, and shows on the entity page without JavaScript next to the earlier
++ * revisions, which stay readable. The request's text, the internal note and who sent it never
++ * reach a public surface. Rejecting creates nothing; only editors decide.
++ */
++const assert = require('assert');
++const {
++ boot, req, suite, steamItem, importItem, createEntity, outbox, cookieFor, editorToken, readerToken, serviceToken,
++ EDITOR, READER,
++} = require('./helpers');
++
++const t = suite('corrections');
++const noScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
++const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++const revCount = (h, summaryId) => h.db.prepare('SELECT COUNT(*) AS n FROM review_summary_revisions WHERE entity_id = ?').get(summaryId).n;
++
++const OLD = 'Every sampled Steam reviewer recommends it without reservation.';
++const NEW = 'Three of the four sampled Steam reviewers recommend it; one does not.';
++const NOTE = 'The overview said every sampled reviewer recommends it; one of the four does not.';
++const REQUEST = 'The summary claims unanimous praise but a negative review is listed right below it.';
++const INTERNAL = 'internal: checked the signal table, reader is right';
++
++async function setup(h) {
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ for (const v of [true, true, true, false]) await importItem(h, steamItem({ votedUp: v }));
++ const sigs = h.db.prepare("SELECT id, recommended FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);
++ const pos = sigs.filter((s) => s.recommended === 1).map((s) => s.id);
++ const neg = sigs.filter((s) => s.recommended === 0).map((s) => s.id);
++ const w = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
++ overview: OLD, overview_signals: pos, pros: [{ text: 'Recommended by the sample', signals: pos }], publish: true,
++ } });
++ assert.strictEqual(w.status, 201, w.text);
++ const summaryId = w.json.summary.id;
++ return { e, pos, neg, summaryId };
++}
++
++async function submit(h, e, body = REQUEST) {
++ const r = await req(h, 'POST', `/e/${e.slug}/correct`, { cookie: cookieFor(readerToken()), form: { target_type: 'summary', body, evidence_url: 'https://store.steampowered.com/app/620/' } });
++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
++ return h.db.prepare('SELECT * FROM review_corrections WHERE body = ?').get(body);
++}
++
++t('accepting a correction publishes a new revision with its note; the page shows it and the history without JavaScript', async () => {
++ const h = await boot();
++ try {
++ const { e, pos, neg, summaryId } = await setup(h);
++ const c = await submit(h, e);
++ assert.strictEqual(c.submitted_by, READER);
++
++ // Only an editor who is a person decides, and nothing is created by a refused attempt.
++ const patch = (token, body, headers) => req(h, 'PATCH', `/api/v1/corrections/${c.id}`, { token, body, headers });
++ const accept = { status: 'accepted', correction_note: NOTE };
++ assert.strictEqual((await patch(readerToken(), accept)).status, 403);
++ assert.strictEqual((await req(h, 'POST', `/editor/corrections/${c.id}`, { cookie: cookieFor(readerToken()), form: { status: 'accepted', correction_note: NOTE } })).status, 403);
++ assert.strictEqual((await req(h, 'POST', `/editor/corrections/${c.id}`, { form: { status: 'accepted', correction_note: NOTE } })).status, 401);
++ assert.strictEqual((await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage', 'reviews.summary.publish'] }), accept)).status, 403, 'a service on its own');
++ assert.strictEqual((await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage', 'reviews.summary.publish'] }), accept, { 'X-OV-Subject': READER })).status, 403, 'a service acting for a reader');
++ const noPublish = await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage'] }), accept, { 'X-OV-Subject': EDITOR });
++ assert.strictEqual(noPublish.status, 403, 'publishing a correction revision needs reviews.summary.publish');
++ assert.strictEqual(noPublish.json.code, 'capability.denied');
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: readerToken(), body: { overview: NEW, overview_signals: pos, correction_note: NOTE } })).status, 403);
++ // A correction of a published summary says what changed.
++ const bare = await patch(editorToken(), { status: 'accepted' });
++ assert.strictEqual(bare.status, 422);
++ assert.strictEqual(bare.json.code, 'correction.note_required');
++ assert.strictEqual(revCount(h, summaryId), 1);
++ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c.id).status, 'open');
++
++ const ok = await patch(editorToken(), {
++ status: 'accepted', note: INTERNAL, correction_note: NOTE,
++ summary: { overview: NEW, overview_signals: [...pos, ...neg], pros: [{ text: 'Recommended by most of the sample', signals: pos }], cons: [{ text: 'Not by everyone', signals: neg }] },
++ });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.json.correction.status, 'accepted');
++ assert.strictEqual(ok.json.correction.resolved_by, EDITOR);
++ assert.strictEqual(ok.json.revision.number, 2);
++ assert.strictEqual(ok.json.revision.status, 'published');
++ assert.deepStrictEqual(ok.json.revision.correction, { note: NOTE, corrects: 1, requested: true });
++ assert.strictEqual(ok.json.revision.author, EDITOR, 'who: the editor who corrected it');
++
++ // Immutable revisions: the old text is still revision 1; the correction is revision 2.
++ const rows = h.db.prepare('SELECT number, content, meta, author FROM review_summary_revisions WHERE entity_id = ? ORDER BY number').all(summaryId);
++ assert.deepStrictEqual(rows.map((r) => [r.number, r.content]), [[1, OLD], [2, NEW]]);
++ assert.strictEqual(JSON.parse(rows[1].meta).correction.note, NOTE);
++ assert.throws(() => h.db.prepare('UPDATE review_summary_revisions SET content = ? WHERE entity_id = ? AND number = 2').run('rewritten', summaryId));
++
++ // The public page, without JavaScript: the corrected text, the note, and the history.
++ const page = await req(h, 'GET', `/e/${e.slug}`);
++ assert.strictEqual(page.status, 200);
++ const text = noScripts(page.text);
++ assert.ok(text.includes(NEW), 'current text reflects the correction');
++ assert.ok(!text.includes(OLD), 'the corrected text is not the current text');
++ assert.match(text, /<strong>Corrected<\/strong>/);
++ assert.match(text, /Revision history/);
++ assert.ok(text.includes(`Correction:</strong> ${NOTE}`));
++ assert.ok(text.includes(`href="/e/${e.slug}/summary/2"`) && text.includes(`href="/e/${e.slug}/summary/1"`), 'both revisions in the history');
++ const r1 = await req(h, 'GET', `/e/${e.slug}/summary/1`);
++ assert.strictEqual(r1.status, 200);
++ assert.ok(noScripts(r1.text).includes(OLD), 'the old revision stays readable');
++ assert.match(r1.text, /superseded/);
++ const r2 = noScripts((await req(h, 'GET', `/e/${e.slug}/summary/2`)).text);
++ assert.ok(r2.includes(NOTE) && r2.includes(NEW));
++ const hist = noScripts((await req(h, 'GET', `/e/${e.slug}/history`)).text);
++ assert.ok(hist.includes(NOTE));
++
++ // Structured data, feeds, events and Search follow the corrected revision.
++ const review = jsonLd(page.text).find((x) => x['@type'] === 'Review');
++ assert.ok(review.reviewBody.includes(NEW) && !review.reviewBody.includes(OLD));
++ assert.ok(review.dateModified, 'the Review says it was modified');
++ const feed = JSON.parse((await req(h, 'GET', '/feed.json')).text);
++ assert.ok(feed.items[0].summary.startsWith(`Correction: ${NOTE}\n${NEW}`), JSON.stringify(feed.items[0]));
++ const events = outbox(h);
++ const upd = events.filter((x) => x.event_type === 'reviews.summary.updated');
++ assert.strictEqual(upd.length, 1);
++ assert.deepStrictEqual(upd[0].payload.correction, { note: NOTE, corrects: 1 });
++ assert.strictEqual(upd[0].subject.revision, 2);
++ const doc = events.filter((x) => x.event_type === 'reviews.index_document.upserted').pop().payload;
++ assert.ok(doc.body.includes(NEW) && !doc.body.includes(OLD), 'Search gets the corrected text');
++
++ // Nothing private leaks: not the request, not who sent it, not the editors' note.
++ const surfaces = [
++ page.text, r1.text, hist, (await req(h, 'GET', `/e/${e.slug}.json`)).text,
++ (await req(h, 'GET', `/api/v1/entities/${e.slug}`)).text, (await req(h, 'GET', `/api/v1/entities/${e.slug}/history`)).text,
++ (await req(h, 'GET', `/api/v1/entities/${e.slug}/summary/revisions/2`)).text,
++ (await req(h, 'GET', '/feed.json')).text, (await req(h, 'GET', '/feed.atom')).text, JSON.stringify(events),
++ ];
++ for (const s of surfaces) {
++ for (const secret of [REQUEST, INTERNAL, READER, c.id]) assert.ok(!s.includes(secret), `public surface carries ${secret}`);
++ }
++ // Editors still see the whole record.
++ const edHist = await req(h, 'GET', `/api/v1/entities/${e.slug}/history`, { token: editorToken() });
++ assert.ok(edHist.text.includes(c.id) && edHist.text.includes(INTERNAL));
++
++ // Closed is closed.
++ assert.strictEqual((await patch(editorToken(), accept)).status, 409);
++ } finally { await h.stop(); }
++});
++
++t('a rejected correction creates no revision; editors correct with plain forms, on their own or from the desk', async () => {
++ const h = await boot();
++ try {
++ const { e, pos, neg, summaryId } = await setup(h);
++ const ed = cookieFor(editorToken());
++ const c1 = await submit(h, e, 'I think this game deserves a higher score overall, honestly.');
++ const desk = noScripts((await req(h, 'GET', '/editor', { cookie: ed })).text);
++ assert.match(desk, /name="correction_note"/, 'the desk asks for the public note when a summary is published');
++ const rej = await req(h, 'POST', `/editor/corrections/${c1.id}`, { cookie: ed, form: { status: 'rejected', note: 'opinion, not an error', correction_note: 'ignored when rejecting' } });
++ assert.strictEqual(rej.status, 303);
++ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c1.id).status, 'rejected');
++ assert.strictEqual(revCount(h, summaryId), 1, 'rejecting creates no revision');
++ assert.ok(!outbox(h).some((x) => x.event_type === 'reviews.summary.updated'));
++
++ // From the desk: accept with a note, the published text carried forward as a correction revision.
++ const c2 = await submit(h, e, 'The signal table lists a review that its source has since edited.');
++ const short = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'fixed' } });
++ assert.strictEqual(short.status, 422, 'a correction note is a sentence readers can use');
++ assert.strictEqual(revCount(h, summaryId), 1);
++ const acc = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'Checked the cited signals against their sources; the text stands.' } });
++ assert.strictEqual(acc.status, 303);
++ const r2 = h.db.prepare('SELECT content, meta FROM review_summary_revisions WHERE entity_id = ? AND number = 2').get(summaryId);
++ assert.strictEqual(r2.content, OLD, 'carried forward');
++ assert.strictEqual(JSON.parse(r2.meta).correction.note, 'Checked the cited signals against their sources; the text stands.');
++
++ // From the edit page, answering a request: the form names it and accepts it on publish.
++ const c3 = await submit(h, e, 'Only three of the four sampled reviewers recommend it, not all of them.');
++ const form = await req(h, 'GET', `/e/${e.slug}/edit?correction=${c3.id}`, { cookie: ed });
++ assert.ok(form.text.includes(`name="correction_id" value="${c3.id}"`));
++ assert.ok(noScripts(form.text).includes('Only three of the four sampled reviewers'), 'editors see the request');
++ const fixed = await req(h, 'POST', `/e/${e.slug}/summary`, { cookie: ed, form: {
++ overview: NEW, overview_signals: [...pos, ...neg], pro_text_0: 'Recommended by most of the sample', pro_signals_0: pos,
++ con_text_0: 'Not by everyone', con_signals_0: neg, correction_id: c3.id, correction_note: NOTE, expected_revision: '2', publish: '0',
++ } });
++ assert.strictEqual(fixed.status, 303, fixed.text.slice(0, 400));
++ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c3.id).status, 'accepted');
++ const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
++ assert.strictEqual(s.published_revision, 3, 'a correction is published at once');
++
++ // An editor corrects without any request.
++ const own = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
++ overview: `${NEW} The sample is recent reviews only.`, overview_signals: pos, correction_note: 'Added that the sample covers recent reviews only.',
++ } });
++ assert.strictEqual(own.status, 201, own.text);
++ assert.strictEqual(own.json.revision.number, 4);
++ assert.deepStrictEqual(own.json.revision.correction, { note: 'Added that the sample covers recent reviews only.', corrects: 3, requested: false });
++ const page = noScripts((await req(h, 'GET', `/e/${e.slug}`)).text);
++ for (const n of [NOTE, 'Added that the sample covers recent reviews only.', 'Checked the cited signals against their sources; the text stands.']) assert.ok(page.includes(n), n);
++ assert.match(page, /3 corrections\./);
++ assert.ok(!page.includes('opinion, not an error'));
++
++ // Nothing published → nothing to correct.
++ const other = await createEntity(h, { name: 'Unsummarised', kind: 'game' });
++ const none = await req(h, 'POST', `/api/v1/entities/${other.slug}/summary/revisions`, { token: editorToken(), body: { overview: 'x', overview_signals: pos, correction_note: 'This cannot be a correction.' } });
++ assert.strictEqual(none.status, 409);
++ assert.strictEqual(none.json.code, 'summary.not_published');
++ } finally { await h.stop(); }
++});
++
++t('a correction of approved AI text is AI-assisted, never "written by a person"', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ for (const v of [true, true, false]) await importItem(h, steamItem({ votedUp: v }));
++ const sigs = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all().map((s) => s.id);
++ const p = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: serviceToken({ client: 'ai', cap: ['reviews.summary.propose'] }), body: {
++ overview: 'All sampled reviewers recommend it.', overview_signals: sigs, workflow: { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ', version: 1 },
++ } });
++ assert.strictEqual(p.status, 201, p.text);
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions/1/review`, { token: editorToken(), body: { decision: 'approved' } })).status, 200);
++ const c = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
++ overview: 'Two of the three sampled reviewers recommend it.', overview_signals: sigs, correction_note: 'The AI draft said all reviewers recommend it; one does not.',
++ } });
++ assert.strictEqual(c.status, 201, c.text);
++ assert.strictEqual(c.json.revision.authorship.mode, 'hybrid');
++ const page = noScripts((await req(h, 'GET', `/e/${e.slug}`)).text);
++ assert.match(page, /Written by a person with AI assistance/);
++ assert.ok(page.includes('The AI draft said all reviewers recommend it; one does not.'));
++ } finally { await h.stop(); }
++});