openvibers/openvibe.news Commits
News: the story API returns the real indexability decision; docs match production ingest (#40)
Changes
@@@ -7,8 +7,9 @@**public at `openvibe.news` since 2026-10-09**, after an independent pre-launch security review whosefixes shipped first; the domain no longer shows the OpenVibe.Sites placeholder. Its capabilities andservice manifest are registered in openvibe-contracts v0.97.0.--The production database holds 12 topics and no stories: it has not ingested anything from the--running OpenVibe.Sources service, because every Sources seed is disabled.++Since 2026-10-11 production ingests from the running OpenVibe.Sources service (NASA news releases, GOV.UK++news, the EU press corner): 70 source items in 18 clusters, 12 topics, and no stories yet, because a story++exists only once an editor opens one from a cluster and publishes it (below).**Domain:** `openvibe.news` · **Port:** 4820 · **Service id:** `news`**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.7; roadmap §15.13, §29, §32.**License:** AGPL-3.0 (same as every OpenVibe service).@@@ -285,8 +286,8 @@ Each grant is `[client, capability, audience]`:| The contract proposals are valid and match the code; every emitted event type is declared. | `test/contracts.test.js` || Health, readiness, release, robots, llms.txt, llms-full.txt, sitemaps, legal, problems, CORS, topics-only seed. | `test/ops.test.js` |--Not yet demonstrated: ingestion from the deployed OpenVibe.Sources (its seeds are disabled until a--person verifies their terms; production has 181 ingest runs and 0 items), delivery of a real item++Demonstrated in production on 2026-10-11: ingestion from the deployed OpenVibe.Sources (70 items, 18++clusters). Not yet demonstrated: a published story (it waits on an editor), delivery of a real itemthrough the running OpenVibe.Events (the `sources.item.*` and `sources.fetch.failed` subscriptionsexist), and an OpenVibe.AI run (OpenVibe.AI runs on the host, but News in production has no`OV_AI_INTERNAL_URL`; the seam is tested against a mock of its runs API).@@@ -31,11 +31,11 @@],"capabilitiesRegistered": true,"serviceManifestRegistered": true,-- "inputs": "OpenVibe.Sources items (category news) via the Events subscriptions sources.item.* and sources.fetch.failed (created in production 2026-09-23) and a cursor pull; every Sources seed is disabled, so no item has arrived yet (181 empty ingest runs, 0 source items)",++ "inputs": "OpenVibe.Sources items (category news) via the Events subscriptions sources.item.* and sources.fetch.failed (created in production 2026-09-23) and a cursor pull; in production since 2026-10-11: 70 source items from NASA news releases, GOV.UK news and the EU press corner, grouped into 18 clusters","ai": "optional seam (news.summarize_story, news.compare_perspectives) tested against a mock; not configured in production (OV_AI_INTERNAL_URL unset); OpenVibe.AI output only ever lands as a draft that needs a person's review",-- "seed": "topics only (12 in production); no stories, source items or clusters",++ "seed": "topics only (12 in production); stories exist only once an editor opens and publishes one","deployed": true,-- "deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news on 127.0.0.1:4820 behind nginx; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Public at openvibe.news since 2026-10-09, after an independent pre-launch security review whose fixes shipped first; the OpenVibe.Sites placeholder is gone. The database still holds no stories: no source item has arrived while every Sources seed is disabled",++ "deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news on 127.0.0.1:4820 behind nginx; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Public at openvibe.news since 2026-10-09, after an independent pre-launch security review whose fixes shipped first; the OpenVibe.Sites placeholder is gone. The database holds 70 source items in 18 clusters and no stories: the first waits on an editor (NEWS_EDITORS or a Network admin) opening one from a cluster at /edit","placeholderInSites": false,"features": ["Sources ingestion by Events subscription and cursor pull, with dedupe",@@@ -102,13 +102,17 @@ function createApi(ctx) {async function storyDto(story) {const head = await stories.head(story);const topic = await publication.topicOf(story);++ // The gate's decision for the published revision. Awaited here: a returned promise would++ // serialize as {} in the JSON, leaving the editor/API without the real robots decision.++ const publishedRev = story.published_revision ? await stories.revision(story, story.published_revision) : null;++ const indexability = publishedRev ? await publication.decide(story, publishedRev) : null;return {id: story.id, slug: story.slug, url: publication.storyUrl(story), state: story.state, cluster_id: story.cluster_id,topic: topic ? topic.slug : null, working_headline: story.working_headline, noindex: Boolean(story.noindex),published_revision: story.published_revision, first_published_at: iso(story.first_published_at), published_at: iso(story.published_at), retracted_at: iso(story.retracted_at),head: head ? { revision: head.number, headline: head.fields.headline, paragraphs: head.fields.paragraphs, authorship: head.meta.authorship || null, system: head.meta.system || null, gaps: head.meta.gaps || null, author: head.author, created_at: head.createdAt } : null,publishable: head ? await stories.problems(story, head) : [{ code: 'story.no_text', detail: 'Nothing has been written yet' }],-- indexability: story.published_revision ? (async () => { const d = await publication.decide(story, await stories.revision(story, story.published_revision)); return { indexable: d.indexable, robots: d.robots, reasons: d.reasons }; })() : null,++ indexability: indexability ? { indexable: indexability.indexable, robots: indexability.robots, reasons: indexability.reasons } : null,sources: (await stories.sources(story, { includeDetached: true })).map((s) => ({ n: s.n, perspective_id: s.perspective_id, detached_at: iso(s.detached_at), item: itemDto(s.item) })),timeline: (await stories.timeline(story)).map((t) => ({ id: t.id, occurred_on: t.occurred_on, text: t.text, source_item_id: t.source_item_id })),perspectives: (await stories.perspectives(story)).map((p) => ({ id: p.id, label: p.label, description: p.description })),@@@ -92,6 +92,11 @@ const BODY = [const r = await t.api(`/stories/${story.id}/publish`, { json: {} });assert.strictEqual(r.status, 200, r.text);assert.strictEqual(r.json().story.state, 'published');++ // The editor/API DTO carries the gate's real decision, not a promise that serializes as {}.++ assert.deepStrictEqual(r.json().story.indexability, { indexable: true, robots: 'index, follow', reasons: [] });++ const detail = (await t.api(`/stories/${story.id}`)).json();++ assert.deepStrictEqual(detail.story.indexability, { indexable: true, robots: 'index, follow', reasons: [] });++ assert.deepStrictEqual(detail.story.publishable, []);const page = await t.get('/stories/europa-clipper-launches-for-jupiter');assert.strictEqual(page.status, 200);assert.match(page.text, /<h1>Europa Clipper launches for Jupiter<\/h1>/);
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
**public at `openvibe.news` since 2026-10-09**, after an independent pre-launch security review whose
**public at `openvibe.news` since 2026-10-09**, after an independent pre-launch security review whose
fixes shipped first; the domain no longer shows the OpenVibe.Sites placeholder. Its capabilities and
fixes shipped first; the domain no longer shows the OpenVibe.Sites placeholder. Its capabilities and
service manifest are registered in openvibe-contracts v0.97.0.
service manifest are registered in openvibe-contracts v0.97.0.
-The production database holds 12 topics and no stories: it has not ingested anything from the
-running OpenVibe.Sources service, because every Sources seed is disabled.
+Since 2026-10-11 production ingests from the running OpenVibe.Sources service (NASA news releases, GOV.UK
+news, the EU press corner): 70 source items in 18 clusters, 12 topics, and no stories yet, because a story
+exists only once an editor opens one from a cluster and publishes it (below).
**Domain:** `openvibe.news` · **Port:** 4820 · **Service id:** `news`
**Domain:** `openvibe.news` · **Port:** 4820 · **Service id:** `news`
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.7; roadmap §15.13, §29, §32.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.7; roadmap §15.13, §29, §32.
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -285,8 +286,8 @@ Each grant is `[client, capability, audience]`:
@@ -285,8 +286,8 @@ Each grant is `[client, capability, audience]`:
| The contract proposals are valid and match the code; every emitted event type is declared. | `test/contracts.test.js` |
| The contract proposals are valid and match the code; every emitted event type is declared. | `test/contracts.test.js` |
| Health, readiness, release, robots, llms.txt, llms-full.txt, sitemaps, legal, problems, CORS, topics-only seed. | `test/ops.test.js` |
| Health, readiness, release, robots, llms.txt, llms-full.txt, sitemaps, legal, problems, CORS, topics-only seed. | `test/ops.test.js` |
-Not yet demonstrated: ingestion from the deployed OpenVibe.Sources (its seeds are disabled until a
-person verifies their terms; production has 181 ingest runs and 0 items), delivery of a real item
+Demonstrated in production on 2026-10-11: ingestion from the deployed OpenVibe.Sources (70 items, 18
+clusters). Not yet demonstrated: a published story (it waits on an editor), delivery of a real item
through the running OpenVibe.Events (the `sources.item.*` and `sources.fetch.failed` subscriptions
through the running OpenVibe.Events (the `sources.item.*` and `sources.fetch.failed` subscriptions
exist), and an OpenVibe.AI run (OpenVibe.AI runs on the host, but News in production has no
exist), and an OpenVibe.AI run (OpenVibe.AI runs on the host, but News in production has no
`OV_AI_INTERNAL_URL`; the seam is tested against a mock of its runs API).
`OV_AI_INTERNAL_URL`; the seam is tested against a mock of its runs API).
@@ -31,11 +31,11 @@
@@ -31,11 +31,11 @@
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"serviceManifestRegistered": true,
"serviceManifestRegistered": true,
- "inputs": "OpenVibe.Sources items (category news) via the Events subscriptions sources.item.* and sources.fetch.failed (created in production 2026-09-23) and a cursor pull; every Sources seed is disabled, so no item has arrived yet (181 empty ingest runs, 0 source items)",
+ "inputs": "OpenVibe.Sources items (category news) via the Events subscriptions sources.item.* and sources.fetch.failed (created in production 2026-09-23) and a cursor pull; in production since 2026-10-11: 70 source items from NASA news releases, GOV.UK news and the EU press corner, grouped into 18 clusters",
"ai": "optional seam (news.summarize_story, news.compare_perspectives) tested against a mock; not configured in production (OV_AI_INTERNAL_URL unset); OpenVibe.AI output only ever lands as a draft that needs a person's review",
"ai": "optional seam (news.summarize_story, news.compare_perspectives) tested against a mock; not configured in production (OV_AI_INTERNAL_URL unset); OpenVibe.AI output only ever lands as a draft that needs a person's review",
- "seed": "topics only (12 in production); no stories, source items or clusters",
+ "seed": "topics only (12 in production); stories exist only once an editor opens and publishes one",
"deployed": true,
"deployed": true,
- "deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news on 127.0.0.1:4820 behind nginx; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Public at openvibe.news since 2026-10-09, after an independent pre-launch security review whose fixes shipped first; the OpenVibe.Sites placeholder is gone. The database still holds no stories: no source item has arrived while every Sources seed is disabled",
+ "deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news on 127.0.0.1:4820 behind nginx; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Public at openvibe.news since 2026-10-09, after an independent pre-launch security review whose fixes shipped first; the OpenVibe.Sites placeholder is gone. The database holds 70 source items in 18 clusters and no stories: the first waits on an editor (NEWS_EDITORS or a Network admin) opening one from a cluster at /edit",
"placeholderInSites": false,
"placeholderInSites": false,
"features": [
"features": [
"Sources ingestion by Events subscription and cursor pull, with dedupe",
"Sources ingestion by Events subscription and cursor pull, with dedupe",
@@ -102,13 +102,17 @@ function createApi(ctx) {
@@ -102,13 +102,17 @@ function createApi(ctx) {
async function storyDto(story) {
async function storyDto(story) {
const head = await stories.head(story);
const head = await stories.head(story);
const topic = await publication.topicOf(story);
const topic = await publication.topicOf(story);
+ // The gate's decision for the published revision. Awaited here: a returned promise would
+ // serialize as {} in the JSON, leaving the editor/API without the real robots decision.
+ const publishedRev = story.published_revision ? await stories.revision(story, story.published_revision) : null;
+ const indexability = publishedRev ? await publication.decide(story, publishedRev) : null;
return {
return {
id: story.id, slug: story.slug, url: publication.storyUrl(story), state: story.state, cluster_id: story.cluster_id,
id: story.id, slug: story.slug, url: publication.storyUrl(story), state: story.state, cluster_id: story.cluster_id,
topic: topic ? topic.slug : null, working_headline: story.working_headline, noindex: Boolean(story.noindex),
topic: topic ? topic.slug : null, working_headline: story.working_headline, noindex: Boolean(story.noindex),
published_revision: story.published_revision, first_published_at: iso(story.first_published_at), published_at: iso(story.published_at), retracted_at: iso(story.retracted_at),
published_revision: story.published_revision, first_published_at: iso(story.first_published_at), published_at: iso(story.published_at), retracted_at: iso(story.retracted_at),
head: head ? { revision: head.number, headline: head.fields.headline, paragraphs: head.fields.paragraphs, authorship: head.meta.authorship || null, system: head.meta.system || null, gaps: head.meta.gaps || null, author: head.author, created_at: head.createdAt } : null,
head: head ? { revision: head.number, headline: head.fields.headline, paragraphs: head.fields.paragraphs, authorship: head.meta.authorship || null, system: head.meta.system || null, gaps: head.meta.gaps || null, author: head.author, created_at: head.createdAt } : null,
publishable: head ? await stories.problems(story, head) : [{ code: 'story.no_text', detail: 'Nothing has been written yet' }],
publishable: head ? await stories.problems(story, head) : [{ code: 'story.no_text', detail: 'Nothing has been written yet' }],
- indexability: story.published_revision ? (async () => { const d = await publication.decide(story, await stories.revision(story, story.published_revision)); return { indexable: d.indexable, robots: d.robots, reasons: d.reasons }; })() : null,
+ indexability: indexability ? { indexable: indexability.indexable, robots: indexability.robots, reasons: indexability.reasons } : null,
sources: (await stories.sources(story, { includeDetached: true })).map((s) => ({ n: s.n, perspective_id: s.perspective_id, detached_at: iso(s.detached_at), item: itemDto(s.item) })),
sources: (await stories.sources(story, { includeDetached: true })).map((s) => ({ n: s.n, perspective_id: s.perspective_id, detached_at: iso(s.detached_at), item: itemDto(s.item) })),
timeline: (await stories.timeline(story)).map((t) => ({ id: t.id, occurred_on: t.occurred_on, text: t.text, source_item_id: t.source_item_id })),
timeline: (await stories.timeline(story)).map((t) => ({ id: t.id, occurred_on: t.occurred_on, text: t.text, source_item_id: t.source_item_id })),
perspectives: (await stories.perspectives(story)).map((p) => ({ id: p.id, label: p.label, description: p.description })),
perspectives: (await stories.perspectives(story)).map((p) => ({ id: p.id, label: p.label, description: p.description })),
@@ -92,6 +92,11 @@ const BODY = [
@@ -92,6 +92,11 @@ const BODY = [
const r = await t.api(`/stories/${story.id}/publish`, { json: {} });
const r = await t.api(`/stories/${story.id}/publish`, { json: {} });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json().story.state, 'published');
assert.strictEqual(r.json().story.state, 'published');
+ // The editor/API DTO carries the gate's real decision, not a promise that serializes as {}.
+ assert.deepStrictEqual(r.json().story.indexability, { indexable: true, robots: 'index, follow', reasons: [] });
+ const detail = (await t.api(`/stories/${story.id}`)).json();
+ assert.deepStrictEqual(detail.story.indexability, { indexable: true, robots: 'index, follow', reasons: [] });
+ assert.deepStrictEqual(detail.story.publishable, []);
const page = await t.get('/stories/europa-clipper-launches-for-jupiter');
const page = await t.get('/stories/europa-clipper-launches-for-jupiter');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.match(page.text, /<h1>Europa Clipper launches for Jupiter<\/h1>/);
assert.match(page.text, /<h1>Europa Clipper launches for Jupiter<\/h1>/);