openvibers/openvibe.contracts Commits

v0.34.0: 51 event payload contracts, each read from its producer's emitter and all active (every one is emitted by code today): news 11 (source.ingested|failed, cluster.updated, story.created|flagged|published|updated|unpublished|retracted, index_document.upserted|deleted), reviews 9 (entity.merged|split, signal.added|removed, summary.published|updated|unpublished, index_document.*), coupons 8 (coupon.created|updated|expired|disabled, report.created, confidence.changed, index_document.*), deals 6 (offer.created|updated|expired, vote.changed, index_document.*), trade 5 (observation.created, source.stale|recovered, index_document.*), games 10 (player.joined|left, skill.leveled, blueprint.unlocked, world.saved, mod.installed|enabled|disabled|grants_changed|revoked) and media.object.deleted / media.object.visibility_changed (OpenVibe.Media 039dc48: trigger-staged, outbox in the changing transaction, identity and state only), which the media manifest now lists. News, Reviews and Deals payloads were checked against envelopes captured from their own code (185, 43 and the test suites); Trade against its one production outbox row. Valid and invalid fixtures for each (268 files). test/run.js: every news/reviews/coupons/deals/trade/games event has a contract owned by its producer, every *.index_document.upserted is a search.index-document@1 with its owner fixed and every .deleted a { type, id, revision } tombstone, Media's object events leak no title, owner, storage or size and never repeat a visibility

OP OpenVibers committed 24 Sept 2026 · f551ca670b · 324 files, +12227 −0


Changes

ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 37fb035c..42cf2b28 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -922,6 +922,465 @@
"compatibility": "backward",
"status": "active",
"adr": "ADR-027"
++ },
++ {
++ "id": "news.source.ingested",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.source.ingested.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.source.failed",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.source.failed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.cluster.updated",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.cluster.updated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.story.created",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.story.created.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.story.flagged",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.story.flagged.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.story.published",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.story.published.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.story.updated",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.story.updated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.story.unpublished",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.story.unpublished.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.story.retracted",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.story.retracted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.index_document.upserted",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.index_document.upserted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "news.index_document.deleted",
++ "version": "1.0.0",
++ "owner": "news",
++ "schema": "events/payloads/news.index_document.deleted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.entity.merged",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.entity.merged.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.entity.split",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.entity.split.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.signal.added",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.signal.added.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.signal.removed",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.signal.removed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.summary.published",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.summary.published.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.summary.updated",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.summary.updated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.summary.unpublished",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.summary.unpublished.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.index_document.upserted",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.index_document.upserted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "reviews.index_document.deleted",
++ "version": "1.0.0",
++ "owner": "reviews",
++ "schema": "events/payloads/reviews.index_document.deleted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.coupon.created",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.coupon.created.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.coupon.updated",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.coupon.updated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.coupon.expired",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.coupon.expired.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.coupon.disabled",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.coupon.disabled.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.report.created",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.report.created.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.confidence.changed",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.confidence.changed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.index_document.upserted",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.index_document.upserted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.index_document.deleted",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "events/payloads/coupons.index_document.deleted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "deals.offer.created",
++ "version": "1.0.0",
++ "owner": "deals",
++ "schema": "events/payloads/deals.offer.created.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "deals.offer.updated",
++ "version": "1.0.0",
++ "owner": "deals",
++ "schema": "events/payloads/deals.offer.updated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "deals.offer.expired",
++ "version": "1.0.0",
++ "owner": "deals",
++ "schema": "events/payloads/deals.offer.expired.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "deals.vote.changed",
++ "version": "1.0.0",
++ "owner": "deals",
++ "schema": "events/payloads/deals.vote.changed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "deals.index_document.upserted",
++ "version": "1.0.0",
++ "owner": "deals",
++ "schema": "events/payloads/deals.index_document.upserted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "deals.index_document.deleted",
++ "version": "1.0.0",
++ "owner": "deals",
++ "schema": "events/payloads/deals.index_document.deleted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "trade.observation.created",
++ "version": "1.0.0",
++ "owner": "trade",
++ "schema": "events/payloads/trade.observation.created.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "trade.source.stale",
++ "version": "1.0.0",
++ "owner": "trade",
++ "schema": "events/payloads/trade.source.stale.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "trade.source.recovered",
++ "version": "1.0.0",
++ "owner": "trade",
++ "schema": "events/payloads/trade.source.recovered.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "trade.index_document.upserted",
++ "version": "1.0.0",
++ "owner": "trade",
++ "schema": "events/payloads/trade.index_document.upserted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "trade.index_document.deleted",
++ "version": "1.0.0",
++ "owner": "trade",
++ "schema": "events/payloads/trade.index_document.deleted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.player.joined",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.player.joined.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.player.left",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.player.left.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.skill.leveled",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.skill.leveled.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.blueprint.unlocked",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.blueprint.unlocked.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.world.saved",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.world.saved.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.mod.installed",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.mod.installed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.mod.enabled",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.mod.enabled.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.mod.disabled",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.mod.disabled.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.mod.grants_changed",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.mod.grants_changed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "games.mod.revoked",
++ "version": "1.0.0",
++ "owner": "games",
++ "schema": "events/payloads/games.mod.revoked.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "media.object.deleted",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.object.deleted.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "media.object.visibility_changed",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.object.visibility_changed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
}
]
}
ddiff --git a/contracts/events/payloads/coupons.confidence.changed.v1.json b/contracts/events/payloads/coupons.confidence.changed.v1.json
nnew file mode 100644
iindex 00000000..6fd0a66c
---- /dev/null
++++ b/contracts/events/payloads/coupons.confidence.changed.v1.json
@@@ -0,0 +1,15 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.confidence.changed.v1.json",
++ "title": "CouponsConfidenceChangedPayload",
++ "description": "coupons.confidence.changed v1 (OpenVibe.Coupons server/domain/coupons.js recompute). A code's confidence moved, in the transaction of the recompute: a new or corrected report, evidence added or withdrawn (evidence on one of the merchant's own domains raises the prior), or the sweep letting old reports decay (to null once no report is younger than 30 days). confidence = (a0 + W) / (a0 + 1 + W + F), rounded to 2 decimals, over recency-weighted worked (W) and failed (F) reports (half-life 7 days, each reporter's latest report only; a0 = 1, or 1.5 with merchant evidence); null = no counted report in the 30-day window. from and to always differ. status is the code's status after the change; a status change also emits coupons.coupon.updated, .expired or .disabled. Never names a reporter. Envelope: subject { type: coupon, id: cpn_… }, visibility internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": ["merchant_id", "from", "to", "status"],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": { "type": "string", "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merchant the code belongs to." },
++ "from": { "type": ["number", "null"], "minimum": 0, "maximum": 1, "description": "Confidence before; null = no counted report." },
++ "to": { "type": ["number", "null"], "minimum": 0, "maximum": 1, "description": "Confidence after; null = no counted report left in the window." },
++ "status": { "enum": ["unknown", "reported_working", "reported_failed", "expired", "disabled"], "description": "The code's status after the change (reported_working at confidence >= 0.6, reported_failed at <= 0.4, given enough recent reports)." }
++ }
++}
ddiff --git a/contracts/events/payloads/coupons.coupon.created.v1.json b/contracts/events/payloads/coupons.coupon.created.v1.json
nnew file mode 100644
iindex 00000000..adcc7ac5
---- /dev/null
++++ b/contracts/events/payloads/coupons.coupon.created.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.created.v1.json",
++ "title": "CouponsCouponCreatedPayload",
++ "description": "coupons.coupon.created v1 (OpenVibe.Coupons server/domain/coupons.js submit and importFromSource, payload from lifecyclePayload). A new code was recorded, in the transaction that inserted it: submitted by a member, by staff or by an AI extraction run (POST /api/v1/coupons/submit or the site form), or imported from an OpenVibe.Sources item (origin source). Submitting a code the merchant already has adds evidence and emits no created event. A new code always starts with status unknown and confidence null: a submission can never carry a status or a confidence. AI-extracted codes, imported codes (unless COUPONS_SOURCES_AUTO_PUBLISH is true and the merchant is active) and codes for a pending merchant start with review_state pending. Left out on purpose: the code text, title, description and restrictions (Search gets them from coupons.index_document.*) and who submitted it (submitters stay inside Coupons). Envelope: subject { type: coupon, id: cpn_… }, visibility public when the new code is publicly listed (published, merchant active, not expired), otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": ["merchant_id", "status", "confidence", "review_state", "expires_at", "canonical_url", "origin"],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": { "type": "string", "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merchant the code belongs to." },
++ "status": { "const": "unknown", "description": "Every new code starts unknown; only people's reports can move it to reported_working or reported_failed." },
++ "confidence": { "type": "null", "description": "Always null at creation: no counted report yet." },
++ "review_state": { "enum": ["pending", "published"], "description": "pending = waits for staff review (AI-extracted, imported without auto-publish, or merchant pending); published = eligible for active results." },
++ "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "Known expiry (UTC; a date-only expiry is 23:59:59.999Z of that day). null = expiry unknown, never defaulted." },
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "The code's page on Coupons (<origin>/c/<coupon id>)." },
++ "origin": { "enum": ["member", "staff", "source", "ai"], "description": "How the code arrived: a member's or a staff member's submission, an OpenVibe.Sources import, or an AI extraction run (X-OV-Origin: ai)." }
++ }
++}
ddiff --git a/contracts/events/payloads/coupons.coupon.disabled.v1.json b/contracts/events/payloads/coupons.coupon.disabled.v1.json
nnew file mode 100644
iindex 00000000..af2678b6
---- /dev/null
++++ b/contracts/events/payloads/coupons.coupon.disabled.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.disabled.v1.json",
++ "title": "CouponsCouponDisabledPayload",
++ "description": "coupons.coupon.disabled v1 (OpenVibe.Coupons server/domain/coupons.js recompute via withdrawSourceItem and setStatus, payload from lifecyclePayload). A code was taken down, in the transaction that recorded it: the last OpenVibe.Sources item that evidenced it was removed at the source (reason source_removed), or staff or a service holding coupons.status.update disabled it (staff, service:<client>[:<note>]). Its page answers 410, it cannot be resubmitted and nothing is deleted. Disabling a merchant does not emit this per code (its codes only get Search tombstones). When the code was in Search, a coupons.index_document.deleted tombstone follows in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code was publicly listed before the change, otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": ["merchant_id", "status", "confidence", "review_state", "expires_at", "canonical_url", "previous_status", "reason"],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": { "type": "string", "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merchant the code belongs to." },
++ "status": { "const": "disabled" },
++ "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 1, "description": "Report confidence at the time (rounded to 2 decimals); null = no counted report in the last 30 days." },
++ "review_state": { "enum": ["pending", "published"] },
++ "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "Known expiry (UTC); null = expiry unknown." },
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "The code's page on Coupons (<origin>/c/<coupon id>); it now answers 410." },
++ "previous_status": { "enum": ["unknown", "reported_working", "reported_failed", "expired"], "description": "Status before it was disabled." },
++ "reason": {
++ "description": "source_removed, staff, or service:<client>[:<note>].",
++ "anyOf": [
++ { "enum": ["source_removed", "staff"] },
++ { "type": "string", "pattern": "^service:[\\s\\S]+$", "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one." }
++ ]
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/coupons.coupon.expired.v1.json b/contracts/events/payloads/coupons.coupon.expired.v1.json
nnew file mode 100644
iindex 00000000..0e5bbc4b
---- /dev/null
++++ b/contracts/events/payloads/coupons.coupon.expired.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.expired.v1.json",
++ "title": "CouponsCouponExpiredPayload",
++ "description": "coupons.coupon.expired v1 (OpenVibe.Coupons server/domain/coupons.js recompute, payload from lifecyclePayload). A code's status became expired, in the transaction that recorded it: the sweep (server/worker.js, every COUPONS_SWEEP_INTERVAL_MS) saw its known expiry pass (reason expiry), a recompute for other work found it past its expiry first (evidence, report or decay), or staff or a service marked it expired (staff, service:<client>[:<note>]). Active lists do not wait for this event: they filter on expires_at at query time. When the code was in Search, a coupons.index_document.deleted tombstone and the merchant's updated document follow in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code was publicly listed before the change, otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": ["merchant_id", "status", "confidence", "review_state", "expires_at", "canonical_url", "previous_status", "reason"],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": { "type": "string", "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merchant the code belongs to." },
++ "status": { "const": "expired" },
++ "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 1, "description": "Report confidence at the time (rounded to 2 decimals); null = no counted report in the last 30 days." },
++ "review_state": { "enum": ["pending", "published"] },
++ "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "The known expiry that passed; null when staff or a service marked a code of unknown expiry expired." },
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "The code's page on Coupons (<origin>/c/<coupon id>); it stays up, noindex." },
++ "previous_status": { "enum": ["unknown", "reported_working", "reported_failed"], "description": "Status before; a disabled code has to be re-enabled before it can be marked expired." },
++ "reason": {
++ "description": "expiry, evidence, report, decay, staff, or service:<client>[:<note>].",
++ "anyOf": [
++ { "enum": ["expiry", "evidence", "report", "decay", "staff"] },
++ { "type": "string", "pattern": "^service:[\\s\\S]+$", "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one." }
++ ]
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/coupons.coupon.updated.v1.json b/contracts/events/payloads/coupons.coupon.updated.v1.json
nnew file mode 100644
iindex 00000000..17d806af
---- /dev/null
++++ b/contracts/events/payloads/coupons.coupon.updated.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.updated.v1.json",
++ "title": "CouponsCouponUpdatedPayload",
++ "description": "coupons.coupon.updated v1 (OpenVibe.Coupons server/domain/coupons.js recompute, approve and setExpiry, payload from lifecyclePayload). Emitted in the transaction of the change when (a) a code's status moved to unknown, reported_working or reported_failed: people's reports through the confidence formula (reason report), evidence added or withdrawn (evidence), old reports decaying (decay), or staff or a service re-enabling a disabled or expired code (staff, service:<client>[:<note>]); (b) staff published a code that waited for review (reason approved; status unchanged, so previous_status equals status and may be any status); or (c) the code's expiry was set or cleared (reason expiry_changed, no previous_status; setExpiry has no HTTP route yet). A move to expired or disabled is coupons.coupon.expired or coupons.coupon.disabled instead, and a confidence change without a status change emits only coupons.confidence.changed. Staff notes are reduced to reason staff; a service's note is copied into the reason. Never names a submitter, reporter or staff member. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code is publicly listed after the change (recompute: before or after), otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": ["merchant_id", "status", "confidence", "review_state", "expires_at", "canonical_url", "reason"],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": { "type": "string", "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merchant the code belongs to." },
++ "status": { "enum": ["unknown", "reported_working", "reported_failed", "expired", "disabled"], "description": "Status after the change. expired and disabled occur only with reason approved or expiry_changed (neither moves the status)." },
++ "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 1, "description": "Report confidence after the change, rounded to 2 decimals; null = no counted report in the last 30 days." },
++ "review_state": { "enum": ["pending", "published"] },
++ "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "Known expiry (UTC); null = expiry unknown." },
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "The code's page on Coupons (<origin>/c/<coupon id>)." },
++ "previous_status": { "enum": ["unknown", "reported_working", "reported_failed", "expired", "disabled"], "description": "Status before the change. Absent with reason expiry_changed." },
++ "reason": {
++ "description": "Why it changed: report, evidence, decay, staff, approved, expiry_changed, or service:<client>[:<note>] when a service holding coupons.status.update re-enabled it.",
++ "anyOf": [
++ { "enum": ["report", "evidence", "decay", "staff", "approved", "expiry_changed"] },
++ { "type": "string", "pattern": "^service:[\\s\\S]+$", "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one." }
++ ]
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/coupons.index_document.deleted.v1.json b/contracts/events/payloads/coupons.index_document.deleted.v1.json
nnew file mode 100644
iindex 00000000..ee7eb6d6
---- /dev/null
++++ b/contracts/events/payloads/coupons.index_document.deleted.v1.json
@@@ -0,0 +1,14 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.index_document.deleted.v1.json",
++ "title": "CouponsIndexDocumentDeletedPayload",
++ "description": "coupons.index_document.deleted v1 (OpenVibe.Coupons server/domain/publication.js sendDocument, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the code left active results (expired, disabled, its merchant disabled) or the merchant is no longer active, so Search drops it at this revision or older. Only sent for a resource Search was sent before: a code or merchant that was never indexed gets no tombstone. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: coupon | merchant, id, revision }, visibility internal, priority important, actor service:coupons.",
++ "type": "object",
++ "required": ["type", "id", "revision"],
++ "additionalProperties": false,
++ "properties": {
++ "type": { "enum": ["coupon", "merchant"] },
++ "id": { "type": "string", "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991, "description": "Index revision of the tombstone; wins over any document at the same or an older revision." }
++ }
++}
ddiff --git a/contracts/events/payloads/coupons.index_document.upserted.v1.json b/contracts/events/payloads/coupons.index_document.upserted.v1.json
nnew file mode 100644
iindex 00000000..203364dc
---- /dev/null
++++ b/contracts/events/payloads/coupons.index_document.upserted.v1.json
@@@ -0,0 +1,13 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.index_document.upserted.v1.json",
++ "title": "CouponsIndexDocumentUpsertedPayload",
++ "description": "coupons.index_document.upserted v1 (OpenVibe.Coupons server/domain/publication.js sendDocument, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a code in active results (type coupon: published, merchant active, not expired or disabled; title '<code> — <title> (<merchant>)', status and expiry in the summary, description and restrictions in the body, facets merchant, status and expiry_known, Sources items in provenance) or of an active merchant (type merchant: facets active_codes; indexability noindex thin_content while it has no active code). Sent in the transaction of every change to what Search should hold (coupons.js syncIndex and syncMerchant); the revision (coupons_index_revisions) grows only when the document changed, and an unchanged document is not sent again. Visibility is always public. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: coupon | merchant, id, revision: <document revision> }, visibility internal, priority important, actor service:coupons.",
++ "allOf": [
++ { "$ref": "../../search/index-document.v1.json" },
++ {
++ "type": "object", "required": ["owner", "type", "deleted"],
++ "properties": { "owner": { "const": "coupons" }, "type": { "enum": ["coupon", "merchant"] }, "id": { "type": "string", "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$" }, "deleted": { "const": false } }
++ }
++ ]
++}
ddiff --git a/contracts/events/payloads/coupons.report.created.v1.json b/contracts/events/payloads/coupons.report.created.v1.json
nnew file mode 100644
iindex 00000000..8c76f0a7
---- /dev/null
++++ b/contracts/events/payloads/coupons.report.created.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.report.created.v1.json",
++ "title": "CouponsReportCreatedPayload",
++ "description": "coupons.report.created v1 (OpenVibe.Coupons server/domain/reports.js report). A signed-in person (site form, a browser-helper install token with scope coupons.report, or a service acting for a person with X-OV-Subject) reported that an active code worked or failed, in the transaction that stored the report. One event per new (reporter, code, UTC day) row: a repeat the same day is deduplicated and a same-day switch to the other outcome corrects that row, and neither emits (both still recompute). The payload never identifies the reporter: not the subject, the HMAC reporter key or the install. reason is present only on failed reports that gave one. A coupons.confidence.changed and, when the status moved, a lifecycle event follow in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": ["merchant_id", "outcome", "channel", "day"],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": { "type": "string", "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merchant of the reported code." },
++ "outcome": { "enum": ["worked", "failed"] },
++ "reason": { "enum": ["invalid", "expired", "min_spend_not_met", "not_eligible", "other"], "description": "Why a failed code failed, when the reporter said. Never on a worked report." },
++ "channel": { "enum": ["site", "extension", "api"], "description": "How the report arrived: the site form, a browser-helper install token, or the API (a Network token or a service acting for a person)." },
++ "day": { "type": "string", "format": "date", "description": "The UTC day of the report (YYYY-MM-DD), the deduplication bucket." }
++ },
++ "if": { "properties": { "outcome": { "const": "worked" } }, "required": ["outcome"] },
++ "then": { "not": { "required": ["reason"] } }
++}
ddiff --git a/contracts/events/payloads/deals.index_document.deleted.v1.json b/contracts/events/payloads/deals.index_document.deleted.v1.json
nnew file mode 100644
iindex 00000000..e8ed10f2
---- /dev/null
++++ b/contracts/events/payloads/deals.index_document.deleted.v1.json
@@@ -0,0 +1,19 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.index_document.deleted.v1.json",
++ "title": "DealsIndexDocumentDeletedPayload",
++ "description": "deals.index_document.deleted v1 (OpenVibe.Deals server/domain/indexing.js indexOffer, via openvibe-publishing/index-hooks indexEvent). A Search tombstone for an offer page: moderators disabled the offer (takedown, including an imported offer disabled because all its Sources items were removed), or the offer was merged into another one (the group is indexed under the target from then on). Sent when the tombstone's revision moved, so a repeat sends nothing. Product pages are never tombstoned: a product without a fresh offer stays in Search as a noindex document. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: offer, id, revision }, visibility internal, priority important, actor service:deals.",
++ "type": "object",
++ "required": ["type", "id", "revision"],
++ "additionalProperties": false,
++ "properties": {
++ "type": { "const": "offer" },
++ "id": { "type": "string", "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 9007199254740991,
++ "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/deals.index_document.upserted.v1.json b/contracts/events/payloads/deals.index_document.upserted.v1.json
nnew file mode 100644
iindex 00000000..5e15e72f
---- /dev/null
++++ b/contracts/events/payloads/deals.index_document.upserted.v1.json
@@@ -0,0 +1,19 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.index_document.upserted.v1.json",
++ "title": "DealsIndexDocumentUpsertedPayload",
++ "description": "deals.index_document.upserted v1 (OpenVibe.Deals server/domain/indexing.js indexOffer and indexProduct, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a canonical offer page (type offer, /d/<slug>) or a product page (type product, /p/<slug>), sent whenever what Search should hold changed: after every offer write (offers.js, importer.js), a product resolved through the API, and the worker's re-index pass when freshness moves. The sequencer (deal_index_revisions) gives an unchanged document its old revision and nothing is sent. Always visibility public and publication_state published; expired offers, offers without a fresh observation and imported or AI text awaiting review stay in Search as noindex documents (reasons expired, stale_price, owner_decision). Offer facets: status, freshness, store, category, currency, product (slug); product facets: brand, category, offers (active offer count). Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: offer | product, id, revision: <document revision> }, visibility internal, priority important, actor service:deals.",
++ "allOf": [
++ { "$ref": "../../search/index-document.v1.json" },
++ {
++ "type": "object",
++ "required": ["owner", "type", "deleted"],
++ "properties": {
++ "owner": { "const": "deals" },
++ "type": { "enum": ["offer", "product"] },
++ "id": { "type": "string", "pattern": "^(dof|dpr)_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "deleted": { "const": false }
++ }
++ }
++ ]
++}
ddiff --git a/contracts/events/payloads/deals.offer.created.v1.json b/contracts/events/payloads/deals.offer.created.v1.json
nnew file mode 100644
iindex 00000000..4f111223
---- /dev/null
++++ b/contracts/events/payloads/deals.offer.created.v1.json
@@@ -0,0 +1,62 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.offer.created.v1.json",
++ "title": "DealsOfferCreatedPayload",
++ "description": "deals.offer.created v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js submit and createImported). A deal was posted: a person submitted a link (submit), or the Sources importer created an offer from a deals-category Sources item (createImported, the only caller that adds imported_from). Written in the transaction that inserted the offer and its first observation, next to deals.index_document.upserted. The payload describes the canonical offer as it now stands: canonical page URL, status, store domain, product, the latest observation (price text exactly as stated, never inferred; null where the source or person stated nothing) with its observed_at, the freshness verdict and the Search-shaped indexability decision. Deliberately left out: the description, the submitter's identity, vote counts and hotness. Envelope: subject { type: offer, id: <offer_id> }, visibility public when the offer is active and the SEO gate lets it be listed, otherwise internal, priority important (default), actor user:<submitter usr_…> for a submission, service:deals for an import. No consumer is built yet.",
++ "type": "object",
++ "required": ["offer_id", "slug", "url", "title", "status", "store", "product_id", "latest_observation", "freshness", "indexability"],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": { "type": "string", "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The canonical (root) offer id." },
++ "slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 80,
++ "description": "Page slug: the slugified title plus the last six characters of the id."
++ },
++ "url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Canonical offer page URL on Deals (/d/<slug>)." },
++ "title": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "status": { "const": "active", "description": "A new offer is always active." },
++ "store": { "type": ["string", "null"], "minLength": 1, "description": "Store domain: the offer link's host without a leading www.; null when none could be derived." },
++ "product_id": { "type": ["string", "null"], "pattern": "^dpr_[0-9A-HJKMNP-TV-Z]{26}$", "description": "Deals product the offer is attached to, if any." },
++ "latest_observation": {
++ "type": ["object", "null"],
++ "required": ["price", "currency", "availability", "observed_at"],
++ "additionalProperties": false,
++ "description": "The newest observation across the offer's merge group; null only when the group has none.",
++ "properties": {
++ "price": { "type": ["string", "null"], "maxLength": 40, "description": "Decimal text as stated; null when no price was stated (never 0)." },
++ "currency": { "type": ["string", "null"], "pattern": "^[A-Z]{3}$", "description": "ISO 4217; never assumed." },
++ "availability": { "enum": ["in_stock", "out_of_stock", "preorder", "discontinued", "limited", "sold_out", "online_only", "in_store_only", null] },
++ "observed_at": { "type": "string", "format": "date-time" }
++ }
++ },
++ "freshness": {
++ "enum": ["fresh", "stale", "unobserved"],
++ "description": "fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one."
++ },
++ "indexability": {
++ "type": "object",
++ "required": ["decision", "reasons"],
++ "additionalProperties": false,
++ "description": "The SEO gate decision in Search's shape (index-hooks searchIndexability).",
++ "properties": {
++ "decision": { "enum": ["index", "noindex"] },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" },
++ "description": "Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review)."
++ }
++ }
++ },
++ "imported_from": {
++ "type": "object",
++ "required": ["service", "type", "id"],
++ "additionalProperties": false,
++ "description": "Only on imports: the OpenVibe.Sources item the offer was created from.",
++ "properties": { "service": { "const": "sources" }, "type": { "const": "item" }, "id": { "type": "string", "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$" } }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/deals.offer.expired.v1.json b/contracts/events/payloads/deals.offer.expired.v1.json
nnew file mode 100644
iindex 00000000..0b75f167
---- /dev/null
++++ b/contracts/events/payloads/deals.offer.expired.v1.json
@@@ -0,0 +1,60 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.offer.expired.v1.json",
++ "title": "DealsOfferExpiredPayload",
++ "description": "deals.offer.expired v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js expireRow). An active offer became expired: its submitter or a moderator marked it expired (POST /offers/:id/expire; reason submitter, moderator or \"moderator: <note>\"), or the worker found that its STATED end time has passed (expireDue; reason stated_expiry). Expiry is never inferred from missing data. Written in the transaction that set the status and the moderation_log row, next to deals.index_document.upserted (the page stays in Search as a noindex document with reason expired). The rest of the payload is the offer as it now stands, as in deals.offer.created. Envelope: subject { type: offer, id: <offer_id> }, visibility internal (an expired offer is never public), priority important (default), actor user:<usr_…> who marked it, service:<calling service> when a service acts without a person, service:deals for the worker. No consumer is built yet.",
++ "type": "object",
++ "required": ["offer_id", "slug", "url", "title", "status", "store", "product_id", "latest_observation", "freshness", "indexability", "reason"],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": { "type": "string", "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The canonical (root) offer id." },
++ "slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 80,
++ "description": "Page slug: the slugified title plus the last six characters of the id."
++ },
++ "url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Canonical offer page URL on Deals (/d/<slug>)." },
++ "title": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "status": { "const": "expired" },
++ "store": { "type": ["string", "null"], "minLength": 1, "description": "Store domain: the offer link's host without a leading www.; null when none could be derived." },
++ "product_id": { "type": ["string", "null"], "pattern": "^dpr_[0-9A-HJKMNP-TV-Z]{26}$", "description": "Deals product the offer is attached to, if any." },
++ "latest_observation": {
++ "type": ["object", "null"],
++ "required": ["price", "currency", "availability", "observed_at"],
++ "additionalProperties": false,
++ "description": "The newest observation across the offer's merge group; null only when the group has none.",
++ "properties": {
++ "price": { "type": ["string", "null"], "maxLength": 40, "description": "Decimal text as stated; null when no price was stated (never 0)." },
++ "currency": { "type": ["string", "null"], "pattern": "^[A-Z]{3}$", "description": "ISO 4217; never assumed." },
++ "availability": { "enum": ["in_stock", "out_of_stock", "preorder", "discontinued", "limited", "sold_out", "online_only", "in_store_only", null] },
++ "observed_at": { "type": "string", "format": "date-time" }
++ }
++ },
++ "freshness": {
++ "enum": ["fresh", "stale", "unobserved"],
++ "description": "fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one."
++ },
++ "indexability": {
++ "type": "object",
++ "required": ["decision", "reasons"],
++ "additionalProperties": false,
++ "description": "The SEO gate decision in Search's shape (index-hooks searchIndexability).",
++ "properties": {
++ "decision": { "enum": ["index", "noindex"] },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" },
++ "description": "Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review)."
++ }
++ }
++ },
++ "reason": {
++ "type": "string",
++ "pattern": "^(submitter|stated_expiry|moderator(: .{1,200})?)$",
++ "description": "submitter, stated_expiry, moderator, or \"moderator: <note>\" (the note as the moderator wrote it, whitespace collapsed)."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/deals.offer.updated.v1.json b/contracts/events/payloads/deals.offer.updated.v1.json
nnew file mode 100644
iindex 00000000..ae2f01fe
---- /dev/null
++++ b/contracts/events/payloads/deals.offer.updated.v1.json
@@@ -0,0 +1,90 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.offer.updated.v1.json",
++ "title": "DealsOfferUpdatedPayload",
++ "description": "deals.offer.updated v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js and server/domain/importer.js). Something about a canonical offer changed; `changed` says what, and a few call sites add one reference: an edit (offers.js update: the edited columns, among title, description, category, expires_at, product_id, ai_summary, review_state, text_origin); a price/availability report by a person (observe) or a newer or re-fetched Sources item (importer importItem) ([observation] + observation_id); a second Sources item with the same link attached to an existing offer ([sources] + observation_id); a Sources item removed at the source ([sources] + source_removed; an imported offer left with no live source is disabled in the same transaction, so status is then disabled); moderators disabling it or re-activating a disabled or expired offer ([status] + moderation); a person reviewing imported or AI text ([review_state]); a merge ([merged] + merged_offer_id, sent for the merge target); an unmerge (two events: [unmerged] + unmerged_offer_id for the former target, and [unmerged] + unmerged_from for the restored offer). Written in the transaction that made the change, next to the offer's (and its product's) deals.index_document.* event. The rest of the payload is the offer as it now stands, as in deals.offer.created. Deliberately left out: old values, the description text, identities and vote counts (deals.vote.changed carries those counts). Envelope: subject { type: offer, id: <offer_id> }, visibility public when the offer is active and the SEO gate lets it be listed, otherwise internal, priority important (default), actor user:<the person who acted, usr_…>, service:<calling service> when a service acts without a person, service:deals for the importer. No consumer is built yet.",
++ "type": "object",
++ "required": ["offer_id", "slug", "url", "title", "status", "store", "product_id", "latest_observation", "freshness", "indexability", "changed"],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": { "type": "string", "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The canonical (root) offer id." },
++ "slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 80,
++ "description": "Page slug: the slugified title plus the last six characters of the id."
++ },
++ "url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Canonical offer page URL on Deals (/d/<slug>)." },
++ "title": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "status": { "enum": ["active", "expired", "disabled"] },
++ "store": { "type": ["string", "null"], "minLength": 1, "description": "Store domain: the offer link's host without a leading www.; null when none could be derived." },
++ "product_id": { "type": ["string", "null"], "pattern": "^dpr_[0-9A-HJKMNP-TV-Z]{26}$", "description": "Deals product the offer is attached to, if any." },
++ "latest_observation": {
++ "type": ["object", "null"],
++ "required": ["price", "currency", "availability", "observed_at"],
++ "additionalProperties": false,
++ "description": "The newest observation across the offer's merge group; null only when the group has none.",
++ "properties": {
++ "price": { "type": ["string", "null"], "maxLength": 40, "description": "Decimal text as stated; null when no price was stated (never 0)." },
++ "currency": { "type": ["string", "null"], "pattern": "^[A-Z]{3}$", "description": "ISO 4217; never assumed." },
++ "availability": { "enum": ["in_stock", "out_of_stock", "preorder", "discontinued", "limited", "sold_out", "online_only", "in_store_only", null] },
++ "observed_at": { "type": "string", "format": "date-time" }
++ }
++ },
++ "freshness": {
++ "enum": ["fresh", "stale", "unobserved"],
++ "description": "fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one."
++ },
++ "indexability": {
++ "type": "object",
++ "required": ["decision", "reasons"],
++ "additionalProperties": false,
++ "description": "The SEO gate decision in Search's shape (index-hooks searchIndexability).",
++ "properties": {
++ "decision": { "enum": ["index", "noindex"] },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" },
++ "description": "Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review)."
++ }
++ }
++ },
++ "changed": {
++ "type": "array",
++ "minItems": 1,
++ "uniqueItems": true,
++ "items": {
++ "enum": ["title", "description", "category", "expires_at", "product_id", "ai_summary", "review_state", "text_origin", "observation", "sources", "status", "merged", "unmerged"]
++ },
++ "description": "What changed: edited columns (update), or one of observation, sources, status, review_state, merged, unmerged."
++ },
++ "observation_id": {
++ "type": "string",
++ "pattern": "^dpo_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [observation] or [sources] from a report or an import: the observation just recorded."
++ },
++ "source_removed": {
++ "type": "string",
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [sources] after a removal: the OpenVibe.Sources item that was removed (takedown, licence)."
++ },
++ "moderation": { "enum": ["disabled", "enabled"], "description": "changed [status] by a moderator: disabled, or enabled (a disabled or expired offer made active again)." },
++ "merged_offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [merged]: the duplicate now merged into this offer."
++ },
++ "unmerged_offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [unmerged], on the former target: the offer split back out."
++ },
++ "unmerged_from": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [unmerged], on the restored offer: the offer it had been merged into."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/deals.vote.changed.v1.json b/contracts/events/payloads/deals.vote.changed.v1.json
nnew file mode 100644
iindex 00000000..2522ccdb
---- /dev/null
++++ b/contracts/events/payloads/deals.vote.changed.v1.json
@@@ -0,0 +1,47 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.vote.changed.v1.json",
++ "title": "DealsVoteChangedPayload",
++ "description": "deals.vote.changed v1 (OpenVibe.Deals server/domain/votes.js apply). A person's effective vote on an offer's merge group changed: set to +1 or -1 (PUT /offers/:id/vote, deals.vote.set) or removed, value 0 (DELETE /offers/:id/vote, deals.vote.remove). Nothing is sent when the vote did not change. Written in the transaction that stored the vote row and a hotness snapshot (reason vote). The payload carries counts and the recomputable hot@1 snapshot, not identities: the voter appears only as the envelope actor, and the IP hash and any vote-ring flags stay in Deals. Envelope: subject { type: offer, id: <canonical offer_id> }, visibility internal, priority important (default), actor user:<voter usr_…> (also when a service casts the vote for them). No consumer is built yet.",
++ "type": "object",
++ "required": ["offer_id", "listing_id", "value", "previous", "weight", "votes", "hotness"],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The canonical (root) offer of the merge group; the vote row is stored on it."
++ },
++ "listing_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The offer the request addressed: the root, or an offer merged into it."
++ },
++ "value": { "enum": [1, -1, 0], "description": "The new effective vote; 0 = removed." },
++ "previous": { "enum": [1, -1, 0], "description": "The effective vote before; always different from value." },
++ "weight": { "type": "number", "description": "Weight stored with this vote: 1, or DEALS_NEW_ACCOUNT_WEIGHT (default 0.25) for an account new to Deals." },
++ "votes": {
++ "type": "object",
++ "required": ["up", "down", "up_weight", "down_weight"],
++ "additionalProperties": false,
++ "description": "Tally of effective votes across the merge group after the change (a voter counts once).",
++ "properties": {
++ "up": { "type": "integer", "minimum": 0 },
++ "down": { "type": "integer", "minimum": 0 },
++ "up_weight": { "type": "number", "description": "Sum of the up voters' weights, 4 decimals." },
++ "down_weight": { "type": "number", "description": "Sum of the down voters' weights, 4 decimals." }
++ }
++ },
++ "hotness": {
++ "type": "object",
++ "required": ["formula", "hot", "score"],
++ "additionalProperties": false,
++ "description": "The snapshot taken with this vote; its inputs are kept in deal_hotness_snapshots (GET /api/v1/offers/:id/hotness).",
++ "properties": {
++ "formula": { "type": "string", "pattern": "^hot@[0-9]+$", "description": "Currently hot@1: hot = round6(score / (ageHours + 2)^1.5)." },
++ "hot": { "type": "number" },
++ "score": { "type": "number", "description": "up_weight - down_weight, 4 decimals." }
++ }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.blueprint.unlocked.v1.json b/contracts/events/payloads/games.blueprint.unlocked.v1.json
nnew file mode 100644
iindex 00000000..6c4da3cc
---- /dev/null
++++ b/contracts/events/payloads/games.blueprint.unlocked.v1.json
@@@ -0,0 +1,132 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.blueprint.unlocked.v1.json",
++ "title": "GamesBlueprintUnlockedPayload",
++ "description": "games.blueprint.unlocked v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts progressEvents, via GameEventRecorder.recordPlayersSaved from game/gameServer.ts flush and savePlayer). A blueprint recipe unlock (learned by consuming a blueprint item) was persisted: derived from the saved unlock set compared with the last committed save of that character, in the same transaction, one event per new recipe. Only for characters that joined while events were on. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "recipe"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "recipe": {
++ "type": "string",
++ "pattern": "^[a-z0-9_]+$",
++ "description": "Content recipe id of the blueprint recipe (e.g. craft_scrap_pistol)."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.mod.disabled.v1.json b/contracts/events/payloads/games.mod.disabled.v1.json
nnew file mode 100644
iindex 00000000..ca8b4583
---- /dev/null
++++ b/contracts/events/payloads/games.mod.disabled.v1.json
@@@ -0,0 +1,55 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.disabled.v1.json",
++ "title": "GamesModDisabledPayload",
++ "description": "games.mod.disabled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.disable in mods/registry.ts; POST /api/v1/mods/:id/disable). An enabled install was disabled: no capability check passes for it until it is enabled again, grants are kept. Not emitted when it was already disabled; a revoked install cannot change status. The payload is the mod only. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.mod.enabled.v1.json b/contracts/events/payloads/games.mod.enabled.v1.json
nnew file mode 100644
iindex 00000000..034ff668
---- /dev/null
++++ b/contracts/events/payloads/games.mod.enabled.v1.json
@@@ -0,0 +1,55 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.enabled.v1.json",
++ "title": "GamesModEnabledPayload",
++ "description": "games.mod.enabled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.enable in mods/registry.ts; POST /api/v1/mods/:id/enable). A disabled install was enabled; its granted capabilities take effect on the next runtime reconcile. Not emitted when it was already enabled; a revoked install cannot be enabled. The payload is the mod only. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.mod.grants_changed.v1.json b/contracts/events/payloads/games.mod.grants_changed.v1.json
nnew file mode 100644
iindex 00000000..dfea221b
---- /dev/null
++++ b/contracts/events/payloads/games.mod.grants_changed.v1.json
@@@ -0,0 +1,77 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.grants_changed.v1.json",
++ "title": "GamesModGrantsChangedPayload",
++ "description": "games.mod.grants_changed v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.grant and ModRegistry.revokeGrant in mods/registry.ts; POST /api/v1/mods/:id/grants, DELETE /api/v1/mods/:id/grants/:capability). One capability of a non-revoked install was granted (granted: [capability], revoked: []) or one active grant was revoked (granted: [], revoked: [capability]); each call changes exactly one capability today. Not emitted when nothing changed. Revoking the whole install is games.mod.revoked instead. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod",
++ "granted",
++ "revoked"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ },
++ "granted": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "Capabilities newly granted by this change."
++ },
++ "revoked": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "Capabilities whose grant this change revoked."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.mod.installed.v1.json b/contracts/events/payloads/games.mod.installed.v1.json
nnew file mode 100644
iindex 00000000..f5b17da7
---- /dev/null
++++ b/contracts/events/payloads/games.mod.installed.v1.json
@@@ -0,0 +1,84 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.installed.v1.json",
++ "title": "GamesModInstalledPayload",
++ "description": "games.mod.installed v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.install in mods/registry.ts; POST /api/v1/mods). A games-content@1 mod was installed after its manifest and content pack were validated. requested is the manifest's capability list, granted the approved subset (only capabilities the games-content@1 runtime binds: games.world.announce, games.prop.place), status whether it was installed enabled. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod",
++ "requested",
++ "granted",
++ "status"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ },
++ "requested": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "manifest.permissions.capabilities, sorted."
++ },
++ "granted": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "The approved subset of requested, sorted (may be empty)."
++ },
++ "status": {
++ "enum": [
++ "enabled",
++ "disabled"
++ ]
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.mod.revoked.v1.json b/contracts/events/payloads/games.mod.revoked.v1.json
nnew file mode 100644
iindex 00000000..28d08c6d
---- /dev/null
++++ b/contracts/events/payloads/games.mod.revoked.v1.json
@@@ -0,0 +1,71 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.revoked.v1.json",
++ "title": "GamesModRevokedPayload",
++ "description": "games.mod.revoked v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.revoke in mods/registry.ts; POST /api/v1/mods/:id/revoke). Terminal: every active grant of the install was revoked and its status set to revoked; the runtime retracts the mod's announcements and props on the next tick. revoked_grants lists the grants that were active (may be empty); reason is the staff-supplied text (cut to 500 characters), absent when none was given. Not emitted again for an install that is already revoked. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority important, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod",
++ "revoked_grants"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ },
++ "revoked_grants": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "Grants active before the revoke, sorted."
++ },
++ "reason": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.player.joined.v1.json b/contracts/events/payloads/games.player.joined.v1.json
nnew file mode 100644
iindex 00000000..6653c9f7
---- /dev/null
++++ b/contracts/events/payloads/games.player.joined.v1.json
@@@ -0,0 +1,131 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.player.joined.v1.json",
++ "title": "GamesPlayerJoinedPayload",
++ "description": "games.player.joined v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts playerJoinedEvent, via GameEventRecorder.recordJoin from game/gameServer.ts handleHello). A character entered the world: written to Games' event_outbox in a transaction of its own once the session exists and the welcome was sent (a new character's row is first written by its next save). restored is true when the character already existed in the database, false for a new one. Only emitted while Events publishing is configured. No position, appearance, inventory, rank or connection data. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "restored"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "restored": {
++ "type": "boolean",
++ "description": "The character was loaded from the database (false: created by this join)."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.player.left.v1.json b/contracts/events/payloads/games.player.left.v1.json
nnew file mode 100644
iindex 00000000..878e3463
---- /dev/null
++++ b/contracts/events/payloads/games.player.left.v1.json
@@@ -0,0 +1,131 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.player.left.v1.json",
++ "title": "GamesPlayerLeftPayload",
++ "description": "games.player.left v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts playerLeftEvent, via GameEventRecorder.recordLeave from game/gameServer.ts onDisconnect -> savePlayer(session, true)). A character's connection closed: written in the same transaction as its final save, after any games.skill.leveled / games.blueprint.unlocked that save produced. session_seconds is the time since its games.player.joined in this process, rounded to whole seconds; 0 when the join was not tracked. No position, appearance, inventory, rank or connection data. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "session_seconds"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "session_seconds": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.skill.leveled.v1.json b/contracts/events/payloads/games.skill.leveled.v1.json
nnew file mode 100644
iindex 00000000..eb13ad66
---- /dev/null
++++ b/contracts/events/payloads/games.skill.leveled.v1.json
@@@ -0,0 +1,144 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.skill.leveled.v1.json",
++ "title": "GamesSkillLeveledPayload",
++ "description": "games.skill.leveled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts progressEvents, via GameEventRecorder.recordPlayersSaved from game/gameServer.ts flush and savePlayer). A persisted skill level went up: derived from what a save actually wrote, compared with the last committed save of that character (for a restored character first the database state at join, for a new one level 1), in the same transaction. One event per raised skill per save, so level can be more than previous_level + 1; never emitted for a decrease, and not re-emitted once the baseline advances on commit. Only for characters that joined while events were on. XP totals are left out. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "skill",
++ "level",
++ "previous_level"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "skill": {
++ "type": "string",
++ "pattern": "^[a-z0-9_]+$",
++ "description": "Content skill id (e.g. mining, woodcutting)."
++ },
++ "level": {
++ "type": "integer",
++ "minimum": 2,
++ "description": "Level now persisted; always greater than previous_level."
++ },
++ "previous_level": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Level at the previous baseline (1 when the skill had none)."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/games.world.saved.v1.json b/contracts/events/payloads/games.world.saved.v1.json
nnew file mode 100644
iindex 00000000..18d0301d
---- /dev/null
++++ b/contracts/events/payloads/games.world.saved.v1.json
@@@ -0,0 +1,42 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.world.saved.v1.json",
++ "title": "GamesWorldSavedPayload",
++ "description": "games.world.saved v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts worldSavedEvent, via GameEventRecorder.recordWorldSaved from game/gameServer.ts flush). The authoritative world was written: a checkpoint at most once per WORLD_SAVED_EVENT_MINUTES (default 15) and only when something was written, and always on shutdown, in the transaction of the flush that triggers it. The counts are totals of the flushes since `since` (the previous event, or process start); players_written counts dirty characters saved by flushes, not the single-character saves on disconnect. Envelope: subject { type: world, id: <world> }, visibility internal, priority low, actor service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "world",
++ "reason",
++ "entities_written",
++ "players_written",
++ "since"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "reason": {
++ "enum": [
++ "checkpoint",
++ "shutdown"
++ ]
++ },
++ "entities_written": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "World entity rows written since `since`."
++ },
++ "players_written": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Character rows written by flushes since `since`."
++ },
++ "since": {
++ "type": "string",
++ "format": "date-time",
++ "description": "Start of the period these totals cover."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/media.object.deleted.v1.json b/contracts/events/payloads/media.object.deleted.v1.json
nnew file mode 100644
iindex 00000000..198d6705
---- /dev/null
++++ b/contracts/events/payloads/media.object.deleted.v1.json
@@@ -0,0 +1,51 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.object.deleted.v1.json",
++ "title": "MediaObjectDeletedPayload",
++ "description": "media.object.deleted v1 (OpenVibe.Media server/events.js objectChangeEvent and recordObjectChanges, commit 039dc48, from the media_object_changes rows that the AFTER UPDATE OF lifecycle_status trigger on media_objects writes). An object's lifecycle_status became deleted: a native object soft-deleted through DELETE /api/v2/:app/objects/:id, or an inherited vods/clips/files/pastes row removed, which marks its object deleted. Every write path counts (operator SQL included), and the event exists if and only if the change committed: the trigger row and the outbox envelope are written in the transaction of the change, or by the relay's drain for a change made outside Media's own transactions. Consumers drop any public copy, index entry or cache they hold for the object (roadmap §29.3). Deliberately minimal, identity and state only: never the title, description or metadata, the owner's ids or subject, storage keys, paths or providers, sizes or hashes. Developer-project sandbox tenants produce none. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "object_id",
++ "app_id",
++ "kind",
++ "legacy_ref",
++ "deleted_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "object_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "kind": {
++ "enum": [
++ "vod",
++ "clip",
++ "file",
++ "thumbnail",
++ "screenshot",
++ "avatar",
++ "asset"
++ ]
++ },
++ "legacy_ref": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300,
++ "description": "The inherited row the object projects (legacy:<app>:<kind>:<id>, a media.media-ref@1 legacy id); null for a native v2 object."
++ },
++ "deleted_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When the change committed, ISO 8601 UTC."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/media.object.visibility_changed.v1.json b/contracts/events/payloads/media.object.visibility_changed.v1.json
nnew file mode 100644
iindex 00000000..85cee90e
---- /dev/null
++++ b/contracts/events/payloads/media.object.visibility_changed.v1.json
@@@ -0,0 +1,115 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.object.visibility_changed.v1.json",
++ "title": "MediaObjectVisibilityChangedPayload",
++ "description": "media.object.visibility_changed v1 (OpenVibe.Media server/events.js objectChangeEvent and recordObjectChanges, commit 039dc48, from the media_object_changes rows that the AFTER UPDATE OF visibility trigger on media_objects writes). An object's visibility changed to another value (public, unlisted or private), by any write path: the v2 objects API, the inherited vods/clips/files rows it projects, or operator SQL. Never emitted when the value did not change, nor for an object that is deleted at the time of the change (that object's last word is media.object.deleted). The event exists if and only if the change committed: the trigger row and the outbox envelope are written in the transaction of the change, or by the relay's drain for a change made outside Media's own transactions. Consumers that hold a public copy, index entry or cache of the object re-check it (roadmap §29.3); a move away from public means stop showing it. Deliberately minimal, identity and state only: never the title, description or metadata, the owner's ids or subject, storage keys, paths or providers, sizes or hashes. Developer-project sandbox tenants produce none. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "object_id",
++ "app_id",
++ "kind",
++ "legacy_ref",
++ "visibility",
++ "previous_visibility",
++ "changed_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "object_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "kind": {
++ "enum": [
++ "vod",
++ "clip",
++ "file",
++ "thumbnail",
++ "screenshot",
++ "avatar",
++ "asset"
++ ]
++ },
++ "legacy_ref": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300,
++ "description": "The inherited row the object projects (legacy:<app>:<kind>:<id>, a media.media-ref@1 legacy id); null for a native v2 object."
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "private"
++ ],
++ "description": "The new value."
++ },
++ "previous_visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "private"
++ ],
++ "description": "The value before the change; never equal to visibility."
++ },
++ "changed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When the change committed, ISO 8601 UTC."
++ }
++ },
++ "not": {
++ "anyOf": [
++ {
++ "required": [
++ "visibility",
++ "previous_visibility"
++ ],
++ "properties": {
++ "visibility": {
++ "const": "public"
++ },
++ "previous_visibility": {
++ "const": "public"
++ }
++ }
++ },
++ {
++ "required": [
++ "visibility",
++ "previous_visibility"
++ ],
++ "properties": {
++ "visibility": {
++ "const": "unlisted"
++ },
++ "previous_visibility": {
++ "const": "unlisted"
++ }
++ }
++ },
++ {
++ "required": [
++ "visibility",
++ "previous_visibility"
++ ],
++ "properties": {
++ "visibility": {
++ "const": "private"
++ },
++ "previous_visibility": {
++ "const": "private"
++ }
++ }
++ }
++ ]
++ }
++}
ddiff --git a/contracts/events/payloads/news.cluster.updated.v1.json b/contracts/events/payloads/news.cluster.updated.v1.json
nnew file mode 100644
iindex 00000000..2d6fc657
---- /dev/null
++++ b/contracts/events/payloads/news.cluster.updated.v1.json
@@@ -0,0 +1,26 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.cluster.updated.v1.json",
++ "title": "NewsClusterUpdatedPayload",
++ "description": "news.cluster.updated v1 (OpenVibe.News server/domain/clusters.js emit, from assign, merge, split and reverse). A story cluster changed, in the transaction that changed it. action: created (a new item started it, adds item_id; or an editor's split made it, adds split_from and audit_id), item_added (an ingested item joined it, adds item_id and rule: duplicate_of when the item follows the item it duplicates, shared_terms with the score when it shares enough entities and terms), merged (an editor merged another cluster into it: absorbed, audit_id, moved), split (an editor moved items out of it into a new cluster: new_cluster, audit_id, moved), merge_reversed (a merge into it was undone: restored, audit_id), reopened (the cluster a reversed merge had absorbed is open again: audit_id) or split_reversed (a split off it was undone: dissolved, audit_id). audit_id is the news_cluster_audit row (cla_…) of the merge, split or reversal. label, status and item_count describe the cluster after the change. Deliberately left out: the key terms and entities, the time window and each member's cluster_reason (GET /api/v1/clusters/:id with news.cluster.read). Envelope: subject { type: cluster, id: clu_… }, visibility internal, priority low, actor service:news (also for an editor's merge, split or reversal; the editor is recorded on the audit row).",
++ "type": "object",
++ "required": ["cluster_id", "action", "status", "label", "item_count"],
++ "additionalProperties": false,
++ "properties": {
++ "cluster_id": { "type": "string", "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "action": { "enum": ["created", "item_added", "merged", "split", "merge_reversed", "reopened", "split_reversed"] },
++ "status": { "enum": ["open", "merged", "dissolved"], "description": "The cluster's status after the change (in practice open: merged and dissolved clusters are not announced by their own event)." },
++ "label": { "type": "string", "minLength": 1, "description": "Derived from the members' top entities (up to 3) or terms (up to 4), joined with ' · '; 'unlabelled' when there are none. Never written by a person or a model." },
++ "item_count": { "type": "integer", "minimum": 0, "description": "Every member item, duplicates and items removed upstream included." },
++ "item_id": { "type": "string", "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$", "description": "created by ingestion and item_added: the item that started or joined the cluster." },
++ "rule": { "enum": ["duplicate_of", "shared_terms"], "description": "item_added only: why the item joined." },
++ "score": { "type": "integer", "minimum": 0, "description": "item_added with rule shared_terms only: 2 × shared entities + shared terms." },
++ "split_from": { "type": "string", "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$", "description": "created by a split only: the cluster the items came from." },
++ "audit_id": { "type": "string", "pattern": "^cla_[0-9A-HJKMNP-TV-Z]{26}$", "description": "Every editor action (and created by a split): the news_cluster_audit row." },
++ "absorbed": { "type": "string", "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$", "description": "merged only: the cluster merged into this one (now status merged)." },
++ "moved": { "type": "integer", "minimum": 0, "description": "merged and split only: how many items moved." },
++ "new_cluster": { "type": "string", "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$", "description": "split only: the cluster the items moved into (announced by its own created event)." },
++ "restored": { "type": "string", "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$", "description": "merge_reversed only: the absorbed cluster, open again (announced by its own reopened event)." },
++ "dissolved": { "type": ["string", "null"], "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$", "description": "split_reversed only: the split-off cluster when it was left empty and dissolved; null when items that joined it after the split keep it open." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.index_document.deleted.v1.json b/contracts/events/payloads/news.index_document.deleted.v1.json
nnew file mode 100644
iindex 00000000..7a61421a
---- /dev/null
++++ b/contracts/events/payloads/news.index_document.deleted.v1.json
@@@ -0,0 +1,14 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.index_document.deleted.v1.json",
++ "title": "NewsIndexDocumentDeletedPayload",
++ "description": "news.index_document.deleted v1 (OpenVibe.News server/domain/publication.js syncIndex, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the story is no longer published, public and listable (unpublished, retracted, or hidden by the gate, e.g. an AI revision no person has reviewed), so Search drops it at this revision or older. Only sent for a story Search was sent before. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: story, id, revision }, visibility internal, priority important, actor service:news.",
++ "type": "object",
++ "required": ["type", "id", "revision"],
++ "additionalProperties": false,
++ "properties": {
++ "type": { "const": "story" },
++ "id": { "type": "string", "pattern": "^sty_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991, "description": "Index revision of the tombstone; wins over any document at the same or an older revision." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.index_document.upserted.v1.json b/contracts/events/payloads/news.index_document.upserted.v1.json
nnew file mode 100644
iindex 00000000..3c4b0374
---- /dev/null
++++ b/contracts/events/payloads/news.index_document.upserted.v1.json
@@@ -0,0 +1,13 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.index_document.upserted.v1.json",
++ "title": "NewsIndexDocumentUpsertedPayload",
++ "description": "news.index_document.upserted v1 (OpenVibe.News server/domain/publication.js syncIndex, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a published (not retracted), public, listable story, sent whenever what Search should hold changed (the revision grows with every change): a publication or new published revision, a topic or noindex change, a person's review of an AI revision, or a cited source changing upstream. The body is the published revision's paragraphs as plain text; facets { topic?, outlets, sources }; provenance names the cited Sources items (and the AI run of an AI draft); language en. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: story, id, revision: <document revision> }, visibility internal, priority important, actor service:news.",
++ "allOf": [
++ { "$ref": "../../search/index-document.v1.json" },
++ {
++ "type": "object", "required": ["owner", "type", "deleted"],
++ "properties": { "owner": { "const": "news" }, "type": { "const": "story" }, "id": { "type": "string", "pattern": "^sty_[0-9A-HJKMNP-TV-Z]{26}$" }, "deleted": { "const": false } }
++ }
++ ]
++}
ddiff --git a/contracts/events/payloads/news.source.failed.v1.json b/contracts/events/payloads/news.source.failed.v1.json
nnew file mode 100644
iindex 00000000..53b2ebab
---- /dev/null
++++ b/contracts/events/payloads/news.source.failed.v1.json
@@@ -0,0 +1,20 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.source.failed.v1.json",
++ "title": "NewsSourceFailedPayload",
++ "description": "news.source.failed v1 (OpenVibe.News server/domain/ingest.js failedEvent). Ingestion of news items failed and nothing was made in its place (no source item, cluster or story text); the same transaction records it in news_ingest_runs. Four call sites, told apart by origin and state: origin sources (ingest.js upstreamFailure: OpenVibe.Sources reported sources.fetch.failed for a news source; state, error_code and http_status are Sources' own, and run_id and consecutive_failures are added); origin pull with state failed (the cursor pull could not read Sources; once per outage, not once per retry); origin webhook with state failed (server/http/webhook.js: a sources.item.created|updated delivery whose item could not be read from Sources, on the first delivery attempt only); origin pull or webhook with state rejected and error_code item.no_headline (a Sources item without a title). Envelope: subject { type: source, id: <source_key> } when the source is known, otherwise { type: ingest, id: <origin> }, visibility internal, priority important, actor service:news.",
++ "type": "object",
++ "required": ["origin", "source_key", "sources_item_id", "state", "error_code", "http_status", "detail"],
++ "additionalProperties": false,
++ "properties": {
++ "origin": { "enum": ["webhook", "pull", "sources"], "description": "webhook: an Events delivery; pull: the cursor pull; sources: a failure Sources reported." },
++ "source_key": { "type": ["string", "null"], "description": "The Sources source, as given; null for a pull failure or when the delivery named none." },
++ "sources_item_id": { "type": ["string", "null"], "description": "The Sources item (itm_…) as given, for a rejected item or an unreadable delivery; null otherwise." },
++ "state": { "type": "string", "minLength": 1, "description": "rejected (an unusable item), failed (News could not read Sources), or for origin sources the fetch state Sources sent, copied as sent (robots_denied, http_error, rate_limited, parse_error…; failed when it sent none)." },
++ "error_code": { "type": ["string", "null"], "description": "item.no_headline for a rejected item; the Sources client's error code (e.g. sources.http_503), else pull.error or sources.error, when Sources could not be read; Sources' own error_code for origin sources (null when it sent none)." },
++ "http_status": { "type": ["integer", "null"], "description": "The HTTP status Sources answered (or reported for its fetch); null when there was none." },
++ "detail": { "type": ["string", "null"], "maxLength": 500, "description": "The error message, cut to 500 characters; null for rejected items and origin sources." },
++ "run_id": { "type": ["string", "null"], "description": "origin sources only: Sources' fetch run (frn_…), as sent." },
++ "consecutive_failures": { "type": ["integer", "null"], "description": "origin sources only: the source's consecutive failed runs Sources reported, null when it reported none." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.source.ingested.v1.json b/contracts/events/payloads/news.source.ingested.v1.json
nnew file mode 100644
iindex 00000000..f68318a4
---- /dev/null
++++ b/contracts/events/payloads/news.source.ingested.v1.json
@@@ -0,0 +1,32 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.source.ingested.v1.json",
++ "title": "NewsSourceIngestedPayload",
++ "description": "news.source.ingested v1 (OpenVibe.News server/domain/ingest.js ingestedEvent, from apply and applyRemoval). An OpenVibe.Sources item of category news changed what News holds, in the transaction that applied it: created (a new news_source_items row, with its dedupe outcome and the cluster it joined; news.cluster.updated follows in the same transaction), updated (Sources sent a newer revision of an item News has; adds previous_revision) or removed (Sources removed the item: sticky, the stored summary is dropped; adds reason and origin). Items arrive through the Events webhook (sources.item.*) or the cursor pull; a replay, an older or equal revision, an item of another category, an update to a removed item and a removal of an item never ingested emit nothing, and an item without a title is news.source.failed (state rejected) instead. Deliberately left out: the headline, outlet, authors, published_at and licensed summary (GET /api/v1/source-items/:id with news.cluster.read) and the cluster_reason explanation. Envelope: subject { type: source_item, id: nsi_… }, visibility internal, priority low, actor service:news.",
++ "type": "object",
++ "required": ["item_id", "action", "status", "sources_item", "source_key", "canonical_url", "duplicate_of", "dedupe_rule", "cluster_id"],
++ "additionalProperties": false,
++ "properties": {
++ "item_id": { "type": "string", "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$", "description": "News' own source item (news_source_items)." },
++ "action": { "enum": ["created", "updated", "removed"] },
++ "status": { "enum": ["active", "duplicate", "removed"], "description": "The item after the change: active or duplicate for created and updated, removed exactly when action is removed." },
++ "sources_item": {
++ "type": "object", "required": ["service", "type", "id", "revision"], "additionalProperties": false,
++ "description": "Typed reference (common/entity-ref@1 shape) to the OpenVibe.Sources item and the Sources revision News now holds.",
++ "properties": {
++ "service": { "const": "sources" },
++ "type": { "const": "item" },
++ "id": { "type": "string", "minLength": 1, "description": "The Sources item id (itm_…), as Sources gave it." },
++ "revision": { "type": "integer", "description": "Sources revision the stored fields came from (1 when Sources sent none)." }
++ }
++ },
++ "source_key": { "type": "string", "minLength": 1, "description": "The Sources source the item came from, as Sources gave it ('unknown' when it gave none)." },
++ "canonical_url": { "type": ["string", "null"], "description": "The item's canonical URL as Sources gave it (not validated by News); null when it had none." },
++ "duplicate_of": { "type": ["string", "null"], "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The original item when status is duplicate (the root of any duplicate chain); null otherwise." },
++ "dedupe_rule": { "enum": ["canonical_url", "content_hash", "near_title", null], "description": "Which dedupe rule matched when the item was first ingested: same normalised URL, same Sources content hash, or a near-identical headline within 48 h; null when it is not a duplicate." },
++ "cluster_id": { "type": ["string", "null"], "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The cluster the item belongs to (every ingested item is placed in one)." },
++ "previous_revision": { "type": "integer", "description": "action updated only: the Sources revision News held before." },
++ "reason": { "type": "string", "minLength": 1, "maxLength": 500, "description": "action removed only: why Sources removed it ('removed upstream' when it gave no reason)." },
++ "origin": { "enum": ["webhook", "pull"], "description": "action removed only: whether the removal came as a sources.item.removed delivery or through the cursor pull." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.story.created.v1.json b/contracts/events/payloads/news.story.created.v1.json
nnew file mode 100644
iindex 00000000..9fd610ed
---- /dev/null
++++ b/contracts/events/payloads/news.story.created.v1.json
@@@ -0,0 +1,14 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.created.v1.json",
++ "title": "NewsStoryCreatedPayload",
++ "description": "news.story.created v1 (OpenVibe.News server/domain/stories.js create). An editor opened a draft story (news.story.create), in the transaction that created it; when it was opened from a cluster, that cluster's active items (not duplicates, not removed upstream) were attached as its first sources. A first text sent with the request becomes revision 1 in the same transaction and is not announced here. Deliberately left out: the working headline, slug and any text (drafts are not public). Envelope: subject { type: story, id: sty_… }, visibility internal, priority low, actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": ["cluster_id", "topic", "sources"],
++ "additionalProperties": false,
++ "properties": {
++ "cluster_id": { "type": ["string", "null"], "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The cluster it was opened from, followed through merges; null for a story opened without one." },
++ "topic": { "type": ["string", "null"], "minLength": 1, "maxLength": 60, "description": "Slug of the story's topic (news_topics); null when none was given." },
++ "sources": { "type": "integer", "minimum": 0, "description": "How many source items were attached at creation." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.story.flagged.v1.json b/contracts/events/payloads/news.story.flagged.v1.json
nnew file mode 100644
iindex 00000000..f0bbfeb0
---- /dev/null
++++ b/contracts/events/payloads/news.story.flagged.v1.json
@@@ -0,0 +1,17 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.flagged.v1.json",
++ "title": "NewsStoryFlaggedPayload",
++ "description": "news.story.flagged v1 (OpenVibe.News server/domain/stories.js flagEvent, from onUpstreamChange). A source item a story cites changed (source_updated) or was removed (source_removed) upstream in OpenVibe.Sources, inside the ingest transaction that applied it: the story has an open editorial flag and, when it has text, a pending system-prepared revision (same paragraphs, refreshed source table) for an editor to check and publish. The published revision does not change. One event per affected story; an open source_updated flag is reused (its pending revision moves on) and announced again, an open source_removed flag is not. Editor flags (correction, update) and retraction flags do not emit this event. Envelope: subject { type: story, id: sty_…, revision: <pending revision, else the published revision, else 0> }, visibility internal, priority important for source_removed and normal for source_updated (normal is not an events.event-envelope@1 priority, so that envelope is refused until News sends low or important), actor service:news.",
++ "type": "object",
++ "required": ["flag_id", "kind", "status", "source_item_id", "pending_revision", "published_revision"],
++ "additionalProperties": false,
++ "properties": {
++ "flag_id": { "type": "string", "pattern": "^flg_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The news_editorial_flags row." },
++ "kind": { "enum": ["source_updated", "source_removed"] },
++ "status": { "const": "open" },
++ "source_item_id": { "type": "string", "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The News source item that changed or was removed." },
++ "pending_revision": { "type": ["integer", "null"], "minimum": 1, "description": "The revision News prepared for an editor; null when the story has no text yet or nothing new was prepared." },
++ "published_revision": { "type": ["integer", "null"], "minimum": 1, "description": "The revision readers see; null when the story was never published." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.story.published.v1.json b/contracts/events/payloads/news.story.published.v1.json
nnew file mode 100644
iindex 00000000..370a3e69
---- /dev/null
++++ b/contracts/events/payloads/news.story.published.v1.json
@@@ -0,0 +1,15 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.published.v1.json",
++ "title": "NewsStoryPublishedPayload",
++ "description": "news.story.published v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). An editor published a story that was a draft or unpublished (news.story.publish, stories.js publish), in the transaction of the change. For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets that from news.index_document.*). A story resting on fewer independent sources than NEWS_MIN_INDEPENDENT_SOURCES (default 2) is still published, with indexability noindex (unsourced). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate lets the story be listed, otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": ["canonical_url", "publication_state", "indexability"],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048 },
++ "publication_state": { "const": "published" },
++ "indexability": { "type": "object", "required": ["decision", "reasons"], "additionalProperties": false, "properties": { "decision": { "enum": ["index", "noindex"] }, "reasons": { "type": "array", "maxItems": 20, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } } } },
++ "topic": { "type": "string", "minLength": 1, "maxLength": 60, "description": "Slug of the story's topic (news_topics); absent when it has none." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.story.retracted.v1.json b/contracts/events/payloads/news.story.retracted.v1.json
nnew file mode 100644
iindex 00000000..ba372c84
---- /dev/null
++++ b/contracts/events/payloads/news.story.retracted.v1.json
@@@ -0,0 +1,23 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.retracted.v1.json",
++ "title": "NewsStoryRetractedPayload",
++ "description": "news.story.retracted v1 (OpenVibe.News server/domain/publication.js afterChange; the envelope is built there, not by index-hooks publicationEvent). An editor retracted a published story (news.story.retract, stories.js retract), in the transaction that stored the published retraction flag: the story stays at its URL with the retraction notice, is noindex, and leaves sitemaps and Search (news.index_document.deleted follows when Search had it). A retracted story stays retracted; unpublishing it later is news.story.unpublished. note is the public retraction notice and flag_id the news_editorial_flags row that holds it. The payload carries no topic. Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate still lets the story be listed (a retraction is noindex, not hidden), otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": ["canonical_url", "publication_state", "indexability", "note", "flag_id"],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Where the story, with its retraction notice, stays." },
++ "publication_state": { "const": "retracted" },
++ "indexability": {
++ "type": "object", "required": ["decision", "reasons"], "additionalProperties": false,
++ "description": "Search-shaped gate decision: always noindex, with retracted among the reasons.",
++ "properties": {
++ "decision": { "const": "noindex" },
++ "reasons": { "type": "array", "minItems": 1, "maxItems": 20, "uniqueItems": true, "contains": { "const": "retracted" }, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } }
++ }
++ },
++ "note": { "type": "string", "minLength": 10, "maxLength": 2000, "description": "The public retraction notice, whitespace collapsed." },
++ "flag_id": { "type": "string", "pattern": "^flg_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The retraction's news_editorial_flags row (kind retraction, status published)." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.story.unpublished.v1.json b/contracts/events/payloads/news.story.unpublished.v1.json
nnew file mode 100644
iindex 00000000..72bb49bf
---- /dev/null
++++ b/contracts/events/payloads/news.story.unpublished.v1.json
@@@ -0,0 +1,15 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.unpublished.v1.json",
++ "title": "NewsStoryUnpublishedPayload",
++ "description": "news.story.unpublished v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). An editor unpublished a published or retracted story (news.story.publish, stories.js unpublish), in the transaction of the change: it leaves the site, feeds and Search. For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets the tombstone from news.index_document.deleted). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": ["canonical_url", "publication_state", "indexability"],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": { "type": ["string", "null"], "description": "null: the event carries a tombstone, which has no URL." },
++ "publication_state": { "const": "unpublished" },
++ "indexability": { "type": ["object", "null"], "required": ["decision", "reasons"], "additionalProperties": false, "properties": { "decision": { "enum": ["index", "noindex"] }, "reasons": { "type": "array", "maxItems": 20, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } } }, "description": "The gate's decision for the unpublished story (noindex, not_published), or null when its published revision cannot be read." },
++ "topic": { "type": "string", "minLength": 1, "maxLength": 60, "description": "Slug of the story's topic (news_topics); absent when it has none." }
++ }
++}
ddiff --git a/contracts/events/payloads/news.story.updated.v1.json b/contracts/events/payloads/news.story.updated.v1.json
nnew file mode 100644
iindex 00000000..92758a91
---- /dev/null
++++ b/contracts/events/payloads/news.story.updated.v1.json
@@@ -0,0 +1,15 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.updated.v1.json",
++ "title": "NewsStoryUpdatedPayload",
++ "description": "news.story.updated v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). A published story now shows a different published revision (an editor published another revision, news.story.publish, stories.js publish) or its canonical URL changed, in the transaction of the change. Republishing the same revision with a correction note, and topic or noindex changes, emit no product event (only news.index_document.* when Search's copy changes). For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets that from news.index_document.*). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate lets the story be listed, otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": ["canonical_url", "publication_state", "indexability"],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048 },
++ "publication_state": { "const": "published" },
++ "indexability": { "type": "object", "required": ["decision", "reasons"], "additionalProperties": false, "properties": { "decision": { "enum": ["index", "noindex"] }, "reasons": { "type": "array", "maxItems": 20, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } } } },
++ "topic": { "type": "string", "minLength": 1, "maxLength": 60, "description": "Slug of the story's topic (news_topics); absent when it has none." }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.entity.merged.v1.json b/contracts/events/payloads/reviews.entity.merged.v1.json
nnew file mode 100644
iindex 00000000..71bd1d81
---- /dev/null
++++ b/contracts/events/payloads/reviews.entity.merged.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.entity.merged.v1.json",
++ "title": "ReviewsEntityMergedPayload",
++ "description": "reviews.entity.merged v1 (OpenVibe.Reviews server/reviews/service.js merge → entityEvent). An editor merged one entity into another (POST /api/v1/entities/:ref/merge, capability reviews.entity.merge, a person only), in the transaction of the merge. Nothing is rewritten: the merged entity keeps its aliases and its signals keep their attribution; it becomes state merged with an active merged_into link (link_id) and the target's aggregate is recomputed over both, which is why reviews.entity.split restores attribution exactly. The same transaction sends Search the merged entity's tombstone and the target's new document (reviews.index_document.*); a published summary of the merged entity keeps its state and no reviews.summary.unpublished is sent. The signal ids, aliases and aggregate are left out (they are in the entity's history, GET /api/v1/entities/:ref/history). Envelope: subject { type: entity, id: <merged entity ent_…> }, visibility public, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": ["entity_id", "into_entity_id", "link_id", "signal_count", "url", "into_url", "note"],
++ "additionalProperties": false,
++ "properties": {
++ "entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merged entity (subject.id); now state merged." },
++ "into_entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The active entity it was merged into." },
++ "link_id": { "type": "string", "pattern": "^lnk_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merged_into link; a later reviews.entity.split ends this link." },
++ "signal_count": { "type": "integer", "minimum": 0, "description": "Active signals of the merged entity and of entities merged into it, now counted in the target's aggregate." },
++ "url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Canonical URL of the merged entity's page (/e/<slug>)." },
++ "into_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Canonical URL of the target entity's page." },
++ "note": { "type": ["string", "null"], "maxLength": 500, "description": "The editor's note (clipped to 500 characters); null when none was given." }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.entity.split.v1.json b/contracts/events/payloads/reviews.entity.split.v1.json
nnew file mode 100644
iindex 00000000..1b5132ce
---- /dev/null
++++ b/contracts/events/payloads/reviews.entity.split.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.entity.split.v1.json",
++ "title": "ReviewsEntitySplitPayload",
++ "description": "reviews.entity.split v1 (OpenVibe.Reviews server/reviews/service.js split → entityEvent). An editor undid a merge (POST /api/v1/entities/:ref/split, capability reviews.entity.split, a person only), in the transaction of the split: the merged_into link (link_id) is ended, the entity is active again with exactly the aliases and signals it had before the merge, and both aggregates are recomputed. A summary of the former target that cites signals which went back with the split is flagged and gets a pending revision in the same transaction; Search gets both entities' documents again (reviews.index_document.upserted). The signal ids are left out (they are in the entity's history). Envelope: subject { type: entity, id: <split entity ent_…> }, visibility public, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": ["entity_id", "from_entity_id", "link_id", "signal_count", "url", "from_url", "note"],
++ "additionalProperties": false,
++ "properties": {
++ "entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity split off again (subject.id); now state active." },
++ "from_entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity it had been merged into." },
++ "link_id": { "type": "string", "pattern": "^lnk_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merged_into link this split ended (the link_id of the reviews.entity.merged event)." },
++ "signal_count": { "type": "integer", "minimum": 0, "description": "Active signals back with the entity (its own and those of entities merged into it)." },
++ "url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Canonical URL of the split entity's page (/e/<slug>)." },
++ "from_url": { "type": ["string", "null"], "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "Canonical URL of the former target's page; null only if that entity no longer exists." },
++ "note": { "type": ["string", "null"], "maxLength": 500, "description": "The editor's note (clipped to 500 characters); null when none was given." }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.index_document.deleted.v1.json b/contracts/events/payloads/reviews.index_document.deleted.v1.json
nnew file mode 100644
iindex 00000000..8fa7fcb1
---- /dev/null
++++ b/contracts/events/payloads/reviews.index_document.deleted.v1.json
@@@ -0,0 +1,14 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.index_document.deleted.v1.json",
++ "title": "ReviewsIndexDocumentDeletedPayload",
++ "description": "reviews.index_document.deleted v1 (OpenVibe.Reviews server/reviews/service.js syncEntity, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the entity was merged into another or deleted (or the gate stopped listing its page), so Search drops it at this revision or older. Only sent for an entity Search was sent before (every entity gets a document when it is created); a split sends the entity's document again. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: entity, id, revision }, visibility internal, priority important, actor service:reviews.",
++ "type": "object",
++ "required": ["type", "id", "revision"],
++ "additionalProperties": false,
++ "properties": {
++ "type": { "const": "entity" },
++ "id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991, "description": "Index revision of the tombstone; wins over any document at the same or an older revision." }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.index_document.upserted.v1.json b/contracts/events/payloads/reviews.index_document.upserted.v1.json
nnew file mode 100644
iindex 00000000..3f409f73
---- /dev/null
++++ b/contracts/events/payloads/reviews.index_document.upserted.v1.json
@@@ -0,0 +1,13 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.index_document.upserted.v1.json",
++ "title": "ReviewsIndexDocumentUpsertedPayload",
++ "description": "reviews.index_document.upserted v1 (OpenVibe.Reviews server/reviews/service.js syncEntity, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of an active entity page: the entity name as title, the published summary (or the entity description) as summary, the summary text and the aggregate's computation lines as body, facets kind / has_aggregate / sources, the published summary's authorship, provenance of up to 40 cited Sources items (plus the AI run of an AI-drafted summary), and the gate's indexability (noindex thin_content / unsourced until signals and a summary back the page). Sent whenever what Search should hold changed (the revision grows with every change), in the transaction of the change; the reconciliation at every start (reconcileIndex) re-sends only documents that differ. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: entity, id, revision: <document revision> }, visibility internal, priority important, actor service:reviews.",
++ "allOf": [
++ { "$ref": "../../search/index-document.v1.json" },
++ {
++ "type": "object", "required": ["owner", "type", "deleted"],
++ "properties": { "owner": { "const": "reviews" }, "type": { "const": "entity" }, "id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$" }, "deleted": { "const": false } }
++ }
++ ]
++}
ddiff --git a/contracts/events/payloads/reviews.signal.added.v1.json b/contracts/events/payloads/reviews.signal.added.v1.json
nnew file mode 100644
iindex 00000000..1dcdf16f
---- /dev/null
++++ b/contracts/events/payloads/reviews.signal.added.v1.json
@@@ -0,0 +1,37 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.signal.added.v1.json",
++ "title": "ReviewsSignalAddedPayload",
++ "description": "reviews.signal.added v1 (OpenVibe.Reviews server/reviews/service.js createSignal → signalEvent). A typed signal was recorded from one revision of an OpenVibe.Sources review item resolved to an entity (an import through POST /api/v1/signals/import or the Sources sync, a sources.item.created|updated event, or an editor resolving a queued item), in the transaction that stored it. Values and provenance are exactly what the source stated and never change afterwards. When the item already had an active signal (a newer item revision, or an editor reattributing the item), `replaces` names it and the reviews.signal.removed for it is written just before this event. Value fields depend on type: recommended (recommendation), positive_count and total_count (recommendation_tally), rating_value, rating_best, rating_worst and rating_count (rating, rating_aggregate); a scale the source did not state is null, never defaulted. Review text, the signal's trust metadata and the resulting aggregate are left out. Envelope: subject { type: signal, id: <signal_id> }, visibility public, priority important, actor the caller (the importing service, e.g. service:sources-sync, or the editor, user usr_…) or service:reviews for the Sources event consumer and sync worker.",
++ "type": "object",
++ "required": ["signal_id", "entity_id", "canonical_entity_id", "type", "source_key", "source_item_id", "item_revision", "observed_at", "source_published_at", "canonical_url", "license_note"],
++ "additionalProperties": false,
++ "properties": {
++ "signal_id": { "type": "string", "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity the signal is attributed to (never rewritten by a merge)." },
++ "canonical_entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity it currently counts under: entity_id, or the entity it is merged into." },
++ "type": { "enum": ["recommendation", "recommendation_tally", "rating", "rating_aggregate"] },
++ "source_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$", "description": "OpenVibe.Sources source key." },
++ "source_item_id": { "type": "string", "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "item_revision": { "type": "integer", "minimum": 1, "description": "The Sources item revision the signal was read from." },
++ "observed_at": { "type": "string", "format": "date-time", "description": "When Sources retrieved that item revision (provenance.retrieved_at)." },
++ "source_published_at": { "type": ["string", "null"], "description": "The item's published_at as Sources gave it; null when it gave none." },
++ "canonical_url": { "type": ["string", "null"], "description": "The item's canonical URL as Sources gave it; null when it has none." },
++ "license_note": { "type": ["string", "null"], "description": "Licence note recorded for the item in Sources; null when none." },
++ "recommended": { "type": "boolean", "description": "recommendation only: whether the reviewer recommends it." },
++ "positive_count": { "type": "integer", "minimum": 0, "description": "recommendation_tally only: positive reviews the source counts." },
++ "total_count": { "type": "integer", "minimum": 0, "description": "recommendation_tally only: all reviews the source counts (≥ positive_count)." },
++ "rating_value": { "type": "number", "description": "rating and rating_aggregate only: the value as stated." },
++ "rating_best": { "type": ["number", "null"], "description": "rating and rating_aggregate only: top of the stated scale; null when the source states none." },
++ "rating_worst": { "type": ["number", "null"], "description": "rating and rating_aggregate only: bottom of the stated scale; null when the source states none." },
++ "rating_count": { "type": "integer", "minimum": 1, "description": "rating and rating_aggregate only: ratings behind the value (1 for a single review)." },
++ "replaces": { "type": "string", "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$", "description": "Present when this signal replaced the item's previous active signal (see reviews.signal.removed)." }
++ },
++ "if": { "properties": { "type": { "const": "recommendation" } }, "required": ["type"] },
++ "then": { "required": ["recommended"] },
++ "else": {
++ "if": { "properties": { "type": { "const": "recommendation_tally" } }, "required": ["type"] },
++ "then": { "required": ["positive_count", "total_count"] },
++ "else": { "required": ["rating_value", "rating_best", "rating_worst", "rating_count"] }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.signal.removed.v1.json b/contracts/events/payloads/reviews.signal.removed.v1.json
nnew file mode 100644
iindex 00000000..b05fce67
---- /dev/null
++++ b/contracts/events/payloads/reviews.signal.removed.v1.json
@@@ -0,0 +1,46 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.signal.removed.v1.json",
++ "title": "ReviewsSignalRemovedPayload",
++ "description": "reviews.signal.removed v1 (OpenVibe.Reviews server/reviews/service.js withdrawSignal → signalEvent). A signal stopped being active, in the transaction of the change. status superseded: a newer revision of the same item replaced it for the same entity (replaced_by names the new signal, whose reviews.signal.added follows). status withdrawn: its source removed the item (sources.item.removed or a removed item on import), the item no longer states a signal, or an editor reattributed or unresolved the item. The payload repeats the signal as recorded (values and provenance never change) plus status and reason; summaries citing it are flagged and get a pending revision, and the entity's aggregate and Search document are refreshed, in the same transaction. Envelope: subject { type: signal, id: <signal_id> }, visibility public, priority important, actor the caller (the importing service, e.g. service:sources-sync, or the editor, user usr_…) or service:reviews for the Sources event consumer and sync worker.",
++ "type": "object",
++ "required": ["signal_id", "entity_id", "canonical_entity_id", "type", "source_key", "source_item_id", "item_revision", "observed_at", "source_published_at", "canonical_url", "license_note", "status", "reason"],
++ "additionalProperties": false,
++ "properties": {
++ "signal_id": { "type": "string", "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity the signal is attributed to (never rewritten by a merge)." },
++ "canonical_entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity it counted under: entity_id, or the entity it is merged into." },
++ "type": { "enum": ["recommendation", "recommendation_tally", "rating", "rating_aggregate"] },
++ "source_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$", "description": "OpenVibe.Sources source key." },
++ "source_item_id": { "type": "string", "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "item_revision": { "type": "integer", "minimum": 1, "description": "The Sources item revision the signal was read from." },
++ "observed_at": { "type": "string", "format": "date-time", "description": "When Sources retrieved that item revision (provenance.retrieved_at)." },
++ "source_published_at": { "type": ["string", "null"], "description": "The item's published_at as Sources gave it; null when it gave none." },
++ "canonical_url": { "type": ["string", "null"], "description": "The item's canonical URL as Sources gave it; null when it has none." },
++ "license_note": { "type": ["string", "null"], "description": "Licence note recorded for the item in Sources; null when none." },
++ "recommended": { "type": "boolean", "description": "recommendation only: whether the reviewer recommends it." },
++ "positive_count": { "type": "integer", "minimum": 0, "description": "recommendation_tally only: positive reviews the source counts." },
++ "total_count": { "type": "integer", "minimum": 0, "description": "recommendation_tally only: all reviews the source counts (≥ positive_count)." },
++ "rating_value": { "type": "number", "description": "rating and rating_aggregate only: the value as stated." },
++ "rating_best": { "type": ["number", "null"], "description": "rating and rating_aggregate only: top of the stated scale; null when the source states none." },
++ "rating_worst": { "type": ["number", "null"], "description": "rating and rating_aggregate only: bottom of the stated scale; null when the source states none." },
++ "rating_count": { "type": "integer", "minimum": 1, "description": "rating and rating_aggregate only: ratings behind the value (1 for a single review)." },
++ "status": { "enum": ["superseded", "withdrawn"], "description": "superseded: replaced by a newer revision of the same item; withdrawn: every other removal." },
++ "reason": { "type": "string", "maxLength": 500, "description": "Why, in plain words (e.g. \"removed by its source: <reason>\", \"source item revised (r1 → r2)\")." },
++ "replaced_by": { "type": "string", "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$", "description": "status superseded only: the signal that replaced it." }
++ },
++ "if": { "properties": { "type": { "const": "recommendation" } }, "required": ["type"] },
++ "then": { "required": ["recommended"] },
++ "else": {
++ "if": { "properties": { "type": { "const": "recommendation_tally" } }, "required": ["type"] },
++ "then": { "required": ["positive_count", "total_count"] },
++ "else": { "required": ["rating_value", "rating_best", "rating_worst", "rating_count"] }
++ },
++ "dependentSchemas": {
++ "status": {
++ "if": { "properties": { "status": { "const": "superseded" } } },
++ "then": { "required": ["replaced_by"] },
++ "else": { "not": { "required": ["replaced_by"] } }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.summary.published.v1.json b/contracts/events/payloads/reviews.summary.published.v1.json
nnew file mode 100644
iindex 00000000..1c5a6b09
---- /dev/null
++++ b/contracts/events/payloads/reviews.summary.published.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.summary.published.v1.json",
++ "title": "ReviewsSummaryPublishedPayload",
++ "description": "reviews.summary.published v1 (OpenVibe.Reviews server/reviews/service.js publishSummary, via openvibe-publishing/index-hooks publicationEvent). An entity's summary that was not published became published (an editor publishing a revision, or approving one with publish on, which is how an AI-drafted revision gets published), in the transaction of the change. For subscribers other than Search: the entity page's canonical URL, publication state and Search-shaped indexability, the entity id and slug, and the revision's authorship; never the summary text or its citations (Search gets those in reviews.index_document.upserted) and never a rating (a summary cannot carry one). correction appears only when the revision published is a correction revision (normally that is reviews.summary.updated). Envelope: subject { type: summary, id: sum_…, revision: <published summary revision> }, visibility public when the entity page is listable by the gate, otherwise internal, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": ["canonical_url", "publication_state", "indexability", "entity_id", "entity_slug", "authorship"],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "The entity page (/e/<slug>) that shows the summary." },
++ "publication_state": { "const": "published" },
++ "indexability": { "type": "object", "required": ["decision", "reasons"], "additionalProperties": false, "properties": { "decision": { "enum": ["index", "noindex"] }, "reasons": { "type": "array", "maxItems": 20, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } } }, "description": "The gate's decision for the entity page after the change, in Search's vocabulary." },
++ "entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity the summary belongs to (one summary per entity)." },
++ "entity_slug": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "maxLength": 90 },
++ "authorship": { "enum": ["human", "ai_assisted", "ai_generated", null], "description": "Authorship of the published revision: human (editor-written), ai_generated (an OpenVibe.AI draft a person approved), ai_assisted (an AI draft a person corrected); null only for a revision without an authorship record." },
++ "correction": {
++ "type": "object", "required": ["note", "corrects"], "additionalProperties": false,
++ "description": "Present when the published revision is a correction: the public note and the revision it corrects.",
++ "properties": {
++ "note": { "type": "string", "minLength": 10, "maxLength": 2000, "description": "What was corrected, as readers see it." },
++ "corrects": { "type": "integer", "minimum": 1, "description": "The summary revision that was published when the correction was made." }
++ }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.summary.unpublished.v1.json b/contracts/events/payloads/reviews.summary.unpublished.v1.json
nnew file mode 100644
iindex 00000000..6265e29c
---- /dev/null
++++ b/contracts/events/payloads/reviews.summary.unpublished.v1.json
@@@ -0,0 +1,16 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.summary.unpublished.v1.json",
++ "title": "ReviewsSummaryUnpublishedPayload",
++ "description": "reviews.summary.unpublished v1 (OpenVibe.Reviews server/reviews/service.js unpublishSummary, via openvibe-publishing/index-hooks publicationEvent). An editor took an entity's published summary down (POST /api/v1/entities/:ref/summary/unpublish), in the transaction of the change. The entity page stays up without the summary; its new Search document goes out as reviews.index_document.upserted. Only this action sends it: merging or deleting an entity leaves its summary's state alone and Search gets the entity's tombstone instead. For subscribers other than Search: publication state, the entity page's indexability after the change, and the entity id and slug; canonical_url is always null from Reviews (the helper is given a tombstone), and authorship and correction are not sent. Envelope: subject { type: summary, id: sum_…, revision: <the revision that was published> }, visibility internal, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": ["canonical_url", "publication_state", "indexability", "entity_id", "entity_slug"],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": { "type": ["string", "null"], "description": "null: the event carries a tombstone, which has no URL." },
++ "publication_state": { "const": "unpublished" },
++ "indexability": { "type": "object", "required": ["decision", "reasons"], "additionalProperties": false, "properties": { "decision": { "enum": ["index", "noindex"] }, "reasons": { "type": "array", "maxItems": 20, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } } }, "description": "The gate's decision for the entity page after the summary came down (Reviews always passes one, so it is never null here)." },
++ "entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity the summary belongs to." },
++ "entity_slug": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "maxLength": 90 }
++ }
++}
ddiff --git a/contracts/events/payloads/reviews.summary.updated.v1.json b/contracts/events/payloads/reviews.summary.updated.v1.json
nnew file mode 100644
iindex 00000000..b12afc78
---- /dev/null
++++ b/contracts/events/payloads/reviews.summary.updated.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.summary.updated.v1.json",
++ "title": "ReviewsSummaryUpdatedPayload",
++ "description": "reviews.summary.updated v1 (OpenVibe.Reviews server/reviews/service.js publishSummary, via openvibe-publishing/index-hooks publicationEvent). A published summary got a different published revision, in the transaction of the change: an editor published another revision, approved the pending revision prepared after a source change, or corrected the summary (POST …/summary/revisions with correction_note, or accepting a correction request), which publishes at once and carries correction. For subscribers other than Search: the entity page's canonical URL, publication state and Search-shaped indexability, the entity id and slug, and the revision's authorship; never the summary text or its citations (Search gets those in reviews.index_document.upserted) and never a rating. Envelope: subject { type: summary, id: sum_…, revision: <published summary revision> }, visibility public when the entity page is listable by the gate, otherwise internal, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": ["canonical_url", "publication_state", "indexability", "entity_id", "entity_slug", "authorship"],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": { "type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 2048, "description": "The entity page (/e/<slug>) that shows the summary." },
++ "publication_state": { "const": "published" },
++ "indexability": { "type": "object", "required": ["decision", "reasons"], "additionalProperties": false, "properties": { "decision": { "enum": ["index", "noindex"] }, "reasons": { "type": "array", "maxItems": 20, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } } }, "description": "The gate's decision for the entity page after the change, in Search's vocabulary." },
++ "entity_id": { "type": "string", "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The entity the summary belongs to (one summary per entity)." },
++ "entity_slug": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "maxLength": 90 },
++ "authorship": { "enum": ["human", "ai_assisted", "ai_generated", null], "description": "Authorship of the published revision: human (editor-written), ai_generated (an OpenVibe.AI draft a person approved), ai_assisted (an AI draft a person corrected); null only for a revision without an authorship record." },
++ "correction": {
++ "type": "object", "required": ["note", "corrects"], "additionalProperties": false,
++ "description": "Present when the published revision is a correction: the public note and the revision it corrects.",
++ "properties": {
++ "note": { "type": "string", "minLength": 10, "maxLength": 2000, "description": "What was corrected, as readers see it." },
++ "corrects": { "type": "integer", "minimum": 1, "description": "The summary revision that was published when the correction was made." }
++ }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/trade.index_document.deleted.v1.json b/contracts/events/payloads/trade.index_document.deleted.v1.json
nnew file mode 100644
iindex 00000000..75d58b07
---- /dev/null
++++ b/contracts/events/payloads/trade.index_document.deleted.v1.json
@@@ -0,0 +1,19 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.index_document.deleted.v1.json",
++ "title": "TradeIndexDocumentDeletedPayload",
++ "description": "trade.index_document.deleted v1 (OpenVibe.Trade server/domain/indexing.js refresh, via openvibe-publishing/index-hooks indexEvent). A Search tombstone for an instrument page: the instrument was archived, or the page stopped being listable (e.g. its reviewed context was retracted). Only sent for a page Search was sent before, and only when the tombstone's revision moved. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: instrument, id, revision }, visibility internal, priority important, actor service:trade.",
++ "type": "object",
++ "required": ["type", "id", "revision"],
++ "additionalProperties": false,
++ "properties": {
++ "type": { "const": "instrument" },
++ "id": { "type": "string", "pattern": "^ins_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 9007199254740991,
++ "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/trade.index_document.upserted.v1.json b/contracts/events/payloads/trade.index_document.upserted.v1.json
nnew file mode 100644
iindex 00000000..bfee0cea
---- /dev/null
++++ b/contracts/events/payloads/trade.index_document.upserted.v1.json
@@@ -0,0 +1,19 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.index_document.upserted.v1.json",
++ "title": "TradeIndexDocumentUpsertedPayload",
++ "description": "trade.index_document.upserted v1 (OpenVibe.Trade server/domain/indexing.js refresh, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of an instrument page (/i/<symbol>), the only public indexable object Trade has (watchlists, alert rules and deliveries never reach Search). Sent while the instrument is active and the SEO gate lets the page be listed (financial context is sensitive: the page is hidden until a person-reviewed context is published), whenever the document changed: after a recorded observation, a context publish, review or retraction, an instrument edit, and the worker's refresh pass. The sequencer (trade_index_revisions) gives an unchanged document its old revision and nothing is sent. A page whose newest monetary observation is too old stays as a noindex document (reason stale_price). Title '<symbol> — <name>', language en, facets kind, symbol, exchange; provenance lists Trade's own cited records. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: instrument, id, revision: <document revision> }, visibility internal, priority important, actor service:trade.",
++ "allOf": [
++ { "$ref": "../../search/index-document.v1.json" },
++ {
++ "type": "object",
++ "required": ["owner", "type", "deleted"],
++ "properties": {
++ "owner": { "const": "trade" },
++ "type": { "const": "instrument" },
++ "id": { "type": "string", "pattern": "^ins_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "deleted": { "const": false }
++ }
++ }
++ ]
++}
ddiff --git a/contracts/events/payloads/trade.observation.created.v1.json b/contracts/events/payloads/trade.observation.created.v1.json
nnew file mode 100644
iindex 00000000..698716a7
---- /dev/null
++++ b/contracts/events/payloads/trade.observation.created.v1.json
@@@ -0,0 +1,60 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.observation.created.v1.json",
++ "title": "TradeObservationCreatedPayload",
++ "description": "trade.observation.created v1 (OpenVibe.Trade server/domain/observations.js record). A market observation was recorded: one value a source stated for an instrument (metric, the decimal exactly as stated, unit, currency when monetary, period), when it was true per the source (observed_at), when the source was fetched (retrieved_at) and when Trade recorded it, with the source reference. Recorded by a feed service through POST /api/v1/observations (trade.observation.write) or by the Sources sync (server/domain/sync.js). Only on creation: a replay of the same (source_key, source_ref) sends nothing, and a different value under the same reference is refused. Written in the transaction that inserted the immutable row, together with the source's freshness update (trade.source.recovered|stale), alert evaluation (trade.alert.triggered) and the instrument's Search document. Leaves out the freshness verdict (computed at read time) and recorded_by (the envelope actor). Envelope: subject { type: observation, id }, visibility public, priority low, actor service:<recording service> (service:trade for the Sources sync). Information only, not investment advice.",
++ "type": "object",
++ "required": ["instrument", "id", "metric", "value", "unit", "currency", "period", "observed_at", "retrieved_at", "recorded_at", "max_age_sec", "source"],
++ "additionalProperties": false,
++ "properties": {
++ "instrument": {
++ "type": "object",
++ "required": ["id", "symbol"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^ins_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "symbol": { "type": "string", "pattern": "^[A-Z0-9][A-Z0-9.-]{0,15}$" }
++ }
++ },
++ "id": { "type": "string", "pattern": "^obs_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "metric": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]{0,79}$", "description": "e.g. price.close, volume, us-gaap:Revenues." },
++ "value": {
++ "type": "string",
++ "pattern": "^-?[0-9]+(\\.[0-9]+)?([eE][-+]?[0-9]+)?$",
++ "maxLength": 64,
++ "description": "The decimal exactly as the source stated it (text, never rounded)."
++ },
++ "unit": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9/%._-]{0,19}$", "description": "e.g. USD, shares, USD/shares." },
++ "currency": { "type": ["string", "null"], "pattern": "^[A-Z]{3}$", "description": "ISO 4217 when the value is monetary; null otherwise." },
++ "period": {
++ "type": ["string", "null"],
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "The period the value covers when the source states one (e.g. a fiscal quarter)."
++ },
++ "observed_at": { "type": "string", "format": "date-time", "description": "When the value was true, per the source." },
++ "retrieved_at": { "type": "string", "format": "date-time", "description": "When the source was fetched." },
++ "recorded_at": { "type": "string", "format": "date-time", "description": "When Trade recorded the row." },
++ "max_age_sec": {
++ "type": ["integer", "null"],
++ "minimum": 60,
++ "maximum": 315360000,
++ "description": "The value's own maximum age, when the recorder gave one."
++ },
++ "source": {
++ "type": "object",
++ "required": ["key", "item_id", "url", "ref"],
++ "additionalProperties": false,
++ "properties": {
++ "key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$", "description": "OpenVibe.Sources source key." },
++ "item_id": {
++ "type": ["string", "null"],
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The Sources item the value came from, when there is one."
++ },
++ "url": { "type": ["string", "null"], "pattern": "^https?://", "description": "The source's page for the value (absolute http(s) URL, normalised)." },
++ "ref": { "type": "string", "minLength": 1, "maxLength": 300, "description": "The source's own reference for this datum; unique per source_key." }
++ }
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/trade.source.recovered.v1.json b/contracts/events/payloads/trade.source.recovered.v1.json
nnew file mode 100644
iindex 00000000..8ff5f37e
---- /dev/null
++++ b/contracts/events/payloads/trade.source.recovered.v1.json
@@@ -0,0 +1,21 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.source.recovered.v1.json",
++ "title": "TradeSourceRecoveredPayload",
++ "description": "trade.source.recovered v1 (OpenVibe.Trade server/domain/freshness.js evaluate). A stale source is fresh again by Trade's own clock: a successful fetch younger than its staleness window is now known, reported through Sources' health during the sync (sync.js reportHealth) or proved by the retrieved_at of a newly recorded observation or filing (noteRetrieval). Sent only on a stale-to-fresh transition; a source that is already fresh the first time Trade evaluates it is not a recovery and sends nothing. Envelope: subject { type: source, id: <source_key> }, visibility public, priority low, actor service:trade.",
++ "type": "object",
++ "required": ["source_key", "stale", "stale_since", "last_success_at", "stale_after_sec", "upstream_status", "evaluated_at"],
++ "additionalProperties": false,
++ "properties": {
++ "source_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$", "description": "OpenVibe.Sources source key." },
++ "stale": { "const": false },
++ "stale_since": { "type": "null", "description": "Always null: the source is fresh." },
++ "last_success_at": { "type": "string", "format": "date-time", "description": "The successful fetch that made it fresh (or a later one)." },
++ "stale_after_sec": { "type": "integer", "description": "The staleness window applied, in seconds." },
++ "upstream_status": {
++ "type": ["string", "null"],
++ "description": "Sources' own health status as last reported (healthy, stale, failing, never_fetched, disabled, manual); null if never reported."
++ },
++ "evaluated_at": { "type": "string", "format": "date-time" }
++ }
++}
ddiff --git a/contracts/events/payloads/trade.source.stale.v1.json b/contracts/events/payloads/trade.source.stale.v1.json
nnew file mode 100644
iindex 00000000..1cc2f932
---- /dev/null
++++ b/contracts/events/payloads/trade.source.stale.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.source.stale.v1.json",
++ "title": "TradeSourceStalePayload",
++ "description": "trade.source.stale v1 (OpenVibe.Trade server/domain/freshness.js evaluate). A source Trade shows data from went stale by Trade's own clock: its last successful fetch is older than its staleness window (Sources' stale_after_sec, else TRADE_DEFAULT_STALE_AFTER_SEC), or no success is known at all. Sent only on a transition (fresh to stale), or once when Trade first evaluates a source that is already stale; replays and further ticks send nothing. Evaluated when the Sources sync reports a source's health (sync.js reportHealth), when a recorded observation or filing proves a retrieval (noteRetrieval), and by the worker (evaluateAll). Sources' own health status is carried as upstream_status but is not the verdict. Envelope: subject { type: source, id: <source_key> }, visibility public, priority low, actor service:trade.",
++ "type": "object",
++ "required": ["source_key", "stale", "stale_since", "last_success_at", "stale_after_sec", "upstream_status", "evaluated_at"],
++ "additionalProperties": false,
++ "properties": {
++ "source_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$", "description": "OpenVibe.Sources source key." },
++ "stale": { "const": true },
++ "stale_since": {
++ "type": "string",
++ "format": "date-time",
++ "description": "Last success + window, or when Trade first saw the source if it never succeeded."
++ },
++ "last_success_at": { "type": ["string", "null"], "format": "date-time", "description": "Last known successful fetch; null when none is known." },
++ "stale_after_sec": { "type": "integer", "description": "The staleness window applied, in seconds." },
++ "upstream_status": {
++ "type": ["string", "null"],
++ "description": "Sources' own health status as last reported (healthy, stale, failing, never_fetched, disabled, manual); null if never reported."
++ },
++ "evaluated_at": { "type": "string", "format": "date-time" }
++ }
++}
ddiff --git a/fixtures/coupons.confidence.changed/invalid/missing-status.json b/fixtures/coupons.confidence.changed/invalid/missing-status.json
nnew file mode 100644
iindex 00000000..abcfd19d
---- /dev/null
++++ b/fixtures/coupons.confidence.changed/invalid/missing-status.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "from": 0.5, "to": 0.6 }
ddiff --git a/fixtures/coupons.confidence.changed/invalid/percent.json b/fixtures/coupons.confidence.changed/invalid/percent.json
nnew file mode 100644
iindex 00000000..c83eae5c
---- /dev/null
++++ b/fixtures/coupons.confidence.changed/invalid/percent.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "from": null, "to": 67, "status": "reported_working" }
ddiff --git a/fixtures/coupons.confidence.changed/valid/decayed-out.json b/fixtures/coupons.confidence.changed/valid/decayed-out.json
nnew file mode 100644
iindex 00000000..f052bb23
---- /dev/null
++++ b/fixtures/coupons.confidence.changed/valid/decayed-out.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "from": 0.33, "to": null, "status": "unknown" }
ddiff --git a/fixtures/coupons.confidence.changed/valid/first-report.json b/fixtures/coupons.confidence.changed/valid/first-report.json
nnew file mode 100644
iindex 00000000..81b9fafe
---- /dev/null
++++ b/fixtures/coupons.confidence.changed/valid/first-report.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "from": null, "to": 0.67, "status": "reported_working" }
ddiff --git a/fixtures/coupons.coupon.created/invalid/missing-origin.json b/fixtures/coupons.coupon.created/invalid/missing-origin.json
nnew file mode 100644
iindex 00000000..036eb89c
---- /dev/null
++++ b/fixtures/coupons.coupon.created/invalid/missing-origin.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "unknown", "confidence": null, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA" }
ddiff --git a/fixtures/coupons.coupon.created/invalid/names-submitter.json b/fixtures/coupons.coupon.created/invalid/names-submitter.json
nnew file mode 100644
iindex 00000000..003c9281
---- /dev/null
++++ b/fixtures/coupons.coupon.created/invalid/names-submitter.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "unknown", "confidence": null, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "origin": "member", "created_by": "usr_01K5R5E4F3G2H1J0K9M8N7P6QR" }
ddiff --git a/fixtures/coupons.coupon.created/invalid/with-confidence.json b/fixtures/coupons.coupon.created/invalid/with-confidence.json
nnew file mode 100644
iindex 00000000..564d5969
---- /dev/null
++++ b/fixtures/coupons.coupon.created/invalid/with-confidence.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "reported_working", "confidence": 0.99, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "origin": "member" }
ddiff --git a/fixtures/coupons.coupon.created/valid/ai-pending-dated.json b/fixtures/coupons.coupon.created/valid/ai-pending-dated.json
nnew file mode 100644
iindex 00000000..5a2bda92
---- /dev/null
++++ b/fixtures/coupons.coupon.created/valid/ai-pending-dated.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "unknown", "confidence": null, "review_state": "pending", "expires_at": "2026-10-31T23:59:59.999Z", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R9B2C3D4E5F6G7H8J9K0MN", "origin": "ai" }
ddiff --git a/fixtures/coupons.coupon.created/valid/member-published.json b/fixtures/coupons.coupon.created/valid/member-published.json
nnew file mode 100644
iindex 00000000..2c75cfbd
---- /dev/null
++++ b/fixtures/coupons.coupon.created/valid/member-published.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "unknown", "confidence": null, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "origin": "member" }
ddiff --git a/fixtures/coupons.coupon.disabled/invalid/confidence-percent.json b/fixtures/coupons.coupon.disabled/invalid/confidence-percent.json
nnew file mode 100644
iindex 00000000..90b462c2
---- /dev/null
++++ b/fixtures/coupons.coupon.disabled/invalid/confidence-percent.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "disabled", "confidence": 33, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "reported_failed", "reason": "staff" }
ddiff --git a/fixtures/coupons.coupon.disabled/invalid/reason-report.json b/fixtures/coupons.coupon.disabled/invalid/reason-report.json
nnew file mode 100644
iindex 00000000..f48b3518
---- /dev/null
++++ b/fixtures/coupons.coupon.disabled/invalid/reason-report.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "disabled", "confidence": 0.33, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "reported_failed", "reason": "report" }
ddiff --git a/fixtures/coupons.coupon.disabled/valid/source-removed.json b/fixtures/coupons.coupon.disabled/valid/source-removed.json
nnew file mode 100644
iindex 00000000..ab65bf1f
---- /dev/null
++++ b/fixtures/coupons.coupon.disabled/valid/source-removed.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "disabled", "confidence": null, "review_state": "pending", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R9B2C3D4E5F6G7H8J9K0MN", "previous_status": "unknown", "reason": "source_removed" }
ddiff --git a/fixtures/coupons.coupon.disabled/valid/staff-expired-code.json b/fixtures/coupons.coupon.disabled/valid/staff-expired-code.json
nnew file mode 100644
iindex 00000000..5571e135
---- /dev/null
++++ b/fixtures/coupons.coupon.disabled/valid/staff-expired-code.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "disabled", "confidence": 0.33, "review_state": "published", "expires_at": "2026-09-20T23:59:59.999Z", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "expired", "reason": "staff" }
ddiff --git a/fixtures/coupons.coupon.expired/invalid/from-disabled.json b/fixtures/coupons.coupon.expired/invalid/from-disabled.json
nnew file mode 100644
iindex 00000000..0c23155f
---- /dev/null
++++ b/fixtures/coupons.coupon.expired/invalid/from-disabled.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "expired", "confidence": null, "review_state": "published", "expires_at": "2026-09-22T23:59:59.999Z", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "disabled", "reason": "expiry" }
ddiff --git a/fixtures/coupons.coupon.expired/invalid/missing-previous-status.json b/fixtures/coupons.coupon.expired/invalid/missing-previous-status.json
nnew file mode 100644
iindex 00000000..a3fa59dc
---- /dev/null
++++ b/fixtures/coupons.coupon.expired/invalid/missing-previous-status.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "expired", "confidence": null, "review_state": "published", "expires_at": "2026-09-22T23:59:59.999Z", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "reason": "expiry" }
ddiff --git a/fixtures/coupons.coupon.expired/invalid/wrong-status.json b/fixtures/coupons.coupon.expired/invalid/wrong-status.json
nnew file mode 100644
iindex 00000000..c48b77ce
---- /dev/null
++++ b/fixtures/coupons.coupon.expired/invalid/wrong-status.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "disabled", "confidence": null, "review_state": "published", "expires_at": "2026-09-22T23:59:59.999Z", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "unknown", "reason": "expiry" }
ddiff --git a/fixtures/coupons.coupon.expired/valid/staff-marked.json b/fixtures/coupons.coupon.expired/valid/staff-marked.json
nnew file mode 100644
iindex 00000000..52fcdbc5
---- /dev/null
++++ b/fixtures/coupons.coupon.expired/valid/staff-marked.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "expired", "confidence": 0.71, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "reported_working", "reason": "staff" }
ddiff --git a/fixtures/coupons.coupon.expired/valid/sweep.json b/fixtures/coupons.coupon.expired/valid/sweep.json
nnew file mode 100644
iindex 00000000..db5a8f1c
---- /dev/null
++++ b/fixtures/coupons.coupon.expired/valid/sweep.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "expired", "confidence": null, "review_state": "published", "expires_at": "2026-09-22T23:59:59.999Z", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "unknown", "reason": "expiry" }
ddiff --git a/fixtures/coupons.coupon.updated/invalid/bad-merchant-id.json b/fixtures/coupons.coupon.updated/invalid/bad-merchant-id.json
nnew file mode 100644
iindex 00000000..53043cc0
---- /dev/null
++++ b/fixtures/coupons.coupon.updated/invalid/bad-merchant-id.json
@@@ -0,0 +1 @@
++{ "merchant_id": "acme-outdoor", "status": "reported_working", "confidence": 0.67, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "unknown", "reason": "report" }
ddiff --git a/fixtures/coupons.coupon.updated/invalid/free-text-reason.json b/fixtures/coupons.coupon.updated/invalid/free-text-reason.json
nnew file mode 100644
iindex 00000000..8cd1f859
---- /dev/null
++++ b/fixtures/coupons.coupon.updated/invalid/free-text-reason.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "reported_failed", "confidence": 0.33, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "unknown", "reason": "staff:customer complained" }
ddiff --git a/fixtures/coupons.coupon.updated/invalid/missing-reason.json b/fixtures/coupons.coupon.updated/invalid/missing-reason.json
nnew file mode 100644
iindex 00000000..bdf67f2c
---- /dev/null
++++ b/fixtures/coupons.coupon.updated/invalid/missing-reason.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "reported_working", "confidence": 0.67, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "unknown" }
ddiff --git a/fixtures/coupons.coupon.updated/valid/expiry-changed.json b/fixtures/coupons.coupon.updated/valid/expiry-changed.json
nnew file mode 100644
iindex 00000000..2b617cdb
---- /dev/null
++++ b/fixtures/coupons.coupon.updated/valid/expiry-changed.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "unknown", "confidence": null, "review_state": "published", "expires_at": "2026-12-31T23:59:59.999Z", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "reason": "expiry_changed" }
ddiff --git a/fixtures/coupons.coupon.updated/valid/report-working.json b/fixtures/coupons.coupon.updated/valid/report-working.json
nnew file mode 100644
iindex 00000000..33bb52d3
---- /dev/null
++++ b/fixtures/coupons.coupon.updated/valid/report-working.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "reported_working", "confidence": 0.67, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "unknown", "reason": "report" }
ddiff --git a/fixtures/coupons.coupon.updated/valid/service-reenabled.json b/fixtures/coupons.coupon.updated/valid/service-reenabled.json
nnew file mode 100644
iindex 00000000..66ca54fa
---- /dev/null
++++ b/fixtures/coupons.coupon.updated/valid/service-reenabled.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "status": "unknown", "confidence": null, "review_state": "published", "expires_at": null, "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "previous_status": "disabled", "reason": "service:moderation:takedown reviewed, code restored" }
ddiff --git a/fixtures/coupons.index_document.deleted/invalid/bad-id.json b/fixtures/coupons.index_document.deleted/invalid/bad-id.json
nnew file mode 100644
iindex 00000000..7ab1197d
---- /dev/null
++++ b/fixtures/coupons.index_document.deleted/invalid/bad-id.json
@@@ -0,0 +1 @@
++{ "type": "coupon", "id": "TRAIL15", "revision": 3 }
ddiff --git a/fixtures/coupons.index_document.deleted/invalid/full-document.json b/fixtures/coupons.index_document.deleted/invalid/full-document.json
nnew file mode 100644
iindex 00000000..e42e1cbb
---- /dev/null
++++ b/fixtures/coupons.index_document.deleted/invalid/full-document.json
@@@ -0,0 +1 @@
++{ "owner": "coupons", "type": "coupon", "id": "cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "revision": 3, "deleted": true }
ddiff --git a/fixtures/coupons.index_document.deleted/invalid/revision-zero.json b/fixtures/coupons.index_document.deleted/invalid/revision-zero.json
nnew file mode 100644
iindex 00000000..faae7676
---- /dev/null
++++ b/fixtures/coupons.index_document.deleted/invalid/revision-zero.json
@@@ -0,0 +1 @@
++{ "type": "merchant", "id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "revision": 0 }
ddiff --git a/fixtures/coupons.index_document.deleted/valid/coupon.json b/fixtures/coupons.index_document.deleted/valid/coupon.json
nnew file mode 100644
iindex 00000000..aa794555
---- /dev/null
++++ b/fixtures/coupons.index_document.deleted/valid/coupon.json
@@@ -0,0 +1 @@
++{ "type": "coupon", "id": "cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "revision": 3 }
ddiff --git a/fixtures/coupons.index_document.deleted/valid/merchant.json b/fixtures/coupons.index_document.deleted/valid/merchant.json
nnew file mode 100644
iindex 00000000..631c6b61
---- /dev/null
++++ b/fixtures/coupons.index_document.deleted/valid/merchant.json
@@@ -0,0 +1 @@
++{ "type": "merchant", "id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "revision": 5 }
ddiff --git a/fixtures/coupons.index_document.upserted/invalid/no-indexability.json b/fixtures/coupons.index_document.upserted/invalid/no-indexability.json
nnew file mode 100644
iindex 00000000..e1beeba0
---- /dev/null
++++ b/fixtures/coupons.index_document.upserted/invalid/no-indexability.json
@@@ -0,0 +1 @@
++{ "owner": "coupons", "type": "coupon", "id": "cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "revision": 2, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "title": "TRAIL15 — 15% off tents (Acme Outdoor)", "summary": "validity unknown; expiry unknown", "body": "", "facets": { "merchant": "acme-outdoor", "status": "unknown", "expiry_known": false }, "language": "en", "provenance": [], "publication_state": "published", "published_at": "2026-09-21T08:05:00.000Z", "updated_at": "2026-09-21T08:05:00.000Z" }
ddiff --git a/fixtures/coupons.index_document.upserted/invalid/other-owner.json b/fixtures/coupons.index_document.upserted/invalid/other-owner.json
nnew file mode 100644
iindex 00000000..bf004d2a
---- /dev/null
++++ b/fixtures/coupons.index_document.upserted/invalid/other-owner.json
@@@ -0,0 +1 @@
++{ "owner": "deals", "type": "coupon", "id": "cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "revision": 2, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "title": "TRAIL15 — 15% off tents (Acme Outdoor)", "summary": "validity unknown; expiry unknown", "body": "", "facets": { "merchant": "acme-outdoor", "status": "unknown", "expiry_known": false }, "language": "en", "provenance": [], "publication_state": "published", "published_at": "2026-09-21T08:05:00.000Z", "updated_at": "2026-09-21T08:05:00.000Z", "indexability": { "decision": "index", "reasons": [] } }
ddiff --git a/fixtures/coupons.index_document.upserted/invalid/tombstone.json b/fixtures/coupons.index_document.upserted/invalid/tombstone.json
nnew file mode 100644
iindex 00000000..e42e1cbb
---- /dev/null
++++ b/fixtures/coupons.index_document.upserted/invalid/tombstone.json
@@@ -0,0 +1 @@
++{ "owner": "coupons", "type": "coupon", "id": "cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "revision": 3, "deleted": true }
ddiff --git a/fixtures/coupons.index_document.upserted/valid/coupon.json b/fixtures/coupons.index_document.upserted/valid/coupon.json
nnew file mode 100644
iindex 00000000..32796fb7
---- /dev/null
++++ b/fixtures/coupons.index_document.upserted/valid/coupon.json
@@@ -0,0 +1 @@
++{ "owner": "coupons", "type": "coupon", "id": "cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "revision": 2, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.coupons/c/cpn_01K5R8Z3Q4M2N7P9T1V6W8X0YA", "title": "TRAIL15 — 15% off tents (Acme Outdoor)", "summary": "reported working; expiry 2026-10-31", "body": "Applies to full-price tents only.\nMinimum spend $50.00\nRegion: US", "facets": { "merchant": "acme-outdoor", "status": "reported_working", "expiry_known": true }, "language": "en", "provenance": [{ "service": "sources", "type": "item", "id": "itm_01K5R6S0T1V2W3X4Y5Z6A7B8C9", "revision": 3, "url": "https://acme-outdoor.com/promos", "retrieved_at": "2026-09-21T08:00:00.000Z" }], "publication_state": "published", "published_at": "2026-09-21T08:05:00.000Z", "updated_at": "2026-09-23T10:12:00.000Z", "indexability": { "decision": "index", "reasons": [] } }
ddiff --git a/fixtures/coupons.index_document.upserted/valid/merchant-thin.json b/fixtures/coupons.index_document.upserted/valid/merchant-thin.json
nnew file mode 100644
iindex 00000000..9cded4bf
---- /dev/null
++++ b/fixtures/coupons.index_document.upserted/valid/merchant-thin.json
@@@ -0,0 +1 @@
++{ "owner": "coupons", "type": "merchant", "id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "revision": 4, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.coupons/m/acme-outdoor", "title": "Acme Outdoor coupon codes", "summary": "0 active codes", "body": "", "facets": { "active_codes": 0 }, "language": "en", "provenance": [], "publication_state": "published", "published_at": "2026-09-20T12:00:00.000Z", "updated_at": "2026-09-20T12:00:00.000Z", "indexability": { "decision": "noindex", "reasons": ["thin_content"] } }
ddiff --git a/fixtures/coupons.report.created/invalid/bad-channel.json b/fixtures/coupons.report.created/invalid/bad-channel.json
nnew file mode 100644
iindex 00000000..9a0b983c
---- /dev/null
++++ b/fixtures/coupons.report.created/invalid/bad-channel.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "outcome": "failed", "reason": "invalid", "channel": "app", "day": "2026-09-23" }
ddiff --git a/fixtures/coupons.report.created/invalid/names-reporter.json b/fixtures/coupons.report.created/invalid/names-reporter.json
nnew file mode 100644
iindex 00000000..c1fb024b
---- /dev/null
++++ b/fixtures/coupons.report.created/invalid/names-reporter.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "outcome": "failed", "channel": "site", "day": "2026-09-23", "reporter": "usr_01K5R5E4F3G2H1J0K9M8N7P6QR" }
ddiff --git a/fixtures/coupons.report.created/invalid/worked-with-reason.json b/fixtures/coupons.report.created/invalid/worked-with-reason.json
nnew file mode 100644
iindex 00000000..bc4eabe3
---- /dev/null
++++ b/fixtures/coupons.report.created/invalid/worked-with-reason.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "outcome": "worked", "reason": "other", "channel": "api", "day": "2026-09-23" }
ddiff --git a/fixtures/coupons.report.created/valid/failed-with-reason.json b/fixtures/coupons.report.created/valid/failed-with-reason.json
nnew file mode 100644
iindex 00000000..a51a98ad
---- /dev/null
++++ b/fixtures/coupons.report.created/valid/failed-with-reason.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "outcome": "failed", "reason": "min_spend_not_met", "channel": "extension", "day": "2026-09-23" }
ddiff --git a/fixtures/coupons.report.created/valid/worked.json b/fixtures/coupons.report.created/valid/worked.json
nnew file mode 100644
iindex 00000000..d8d9ddf2
---- /dev/null
++++ b/fixtures/coupons.report.created/valid/worked.json
@@@ -0,0 +1 @@
++{ "merchant_id": "mer_01K5R7A1B2C3D4E5F6G7H8J9KM", "outcome": "worked", "channel": "site", "day": "2026-09-23" }
ddiff --git a/fixtures/deals.index_document.deleted/invalid/full-document.json b/fixtures/deals.index_document.deleted/invalid/full-document.json
nnew file mode 100644
iindex 00000000..9fabf286
---- /dev/null
++++ b/fixtures/deals.index_document.deleted/invalid/full-document.json
@@@ -0,0 +1,7 @@
++{
++ "owner": "deals",
++ "type": "offer",
++ "id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPV",
++ "revision": 5,
++ "deleted": true
++}
ddiff --git a/fixtures/deals.index_document.deleted/invalid/product.json b/fixtures/deals.index_document.deleted/invalid/product.json
nnew file mode 100644
iindex 00000000..126112cc
---- /dev/null
++++ b/fixtures/deals.index_document.deleted/invalid/product.json
@@@ -0,0 +1,5 @@
++{
++ "type": "product",
++ "id": "dpr_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "revision": 2
++}
ddiff --git a/fixtures/deals.index_document.deleted/valid/tombstone.json b/fixtures/deals.index_document.deleted/valid/tombstone.json
nnew file mode 100644
iindex 00000000..00921943
---- /dev/null
++++ b/fixtures/deals.index_document.deleted/valid/tombstone.json
@@@ -0,0 +1,5 @@
++{
++ "type": "offer",
++ "id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPV",
++ "revision": 5
++}
ddiff --git a/fixtures/deals.index_document.upserted/invalid/other-owner.json b/fixtures/deals.index_document.upserted/invalid/other-owner.json
nnew file mode 100644
iindex 00000000..3a2a3c52
---- /dev/null
++++ b/fixtures/deals.index_document.upserted/invalid/other-owner.json
@@@ -0,0 +1,19 @@
++{
++ "owner": "coupons",
++ "type": "offer",
++ "id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 3,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "summary": "Two-port GaN charger, price seen on the store page.",
++ "body": "USB-C charger 65W\nTwo-port GaN charger, price seen on the store page.\nshop.example\n29.99 USD as of 2026-09-23T10:15:00.000Z",
++ "facets": { "status": "active", "freshness": "fresh", "store": "shop.example", "category": "electronics", "currency": "USD" },
++ "authorship": "human",
++ "provenance": [],
++ "publication_state": "published",
++ "published_at": "2026-09-23T10:15:00.000Z",
++ "updated_at": "2026-09-23T10:15:00.000Z",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/deals.index_document.upserted/invalid/tombstone-as-upsert.json b/fixtures/deals.index_document.upserted/invalid/tombstone-as-upsert.json
nnew file mode 100644
iindex 00000000..24c721e9
---- /dev/null
++++ b/fixtures/deals.index_document.upserted/invalid/tombstone-as-upsert.json
@@@ -0,0 +1,7 @@
++{
++ "owner": "deals",
++ "type": "offer",
++ "id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 4,
++ "deleted": true
++}
ddiff --git a/fixtures/deals.index_document.upserted/invalid/watch-id.json b/fixtures/deals.index_document.upserted/invalid/watch-id.json
nnew file mode 100644
iindex 00000000..874f19eb
---- /dev/null
++++ b/fixtures/deals.index_document.upserted/invalid/watch-id.json
@@@ -0,0 +1,19 @@
++{
++ "owner": "deals",
++ "type": "offer",
++ "id": "dwt_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 3,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "summary": "Two-port GaN charger, price seen on the store page.",
++ "body": "USB-C charger 65W\nTwo-port GaN charger, price seen on the store page.\nshop.example\n29.99 USD as of 2026-09-23T10:15:00.000Z",
++ "facets": { "status": "active", "freshness": "fresh", "store": "shop.example", "category": "electronics", "currency": "USD" },
++ "authorship": "human",
++ "provenance": [],
++ "publication_state": "published",
++ "published_at": "2026-09-23T10:15:00.000Z",
++ "updated_at": "2026-09-23T10:15:00.000Z",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/deals.index_document.upserted/valid/offer.json b/fixtures/deals.index_document.upserted/valid/offer.json
nnew file mode 100644
iindex 00000000..661b5a5b
---- /dev/null
++++ b/fixtures/deals.index_document.upserted/valid/offer.json
@@@ -0,0 +1,19 @@
++{
++ "owner": "deals",
++ "type": "offer",
++ "id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 3,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "summary": "Two-port GaN charger, price seen on the store page.",
++ "body": "USB-C charger 65W\nTwo-port GaN charger, price seen on the store page.\nshop.example\n29.99 USD as of 2026-09-23T10:15:00.000Z",
++ "facets": { "status": "active", "freshness": "fresh", "store": "shop.example", "category": "electronics", "currency": "USD" },
++ "authorship": "human",
++ "provenance": [],
++ "publication_state": "published",
++ "published_at": "2026-09-23T10:15:00.000Z",
++ "updated_at": "2026-09-23T10:15:00.000Z",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/deals.index_document.upserted/valid/product-noindex.json b/fixtures/deals.index_document.upserted/valid/product-noindex.json
nnew file mode 100644
iindex 00000000..3722e19a
---- /dev/null
++++ b/fixtures/deals.index_document.upserted/valid/product-noindex.json
@@@ -0,0 +1,18 @@
++{
++ "owner": "deals",
++ "type": "product",
++ "id": "dpr_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "revision": 7,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.deals/p/hose-reel-30m-k0mnps",
++ "title": "Hose reel 30 m",
++ "body": "Hose reel 30 m\nGardenCo\nGardenCo HR-30",
++ "facets": { "brand": "GardenCo", "category": "garden", "offers": 1 },
++ "authorship": "human",
++ "provenance": [],
++ "publication_state": "published",
++ "published_at": "2026-09-20T08:00:00.000Z",
++ "updated_at": "2026-09-21T06:00:00.000Z",
++ "indexability": { "decision": "noindex", "reasons": ["stale_price"] }
++}
ddiff --git a/fixtures/deals.offer.created/invalid/expired-at-creation.json b/fixtures/deals.offer.created/invalid/expired-at-creation.json
nnew file mode 100644
iindex 00000000..f9a5ecfa
---- /dev/null
++++ b/fixtures/deals.offer.created/invalid/expired-at-creation.json
@@@ -0,0 +1,12 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "expired",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/deals.offer.created/invalid/missing-indexability.json b/fixtures/deals.offer.created/invalid/missing-indexability.json
nnew file mode 100644
iindex 00000000..5a360582
---- /dev/null
++++ b/fixtures/deals.offer.created/invalid/missing-indexability.json
@@@ -0,0 +1,11 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh"
++}
ddiff --git a/fixtures/deals.offer.created/invalid/numeric-price.json b/fixtures/deals.offer.created/invalid/numeric-price.json
nnew file mode 100644
iindex 00000000..5cefad87
---- /dev/null
++++ b/fixtures/deals.offer.created/invalid/numeric-price.json
@@@ -0,0 +1,12 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": 29.99, "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/deals.offer.created/invalid/submitter-identity.json b/fixtures/deals.offer.created/invalid/submitter-identity.json
nnew file mode 100644
iindex 00000000..686b9070
---- /dev/null
++++ b/fixtures/deals.offer.created/invalid/submitter-identity.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "submitted_by": "usr_01JAB2C3D4E5F6G7H8J9K0MNPX"
++}
ddiff --git a/fixtures/deals.offer.created/valid/import-no-price.json b/fixtures/deals.offer.created/valid/import-no-price.json
nnew file mode 100644
iindex 00000000..193ee7a4
---- /dev/null
++++ b/fixtures/deals.offer.created/valid/import-no-price.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "slug": "garden-hose-reel-weekend-sale-k0mnpr",
++ "url": "https://openvibe.deals/d/garden-hose-reel-weekend-sale-k0mnpr",
++ "title": "Garden hose reel weekend sale",
++ "status": "active",
++ "store": "garden.example",
++ "product_id": "dpr_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "latest_observation": { "price": null, "currency": null, "availability": null, "observed_at": "2026-09-23T06:00:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "noindex", "reasons": ["owner_decision"] },
++ "imported_from": { "service": "sources", "type": "item", "id": "itm_01JAB2C3D4E5F6G7H8J9K0MNPW" }
++}
ddiff --git a/fixtures/deals.offer.created/valid/submission.json b/fixtures/deals.offer.created/valid/submission.json
nnew file mode 100644
iindex 00000000..7f94cfac
---- /dev/null
++++ b/fixtures/deals.offer.created/valid/submission.json
@@@ -0,0 +1,12 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/deals.offer.expired/invalid/guessed-reason.json b/fixtures/deals.offer.expired/invalid/guessed-reason.json
nnew file mode 100644
iindex 00000000..a3a92874
---- /dev/null
++++ b/fixtures/deals.offer.expired/invalid/guessed-reason.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "expired",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "noindex", "reasons": ["expired"] },
++ "reason": "sold_out"
++}
ddiff --git a/fixtures/deals.offer.expired/invalid/missing-reason.json b/fixtures/deals.offer.expired/invalid/missing-reason.json
nnew file mode 100644
iindex 00000000..2078b890
---- /dev/null
++++ b/fixtures/deals.offer.expired/invalid/missing-reason.json
@@@ -0,0 +1,12 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "expired",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "noindex", "reasons": ["expired"] }
++}
ddiff --git a/fixtures/deals.offer.expired/invalid/still-active.json b/fixtures/deals.offer.expired/invalid/still-active.json
nnew file mode 100644
iindex 00000000..4a8ced03
---- /dev/null
++++ b/fixtures/deals.offer.expired/invalid/still-active.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "reason": "submitter"
++}
ddiff --git a/fixtures/deals.offer.expired/valid/moderator-note.json b/fixtures/deals.offer.expired/valid/moderator-note.json
nnew file mode 100644
iindex 00000000..a8fa47b1
---- /dev/null
++++ b/fixtures/deals.offer.expired/valid/moderator-note.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "expired",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "noindex", "reasons": ["expired"] },
++ "reason": "moderator: the shop ended the sale early"
++}
ddiff --git a/fixtures/deals.offer.expired/valid/stated-expiry-stale.json b/fixtures/deals.offer.expired/valid/stated-expiry-stale.json
nnew file mode 100644
iindex 00000000..8ab2be6d
---- /dev/null
++++ b/fixtures/deals.offer.expired/valid/stated-expiry-stale.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "expired",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "stale",
++ "indexability": { "decision": "noindex", "reasons": ["expired", "stale_price"] },
++ "reason": "stated_expiry"
++}
ddiff --git a/fixtures/deals.offer.expired/valid/submitter.json b/fixtures/deals.offer.expired/valid/submitter.json
nnew file mode 100644
iindex 00000000..eaa56777
---- /dev/null
++++ b/fixtures/deals.offer.expired/valid/submitter.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "expired",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "noindex", "reasons": ["expired"] },
++ "reason": "submitter"
++}
ddiff --git a/fixtures/deals.offer.updated/invalid/bad-merged-id.json b/fixtures/deals.offer.updated/invalid/bad-merged-id.json
nnew file mode 100644
iindex 00000000..9715f129
---- /dev/null
++++ b/fixtures/deals.offer.updated/invalid/bad-merged-id.json
@@@ -0,0 +1,14 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "changed": ["merged"],
++ "merged_offer_id": "B"
++}
ddiff --git a/fixtures/deals.offer.updated/invalid/missing-changed.json b/fixtures/deals.offer.updated/invalid/missing-changed.json
nnew file mode 100644
iindex 00000000..7f94cfac
---- /dev/null
++++ b/fixtures/deals.offer.updated/invalid/missing-changed.json
@@@ -0,0 +1,12 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/deals.offer.updated/invalid/unknown-change.json b/fixtures/deals.offer.updated/invalid/unknown-change.json
nnew file mode 100644
iindex 00000000..c0d9e8c0
---- /dev/null
++++ b/fixtures/deals.offer.updated/invalid/unknown-change.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "changed": ["price"]
++}
ddiff --git a/fixtures/deals.offer.updated/valid/edit.json b/fixtures/deals.offer.updated/valid/edit.json
nnew file mode 100644
iindex 00000000..79b55e46
---- /dev/null
++++ b/fixtures/deals.offer.updated/valid/edit.json
@@@ -0,0 +1,13 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W (GaN)",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "changed": ["title", "category"]
++}
ddiff --git a/fixtures/deals.offer.updated/valid/merge.json b/fixtures/deals.offer.updated/valid/merge.json
nnew file mode 100644
iindex 00000000..094276b4
---- /dev/null
++++ b/fixtures/deals.offer.updated/valid/merge.json
@@@ -0,0 +1,14 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "changed": ["merged"],
++ "merged_offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPV"
++}
ddiff --git a/fixtures/deals.offer.updated/valid/observation.json b/fixtures/deals.offer.updated/valid/observation.json
nnew file mode 100644
iindex 00000000..f024d468
---- /dev/null
++++ b/fixtures/deals.offer.updated/valid/observation.json
@@@ -0,0 +1,14 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "slug": "usb-c-charger-65w-k0mnpq",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpq",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "24.99", "currency": "USD", "availability": "limited", "observed_at": "2026-09-23T14:02:11.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "changed": ["observation"],
++ "observation_id": "dpo_01JAB2C3D4E5F6G7H8J9K0MNPT"
++}
ddiff --git a/fixtures/deals.offer.updated/valid/source-removed-disabled.json b/fixtures/deals.offer.updated/valid/source-removed-disabled.json
nnew file mode 100644
iindex 00000000..5fc584e5
---- /dev/null
++++ b/fixtures/deals.offer.updated/valid/source-removed-disabled.json
@@@ -0,0 +1,14 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "slug": "garden-hose-reel-weekend-sale-k0mnpr",
++ "url": "https://openvibe.deals/d/garden-hose-reel-weekend-sale-k0mnpr",
++ "title": "Garden hose reel weekend sale",
++ "status": "disabled",
++ "store": "garden.example",
++ "product_id": "dpr_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "latest_observation": { "price": null, "currency": null, "availability": null, "observed_at": "2026-09-23T06:00:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "noindex", "reasons": ["takedown", "owner_decision"] },
++ "changed": ["sources"],
++ "source_removed": "itm_01JAB2C3D4E5F6G7H8J9K0MNPW"
++}
ddiff --git a/fixtures/deals.offer.updated/valid/unmerge-restored.json b/fixtures/deals.offer.updated/valid/unmerge-restored.json
nnew file mode 100644
iindex 00000000..fc2e34be
---- /dev/null
++++ b/fixtures/deals.offer.updated/valid/unmerge-restored.json
@@@ -0,0 +1,14 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPV",
++ "slug": "usb-c-charger-65w-k0mnpv",
++ "url": "https://openvibe.deals/d/usb-c-charger-65w-k0mnpv",
++ "title": "USB-C charger 65W",
++ "status": "active",
++ "store": "shop.example",
++ "product_id": null,
++ "latest_observation": { "price": "29.99", "currency": "USD", "availability": "in_stock", "observed_at": "2026-09-23T10:15:00.000Z" },
++ "freshness": "fresh",
++ "indexability": { "decision": "index", "reasons": [] },
++ "changed": ["unmerged"],
++ "unmerged_from": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/deals.vote.changed/invalid/missing-hotness.json b/fixtures/deals.vote.changed/invalid/missing-hotness.json
nnew file mode 100644
iindex 00000000..a0210080
---- /dev/null
++++ b/fixtures/deals.vote.changed/invalid/missing-hotness.json
@@@ -0,0 +1,8 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "listing_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "value": 1,
++ "previous": 0,
++ "weight": 1,
++ "votes": { "up": 3, "down": 1, "up_weight": 2.25, "down_weight": 1 }
++}
ddiff --git a/fixtures/deals.vote.changed/invalid/value-two.json b/fixtures/deals.vote.changed/invalid/value-two.json
nnew file mode 100644
iindex 00000000..d11dcf59
---- /dev/null
++++ b/fixtures/deals.vote.changed/invalid/value-two.json
@@@ -0,0 +1,9 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "listing_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "value": 2,
++ "previous": 0,
++ "weight": 1,
++ "votes": { "up": 3, "down": 1, "up_weight": 2.25, "down_weight": 1 },
++ "hotness": { "formula": "hot@1", "hot": 0.067496, "score": 1.25 }
++}
ddiff --git a/fixtures/deals.vote.changed/invalid/voter-identity.json b/fixtures/deals.vote.changed/invalid/voter-identity.json
nnew file mode 100644
iindex 00000000..56570c8a
---- /dev/null
++++ b/fixtures/deals.vote.changed/invalid/voter-identity.json
@@@ -0,0 +1,10 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "listing_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "value": 1,
++ "previous": 0,
++ "weight": 1,
++ "votes": { "up": 3, "down": 1, "up_weight": 2.25, "down_weight": 1 },
++ "hotness": { "formula": "hot@1", "hot": 0.067496, "score": 1.25 },
++ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPX"
++}
ddiff --git a/fixtures/deals.vote.changed/valid/removed-on-merged-listing.json b/fixtures/deals.vote.changed/valid/removed-on-merged-listing.json
nnew file mode 100644
iindex 00000000..29d59b68
---- /dev/null
++++ b/fixtures/deals.vote.changed/valid/removed-on-merged-listing.json
@@@ -0,0 +1,9 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "listing_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPV",
++ "value": 0,
++ "previous": -1,
++ "weight": 0.25,
++ "votes": { "up": 3, "down": 0, "up_weight": 2.25, "down_weight": 0 },
++ "hotness": { "formula": "hot@1", "hot": 0.029138, "score": 2.25 }
++}
ddiff --git a/fixtures/deals.vote.changed/valid/upvote.json b/fixtures/deals.vote.changed/valid/upvote.json
nnew file mode 100644
iindex 00000000..6106cdbd
---- /dev/null
++++ b/fixtures/deals.vote.changed/valid/upvote.json
@@@ -0,0 +1,9 @@
++{
++ "offer_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "listing_id": "dof_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "value": 1,
++ "previous": 0,
++ "weight": 1,
++ "votes": { "up": 3, "down": 1, "up_weight": 2.25, "down_weight": 1 },
++ "hotness": { "formula": "hot@1", "hot": 0.067496, "score": 1.25 }
++}
ddiff --git a/fixtures/games.blueprint.unlocked/invalid/bad-subject-type.json b/fixtures/games.blueprint.unlocked/invalid/bad-subject-type.json
nnew file mode 100644
iindex 00000000..a03cc227
---- /dev/null
++++ b/fixtures/games.blueprint.unlocked/invalid/bad-subject-type.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "service",
++ "id": "games"
++ }
++ },
++ "world": "openvibeville_v2",
++ "recipe": "craft_still"
++}
ddiff --git a/fixtures/games.blueprint.unlocked/invalid/extra-property.json b/fixtures/games.blueprint.unlocked/invalid/extra-property.json
nnew file mode 100644
iindex 00000000..3c980f06
---- /dev/null
++++ b/fixtures/games.blueprint.unlocked/invalid/extra-property.json
@@@ -0,0 +1,15 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "recipe": "craft_still",
++ "item": "blueprint_still"
++}
ddiff --git a/fixtures/games.blueprint.unlocked/invalid/missing-recipe.json b/fixtures/games.blueprint.unlocked/invalid/missing-recipe.json
nnew file mode 100644
iindex 00000000..b7d58f78
---- /dev/null
++++ b/fixtures/games.blueprint.unlocked/invalid/missing-recipe.json
@@@ -0,0 +1,13 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2"
++}
ddiff --git a/fixtures/games.blueprint.unlocked/valid/guest.json b/fixtures/games.blueprint.unlocked/valid/guest.json
nnew file mode 100644
iindex 00000000..a37a2957
---- /dev/null
++++ b/fixtures/games.blueprint.unlocked/valid/guest.json
@@@ -0,0 +1,10 @@
++{
++ "player": {
++ "id": "01kz4a7r2p9b6g3e8v0z",
++ "slot": 0,
++ "name": "Guest 42",
++ "account": "guest"
++ },
++ "world": "openvibeville_v2",
++ "recipe": "craft_still"
++}
ddiff --git a/fixtures/games.blueprint.unlocked/valid/network.json b/fixtures/games.blueprint.unlocked/valid/network.json
nnew file mode 100644
iindex 00000000..397cb20a
---- /dev/null
++++ b/fixtures/games.blueprint.unlocked/valid/network.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "recipe": "craft_scrap_pistol"
++}
ddiff --git a/fixtures/games.mod.disabled/invalid/bad-version.json b/fixtures/games.mod.disabled/invalid/bad-version.json
nnew file mode 100644
iindex 00000000..2b5538af
---- /dev/null
++++ b/fixtures/games.mod.disabled/invalid/bad-version.json
@@@ -0,0 +1,9 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "v1",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ }
++}
ddiff --git a/fixtures/games.mod.disabled/invalid/extra-property.json b/fixtures/games.mod.disabled/invalid/extra-property.json
nnew file mode 100644
iindex 00000000..1b118710
---- /dev/null
++++ b/fixtures/games.mod.disabled/invalid/extra-property.json
@@@ -0,0 +1,10 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "granted": []
++}
ddiff --git a/fixtures/games.mod.disabled/invalid/missing-mod-version.json b/fixtures/games.mod.disabled/invalid/missing-mod-version.json
nnew file mode 100644
iindex 00000000..c77ac081
---- /dev/null
++++ b/fixtures/games.mod.disabled/invalid/missing-mod-version.json
@@@ -0,0 +1,8 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ }
++}
ddiff --git a/fixtures/games.mod.disabled/valid/disabled.json b/fixtures/games.mod.disabled/valid/disabled.json
nnew file mode 100644
iindex 00000000..0b8d2df9
---- /dev/null
++++ b/fixtures/games.mod.disabled/valid/disabled.json
@@@ -0,0 +1,9 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "first-party"
++ }
++}
ddiff --git a/fixtures/games.mod.enabled/invalid/extra-status.json b/fixtures/games.mod.enabled/invalid/extra-status.json
nnew file mode 100644
iindex 00000000..abe19b19
---- /dev/null
++++ b/fixtures/games.mod.enabled/invalid/extra-status.json
@@@ -0,0 +1,10 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "status": "enabled"
++}
ddiff --git a/fixtures/games.mod.enabled/invalid/missing-mod.json b/fixtures/games.mod.enabled/invalid/missing-mod.json
nnew file mode 100644
iindex 00000000..0967ef42
---- /dev/null
++++ b/fixtures/games.mod.enabled/invalid/missing-mod.json
@@@ -0,0 +1 @@
++{}
ddiff --git a/fixtures/games.mod.enabled/invalid/unknown-trust-tier.json b/fixtures/games.mod.enabled/invalid/unknown-trust-tier.json
nnew file mode 100644
iindex 00000000..60e09dfe
---- /dev/null
++++ b/fixtures/games.mod.enabled/invalid/unknown-trust-tier.json
@@@ -0,0 +1,9 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "trusted"
++ }
++}
ddiff --git a/fixtures/games.mod.enabled/valid/enabled.json b/fixtures/games.mod.enabled/valid/enabled.json
nnew file mode 100644
iindex 00000000..16c80ceb
---- /dev/null
++++ b/fixtures/games.mod.enabled/valid/enabled.json
@@@ -0,0 +1,9 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ }
++}
ddiff --git a/fixtures/games.mod.grants_changed/invalid/bad-capability.json b/fixtures/games.mod.grants_changed/invalid/bad-capability.json
nnew file mode 100644
iindex 00000000..5656d8eb
---- /dev/null
++++ b/fixtures/games.mod.grants_changed/invalid/bad-capability.json
@@@ -0,0 +1,13 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "granted": [
++ "announce"
++ ],
++ "revoked": []
++}
ddiff --git a/fixtures/games.mod.grants_changed/invalid/capability-string.json b/fixtures/games.mod.grants_changed/invalid/capability-string.json
nnew file mode 100644
iindex 00000000..b96e37f1
---- /dev/null
++++ b/fixtures/games.mod.grants_changed/invalid/capability-string.json
@@@ -0,0 +1,11 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "granted": "games.world.announce",
++ "revoked": []
++}
ddiff --git a/fixtures/games.mod.grants_changed/invalid/missing-revoked.json b/fixtures/games.mod.grants_changed/invalid/missing-revoked.json
nnew file mode 100644
iindex 00000000..78c8fc52
---- /dev/null
++++ b/fixtures/games.mod.grants_changed/invalid/missing-revoked.json
@@@ -0,0 +1,12 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "granted": [
++ "games.world.announce"
++ ]
++}
ddiff --git a/fixtures/games.mod.grants_changed/valid/granted.json b/fixtures/games.mod.grants_changed/valid/granted.json
nnew file mode 100644
iindex 00000000..ec7060a1
---- /dev/null
++++ b/fixtures/games.mod.grants_changed/valid/granted.json
@@@ -0,0 +1,13 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "granted": [
++ "games.world.announce"
++ ],
++ "revoked": []
++}
ddiff --git a/fixtures/games.mod.grants_changed/valid/revoked.json b/fixtures/games.mod.grants_changed/valid/revoked.json
nnew file mode 100644
iindex 00000000..dd8ef9b6
---- /dev/null
++++ b/fixtures/games.mod.grants_changed/valid/revoked.json
@@@ -0,0 +1,13 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "granted": [],
++ "revoked": [
++ "games.prop.place"
++ ]
++}
ddiff --git a/fixtures/games.mod.installed/invalid/bad-mod-id.json b/fixtures/games.mod.installed/invalid/bad-mod-id.json
nnew file mode 100644
iindex 00000000..a5342f74
---- /dev/null
++++ b/fixtures/games.mod.installed/invalid/bad-mod-id.json
@@@ -0,0 +1,12 @@
++{
++ "mod": {
++ "id": "mod_town_square",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "requested": [],
++ "granted": [],
++ "status": "enabled"
++}
ddiff --git a/fixtures/games.mod.installed/invalid/camelcase-trust-tier.json b/fixtures/games.mod.installed/invalid/camelcase-trust-tier.json
nnew file mode 100644
iindex 00000000..5cf5260e
---- /dev/null
++++ b/fixtures/games.mod.installed/invalid/camelcase-trust-tier.json
@@@ -0,0 +1,12 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trustTier": "unreviewed"
++ },
++ "requested": [],
++ "granted": [],
++ "status": "enabled"
++}
ddiff --git a/fixtures/games.mod.installed/invalid/revoked-status.json b/fixtures/games.mod.installed/invalid/revoked-status.json
nnew file mode 100644
iindex 00000000..5432e46b
---- /dev/null
++++ b/fixtures/games.mod.installed/invalid/revoked-status.json
@@@ -0,0 +1,14 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "requested": [
++ "games.prop.place"
++ ],
++ "granted": [],
++ "status": "revoked"
++}
ddiff --git a/fixtures/games.mod.installed/valid/disabled-nothing-granted.json b/fixtures/games.mod.installed/valid/disabled-nothing-granted.json
nnew file mode 100644
iindex 00000000..356767be
---- /dev/null
++++ b/fixtures/games.mod.installed/valid/disabled-nothing-granted.json
@@@ -0,0 +1,12 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Campfire",
++ "version": "0.2.0-beta.1",
++ "target": "games.browser",
++ "trust_tier": "reviewed"
++ },
++ "requested": [],
++ "granted": [],
++ "status": "disabled"
++}
ddiff --git a/fixtures/games.mod.installed/valid/enabled-with-grant.json b/fixtures/games.mod.installed/valid/enabled-with-grant.json
nnew file mode 100644
iindex 00000000..51dc1d1e
---- /dev/null
++++ b/fixtures/games.mod.installed/valid/enabled-with-grant.json
@@@ -0,0 +1,17 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "requested": [
++ "games.prop.place",
++ "games.world.announce"
++ ],
++ "granted": [
++ "games.prop.place"
++ ],
++ "status": "enabled"
++}
ddiff --git a/fixtures/games.mod.revoked/invalid/empty-reason.json b/fixtures/games.mod.revoked/invalid/empty-reason.json
nnew file mode 100644
iindex 00000000..5358b546
---- /dev/null
++++ b/fixtures/games.mod.revoked/invalid/empty-reason.json
@@@ -0,0 +1,11 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "revoked_grants": [],
++ "reason": ""
++}
ddiff --git a/fixtures/games.mod.revoked/invalid/missing-revoked-grants.json b/fixtures/games.mod.revoked/invalid/missing-revoked-grants.json
nnew file mode 100644
iindex 00000000..7c1a9f19
---- /dev/null
++++ b/fixtures/games.mod.revoked/invalid/missing-revoked-grants.json
@@@ -0,0 +1,10 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "reason": "test"
++}
ddiff --git a/fixtures/games.mod.revoked/invalid/reason-too-long.json b/fixtures/games.mod.revoked/invalid/reason-too-long.json
nnew file mode 100644
iindex 00000000..aa7a355d
---- /dev/null
++++ b/fixtures/games.mod.revoked/invalid/reason-too-long.json
@@@ -0,0 +1,11 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "revoked_grants": [],
++ "reason": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
++}
ddiff --git a/fixtures/games.mod.revoked/valid/no-reason-no-grants.json b/fixtures/games.mod.revoked/valid/no-reason-no-grants.json
nnew file mode 100644
iindex 00000000..e3475ad4
---- /dev/null
++++ b/fixtures/games.mod.revoked/valid/no-reason-no-grants.json
@@@ -0,0 +1,10 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "revoked_grants": []
++}
ddiff --git a/fixtures/games.mod.revoked/valid/with-reason.json b/fixtures/games.mod.revoked/valid/with-reason.json
nnew file mode 100644
iindex 00000000..0370c3d2
---- /dev/null
++++ b/fixtures/games.mod.revoked/valid/with-reason.json
@@@ -0,0 +1,14 @@
++{
++ "mod": {
++ "id": "mod_01JAD3F5G7H9K1M3N5P7Q9R1S3",
++ "name": "Town Square",
++ "version": "1.0.0",
++ "target": "games.browser",
++ "trust_tier": "unreviewed"
++ },
++ "revoked_grants": [
++ "games.prop.place",
++ "games.world.announce"
++ ],
++ "reason": "Props block the market entrance"
++}
ddiff --git a/fixtures/games.player.joined/invalid/guest-with-subject.json b/fixtures/games.player.joined/invalid/guest-with-subject.json
nnew file mode 100644
iindex 00000000..155a2ea0
---- /dev/null
++++ b/fixtures/games.player.joined/invalid/guest-with-subject.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz4a7r2p9b6g3e8v0z",
++ "slot": 0,
++ "name": "Guest 42",
++ "account": "guest",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "restored": false
++}
ddiff --git a/fixtures/games.player.joined/invalid/missing-restored.json b/fixtures/games.player.joined/invalid/missing-restored.json
nnew file mode 100644
iindex 00000000..b7d58f78
---- /dev/null
++++ b/fixtures/games.player.joined/invalid/missing-restored.json
@@@ -0,0 +1,13 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2"
++}
ddiff --git a/fixtures/games.player.joined/invalid/network-without-subject.json b/fixtures/games.player.joined/invalid/network-without-subject.json
nnew file mode 100644
iindex 00000000..78a393f0
---- /dev/null
++++ b/fixtures/games.player.joined/invalid/network-without-subject.json
@@@ -0,0 +1,10 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network"
++ },
++ "world": "openvibeville_v2",
++ "restored": true
++}
ddiff --git a/fixtures/games.player.joined/invalid/uppercase-player-id.json b/fixtures/games.player.joined/invalid/uppercase-player-id.json
nnew file mode 100644
iindex 00000000..3365f4ba
---- /dev/null
++++ b/fixtures/games.player.joined/invalid/uppercase-player-id.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01KZ3M8Q4D7XW2HT5N9C",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "restored": true
++}
ddiff --git a/fixtures/games.player.joined/valid/guest-new.json b/fixtures/games.player.joined/valid/guest-new.json
nnew file mode 100644
iindex 00000000..ab8c3020
---- /dev/null
++++ b/fixtures/games.player.joined/valid/guest-new.json
@@@ -0,0 +1,10 @@
++{
++ "player": {
++ "id": "01kz4a7r2p9b6g3e8v0z",
++ "slot": 0,
++ "name": "Guest 42",
++ "account": "guest"
++ },
++ "world": "openvibeville_v2",
++ "restored": false
++}
ddiff --git a/fixtures/games.player.joined/valid/network-guest-session.json b/fixtures/games.player.joined/valid/network-guest-session.json
nnew file mode 100644
iindex 00000000..60c134d9
---- /dev/null
++++ b/fixtures/games.player.joined/valid/network-guest-session.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz4a7r2p9b6g3e8v0y",
++ "slot": 0,
++ "name": "Wanderer",
++ "account": "network",
++ "subject": {
++ "type": "guest",
++ "id": "gst_01JAC7T9W2X4Y6Z8A0B1C3D5E7"
++ }
++ },
++ "world": "openvibeville_v2",
++ "restored": false
++}
ddiff --git a/fixtures/games.player.joined/valid/network-restored.json b/fixtures/games.player.joined/valid/network-restored.json
nnew file mode 100644
iindex 00000000..2250140e
---- /dev/null
++++ b/fixtures/games.player.joined/valid/network-restored.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "restored": true
++}
ddiff --git a/fixtures/games.player.left/invalid/extra-property.json b/fixtures/games.player.left/invalid/extra-property.json
nnew file mode 100644
iindex 00000000..78f03c2c
---- /dev/null
++++ b/fixtures/games.player.left/invalid/extra-property.json
@@@ -0,0 +1,15 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "session_seconds": 10,
++ "restored": true
++}
ddiff --git a/fixtures/games.player.left/invalid/fractional-seconds.json b/fixtures/games.player.left/invalid/fractional-seconds.json
nnew file mode 100644
iindex 00000000..2cfe3063
---- /dev/null
++++ b/fixtures/games.player.left/invalid/fractional-seconds.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "session_seconds": 61.4
++}
ddiff --git a/fixtures/games.player.left/invalid/slot-out-of-range.json b/fixtures/games.player.left/invalid/slot-out-of-range.json
nnew file mode 100644
iindex 00000000..2868a80d
---- /dev/null
++++ b/fixtures/games.player.left/invalid/slot-out-of-range.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 3,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "session_seconds": 10
++}
ddiff --git a/fixtures/games.player.left/valid/guest-untracked.json b/fixtures/games.player.left/valid/guest-untracked.json
nnew file mode 100644
iindex 00000000..394673c2
---- /dev/null
++++ b/fixtures/games.player.left/valid/guest-untracked.json
@@@ -0,0 +1,10 @@
++{
++ "player": {
++ "id": "01kz4a7r2p9b6g3e8v0z",
++ "slot": 0,
++ "name": "Guest 42",
++ "account": "guest"
++ },
++ "world": "openvibeville_v2",
++ "session_seconds": 0
++}
ddiff --git a/fixtures/games.player.left/valid/network.json b/fixtures/games.player.left/valid/network.json
nnew file mode 100644
iindex 00000000..785397b1
---- /dev/null
++++ b/fixtures/games.player.left/valid/network.json
@@@ -0,0 +1,14 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "session_seconds": 1834
++}
ddiff --git a/fixtures/games.skill.leveled/invalid/bad-skill-id.json b/fixtures/games.skill.leveled/invalid/bad-skill-id.json
nnew file mode 100644
iindex 00000000..178f8032
---- /dev/null
++++ b/fixtures/games.skill.leveled/invalid/bad-skill-id.json
@@@ -0,0 +1,16 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "skill": "Mining Skill",
++ "level": 2,
++ "previous_level": 1
++}
ddiff --git a/fixtures/games.skill.leveled/invalid/level-one.json b/fixtures/games.skill.leveled/invalid/level-one.json
nnew file mode 100644
iindex 00000000..8fd210a7
---- /dev/null
++++ b/fixtures/games.skill.leveled/invalid/level-one.json
@@@ -0,0 +1,16 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "skill": "mining",
++ "level": 1,
++ "previous_level": 1
++}
ddiff --git a/fixtures/games.skill.leveled/invalid/missing-previous-level.json b/fixtures/games.skill.leveled/invalid/missing-previous-level.json
nnew file mode 100644
iindex 00000000..45593e46
---- /dev/null
++++ b/fixtures/games.skill.leveled/invalid/missing-previous-level.json
@@@ -0,0 +1,15 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "skill": "mining",
++ "level": 5
++}
ddiff --git a/fixtures/games.skill.leveled/valid/first-save-guest.json b/fixtures/games.skill.leveled/valid/first-save-guest.json
nnew file mode 100644
iindex 00000000..0a6398ce
---- /dev/null
++++ b/fixtures/games.skill.leveled/valid/first-save-guest.json
@@@ -0,0 +1,12 @@
++{
++ "player": {
++ "id": "01kz4a7r2p9b6g3e8v0z",
++ "slot": 0,
++ "name": "Guest 42",
++ "account": "guest"
++ },
++ "world": "openvibeville_v2",
++ "skill": "woodcutting",
++ "level": 3,
++ "previous_level": 1
++}
ddiff --git a/fixtures/games.skill.leveled/valid/one-level.json b/fixtures/games.skill.leveled/valid/one-level.json
nnew file mode 100644
iindex 00000000..1ec83878
---- /dev/null
++++ b/fixtures/games.skill.leveled/valid/one-level.json
@@@ -0,0 +1,16 @@
++{
++ "player": {
++ "id": "01kz3m8q4d7xw2ht5n9c",
++ "slot": 1,
++ "name": "Rustbucket",
++ "account": "network",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "world": "openvibeville_v2",
++ "skill": "mining",
++ "level": 5,
++ "previous_level": 4
++}
ddiff --git a/fixtures/games.world.saved/invalid/epoch-ms-since.json b/fixtures/games.world.saved/invalid/epoch-ms-since.json
nnew file mode 100644
iindex 00000000..06533c94
---- /dev/null
++++ b/fixtures/games.world.saved/invalid/epoch-ms-since.json
@@@ -0,0 +1,7 @@
++{
++ "world": "openvibeville_v2",
++ "reason": "checkpoint",
++ "entities_written": 1,
++ "players_written": 0,
++ "since": 1790212534078
++}
ddiff --git a/fixtures/games.world.saved/invalid/missing-world.json b/fixtures/games.world.saved/invalid/missing-world.json
nnew file mode 100644
iindex 00000000..c7613926
---- /dev/null
++++ b/fixtures/games.world.saved/invalid/missing-world.json
@@@ -0,0 +1,6 @@
++{
++ "reason": "checkpoint",
++ "entities_written": 1,
++ "players_written": 0,
++ "since": "2026-09-24T01:15:34.078Z"
++}
ddiff --git a/fixtures/games.world.saved/invalid/unknown-reason.json b/fixtures/games.world.saved/invalid/unknown-reason.json
nnew file mode 100644
iindex 00000000..d5e7a09f
---- /dev/null
++++ b/fixtures/games.world.saved/invalid/unknown-reason.json
@@@ -0,0 +1,7 @@
++{
++ "world": "openvibeville_v2",
++ "reason": "manual",
++ "entities_written": 1,
++ "players_written": 0,
++ "since": "2026-09-24T01:15:34.078Z"
++}
ddiff --git a/fixtures/games.world.saved/valid/checkpoint.json b/fixtures/games.world.saved/valid/checkpoint.json
nnew file mode 100644
iindex 00000000..33987920
---- /dev/null
++++ b/fixtures/games.world.saved/valid/checkpoint.json
@@@ -0,0 +1,7 @@
++{
++ "world": "openvibeville_v2",
++ "reason": "checkpoint",
++ "entities_written": 91,
++ "players_written": 0,
++ "since": "2026-09-24T01:15:34.078Z"
++}
ddiff --git a/fixtures/games.world.saved/valid/shutdown.json b/fixtures/games.world.saved/valid/shutdown.json
nnew file mode 100644
iindex 00000000..cbfb8950
---- /dev/null
++++ b/fixtures/games.world.saved/valid/shutdown.json
@@@ -0,0 +1,7 @@
++{
++ "world": "openvibeville_v2",
++ "reason": "shutdown",
++ "entities_written": 0,
++ "players_written": 3,
++ "since": "2026-09-23T22:40:02.511Z"
++}
ddiff --git a/fixtures/media.object.deleted/invalid/legacy-object-id.json b/fixtures/media.object.deleted/invalid/legacy-object-id.json
nnew file mode 100644
iindex 00000000..ac254f72
---- /dev/null
++++ b/fixtures/media.object.deleted/invalid/legacy-object-id.json
@@@ -0,0 +1 @@
++{ "object_id": "legacy:live:vod:123", "app_id": "live", "kind": "vod", "legacy_ref": "legacy:live:vod:123", "deleted_at": "2026-09-24T01:02:03.000Z" }
ddiff --git a/fixtures/media.object.deleted/invalid/no-time.json b/fixtures/media.object.deleted/invalid/no-time.json
nnew file mode 100644
iindex 00000000..f706474a
---- /dev/null
++++ b/fixtures/media.object.deleted/invalid/no-time.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "kind": "paste", "legacy_ref": null }
ddiff --git a/fixtures/media.object.deleted/invalid/storage-key.json b/fixtures/media.object.deleted/invalid/storage-key.json
nnew file mode 100644
iindex 00000000..50c79d12
---- /dev/null
++++ b/fixtures/media.object.deleted/invalid/storage-key.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "kind": "vod", "legacy_ref": null, "deleted_at": "2026-09-24T01:02:03.000Z", "storage_key": "vods/live/123.mp4" }
ddiff --git a/fixtures/media.object.deleted/valid/inherited-vod.json b/fixtures/media.object.deleted/valid/inherited-vod.json
nnew file mode 100644
iindex 00000000..2048fcf1
---- /dev/null
++++ b/fixtures/media.object.deleted/valid/inherited-vod.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "kind": "vod", "legacy_ref": "legacy:live:vod:123", "deleted_at": "2026-09-24T01:02:03.000Z" }
ddiff --git a/fixtures/media.object.deleted/valid/native-file.json b/fixtures/media.object.deleted/valid/native-file.json
nnew file mode 100644
iindex 00000000..1bb4e1b8
---- /dev/null
++++ b/fixtures/media.object.deleted/valid/native-file.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPR", "app_id": "prj_01JAB2C3D4E5F6G7H8J9K0MNPS", "kind": "file", "legacy_ref": null, "deleted_at": "2026-09-24T01:02:03.000Z" }
ddiff --git a/fixtures/media.object.visibility_changed/invalid/deleted-is-not-a-visibility.json b/fixtures/media.object.visibility_changed/invalid/deleted-is-not-a-visibility.json
nnew file mode 100644
iindex 00000000..fa0ac8d2
---- /dev/null
++++ b/fixtures/media.object.visibility_changed/invalid/deleted-is-not-a-visibility.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "kind": "clip", "legacy_ref": null, "visibility": "deleted", "previous_visibility": "public", "changed_at": "2026-09-24T01:02:03.000Z" }
ddiff --git a/fixtures/media.object.visibility_changed/invalid/unchanged.json b/fixtures/media.object.visibility_changed/invalid/unchanged.json
nnew file mode 100644
iindex 00000000..f92425fb
---- /dev/null
++++ b/fixtures/media.object.visibility_changed/invalid/unchanged.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "kind": "clip", "legacy_ref": null, "visibility": "unlisted", "previous_visibility": "unlisted", "changed_at": "2026-09-24T01:02:03.000Z" }
ddiff --git a/fixtures/media.object.visibility_changed/invalid/with-title.json b/fixtures/media.object.visibility_changed/invalid/with-title.json
nnew file mode 100644
iindex 00000000..59b9081d
---- /dev/null
++++ b/fixtures/media.object.visibility_changed/invalid/with-title.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "kind": "vod", "legacy_ref": null, "visibility": "public", "previous_visibility": "private", "changed_at": "2026-09-24T01:02:03.000Z", "title": "My stream" }
ddiff --git a/fixtures/media.object.visibility_changed/valid/clip-made-private.json b/fixtures/media.object.visibility_changed/valid/clip-made-private.json
nnew file mode 100644
iindex 00000000..c18bf417
---- /dev/null
++++ b/fixtures/media.object.visibility_changed/valid/clip-made-private.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "kind": "clip", "legacy_ref": "legacy:live:clip:77", "visibility": "private", "previous_visibility": "public", "changed_at": "2026-09-24T01:02:03.000Z" }
ddiff --git a/fixtures/media.object.visibility_changed/valid/native-unlisted-to-public.json b/fixtures/media.object.visibility_changed/valid/native-unlisted-to-public.json
nnew file mode 100644
iindex 00000000..030e2dd2
---- /dev/null
++++ b/fixtures/media.object.visibility_changed/valid/native-unlisted-to-public.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPR", "app_id": "tools", "kind": "asset", "legacy_ref": null, "visibility": "public", "previous_visibility": "unlisted", "changed_at": "2026-09-24T01:02:03.000Z" }
ddiff --git a/fixtures/news.cluster.updated/invalid/bad-audit-id.json b/fixtures/news.cluster.updated/invalid/bad-audit-id.json
nnew file mode 100644
iindex 00000000..2f722a0d
---- /dev/null
++++ b/fixtures/news.cluster.updated/invalid/bad-audit-id.json
@@@ -0,0 +1,10 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "merged",
++ "status": "open",
++ "label": "jupiter",
++ "item_count": 5,
++ "absorbed": "clu_01K5RZ9B2C4E6G8J0M2P4R6T8V",
++ "audit_id": "aud_01K5S0C2E4G6J8M0P2R4T6V8XY",
++ "moved": 2
++}
ddiff --git a/fixtures/news.cluster.updated/invalid/missing-label.json b/fixtures/news.cluster.updated/invalid/missing-label.json
nnew file mode 100644
iindex 00000000..ad524473
---- /dev/null
++++ b/fixtures/news.cluster.updated/invalid/missing-label.json
@@@ -0,0 +1,7 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "created",
++ "status": "open",
++ "item_count": 1,
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD"
++}
ddiff --git a/fixtures/news.cluster.updated/invalid/terms-leaked.json b/fixtures/news.cluster.updated/invalid/terms-leaked.json
nnew file mode 100644
iindex 00000000..3125ef64
---- /dev/null
++++ b/fixtures/news.cluster.updated/invalid/terms-leaked.json
@@@ -0,0 +1,11 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "created",
++ "status": "open",
++ "label": "jupiter",
++ "item_count": 1,
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "terms": {
++ "jupiter": 1
++ }
++}
ddiff --git a/fixtures/news.cluster.updated/invalid/unknown-action.json b/fixtures/news.cluster.updated/invalid/unknown-action.json
nnew file mode 100644
iindex 00000000..76be02c1
---- /dev/null
++++ b/fixtures/news.cluster.updated/invalid/unknown-action.json
@@@ -0,0 +1,7 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "renamed",
++ "status": "open",
++ "label": "jupiter",
++ "item_count": 1
++}
ddiff --git a/fixtures/news.cluster.updated/valid/created-by-split.json b/fixtures/news.cluster.updated/valid/created-by-split.json
nnew file mode 100644
iindex 00000000..0a11562d
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/created-by-split.json
@@@ -0,0 +1,9 @@
++{
++ "cluster_id": "clu_01K5S0A1B3D5F7H9K1M3P5R7SW",
++ "action": "created",
++ "status": "open",
++ "label": "jupiter",
++ "item_count": 1,
++ "split_from": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "audit_id": "cla_01K5S0C2E4G6J8M0P2R4T6V8XY"
++}
ddiff --git a/fixtures/news.cluster.updated/valid/created.json b/fixtures/news.cluster.updated/valid/created.json
nnew file mode 100644
iindex 00000000..7d7c4f64
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/created.json
@@@ -0,0 +1,8 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "created",
++ "status": "open",
++ "label": "europa · europa clipper · falcon heavy",
++ "item_count": 1,
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD"
++}
ddiff --git a/fixtures/news.cluster.updated/valid/item-added-duplicate.json b/fixtures/news.cluster.updated/valid/item-added-duplicate.json
nnew file mode 100644
iindex 00000000..b2328c3f
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/item-added-duplicate.json
@@@ -0,0 +1,9 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "item_added",
++ "status": "open",
++ "label": "europa clipper · jupiter · nasa",
++ "item_count": 3,
++ "item_id": "nsi_01K5RZ4F0A6H3K8PQ2S5W7Y9ZE",
++ "rule": "duplicate_of"
++}
ddiff --git a/fixtures/news.cluster.updated/valid/item-added.json b/fixtures/news.cluster.updated/valid/item-added.json
nnew file mode 100644
iindex 00000000..e61a2f0e
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/item-added.json
@@@ -0,0 +1,10 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "item_added",
++ "status": "open",
++ "label": "europa clipper · jupiter · nasa",
++ "item_count": 2,
++ "item_id": "nsi_01K5RZ4F0A6H3K8PQ2S5W7Y9ZE",
++ "rule": "shared_terms",
++ "score": 10
++}
ddiff --git a/fixtures/news.cluster.updated/valid/merge-reversed.json b/fixtures/news.cluster.updated/valid/merge-reversed.json
nnew file mode 100644
iindex 00000000..337190ec
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/merge-reversed.json
@@@ -0,0 +1,9 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "merge_reversed",
++ "status": "open",
++ "label": "jupiter · europa clipper · nasa",
++ "item_count": 3,
++ "audit_id": "cla_01K5S0D3F5H7K9N1Q3S5V7X9Z1",
++ "restored": "clu_01K5RZ9B2C4E6G8J0M2P4R6T8V"
++}
ddiff --git a/fixtures/news.cluster.updated/valid/merged.json b/fixtures/news.cluster.updated/valid/merged.json
nnew file mode 100644
iindex 00000000..6c3e255d
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/merged.json
@@@ -0,0 +1,10 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "merged",
++ "status": "open",
++ "label": "jupiter · europa clipper · nasa",
++ "item_count": 5,
++ "absorbed": "clu_01K5RZ9B2C4E6G8J0M2P4R6T8V",
++ "audit_id": "cla_01K5S0C2E4G6J8M0P2R4T6V8XY",
++ "moved": 2
++}
ddiff --git a/fixtures/news.cluster.updated/valid/reopened.json b/fixtures/news.cluster.updated/valid/reopened.json
nnew file mode 100644
iindex 00000000..ef734258
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/reopened.json
@@@ -0,0 +1,8 @@
++{
++ "cluster_id": "clu_01K5RZ9B2C4E6G8J0M2P4R6T8V",
++ "action": "reopened",
++ "status": "open",
++ "label": "bakery · local · prize · regional",
++ "item_count": 2,
++ "audit_id": "cla_01K5S0D3F5H7K9N1Q3S5V7X9Z1"
++}
ddiff --git a/fixtures/news.cluster.updated/valid/split-reversed-kept-open.json b/fixtures/news.cluster.updated/valid/split-reversed-kept-open.json
nnew file mode 100644
iindex 00000000..71e79f54
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/split-reversed-kept-open.json
@@@ -0,0 +1,9 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "split_reversed",
++ "status": "open",
++ "label": "unlabelled",
++ "item_count": 4,
++ "audit_id": "cla_01K5S0D3F5H7K9N1Q3S5V7X9Z1",
++ "dissolved": null
++}
ddiff --git a/fixtures/news.cluster.updated/valid/split-reversed.json b/fixtures/news.cluster.updated/valid/split-reversed.json
nnew file mode 100644
iindex 00000000..17a10127
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/split-reversed.json
@@@ -0,0 +1,9 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "split_reversed",
++ "status": "open",
++ "label": "jupiter · europa clipper · nasa",
++ "item_count": 4,
++ "audit_id": "cla_01K5S0D3F5H7K9N1Q3S5V7X9Z1",
++ "dissolved": "clu_01K5S0A1B3D5F7H9K1M3P5R7SW"
++}
ddiff --git a/fixtures/news.cluster.updated/valid/split.json b/fixtures/news.cluster.updated/valid/split.json
nnew file mode 100644
iindex 00000000..3bf19923
---- /dev/null
++++ b/fixtures/news.cluster.updated/valid/split.json
@@@ -0,0 +1,10 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "action": "split",
++ "status": "open",
++ "label": "europa clipper · jupiter · nasa",
++ "item_count": 3,
++ "new_cluster": "clu_01K5S0A1B3D5F7H9K1M3P5R7SW",
++ "audit_id": "cla_01K5S0C2E4G6J8M0P2R4T6V8XY",
++ "moved": 1
++}
ddiff --git a/fixtures/news.index_document.deleted/invalid/full-tombstone.json b/fixtures/news.index_document.deleted/invalid/full-tombstone.json
nnew file mode 100644
iindex 00000000..8860681c
---- /dev/null
++++ b/fixtures/news.index_document.deleted/invalid/full-tombstone.json
@@@ -0,0 +1,7 @@
++{
++ "owner": "news",
++ "type": "story",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 4,
++ "deleted": true
++}
ddiff --git a/fixtures/news.index_document.deleted/invalid/revision-zero.json b/fixtures/news.index_document.deleted/invalid/revision-zero.json
nnew file mode 100644
iindex 00000000..ef82e378
---- /dev/null
++++ b/fixtures/news.index_document.deleted/invalid/revision-zero.json
@@@ -0,0 +1,5 @@
++{
++ "type": "story",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 0
++}
ddiff --git a/fixtures/news.index_document.deleted/invalid/wrong-type.json b/fixtures/news.index_document.deleted/invalid/wrong-type.json
nnew file mode 100644
iindex 00000000..d34bce90
---- /dev/null
++++ b/fixtures/news.index_document.deleted/invalid/wrong-type.json
@@@ -0,0 +1,5 @@
++{
++ "type": "post",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 4
++}
ddiff --git a/fixtures/news.index_document.deleted/valid/tombstone.json b/fixtures/news.index_document.deleted/valid/tombstone.json
nnew file mode 100644
iindex 00000000..a2fcc0e4
---- /dev/null
++++ b/fixtures/news.index_document.deleted/valid/tombstone.json
@@@ -0,0 +1,5 @@
++{
++ "type": "story",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 4
++}
ddiff --git a/fixtures/news.index_document.upserted/invalid/bad-id.json b/fixtures/news.index_document.upserted/invalid/bad-id.json
nnew file mode 100644
iindex 00000000..b5a246f6
---- /dev/null
++++ b/fixtures/news.index_document.upserted/invalid/bad-id.json
@@@ -0,0 +1,20 @@
++{
++ "owner": "news",
++ "type": "story",
++ "id": "pst_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 1,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "title": "Europa Clipper is on its way to Jupiter",
++ "body": "",
++ "facets": {},
++ "provenance": [],
++ "publication_state": "published",
++ "published_at": null,
++ "updated_at": null,
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ }
++}
ddiff --git a/fixtures/news.index_document.upserted/invalid/missing-title.json b/fixtures/news.index_document.upserted/invalid/missing-title.json
nnew file mode 100644
iindex 00000000..284a2eb0
---- /dev/null
++++ b/fixtures/news.index_document.upserted/invalid/missing-title.json
@@@ -0,0 +1,19 @@
++{
++ "owner": "news",
++ "type": "story",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 1,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "body": "",
++ "facets": {},
++ "provenance": [],
++ "publication_state": "published",
++ "published_at": null,
++ "updated_at": null,
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ }
++}
ddiff --git a/fixtures/news.index_document.upserted/invalid/tombstone.json b/fixtures/news.index_document.upserted/invalid/tombstone.json
nnew file mode 100644
iindex 00000000..8860681c
---- /dev/null
++++ b/fixtures/news.index_document.upserted/invalid/tombstone.json
@@@ -0,0 +1,7 @@
++{
++ "owner": "news",
++ "type": "story",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 4,
++ "deleted": true
++}
ddiff --git a/fixtures/news.index_document.upserted/invalid/wrong-owner.json b/fixtures/news.index_document.upserted/invalid/wrong-owner.json
nnew file mode 100644
iindex 00000000..8b3ffa36
---- /dev/null
++++ b/fixtures/news.index_document.upserted/invalid/wrong-owner.json
@@@ -0,0 +1,20 @@
++{
++ "owner": "blog",
++ "type": "story",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 1,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "title": "Europa Clipper is on its way to Jupiter",
++ "body": "",
++ "facets": {},
++ "provenance": [],
++ "publication_state": "published",
++ "published_at": null,
++ "updated_at": null,
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ }
++}
ddiff --git a/fixtures/news.index_document.upserted/valid/ai-assisted.json b/fixtures/news.index_document.upserted/valid/ai-assisted.json
nnew file mode 100644
iindex 00000000..eccf2cb1
---- /dev/null
++++ b/fixtures/news.index_document.upserted/valid/ai-assisted.json
@@@ -0,0 +1,54 @@
++{
++ "owner": "news",
++ "type": "story",
++ "id": "sty_01K5S1E4G6J8M0P2R4T6V8X0Z2",
++ "revision": 3,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "title": "Europa Clipper is on its way to Jupiter",
++ "summary": "NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, beginning a cruise of several years to Jupiter and its moon Europa.",
++ "body": "NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, beginning a cruise of several years to Jupiter and its moon Europa.\n\nAccording to NASA the spacecraft should arrive at Jupiter in 2030, after which it will make repeated close passes of Europa to study its ice shell and the ocean beneath.",
++ "facets": {
++ "topic": "space",
++ "outlets": [
++ "Wire A",
++ "Paper B",
++ "Site C"
++ ],
++ "sources": 3
++ },
++ "language": "en",
++ "authorship": "ai_assisted",
++ "provenance": [
++ {
++ "service": "ai",
++ "type": "run",
++ "id": "run_01K5S4K9N1Q3S5V7X9Z1B3D5F7",
++ "label": "news.summarize_story"
++ },
++ {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "label": "NASA launches Europa Clipper probe to Jupiter moon Europa",
++ "url": "https://wire-a.example/space/europa-clipper-launch",
++ "retrieved_at": "2026-09-22T11:00:00.000Z"
++ },
++ {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ3X8Y0Z2B4D6F8H0K2M4P",
++ "label": "Europa Clipper lifts off as NASA begins long trip to Jupiter",
++ "url": "https://paper-b.example/2026/09/22/europa-clipper",
++ "retrieved_at": "2026-09-22T11:00:00.000Z"
++ }
++ ],
++ "publication_state": "published",
++ "published_at": "2026-09-22T12:00:00.000Z",
++ "updated_at": "2026-09-22T14:30:00.000Z",
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ }
++}
ddiff --git a/fixtures/news.index_document.upserted/valid/human-noindex.json b/fixtures/news.index_document.upserted/valid/human-noindex.json
nnew file mode 100644
iindex 00000000..1c272e4f
---- /dev/null
++++ b/fixtures/news.index_document.upserted/valid/human-noindex.json
@@@ -0,0 +1,39 @@
++{
++ "owner": "news",
++ "type": "story",
++ "id": "sty_01K5S1F5H7K9N1Q3S5V7X9Z1B3",
++ "revision": 1,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.news/stories/treatment-plant-expansion-approved",
++ "title": "Treatment plant expansion approved",
++ "summary": "The valley water authority approved the expansion of its treatment plant.",
++ "body": "The valley water authority approved the expansion of its treatment plant.",
++ "facets": {
++ "outlets": [
++ "Valley Courier"
++ ],
++ "sources": 1
++ },
++ "language": "en",
++ "authorship": "human",
++ "provenance": [
++ {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ5A1B3C5D7E9F1G3H5J7K",
++ "label": "Water authority backs plant expansion",
++ "url": "https://valley-courier.example/water/plant",
++ "retrieved_at": "2026-09-22T09:15:00.000Z"
++ }
++ ],
++ "publication_state": "published",
++ "published_at": "2026-09-22T12:00:00.000Z",
++ "updated_at": "2026-09-22T12:00:00.000Z",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "unsourced"
++ ]
++ }
++}
ddiff --git a/fixtures/news.source.failed/invalid/detail-too-long.json b/fixtures/news.source.failed/invalid/detail-too-long.json
nnew file mode 100644
iindex 00000000..52336cf6
---- /dev/null
++++ b/fixtures/news.source.failed/invalid/detail-too-long.json
@@@ -0,0 +1,9 @@
++{
++ "origin": "pull",
++ "source_key": null,
++ "sources_item_id": null,
++ "state": "failed",
++ "error_code": "pull.error",
++ "http_status": null,
++ "detail": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
++}
ddiff --git a/fixtures/news.source.failed/invalid/extra-property.json b/fixtures/news.source.failed/invalid/extra-property.json
nnew file mode 100644
iindex 00000000..906a4bb5
---- /dev/null
++++ b/fixtures/news.source.failed/invalid/extra-property.json
@@@ -0,0 +1,10 @@
++{
++ "origin": "pull",
++ "source_key": null,
++ "sources_item_id": null,
++ "state": "failed",
++ "error_code": "pull.error",
++ "http_status": null,
++ "detail": null,
++ "cursor": 42
++}
ddiff --git a/fixtures/news.source.failed/invalid/missing-state.json b/fixtures/news.source.failed/invalid/missing-state.json
nnew file mode 100644
iindex 00000000..3be502b0
---- /dev/null
++++ b/fixtures/news.source.failed/invalid/missing-state.json
@@@ -0,0 +1,8 @@
++{
++ "origin": "pull",
++ "source_key": null,
++ "sources_item_id": null,
++ "error_code": "pull.error",
++ "http_status": null,
++ "detail": null
++}
ddiff --git a/fixtures/news.source.failed/invalid/unknown-origin.json b/fixtures/news.source.failed/invalid/unknown-origin.json
nnew file mode 100644
iindex 00000000..c6c9aaf2
---- /dev/null
++++ b/fixtures/news.source.failed/invalid/unknown-origin.json
@@@ -0,0 +1,9 @@
++{
++ "origin": "cron",
++ "source_key": null,
++ "sources_item_id": null,
++ "state": "failed",
++ "error_code": "pull.error",
++ "http_status": null,
++ "detail": null
++}
ddiff --git a/fixtures/news.source.failed/valid/pull-outage.json b/fixtures/news.source.failed/valid/pull-outage.json
nnew file mode 100644
iindex 00000000..a642e77a
---- /dev/null
++++ b/fixtures/news.source.failed/valid/pull-outage.json
@@@ -0,0 +1,9 @@
++{
++ "origin": "pull",
++ "source_key": null,
++ "sources_item_id": null,
++ "state": "failed",
++ "error_code": "sources.http_503",
++ "http_status": 503,
++ "detail": "OpenVibe.Sources answered 503: down"
++}
ddiff --git a/fixtures/news.source.failed/valid/rejected-item.json b/fixtures/news.source.failed/valid/rejected-item.json
nnew file mode 100644
iindex 00000000..d36f7d02
---- /dev/null
++++ b/fixtures/news.source.failed/valid/rejected-item.json
@@@ -0,0 +1,9 @@
++{
++ "origin": "pull",
++ "source_key": "paper-b",
++ "sources_item_id": "itm_01K5RZ3X8Y0Z2B4D6F8H0K2M4P",
++ "state": "rejected",
++ "error_code": "item.no_headline",
++ "http_status": null,
++ "detail": null
++}
ddiff --git a/fixtures/news.source.failed/valid/upstream-fetch.json b/fixtures/news.source.failed/valid/upstream-fetch.json
nnew file mode 100644
iindex 00000000..8dc0be4f
---- /dev/null
++++ b/fixtures/news.source.failed/valid/upstream-fetch.json
@@@ -0,0 +1,11 @@
++{
++ "origin": "sources",
++ "source_key": "wire-a",
++ "sources_item_id": null,
++ "state": "http_error",
++ "error_code": "http_503",
++ "http_status": 503,
++ "detail": null,
++ "run_id": "frn_01K5S3J8M0P2R4T6V8X0Z2B4D6",
++ "consecutive_failures": 3
++}
ddiff --git a/fixtures/news.source.failed/valid/webhook-unreadable.json b/fixtures/news.source.failed/valid/webhook-unreadable.json
nnew file mode 100644
iindex 00000000..f0a6b5dd
---- /dev/null
++++ b/fixtures/news.source.failed/valid/webhook-unreadable.json
@@@ -0,0 +1,9 @@
++{
++ "origin": "webhook",
++ "source_key": "wire-a",
++ "sources_item_id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "state": "failed",
++ "error_code": "sources.error",
++ "http_status": null,
++ "detail": "connect ECONNREFUSED 127.0.0.1:4700"
++}
ddiff --git a/fixtures/news.source.ingested/invalid/bad-item-id.json b/fixtures/news.source.ingested/invalid/bad-item-id.json
nnew file mode 100644
iindex 00000000..099ed61e
---- /dev/null
++++ b/fixtures/news.source.ingested/invalid/bad-item-id.json
@@@ -0,0 +1,16 @@
++{
++ "item_id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "action": "created",
++ "status": "active",
++ "sources_item": {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "revision": 1
++ },
++ "source_key": "wire-a",
++ "canonical_url": null,
++ "duplicate_of": null,
++ "dedupe_rule": null,
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T"
++}
ddiff --git a/fixtures/news.source.ingested/invalid/headline-leaked.json b/fixtures/news.source.ingested/invalid/headline-leaked.json
nnew file mode 100644
iindex 00000000..ce2f3236
---- /dev/null
++++ b/fixtures/news.source.ingested/invalid/headline-leaked.json
@@@ -0,0 +1,17 @@
++{
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "action": "created",
++ "status": "active",
++ "sources_item": {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "revision": 1
++ },
++ "source_key": "wire-a",
++ "canonical_url": null,
++ "duplicate_of": null,
++ "dedupe_rule": null,
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "headline": "NASA launches Europa Clipper"
++}
ddiff --git a/fixtures/news.source.ingested/invalid/missing-sources-item.json b/fixtures/news.source.ingested/invalid/missing-sources-item.json
nnew file mode 100644
iindex 00000000..b2c6158a
---- /dev/null
++++ b/fixtures/news.source.ingested/invalid/missing-sources-item.json
@@@ -0,0 +1,10 @@
++{
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "action": "created",
++ "status": "active",
++ "source_key": "wire-a",
++ "canonical_url": null,
++ "duplicate_of": null,
++ "dedupe_rule": null,
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T"
++}
ddiff --git a/fixtures/news.source.ingested/invalid/unknown-dedupe-rule.json b/fixtures/news.source.ingested/invalid/unknown-dedupe-rule.json
nnew file mode 100644
iindex 00000000..d65d1ccb
---- /dev/null
++++ b/fixtures/news.source.ingested/invalid/unknown-dedupe-rule.json
@@@ -0,0 +1,16 @@
++{
++ "item_id": "nsi_01K5RZ4F0A6H3K8PQ2S5W7Y9ZE",
++ "action": "created",
++ "status": "duplicate",
++ "sources_item": {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ3X8Y0Z2B4D6F8H0K2M4P",
++ "revision": 1
++ },
++ "source_key": "paper-b",
++ "canonical_url": null,
++ "duplicate_of": "nsi_01K5RYX2C4E6G8J0M2P4R6T8VA",
++ "dedupe_rule": "fuzzy",
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T"
++}
ddiff --git a/fixtures/news.source.ingested/valid/created-duplicate.json b/fixtures/news.source.ingested/valid/created-duplicate.json
nnew file mode 100644
iindex 00000000..c0f329fa
---- /dev/null
++++ b/fixtures/news.source.ingested/valid/created-duplicate.json
@@@ -0,0 +1,16 @@
++{
++ "item_id": "nsi_01K5RZ4F0A6H3K8PQ2S5W7Y9ZE",
++ "action": "created",
++ "status": "duplicate",
++ "sources_item": {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ3X8Y0Z2B4D6F8H0K2M4P",
++ "revision": 1
++ },
++ "source_key": "paper-b",
++ "canonical_url": "https://www.wire-a.example/space/europa-clipper-launch/?utm_source=feed",
++ "duplicate_of": "nsi_01K5RYX2C4E6G8J0M2P4R6T8VA",
++ "dedupe_rule": "canonical_url",
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T"
++}
ddiff --git a/fixtures/news.source.ingested/valid/created.json b/fixtures/news.source.ingested/valid/created.json
nnew file mode 100644
iindex 00000000..5ae26ad0
---- /dev/null
++++ b/fixtures/news.source.ingested/valid/created.json
@@@ -0,0 +1,16 @@
++{
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "action": "created",
++ "status": "active",
++ "sources_item": {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "revision": 1
++ },
++ "source_key": "wire-a",
++ "canonical_url": "https://wire-a.example/space/europa-clipper-launch",
++ "duplicate_of": null,
++ "dedupe_rule": null,
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T"
++}
ddiff --git a/fixtures/news.source.ingested/valid/removed.json b/fixtures/news.source.ingested/valid/removed.json
nnew file mode 100644
iindex 00000000..31d2b18e
---- /dev/null
++++ b/fixtures/news.source.ingested/valid/removed.json
@@@ -0,0 +1,18 @@
++{
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "action": "removed",
++ "status": "removed",
++ "sources_item": {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "revision": 3
++ },
++ "source_key": "wire-a",
++ "canonical_url": null,
++ "duplicate_of": null,
++ "dedupe_rule": null,
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "reason": "licence withdrawn by the publisher",
++ "origin": "webhook"
++}
ddiff --git a/fixtures/news.source.ingested/valid/updated.json b/fixtures/news.source.ingested/valid/updated.json
nnew file mode 100644
iindex 00000000..3627843f
---- /dev/null
++++ b/fixtures/news.source.ingested/valid/updated.json
@@@ -0,0 +1,17 @@
++{
++ "item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "action": "updated",
++ "status": "active",
++ "sources_item": {
++ "service": "sources",
++ "type": "item",
++ "id": "itm_01K5RZ2V7W9Y1A3C5E7G9J1K3M",
++ "revision": 2
++ },
++ "source_key": "wire-a",
++ "canonical_url": "https://wire-a.example/space/europa-clipper-launch",
++ "duplicate_of": null,
++ "dedupe_rule": null,
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "previous_revision": 1
++}
ddiff --git a/fixtures/news.story.created/invalid/headline-leaked.json b/fixtures/news.story.created/invalid/headline-leaked.json
nnew file mode 100644
iindex 00000000..b69a970b
---- /dev/null
++++ b/fixtures/news.story.created/invalid/headline-leaked.json
@@@ -0,0 +1,6 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "topic": "space",
++ "sources": 3,
++ "headline": "Europa Clipper is on its way to Jupiter"
++}
ddiff --git a/fixtures/news.story.created/invalid/missing-sources.json b/fixtures/news.story.created/invalid/missing-sources.json
nnew file mode 100644
iindex 00000000..d35bbb0c
---- /dev/null
++++ b/fixtures/news.story.created/invalid/missing-sources.json
@@@ -0,0 +1,4 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "topic": "space"
++}
ddiff --git a/fixtures/news.story.created/invalid/negative-sources.json b/fixtures/news.story.created/invalid/negative-sources.json
nnew file mode 100644
iindex 00000000..6ca29e8b
---- /dev/null
++++ b/fixtures/news.story.created/invalid/negative-sources.json
@@@ -0,0 +1,5 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "topic": null,
++ "sources": -1
++}
ddiff --git a/fixtures/news.story.created/valid/blank.json b/fixtures/news.story.created/valid/blank.json
nnew file mode 100644
iindex 00000000..5b128032
---- /dev/null
++++ b/fixtures/news.story.created/valid/blank.json
@@@ -0,0 +1,5 @@
++{
++ "cluster_id": null,
++ "topic": null,
++ "sources": 0
++}
ddiff --git a/fixtures/news.story.created/valid/from-cluster.json b/fixtures/news.story.created/valid/from-cluster.json
nnew file mode 100644
iindex 00000000..46cbb926
---- /dev/null
++++ b/fixtures/news.story.created/valid/from-cluster.json
@@@ -0,0 +1,5 @@
++{
++ "cluster_id": "clu_01K5RZ3M8R5D1F7H3K9N2Q4S6T",
++ "topic": "space",
++ "sources": 3
++}
ddiff --git a/fixtures/news.story.flagged/invalid/editor-kind.json b/fixtures/news.story.flagged/invalid/editor-kind.json
nnew file mode 100644
iindex 00000000..21413ded
---- /dev/null
++++ b/fixtures/news.story.flagged/invalid/editor-kind.json
@@@ -0,0 +1,8 @@
++{
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4",
++ "kind": "correction",
++ "status": "open",
++ "source_item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "pending_revision": null,
++ "published_revision": 1
++}
ddiff --git a/fixtures/news.story.flagged/invalid/missing-source-item.json b/fixtures/news.story.flagged/invalid/missing-source-item.json
nnew file mode 100644
iindex 00000000..23ef89a7
---- /dev/null
++++ b/fixtures/news.story.flagged/invalid/missing-source-item.json
@@@ -0,0 +1,8 @@
++{
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4",
++ "kind": "source_removed",
++ "status": "open",
++ "source_item_id": null,
++ "pending_revision": 2,
++ "published_revision": 1
++}
ddiff --git a/fixtures/news.story.flagged/invalid/resolved-status.json b/fixtures/news.story.flagged/invalid/resolved-status.json
nnew file mode 100644
iindex 00000000..b919e99e
---- /dev/null
++++ b/fixtures/news.story.flagged/invalid/resolved-status.json
@@@ -0,0 +1,8 @@
++{
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4",
++ "kind": "source_updated",
++ "status": "resolved",
++ "source_item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "pending_revision": 2,
++ "published_revision": 1
++}
ddiff --git a/fixtures/news.story.flagged/valid/source-removed.json b/fixtures/news.story.flagged/valid/source-removed.json
nnew file mode 100644
iindex 00000000..f39731af
---- /dev/null
++++ b/fixtures/news.story.flagged/valid/source-removed.json
@@@ -0,0 +1,8 @@
++{
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4",
++ "kind": "source_removed",
++ "status": "open",
++ "source_item_id": "nsi_01K5RZ3M8Q2W7T4XC9V6B1N0JD",
++ "pending_revision": 2,
++ "published_revision": 1
++}
ddiff --git a/fixtures/news.story.flagged/valid/source-updated-draft.json b/fixtures/news.story.flagged/valid/source-updated-draft.json
nnew file mode 100644
iindex 00000000..e706eea3
---- /dev/null
++++ b/fixtures/news.story.flagged/valid/source-updated-draft.json
@@@ -0,0 +1,8 @@
++{
++ "flag_id": "flg_01K5S2H7K9N1Q3S5V7X9Z1B3D5",
++ "kind": "source_updated",
++ "status": "open",
++ "source_item_id": "nsi_01K5RZ4F0A6H3K8PQ2S5W7Y9ZE",
++ "pending_revision": null,
++ "published_revision": null
++}
ddiff --git a/fixtures/news.story.published/invalid/body-leaked.json b/fixtures/news.story.published/invalid/body-leaked.json
nnew file mode 100644
iindex 00000000..caf4fa81
---- /dev/null
++++ b/fixtures/news.story.published/invalid/body-leaked.json
@@@ -0,0 +1,9 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "published",
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ },
++ "body": "NASA launched the Europa Clipper spacecraft."
++}
ddiff --git a/fixtures/news.story.published/invalid/relative-url.json b/fixtures/news.story.published/invalid/relative-url.json
nnew file mode 100644
iindex 00000000..90abe585
---- /dev/null
++++ b/fixtures/news.story.published/invalid/relative-url.json
@@@ -0,0 +1,8 @@
++{
++ "canonical_url": "/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "published",
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ }
++}
ddiff --git a/fixtures/news.story.published/invalid/wrong-state.json b/fixtures/news.story.published/invalid/wrong-state.json
nnew file mode 100644
iindex 00000000..ec90b817
---- /dev/null
++++ b/fixtures/news.story.published/invalid/wrong-state.json
@@@ -0,0 +1,8 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "unpublished",
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ }
++}
ddiff --git a/fixtures/news.story.published/valid/indexed.json b/fixtures/news.story.published/valid/indexed.json
nnew file mode 100644
iindex 00000000..0cff8f19
---- /dev/null
++++ b/fixtures/news.story.published/valid/indexed.json
@@@ -0,0 +1,9 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "published",
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ },
++ "topic": "space"
++}
ddiff --git a/fixtures/news.story.published/valid/unsourced-no-topic.json b/fixtures/news.story.published/valid/unsourced-no-topic.json
nnew file mode 100644
iindex 00000000..feb9cd34
---- /dev/null
++++ b/fixtures/news.story.published/valid/unsourced-no-topic.json
@@@ -0,0 +1,10 @@
++{
++ "canonical_url": "https://openvibe.news/stories/valley-water-restrictions-to-end",
++ "publication_state": "published",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "unsourced"
++ ]
++ }
++}
ddiff --git a/fixtures/news.story.retracted/invalid/indexed-retraction.json b/fixtures/news.story.retracted/invalid/indexed-retraction.json
nnew file mode 100644
iindex 00000000..659c941d
---- /dev/null
++++ b/fixtures/news.story.retracted/invalid/indexed-retraction.json
@@@ -0,0 +1,10 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "retracted",
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ },
++ "note": "Our sources described a rehearsal, not the launch.",
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4"
++}
ddiff --git a/fixtures/news.story.retracted/invalid/missing-flag.json b/fixtures/news.story.retracted/invalid/missing-flag.json
nnew file mode 100644
iindex 00000000..9f30fc9c
---- /dev/null
++++ b/fixtures/news.story.retracted/invalid/missing-flag.json
@@@ -0,0 +1,11 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "retracted",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "retracted"
++ ]
++ },
++ "note": "Our sources described a rehearsal, not the launch."
++}
ddiff --git a/fixtures/news.story.retracted/invalid/no-retracted-reason.json b/fixtures/news.story.retracted/invalid/no-retracted-reason.json
nnew file mode 100644
iindex 00000000..1305812f
---- /dev/null
++++ b/fixtures/news.story.retracted/invalid/no-retracted-reason.json
@@@ -0,0 +1,12 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "retracted",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "unsourced"
++ ]
++ },
++ "note": "Our sources described a rehearsal, not the launch.",
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4"
++}
ddiff --git a/fixtures/news.story.retracted/invalid/note-too-short.json b/fixtures/news.story.retracted/invalid/note-too-short.json
nnew file mode 100644
iindex 00000000..438bd202
---- /dev/null
++++ b/fixtures/news.story.retracted/invalid/note-too-short.json
@@@ -0,0 +1,12 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "retracted",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "retracted"
++ ]
++ },
++ "note": "Wrong.",
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4"
++}
ddiff --git a/fixtures/news.story.retracted/valid/retracted-and-unsourced.json b/fixtures/news.story.retracted/valid/retracted-and-unsourced.json
nnew file mode 100644
iindex 00000000..c91a1604
---- /dev/null
++++ b/fixtures/news.story.retracted/valid/retracted-and-unsourced.json
@@@ -0,0 +1,13 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "retracted",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "retracted",
++ "unsourced"
++ ]
++ },
++ "note": "The launch date we reported was wrong; the mission has not launched.",
++ "flag_id": "flg_01K5S2H7K9N1Q3S5V7X9Z1B3D5"
++}
ddiff --git a/fixtures/news.story.retracted/valid/retracted.json b/fixtures/news.story.retracted/valid/retracted.json
nnew file mode 100644
iindex 00000000..be04299c
---- /dev/null
++++ b/fixtures/news.story.retracted/valid/retracted.json
@@@ -0,0 +1,12 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-the-launch-in-numbers",
++ "publication_state": "retracted",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "retracted"
++ ]
++ },
++ "note": "Our sources described a rehearsal, not the launch.",
++ "flag_id": "flg_01K5S2G6J8M0P2R4T6V8X0Z2B4"
++}
ddiff --git a/fixtures/news.story.unpublished/invalid/extra-property.json b/fixtures/news.story.unpublished/invalid/extra-property.json
nnew file mode 100644
iindex 00000000..fcaff6c5
---- /dev/null
++++ b/fixtures/news.story.unpublished/invalid/extra-property.json
@@@ -0,0 +1,6 @@
++{
++ "canonical_url": null,
++ "publication_state": "unpublished",
++ "indexability": null,
++ "note": "Taken down"
++}
ddiff --git a/fixtures/news.story.unpublished/invalid/missing-canonical-url.json b/fixtures/news.story.unpublished/invalid/missing-canonical-url.json
nnew file mode 100644
iindex 00000000..54a91f4d
---- /dev/null
++++ b/fixtures/news.story.unpublished/invalid/missing-canonical-url.json
@@@ -0,0 +1,4 @@
++{
++ "publication_state": "unpublished",
++ "indexability": null
++}
ddiff --git a/fixtures/news.story.unpublished/invalid/wrong-state.json b/fixtures/news.story.unpublished/invalid/wrong-state.json
nnew file mode 100644
iindex 00000000..ae9b9399
---- /dev/null
++++ b/fixtures/news.story.unpublished/invalid/wrong-state.json
@@@ -0,0 +1,5 @@
++{
++ "canonical_url": null,
++ "publication_state": "retracted",
++ "indexability": null
++}
ddiff --git a/fixtures/news.story.unpublished/valid/no-decision.json b/fixtures/news.story.unpublished/valid/no-decision.json
nnew file mode 100644
iindex 00000000..9a4d1ba8
---- /dev/null
++++ b/fixtures/news.story.unpublished/valid/no-decision.json
@@@ -0,0 +1,5 @@
++{
++ "canonical_url": null,
++ "publication_state": "unpublished",
++ "indexability": null
++}
ddiff --git a/fixtures/news.story.unpublished/valid/unpublished.json b/fixtures/news.story.unpublished/valid/unpublished.json
nnew file mode 100644
iindex 00000000..9de7d2b8
---- /dev/null
++++ b/fixtures/news.story.unpublished/valid/unpublished.json
@@@ -0,0 +1,11 @@
++{
++ "canonical_url": null,
++ "publication_state": "unpublished",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "not_published"
++ ]
++ },
++ "topic": "space"
++}
ddiff --git a/fixtures/news.story.updated/invalid/bad-reason.json b/fixtures/news.story.updated/invalid/bad-reason.json
nnew file mode 100644
iindex 00000000..17d175ac
---- /dev/null
++++ b/fixtures/news.story.updated/invalid/bad-reason.json
@@@ -0,0 +1,10 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "published",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "Not Sourced"
++ ]
++ }
++}
ddiff --git a/fixtures/news.story.updated/invalid/missing-indexability.json b/fixtures/news.story.updated/invalid/missing-indexability.json
nnew file mode 100644
iindex 00000000..6d68dc2c
---- /dev/null
++++ b/fixtures/news.story.updated/invalid/missing-indexability.json
@@@ -0,0 +1,5 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "published",
++ "topic": "space"
++}
ddiff --git a/fixtures/news.story.updated/valid/republished.json b/fixtures/news.story.updated/valid/republished.json
nnew file mode 100644
iindex 00000000..0cff8f19
---- /dev/null
++++ b/fixtures/news.story.updated/valid/republished.json
@@@ -0,0 +1,9 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "published",
++ "indexability": {
++ "decision": "index",
++ "reasons": []
++ },
++ "topic": "space"
++}
ddiff --git a/fixtures/news.story.updated/valid/unsupported-claims.json b/fixtures/news.story.updated/valid/unsupported-claims.json
nnew file mode 100644
iindex 00000000..e33d230b
---- /dev/null
++++ b/fixtures/news.story.updated/valid/unsupported-claims.json
@@@ -0,0 +1,10 @@
++{
++ "canonical_url": "https://openvibe.news/stories/europa-clipper-launches-for-jupiter",
++ "publication_state": "published",
++ "indexability": {
++ "decision": "noindex",
++ "reasons": [
++ "unsupported_claims"
++ ]
++ }
++}
ddiff --git a/fixtures/reviews.entity.merged/invalid/missing-link-id.json b/fixtures/reviews.entity.merged/invalid/missing-link-id.json
nnew file mode 100644
iindex 00000000..a4b529ab
---- /dev/null
++++ b/fixtures/reviews.entity.merged/invalid/missing-link-id.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "into_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "signal_count": 2, "url": "https://openvibe.reviews/e/portal-two", "into_url": "https://openvibe.reviews/e/portal-2", "note": null }
ddiff --git a/fixtures/reviews.entity.merged/invalid/signal-ids-listed.json b/fixtures/reviews.entity.merged/invalid/signal-ids-listed.json
nnew file mode 100644
iindex 00000000..4427a2e5
---- /dev/null
++++ b/fixtures/reviews.entity.merged/invalid/signal-ids-listed.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "into_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "link_id": "lnk_01M38GW38EQW403PWP85Z8WVH5", "signal_count": 1, "signals": ["sig_01M38GW39B6QJ8K2W4R7T5NXZM"], "url": "https://openvibe.reviews/e/portal-two", "into_url": "https://openvibe.reviews/e/portal-2", "note": null }
ddiff --git a/fixtures/reviews.entity.merged/invalid/slug-not-id.json b/fixtures/reviews.entity.merged/invalid/slug-not-id.json
nnew file mode 100644
iindex 00000000..e6258dd6
---- /dev/null
++++ b/fixtures/reviews.entity.merged/invalid/slug-not-id.json
@@@ -0,0 +1 @@
++{ "entity_id": "portal-two", "into_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "link_id": "lnk_01M38GW38EQW403PWP85Z8WVH5", "signal_count": 2, "url": "https://openvibe.reviews/e/portal-two", "into_url": "https://openvibe.reviews/e/portal-2", "note": null }
ddiff --git a/fixtures/reviews.entity.merged/valid/no-note.json b/fixtures/reviews.entity.merged/valid/no-note.json
nnew file mode 100644
iindex 00000000..9a5cdfd5
---- /dev/null
++++ b/fixtures/reviews.entity.merged/valid/no-note.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M3A0Q7ZK2V5T8R1N4HDXWB6C", "into_entity_id": "ent_01M39YF4C2PXJ7H3E8M0QK5RSV", "link_id": "lnk_01M3A0R1B9D6G2F7Y4T8N3KJMW", "signal_count": 0, "url": "https://openvibe.reviews/e/widget-3000-2", "into_url": "https://openvibe.reviews/e/widget-3000", "note": null }
ddiff --git a/fixtures/reviews.entity.merged/valid/with-note.json b/fixtures/reviews.entity.merged/valid/with-note.json
nnew file mode 100644
iindex 00000000..6f326389
---- /dev/null
++++ b/fixtures/reviews.entity.merged/valid/with-note.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "into_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "link_id": "lnk_01M38GW38EQW403PWP85Z8WVH5", "signal_count": 2, "url": "https://openvibe.reviews/e/portal-two", "into_url": "https://openvibe.reviews/e/portal-2", "note": "Same game, duplicate listing" }
ddiff --git a/fixtures/reviews.entity.split/invalid/bad-link-id.json b/fixtures/reviews.entity.split/invalid/bad-link-id.json
nnew file mode 100644
iindex 00000000..74d2e812
---- /dev/null
++++ b/fixtures/reviews.entity.split/invalid/bad-link-id.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "from_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "link_id": "merged_into", "signal_count": 2, "url": "https://openvibe.reviews/e/portal-two", "from_url": "https://openvibe.reviews/e/portal-2", "note": null }
ddiff --git a/fixtures/reviews.entity.split/invalid/into-instead-of-from.json b/fixtures/reviews.entity.split/invalid/into-instead-of-from.json
nnew file mode 100644
iindex 00000000..0879709b
---- /dev/null
++++ b/fixtures/reviews.entity.split/invalid/into-instead-of-from.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "into_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "from_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "link_id": "lnk_01M38GW38EQW403PWP85Z8WVH5", "signal_count": 2, "url": "https://openvibe.reviews/e/portal-two", "from_url": "https://openvibe.reviews/e/portal-2", "note": null }
ddiff --git a/fixtures/reviews.entity.split/invalid/missing-from-entity.json b/fixtures/reviews.entity.split/invalid/missing-from-entity.json
nnew file mode 100644
iindex 00000000..6071cedf
---- /dev/null
++++ b/fixtures/reviews.entity.split/invalid/missing-from-entity.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "link_id": "lnk_01M38GW38EQW403PWP85Z8WVH5", "signal_count": 2, "url": "https://openvibe.reviews/e/portal-two", "from_url": "https://openvibe.reviews/e/portal-2", "note": null }
ddiff --git a/fixtures/reviews.entity.split/valid/no-note.json b/fixtures/reviews.entity.split/valid/no-note.json
nnew file mode 100644
iindex 00000000..b5d4349b
---- /dev/null
++++ b/fixtures/reviews.entity.split/valid/no-note.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "from_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "link_id": "lnk_01M38GW38EQW403PWP85Z8WVH5", "signal_count": 0, "url": "https://openvibe.reviews/e/portal-two", "from_url": "https://openvibe.reviews/e/portal-2", "note": null }
ddiff --git a/fixtures/reviews.entity.split/valid/split.json b/fixtures/reviews.entity.split/valid/split.json
nnew file mode 100644
iindex 00000000..26ddb4ac
---- /dev/null
++++ b/fixtures/reviews.entity.split/valid/split.json
@@@ -0,0 +1 @@
++{ "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "from_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "link_id": "lnk_01M38GW38EQW403PWP85Z8WVH5", "signal_count": 2, "url": "https://openvibe.reviews/e/portal-two", "from_url": "https://openvibe.reviews/e/portal-2", "note": "Actually two different editions" }
ddiff --git a/fixtures/reviews.index_document.deleted/invalid/full-document.json b/fixtures/reviews.index_document.deleted/invalid/full-document.json
nnew file mode 100644
iindex 00000000..9f80c544
---- /dev/null
++++ b/fixtures/reviews.index_document.deleted/invalid/full-document.json
@@@ -0,0 +1 @@
++{ "owner": "reviews", "type": "entity", "id": "ent_01M38GW372A3PM7110AHHTYM7C", "revision": 4, "deleted": true }
ddiff --git a/fixtures/reviews.index_document.deleted/invalid/revision-zero.json b/fixtures/reviews.index_document.deleted/invalid/revision-zero.json
nnew file mode 100644
iindex 00000000..a6f8cc64
---- /dev/null
++++ b/fixtures/reviews.index_document.deleted/invalid/revision-zero.json
@@@ -0,0 +1 @@
++{ "type": "entity", "id": "ent_01M38GW372A3PM7110AHHTYM7C", "revision": 0 }
ddiff --git a/fixtures/reviews.index_document.deleted/invalid/slug-id.json b/fixtures/reviews.index_document.deleted/invalid/slug-id.json
nnew file mode 100644
iindex 00000000..efdcbf0a
---- /dev/null
++++ b/fixtures/reviews.index_document.deleted/invalid/slug-id.json
@@@ -0,0 +1 @@
++{ "type": "entity", "id": "portal-two", "revision": 4 }
ddiff --git a/fixtures/reviews.index_document.deleted/valid/merged.json b/fixtures/reviews.index_document.deleted/valid/merged.json
nnew file mode 100644
iindex 00000000..820c4b84
---- /dev/null
++++ b/fixtures/reviews.index_document.deleted/valid/merged.json
@@@ -0,0 +1 @@
++{ "type": "entity", "id": "ent_01M38GW372A3PM7110AHHTYM7C", "revision": 4 }
ddiff --git a/fixtures/reviews.index_document.upserted/invalid/other-owner.json b/fixtures/reviews.index_document.upserted/invalid/other-owner.json
nnew file mode 100644
iindex 00000000..8a5afc3a
---- /dev/null
++++ b/fixtures/reviews.index_document.upserted/invalid/other-owner.json
@@@ -0,0 +1 @@
++{ "owner": "wiki", "type": "entity", "id": "ent_01M38GW2QD088TQ08VN985JTXT", "revision": 1, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.reviews/e/portal-2", "title": "Portal 2", "body": "", "facets": { "kind": "game", "has_aggregate": false, "sources": [] }, "provenance": [], "publication_state": "published", "published_at": null, "updated_at": null, "indexability": { "decision": "noindex", "reasons": ["thin_content", "unsourced"] } }
ddiff --git a/fixtures/reviews.index_document.upserted/invalid/summary-type.json b/fixtures/reviews.index_document.upserted/invalid/summary-type.json
nnew file mode 100644
iindex 00000000..a77e32c6
---- /dev/null
++++ b/fixtures/reviews.index_document.upserted/invalid/summary-type.json
@@@ -0,0 +1 @@
++{ "owner": "reviews", "type": "summary", "id": "sum_01M38GW34CFNJCVW5PQEN5W1JA", "revision": 1, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.reviews/e/portal-2", "title": "Portal 2", "body": "", "facets": {}, "provenance": [], "publication_state": "published", "published_at": null, "updated_at": null, "indexability": { "decision": "index", "reasons": [] } }
ddiff --git a/fixtures/reviews.index_document.upserted/invalid/tombstone-as-upsert.json b/fixtures/reviews.index_document.upserted/invalid/tombstone-as-upsert.json
nnew file mode 100644
iindex 00000000..9f80c544
---- /dev/null
++++ b/fixtures/reviews.index_document.upserted/invalid/tombstone-as-upsert.json
@@@ -0,0 +1 @@
++{ "owner": "reviews", "type": "entity", "id": "ent_01M38GW372A3PM7110AHHTYM7C", "revision": 4, "deleted": true }
ddiff --git a/fixtures/reviews.index_document.upserted/valid/new-entity.json b/fixtures/reviews.index_document.upserted/valid/new-entity.json
nnew file mode 100644
iindex 00000000..2c24bb59
---- /dev/null
++++ b/fixtures/reviews.index_document.upserted/valid/new-entity.json
@@@ -0,0 +1 @@
++{ "owner": "reviews", "type": "entity", "id": "ent_01M38GW2QD088TQ08VN985JTXT", "revision": 1, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.reviews/e/portal-2", "title": "Portal 2", "body": "", "facets": { "kind": "game", "has_aggregate": false, "sources": [] }, "provenance": [], "publication_state": "published", "published_at": "2026-09-20T11:55:00.000Z", "updated_at": "2026-09-20T11:55:00.000Z", "indexability": { "decision": "noindex", "reasons": ["thin_content", "unsourced"] } }
ddiff --git a/fixtures/reviews.index_document.upserted/valid/with-summary.json b/fixtures/reviews.index_document.upserted/valid/with-summary.json
nnew file mode 100644
iindex 00000000..92258a8c
---- /dev/null
++++ b/fixtures/reviews.index_document.upserted/valid/with-summary.json
@@@ -0,0 +1 @@
++{ "owner": "reviews", "type": "entity", "id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "revision": 3, "deleted": false, "visibility": "public", "canonical_url": "https://openvibe.reviews/e/widget-3000", "title": "Widget 3000", "summary": "Shoppers at the one sampled store rate this widget well on average, with over a hundred ratings behind that average, according to the listing.", "body": "Shoppers at the one sampled store rate this widget well on average, with over a hundred ratings behind that average, according to the listing.\nrating: 84% = Σ(value ÷ best × count) ÷ Σ count over 1 signal and 120 ratings; on the shared 5-point scale: 4.2 = Σ(value × count) ÷ Σ count", "facets": { "kind": "product", "has_aggregate": true, "sources": ["shop-example"] }, "authorship": "ai_generated", "provenance": [{ "service": "ai", "type": "run", "id": "run_01J9ZZZZZZZZZZZZZZZZZZZZZZ", "label": "reviews.summarize_entity" }, { "service": "sources", "type": "item", "id": "itm_01M2ZB05BG9JCWV5MRXRD4ZTBG", "url": "https://shop.example/p/widget", "retrieved_at": "2026-09-20T12:00:06.000Z" }], "publication_state": "published", "published_at": "2026-09-20T11:58:00.000Z", "updated_at": "2026-09-20T12:10:00.000Z", "indexability": { "decision": "index", "reasons": [] } }
ddiff --git a/fixtures/reviews.signal.added/invalid/rating-without-value.json b/fixtures/reviews.signal.added/invalid/rating-without-value.json
nnew file mode 100644
iindex 00000000..7b91b2d8
---- /dev/null
++++ b/fixtures/reviews.signal.added/invalid/rating-without-value.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW33AG2JGY97E8YACRG2G", "entity_id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "canonical_entity_id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "type": "rating", "source_key": "shop-example", "source_item_id": "itm_01M2ZB05BG9JCWV5MRXRD4ZTBG", "item_revision": 1, "observed_at": "2026-09-20T12:00:06.000Z", "source_published_at": null, "canonical_url": null, "license_note": null, "rating_best": 5, "rating_worst": 1, "rating_count": 1 }
ddiff --git a/fixtures/reviews.signal.added/invalid/recommended-as-integer.json b/fixtures/reviews.signal.added/invalid/recommended-as-integer.json
nnew file mode 100644
iindex 00000000..6070c5f3
---- /dev/null
++++ b/fixtures/reviews.signal.added/invalid/recommended-as-integer.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW2W9GCC5P6WKXXFVXBQY", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB00F8E73MN708E0S8WFM0", "item_revision": 1, "observed_at": "2026-09-20T12:00:01.000Z", "source_published_at": null, "canonical_url": null, "license_note": null, "recommended": 1 }
ddiff --git a/fixtures/reviews.signal.added/invalid/review-text.json b/fixtures/reviews.signal.added/invalid/review-text.json
nnew file mode 100644
iindex 00000000..80901354
---- /dev/null
++++ b/fixtures/reviews.signal.added/invalid/review-text.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW2W9GCC5P6WKXXFVXBQY", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB00F8E73MN708E0S8WFM0", "item_revision": 1, "observed_at": "2026-09-20T12:00:01.000Z", "source_published_at": null, "canonical_url": null, "license_note": null, "recommended": true, "text": "Loved every puzzle." }
ddiff --git a/fixtures/reviews.signal.added/invalid/unknown-type.json b/fixtures/reviews.signal.added/invalid/unknown-type.json
nnew file mode 100644
iindex 00000000..e849c550
---- /dev/null
++++ b/fixtures/reviews.signal.added/invalid/unknown-type.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW2W9GCC5P6WKXXFVXBQY", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "sentiment", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB00F8E73MN708E0S8WFM0", "item_revision": 1, "observed_at": "2026-09-20T12:00:01.000Z", "source_published_at": null, "canonical_url": null, "license_note": null }
ddiff --git a/fixtures/reviews.signal.added/valid/rating-aggregate.json b/fixtures/reviews.signal.added/valid/rating-aggregate.json
nnew file mode 100644
iindex 00000000..08900b9c
---- /dev/null
++++ b/fixtures/reviews.signal.added/valid/rating-aggregate.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW33AG2JGY97E8YACRG2G", "entity_id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "canonical_entity_id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "type": "rating_aggregate", "source_key": "shop-example", "source_item_id": "itm_01M2ZB05BG9JCWV5MRXRD4ZTBG", "item_revision": 1, "observed_at": "2026-09-20T12:00:06.000Z", "source_published_at": null, "canonical_url": "https://shop.example/p/widget", "license_note": "Product data as published by shop.example", "rating_value": 4.2, "rating_best": 5, "rating_worst": null, "rating_count": 120 }
ddiff --git a/fixtures/reviews.signal.added/valid/recommendation.json b/fixtures/reviews.signal.added/valid/recommendation.json
nnew file mode 100644
iindex 00000000..d14453c6
---- /dev/null
++++ b/fixtures/reviews.signal.added/valid/recommendation.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW2W9GCC5P6WKXXFVXBQY", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB00F8E73MN708E0S8WFM0", "item_revision": 1, "observed_at": "2026-09-20T12:00:01.000Z", "source_published_at": "2026-09-01T00:00:00.000Z", "canonical_url": null, "license_note": null, "recommended": true }
ddiff --git a/fixtures/reviews.signal.added/valid/tally-replaces.json b/fixtures/reviews.signal.added/valid/tally-replaces.json
nnew file mode 100644
iindex 00000000..533cdb13
---- /dev/null
++++ b/fixtures/reviews.signal.added/valid/tally-replaces.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW33Q3WGNNV0RVRERXSA3", "entity_id": "ent_01M38GW372A3PM7110AHHTYM7C", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation_tally", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB04C8N9TB7GZ9266TR9EY", "item_revision": 2, "observed_at": "2026-09-20T13:00:05.000Z", "source_published_at": "2026-09-01T00:00:00.000Z", "canonical_url": null, "license_note": null, "positive_count": 812, "total_count": 1000, "replaces": "sig_01M38GW31D8P3PQ89VKPPRBT9T" }
ddiff --git a/fixtures/reviews.signal.removed/invalid/deleted-status.json b/fixtures/reviews.signal.removed/invalid/deleted-status.json
nnew file mode 100644
iindex 00000000..df5fa6c9
---- /dev/null
++++ b/fixtures/reviews.signal.removed/invalid/deleted-status.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW30H8EXZTMFC3DWMR8RR", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB03D0JFTD8H9CZE26KNHR", "item_revision": 1, "observed_at": "2026-09-20T12:00:04.000Z", "source_published_at": null, "canonical_url": null, "license_note": null, "recommended": false, "status": "deleted", "reason": "removed by its source: author deleted the review" }
ddiff --git a/fixtures/reviews.signal.removed/invalid/missing-status.json b/fixtures/reviews.signal.removed/invalid/missing-status.json
nnew file mode 100644
iindex 00000000..540f6113
---- /dev/null
++++ b/fixtures/reviews.signal.removed/invalid/missing-status.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW30H8EXZTMFC3DWMR8RR", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB03D0JFTD8H9CZE26KNHR", "item_revision": 1, "observed_at": "2026-09-20T12:00:04.000Z", "source_published_at": null, "canonical_url": null, "license_note": null, "recommended": false, "reason": "removed by its source: author deleted the review" }
ddiff --git a/fixtures/reviews.signal.removed/invalid/superseded-without-replacement.json b/fixtures/reviews.signal.removed/invalid/superseded-without-replacement.json
nnew file mode 100644
iindex 00000000..899baff9
---- /dev/null
++++ b/fixtures/reviews.signal.removed/invalid/superseded-without-replacement.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW2W9GCC5P6WKXXFVXBQY", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB00F8E73MN708E0S8WFM0", "item_revision": 1, "observed_at": "2026-09-20T12:00:01.000Z", "source_published_at": null, "canonical_url": null, "license_note": null, "recommended": true, "status": "superseded", "reason": "source item revised (r1 → r2)" }
ddiff --git a/fixtures/reviews.signal.removed/invalid/tally-without-counts.json b/fixtures/reviews.signal.removed/invalid/tally-without-counts.json
nnew file mode 100644
iindex 00000000..6dde1610
---- /dev/null
++++ b/fixtures/reviews.signal.removed/invalid/tally-without-counts.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW31D8P3PQ89VKPPRBT9T", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation_tally", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB04C8N9TB7GZ9266TR9EY", "item_revision": 1, "observed_at": "2026-09-20T12:00:05.000Z", "source_published_at": null, "canonical_url": null, "license_note": null, "status": "withdrawn", "reason": "the source no longer states a signal (no value)" }
ddiff --git a/fixtures/reviews.signal.removed/valid/superseded.json b/fixtures/reviews.signal.removed/valid/superseded.json
nnew file mode 100644
iindex 00000000..b2c59ad3
---- /dev/null
++++ b/fixtures/reviews.signal.removed/valid/superseded.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW2W9GCC5P6WKXXFVXBQY", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "canonical_entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "type": "recommendation", "source_key": "steam-reviews-portal-2", "source_item_id": "itm_01M2ZB00F8E73MN708E0S8WFM0", "item_revision": 1, "observed_at": "2026-09-20T12:00:01.000Z", "source_published_at": "2026-09-01T00:00:00.000Z", "canonical_url": null, "license_note": null, "recommended": true, "status": "superseded", "reason": "source item revised (r1 → r2)", "replaced_by": "sig_01M38GW33Q3WGNNV0RVRERXSA3" }
ddiff --git a/fixtures/reviews.signal.removed/valid/withdrawn-by-source.json b/fixtures/reviews.signal.removed/valid/withdrawn-by-source.json
nnew file mode 100644
iindex 00000000..ebf590ce
---- /dev/null
++++ b/fixtures/reviews.signal.removed/valid/withdrawn-by-source.json
@@@ -0,0 +1 @@
++{ "signal_id": "sig_01M38GW33AG2JGY97E8YACRG2G", "entity_id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "canonical_entity_id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "type": "rating_aggregate", "source_key": "shop-example", "source_item_id": "itm_01M2ZB05BG9JCWV5MRXRD4ZTBG", "item_revision": 1, "observed_at": "2026-09-20T12:00:06.000Z", "source_published_at": null, "canonical_url": "https://shop.example/p/widget", "license_note": "Product data as published by shop.example", "rating_value": 4.2, "rating_best": 5, "rating_worst": null, "rating_count": 120, "status": "withdrawn", "reason": "removed by its source: takedown" }
ddiff --git a/fixtures/reviews.summary.published/invalid/carries-rating.json b/fixtures/reviews.summary.published/invalid/carries-rating.json
nnew file mode 100644
iindex 00000000..40fbe6f1
---- /dev/null
++++ b/fixtures/reviews.summary.published/invalid/carries-rating.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human", "rating": 4.5 }
ddiff --git a/fixtures/reviews.summary.published/invalid/missing-authorship.json b/fixtures/reviews.summary.published/invalid/missing-authorship.json
nnew file mode 100644
iindex 00000000..b6b34040
---- /dev/null
++++ b/fixtures/reviews.summary.published/invalid/missing-authorship.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2" }
ddiff --git a/fixtures/reviews.summary.published/invalid/unpublished-state.json b/fixtures/reviews.summary.published/invalid/unpublished-state.json
nnew file mode 100644
iindex 00000000..6dd70451
---- /dev/null
++++ b/fixtures/reviews.summary.published/invalid/unpublished-state.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "unpublished", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human" }
ddiff --git a/fixtures/reviews.summary.published/valid/ai-approved-noindex.json b/fixtures/reviews.summary.published/valid/ai-approved-noindex.json
nnew file mode 100644
iindex 00000000..415fb304
---- /dev/null
++++ b/fixtures/reviews.summary.published/valid/ai-approved-noindex.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/widget-3000", "publication_state": "published", "indexability": { "decision": "noindex", "reasons": ["thin_content"] }, "entity_id": "ent_01M38GW32MMJWAVC9KPYXC759Y", "entity_slug": "widget-3000", "authorship": "ai_generated" }
ddiff --git a/fixtures/reviews.summary.published/valid/human.json b/fixtures/reviews.summary.published/valid/human.json
nnew file mode 100644
iindex 00000000..ebfb04d7
---- /dev/null
++++ b/fixtures/reviews.summary.published/valid/human.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human" }
ddiff --git a/fixtures/reviews.summary.unpublished/invalid/bad-slug.json b/fixtures/reviews.summary.unpublished/invalid/bad-slug.json
nnew file mode 100644
iindex 00000000..f630c9b9
---- /dev/null
++++ b/fixtures/reviews.summary.unpublished/invalid/bad-slug.json
@@@ -0,0 +1 @@
++{ "canonical_url": null, "publication_state": "unpublished", "indexability": { "decision": "noindex", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "Portal 2" }
ddiff --git a/fixtures/reviews.summary.unpublished/invalid/published-state.json b/fixtures/reviews.summary.unpublished/invalid/published-state.json
nnew file mode 100644
iindex 00000000..c303da3b
---- /dev/null
++++ b/fixtures/reviews.summary.unpublished/invalid/published-state.json
@@@ -0,0 +1 @@
++{ "canonical_url": null, "publication_state": "published", "indexability": { "decision": "noindex", "reasons": ["thin_content"] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2" }
ddiff --git a/fixtures/reviews.summary.unpublished/invalid/with-authorship.json b/fixtures/reviews.summary.unpublished/invalid/with-authorship.json
nnew file mode 100644
iindex 00000000..c3dfceaf
---- /dev/null
++++ b/fixtures/reviews.summary.unpublished/invalid/with-authorship.json
@@@ -0,0 +1 @@
++{ "canonical_url": null, "publication_state": "unpublished", "indexability": { "decision": "noindex", "reasons": ["thin_content"] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human" }
ddiff --git a/fixtures/reviews.summary.unpublished/valid/unpublished.json b/fixtures/reviews.summary.unpublished/valid/unpublished.json
nnew file mode 100644
iindex 00000000..ca2a15da
---- /dev/null
++++ b/fixtures/reviews.summary.unpublished/valid/unpublished.json
@@@ -0,0 +1 @@
++{ "canonical_url": null, "publication_state": "unpublished", "indexability": { "decision": "noindex", "reasons": ["thin_content"] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2" }
ddiff --git a/fixtures/reviews.summary.updated/invalid/correction-leaks-request.json b/fixtures/reviews.summary.updated/invalid/correction-leaks-request.json
nnew file mode 100644
iindex 00000000..a1d55fc2
---- /dev/null
++++ b/fixtures/reviews.summary.updated/invalid/correction-leaks-request.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human", "correction": { "note": "The overview said all reviewers recommend it; one does not.", "corrects": 2, "request": "crr_01M38GW3A5FSK4H3P7C776SPSF" } }
ddiff --git a/fixtures/reviews.summary.updated/invalid/correction-note-too-short.json b/fixtures/reviews.summary.updated/invalid/correction-note-too-short.json
nnew file mode 100644
iindex 00000000..cd7d515f
---- /dev/null
++++ b/fixtures/reviews.summary.updated/invalid/correction-note-too-short.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human", "correction": { "note": "typo", "corrects": 2 } }
ddiff --git a/fixtures/reviews.summary.updated/invalid/missing-indexability.json b/fixtures/reviews.summary.updated/invalid/missing-indexability.json
nnew file mode 100644
iindex 00000000..37c74ca6
---- /dev/null
++++ b/fixtures/reviews.summary.updated/invalid/missing-indexability.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human" }
ddiff --git a/fixtures/reviews.summary.updated/valid/correction.json b/fixtures/reviews.summary.updated/valid/correction.json
nnew file mode 100644
iindex 00000000..be96df44
---- /dev/null
++++ b/fixtures/reviews.summary.updated/valid/correction.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "ai_assisted", "correction": { "note": "The overview said all reviewers recommend it; one does not.", "corrects": 2 } }
ddiff --git a/fixtures/reviews.summary.updated/valid/new-revision.json b/fixtures/reviews.summary.updated/valid/new-revision.json
nnew file mode 100644
iindex 00000000..ebfb04d7
---- /dev/null
++++ b/fixtures/reviews.summary.updated/valid/new-revision.json
@@@ -0,0 +1 @@
++{ "canonical_url": "https://openvibe.reviews/e/portal-2", "publication_state": "published", "indexability": { "decision": "index", "reasons": [] }, "entity_id": "ent_01M38GW2QD088TQ08VN985JTXT", "entity_slug": "portal-2", "authorship": "human" }
ddiff --git a/fixtures/trade.index_document.deleted/invalid/full-document.json b/fixtures/trade.index_document.deleted/invalid/full-document.json
nnew file mode 100644
iindex 00000000..584dd8fa
---- /dev/null
++++ b/fixtures/trade.index_document.deleted/invalid/full-document.json
@@@ -0,0 +1,7 @@
++{
++ "owner": "trade",
++ "type": "instrument",
++ "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 3,
++ "deleted": true
++}
ddiff --git a/fixtures/trade.index_document.deleted/invalid/symbol-as-id.json b/fixtures/trade.index_document.deleted/invalid/symbol-as-id.json
nnew file mode 100644
iindex 00000000..4d1a4fe3
---- /dev/null
++++ b/fixtures/trade.index_document.deleted/invalid/symbol-as-id.json
@@@ -0,0 +1,5 @@
++{
++ "type": "instrument",
++ "id": "ACME",
++ "revision": 3
++}
ddiff --git a/fixtures/trade.index_document.deleted/valid/tombstone.json b/fixtures/trade.index_document.deleted/valid/tombstone.json
nnew file mode 100644
iindex 00000000..4bfe7650
---- /dev/null
++++ b/fixtures/trade.index_document.deleted/valid/tombstone.json
@@@ -0,0 +1,5 @@
++{
++ "type": "instrument",
++ "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 3
++}
ddiff --git a/fixtures/trade.index_document.upserted/invalid/tombstone-as-upsert.json b/fixtures/trade.index_document.upserted/invalid/tombstone-as-upsert.json
nnew file mode 100644
iindex 00000000..584dd8fa
---- /dev/null
++++ b/fixtures/trade.index_document.upserted/invalid/tombstone-as-upsert.json
@@@ -0,0 +1,7 @@
++{
++ "owner": "trade",
++ "type": "instrument",
++ "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 3,
++ "deleted": true
++}
ddiff --git a/fixtures/trade.index_document.upserted/invalid/watchlist.json b/fixtures/trade.index_document.upserted/invalid/watchlist.json
nnew file mode 100644
iindex 00000000..f2be6f14
---- /dev/null
++++ b/fixtures/trade.index_document.upserted/invalid/watchlist.json
@@@ -0,0 +1,20 @@
++{
++ "owner": "trade",
++ "type": "watchlist",
++ "id": "wl_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 2,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.trade/i/ACME",
++ "title": "ACME — Acme Corporation",
++ "summary": "Acme Corporation reported its latest recorded close and filings; see the cited observations.",
++ "body": "Acme Corporation reported its latest recorded close and filings; see the cited observations.\n\nAlso known as: ACME, Acme Corporation",
++ "facets": { "kind": "equity", "symbol": "ACME", "exchange": "NYSE" },
++ "language": "en",
++ "authorship": "ai_generated",
++ "provenance": [{ "service": "trade", "type": "observation", "id": "obs_01JAB2C3D4E5F6G7H8J9K0MNPR" }],
++ "publication_state": "published",
++ "published_at": "2026-09-22T13:00:00.000Z",
++ "updated_at": "2026-09-22T12:40:00.000Z",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/trade.index_document.upserted/valid/instrument.json b/fixtures/trade.index_document.upserted/valid/instrument.json
nnew file mode 100644
iindex 00000000..6f9b1861
---- /dev/null
++++ b/fixtures/trade.index_document.upserted/valid/instrument.json
@@@ -0,0 +1,20 @@
++{
++ "owner": "trade",
++ "type": "instrument",
++ "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 2,
++ "deleted": false,
++ "visibility": "public",
++ "canonical_url": "https://openvibe.trade/i/ACME",
++ "title": "ACME — Acme Corporation",
++ "summary": "Acme Corporation reported its latest recorded close and filings; see the cited observations.",
++ "body": "Acme Corporation reported its latest recorded close and filings; see the cited observations.\n\nAlso known as: ACME, Acme Corporation",
++ "facets": { "kind": "equity", "symbol": "ACME", "exchange": "NYSE" },
++ "language": "en",
++ "authorship": "ai_generated",
++ "provenance": [{ "service": "trade", "type": "observation", "id": "obs_01JAB2C3D4E5F6G7H8J9K0MNPR" }],
++ "publication_state": "published",
++ "published_at": "2026-09-22T13:00:00.000Z",
++ "updated_at": "2026-09-22T12:40:00.000Z",
++ "indexability": { "decision": "index", "reasons": [] }
++}
ddiff --git a/fixtures/trade.observation.created/invalid/missing-observed-at.json b/fixtures/trade.observation.created/invalid/missing-observed-at.json
nnew file mode 100644
iindex 00000000..53e1138e
---- /dev/null
++++ b/fixtures/trade.observation.created/invalid/missing-observed-at.json
@@@ -0,0 +1,13 @@
++{
++ "instrument": { "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ", "symbol": "ACME" },
++ "id": "obs_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "metric": "price.close",
++ "value": "10.50",
++ "unit": "USD",
++ "currency": "USD",
++ "period": null,
++ "retrieved_at": "2026-09-22T12:00:00.000Z",
++ "recorded_at": "2026-09-22T12:00:03.412Z",
++ "max_age_sec": null,
++ "source": { "key": "test-feed", "item_id": null, "url": "https://example.org/acme/close", "ref": "r1" }
++}
ddiff --git a/fixtures/trade.observation.created/invalid/numeric-value.json b/fixtures/trade.observation.created/invalid/numeric-value.json
nnew file mode 100644
iindex 00000000..e0379d98
---- /dev/null
++++ b/fixtures/trade.observation.created/invalid/numeric-value.json
@@@ -0,0 +1,14 @@
++{
++ "instrument": { "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ", "symbol": "ACME" },
++ "id": "obs_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "metric": "price.close",
++ "value": 10.5,
++ "unit": "USD",
++ "currency": "USD",
++ "period": null,
++ "observed_at": "2026-09-22T11:59:00.000Z",
++ "retrieved_at": "2026-09-22T12:00:00.000Z",
++ "recorded_at": "2026-09-22T12:00:03.412Z",
++ "max_age_sec": null,
++ "source": { "key": "test-feed", "item_id": null, "url": "https://example.org/acme/close", "ref": "r1" }
++}
ddiff --git a/fixtures/trade.observation.created/invalid/with-freshness.json b/fixtures/trade.observation.created/invalid/with-freshness.json
nnew file mode 100644
iindex 00000000..5ab7657b
---- /dev/null
++++ b/fixtures/trade.observation.created/invalid/with-freshness.json
@@@ -0,0 +1,15 @@
++{
++ "instrument": { "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ", "symbol": "ACME" },
++ "id": "obs_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "metric": "price.close",
++ "value": "10.50",
++ "unit": "USD",
++ "currency": "USD",
++ "period": null,
++ "observed_at": "2026-09-22T11:59:00.000Z",
++ "retrieved_at": "2026-09-22T12:00:00.000Z",
++ "recorded_at": "2026-09-22T12:00:03.412Z",
++ "max_age_sec": null,
++ "source": { "key": "test-feed", "item_id": null, "url": "https://example.org/acme/close", "ref": "r1" },
++ "freshness": { "stale": false }
++}
ddiff --git a/fixtures/trade.observation.created/valid/price-close.json b/fixtures/trade.observation.created/valid/price-close.json
nnew file mode 100644
iindex 00000000..d089e053
---- /dev/null
++++ b/fixtures/trade.observation.created/valid/price-close.json
@@@ -0,0 +1,14 @@
++{
++ "instrument": { "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ", "symbol": "ACME" },
++ "id": "obs_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "metric": "price.close",
++ "value": "10.50",
++ "unit": "USD",
++ "currency": "USD",
++ "period": null,
++ "observed_at": "2026-09-22T11:59:00.000Z",
++ "retrieved_at": "2026-09-22T12:00:00.000Z",
++ "recorded_at": "2026-09-22T12:00:03.412Z",
++ "max_age_sec": null,
++ "source": { "key": "test-feed", "item_id": null, "url": "https://example.org/acme/close", "ref": "r1" }
++}
ddiff --git a/fixtures/trade.observation.created/valid/shares-outstanding.json b/fixtures/trade.observation.created/valid/shares-outstanding.json
nnew file mode 100644
iindex 00000000..5dd453d2
---- /dev/null
++++ b/fixtures/trade.observation.created/valid/shares-outstanding.json
@@@ -0,0 +1,19 @@
++{
++ "instrument": { "id": "ins_01JAB2C3D4E5F6G7H8J9K0MNPQ", "symbol": "ACME" },
++ "id": "obs_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "metric": "dei:EntityCommonStockSharesOutstanding",
++ "value": "152300000",
++ "unit": "shares",
++ "currency": null,
++ "period": "2026-06-30",
++ "observed_at": "2026-06-30T00:00:00.000Z",
++ "retrieved_at": "2026-09-23T03:10:00.000Z",
++ "recorded_at": "2026-09-23T03:10:02.000Z",
++ "max_age_sec": 7776000,
++ "source": {
++ "key": "sec-xbrl-filings",
++ "item_id": "itm_01JAB2C3D4E5F6G7H8J9K0MNPT",
++ "url": null,
++ "ref": "0000000000-26-000123:dei:EntityCommonStockSharesOutstanding"
++ }
++}
ddiff --git a/fixtures/trade.source.recovered/invalid/no-last-success.json b/fixtures/trade.source.recovered/invalid/no-last-success.json
nnew file mode 100644
iindex 00000000..fa11b41b
---- /dev/null
++++ b/fixtures/trade.source.recovered/invalid/no-last-success.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "test-feed",
++ "stale": false,
++ "stale_since": null,
++ "last_success_at": null,
++ "stale_after_sec": 10800,
++ "upstream_status": "healthy",
++ "evaluated_at": "2026-09-23T15:30:04.000Z"
++}
ddiff --git a/fixtures/trade.source.recovered/invalid/with-stale-since.json b/fixtures/trade.source.recovered/invalid/with-stale-since.json
nnew file mode 100644
iindex 00000000..3d012664
---- /dev/null
++++ b/fixtures/trade.source.recovered/invalid/with-stale-since.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "test-feed",
++ "stale": false,
++ "stale_since": "2026-09-23T15:00:00.000Z",
++ "last_success_at": "2026-09-23T15:30:00.000Z",
++ "stale_after_sec": 10800,
++ "upstream_status": "healthy",
++ "evaluated_at": "2026-09-23T15:30:04.000Z"
++}
ddiff --git a/fixtures/trade.source.recovered/valid/proved-by-retrieval.json b/fixtures/trade.source.recovered/valid/proved-by-retrieval.json
nnew file mode 100644
iindex 00000000..8e3721fa
---- /dev/null
++++ b/fixtures/trade.source.recovered/valid/proved-by-retrieval.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "test-feed",
++ "stale": false,
++ "stale_since": null,
++ "last_success_at": "2026-09-23T15:30:00.000Z",
++ "stale_after_sec": 10800,
++ "upstream_status": null,
++ "evaluated_at": "2026-09-23T15:30:04.000Z"
++}
ddiff --git a/fixtures/trade.source.recovered/valid/recovered.json b/fixtures/trade.source.recovered/valid/recovered.json
nnew file mode 100644
iindex 00000000..1fde181e
---- /dev/null
++++ b/fixtures/trade.source.recovered/valid/recovered.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "test-feed",
++ "stale": false,
++ "stale_since": null,
++ "last_success_at": "2026-09-23T15:30:00.000Z",
++ "stale_after_sec": 10800,
++ "upstream_status": "healthy",
++ "evaluated_at": "2026-09-23T15:30:04.000Z"
++}
ddiff --git a/fixtures/trade.source.stale/invalid/no-stale-since.json b/fixtures/trade.source.stale/invalid/no-stale-since.json
nnew file mode 100644
iindex 00000000..eeefc242
---- /dev/null
++++ b/fixtures/trade.source.stale/invalid/no-stale-since.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "sec-xbrl-filings",
++ "stale": true,
++ "stale_since": null,
++ "last_success_at": null,
++ "stale_after_sec": 2700,
++ "upstream_status": "disabled",
++ "evaluated_at": "2026-09-23T03:25:25.318Z"
++}
ddiff --git a/fixtures/trade.source.stale/invalid/not-stale.json b/fixtures/trade.source.stale/invalid/not-stale.json
nnew file mode 100644
iindex 00000000..5b60fae9
---- /dev/null
++++ b/fixtures/trade.source.stale/invalid/not-stale.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "sec-xbrl-filings",
++ "stale": false,
++ "stale_since": "2026-09-23T03:25:25.317Z",
++ "last_success_at": null,
++ "stale_after_sec": 2700,
++ "upstream_status": "disabled",
++ "evaluated_at": "2026-09-23T03:25:25.318Z"
++}
ddiff --git a/fixtures/trade.source.stale/invalid/rendered-text.json b/fixtures/trade.source.stale/invalid/rendered-text.json
nnew file mode 100644
iindex 00000000..69466473
---- /dev/null
++++ b/fixtures/trade.source.stale/invalid/rendered-text.json
@@@ -0,0 +1,10 @@
++{
++ "source_key": "sec-xbrl-filings",
++ "stale": true,
++ "stale_since": "2026-09-23T03:25:25.317Z",
++ "last_success_at": null,
++ "stale_after_sec": 2700,
++ "upstream_status": "disabled",
++ "evaluated_at": "2026-09-23T03:25:25.318Z",
++ "message": "SEC filings are stale"
++}
ddiff --git a/fixtures/trade.source.stale/valid/never-succeeded.json b/fixtures/trade.source.stale/valid/never-succeeded.json
nnew file mode 100644
iindex 00000000..94bd69bd
---- /dev/null
++++ b/fixtures/trade.source.stale/valid/never-succeeded.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "sec-xbrl-filings",
++ "stale": true,
++ "stale_since": "2026-09-23T03:25:25.317Z",
++ "last_success_at": null,
++ "stale_after_sec": 2700,
++ "upstream_status": "disabled",
++ "evaluated_at": "2026-09-23T03:25:25.318Z"
++}
ddiff --git a/fixtures/trade.source.stale/valid/window-passed.json b/fixtures/trade.source.stale/valid/window-passed.json
nnew file mode 100644
iindex 00000000..e7cfc4c6
---- /dev/null
++++ b/fixtures/trade.source.stale/valid/window-passed.json
@@@ -0,0 +1,9 @@
++{
++ "source_key": "test-feed",
++ "stale": true,
++ "stale_since": "2026-09-23T15:00:00.000Z",
++ "last_success_at": "2026-09-23T12:00:00.000Z",
++ "stale_after_sec": 10800,
++ "upstream_status": "failing",
++ "evaluated_at": "2026-09-23T15:01:00.000Z"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 05941cd1..e5b74d4a 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -11196,6 +11196,4378 @@
}
}
}
++ },
++ "news.source.ingested": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.source.ingested.v1.json",
++ "title": "NewsSourceIngestedPayload",
++ "description": "news.source.ingested v1 (OpenVibe.News server/domain/ingest.js ingestedEvent, from apply and applyRemoval). An OpenVibe.Sources item of category news changed what News holds, in the transaction that applied it: created (a new news_source_items row, with its dedupe outcome and the cluster it joined; news.cluster.updated follows in the same transaction), updated (Sources sent a newer revision of an item News has; adds previous_revision) or removed (Sources removed the item: sticky, the stored summary is dropped; adds reason and origin). Items arrive through the Events webhook (sources.item.*) or the cursor pull; a replay, an older or equal revision, an item of another category, an update to a removed item and a removal of an item never ingested emit nothing, and an item without a title is news.source.failed (state rejected) instead. Deliberately left out: the headline, outlet, authors, published_at and licensed summary (GET /api/v1/source-items/:id with news.cluster.read) and the cluster_reason explanation. Envelope: subject { type: source_item, id: nsi_… }, visibility internal, priority low, actor service:news.",
++ "type": "object",
++ "required": [
++ "item_id",
++ "action",
++ "status",
++ "sources_item",
++ "source_key",
++ "canonical_url",
++ "duplicate_of",
++ "dedupe_rule",
++ "cluster_id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "item_id": {
++ "type": "string",
++ "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "News' own source item (news_source_items)."
++ },
++ "action": {
++ "enum": [
++ "created",
++ "updated",
++ "removed"
++ ]
++ },
++ "status": {
++ "enum": [
++ "active",
++ "duplicate",
++ "removed"
++ ],
++ "description": "The item after the change: active or duplicate for created and updated, removed exactly when action is removed."
++ },
++ "sources_item": {
++ "type": "object",
++ "required": [
++ "service",
++ "type",
++ "id",
++ "revision"
++ ],
++ "additionalProperties": false,
++ "description": "Typed reference (common/entity-ref@1 shape) to the OpenVibe.Sources item and the Sources revision News now holds.",
++ "properties": {
++ "service": {
++ "const": "sources"
++ },
++ "type": {
++ "const": "item"
++ },
++ "id": {
++ "type": "string",
++ "minLength": 1,
++ "description": "The Sources item id (itm_…), as Sources gave it."
++ },
++ "revision": {
++ "type": "integer",
++ "description": "Sources revision the stored fields came from (1 when Sources sent none)."
++ }
++ }
++ },
++ "source_key": {
++ "type": "string",
++ "minLength": 1,
++ "description": "The Sources source the item came from, as Sources gave it ('unknown' when it gave none)."
++ },
++ "canonical_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The item's canonical URL as Sources gave it (not validated by News); null when it had none."
++ },
++ "duplicate_of": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The original item when status is duplicate (the root of any duplicate chain); null otherwise."
++ },
++ "dedupe_rule": {
++ "enum": [
++ "canonical_url",
++ "content_hash",
++ "near_title",
++ null
++ ],
++ "description": "Which dedupe rule matched when the item was first ingested: same normalised URL, same Sources content hash, or a near-identical headline within 48 h; null when it is not a duplicate."
++ },
++ "cluster_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The cluster the item belongs to (every ingested item is placed in one)."
++ },
++ "previous_revision": {
++ "type": "integer",
++ "description": "action updated only: the Sources revision News held before."
++ },
++ "reason": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500,
++ "description": "action removed only: why Sources removed it ('removed upstream' when it gave no reason)."
++ },
++ "origin": {
++ "enum": [
++ "webhook",
++ "pull"
++ ],
++ "description": "action removed only: whether the removal came as a sources.item.removed delivery or through the cursor pull."
++ }
++ }
++ }
++ },
++ "news.source.failed": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.source.failed.v1.json",
++ "title": "NewsSourceFailedPayload",
++ "description": "news.source.failed v1 (OpenVibe.News server/domain/ingest.js failedEvent). Ingestion of news items failed and nothing was made in its place (no source item, cluster or story text); the same transaction records it in news_ingest_runs. Four call sites, told apart by origin and state: origin sources (ingest.js upstreamFailure: OpenVibe.Sources reported sources.fetch.failed for a news source; state, error_code and http_status are Sources' own, and run_id and consecutive_failures are added); origin pull with state failed (the cursor pull could not read Sources; once per outage, not once per retry); origin webhook with state failed (server/http/webhook.js: a sources.item.created|updated delivery whose item could not be read from Sources, on the first delivery attempt only); origin pull or webhook with state rejected and error_code item.no_headline (a Sources item without a title). Envelope: subject { type: source, id: <source_key> } when the source is known, otherwise { type: ingest, id: <origin> }, visibility internal, priority important, actor service:news.",
++ "type": "object",
++ "required": [
++ "origin",
++ "source_key",
++ "sources_item_id",
++ "state",
++ "error_code",
++ "http_status",
++ "detail"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "origin": {
++ "enum": [
++ "webhook",
++ "pull",
++ "sources"
++ ],
++ "description": "webhook: an Events delivery; pull: the cursor pull; sources: a failure Sources reported."
++ },
++ "source_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The Sources source, as given; null for a pull failure or when the delivery named none."
++ },
++ "sources_item_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The Sources item (itm_…) as given, for a rejected item or an unreadable delivery; null otherwise."
++ },
++ "state": {
++ "type": "string",
++ "minLength": 1,
++ "description": "rejected (an unusable item), failed (News could not read Sources), or for origin sources the fetch state Sources sent, copied as sent (robots_denied, http_error, rate_limited, parse_error…; failed when it sent none)."
++ },
++ "error_code": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "item.no_headline for a rejected item; the Sources client's error code (e.g. sources.http_503), else pull.error or sources.error, when Sources could not be read; Sources' own error_code for origin sources (null when it sent none)."
++ },
++ "http_status": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "description": "The HTTP status Sources answered (or reported for its fetch); null when there was none."
++ },
++ "detail": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 500,
++ "description": "The error message, cut to 500 characters; null for rejected items and origin sources."
++ },
++ "run_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "origin sources only: Sources' fetch run (frn_…), as sent."
++ },
++ "consecutive_failures": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "description": "origin sources only: the source's consecutive failed runs Sources reported, null when it reported none."
++ }
++ }
++ }
++ },
++ "news.cluster.updated": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.cluster.updated.v1.json",
++ "title": "NewsClusterUpdatedPayload",
++ "description": "news.cluster.updated v1 (OpenVibe.News server/domain/clusters.js emit, from assign, merge, split and reverse). A story cluster changed, in the transaction that changed it. action: created (a new item started it, adds item_id; or an editor's split made it, adds split_from and audit_id), item_added (an ingested item joined it, adds item_id and rule: duplicate_of when the item follows the item it duplicates, shared_terms with the score when it shares enough entities and terms), merged (an editor merged another cluster into it: absorbed, audit_id, moved), split (an editor moved items out of it into a new cluster: new_cluster, audit_id, moved), merge_reversed (a merge into it was undone: restored, audit_id), reopened (the cluster a reversed merge had absorbed is open again: audit_id) or split_reversed (a split off it was undone: dissolved, audit_id). audit_id is the news_cluster_audit row (cla_…) of the merge, split or reversal. label, status and item_count describe the cluster after the change. Deliberately left out: the key terms and entities, the time window and each member's cluster_reason (GET /api/v1/clusters/:id with news.cluster.read). Envelope: subject { type: cluster, id: clu_… }, visibility internal, priority low, actor service:news (also for an editor's merge, split or reversal; the editor is recorded on the audit row).",
++ "type": "object",
++ "required": [
++ "cluster_id",
++ "action",
++ "status",
++ "label",
++ "item_count"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "cluster_id": {
++ "type": "string",
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "action": {
++ "enum": [
++ "created",
++ "item_added",
++ "merged",
++ "split",
++ "merge_reversed",
++ "reopened",
++ "split_reversed"
++ ]
++ },
++ "status": {
++ "enum": [
++ "open",
++ "merged",
++ "dissolved"
++ ],
++ "description": "The cluster's status after the change (in practice open: merged and dissolved clusters are not announced by their own event)."
++ },
++ "label": {
++ "type": "string",
++ "minLength": 1,
++ "description": "Derived from the members' top entities (up to 3) or terms (up to 4), joined with ' · '; 'unlabelled' when there are none. Never written by a person or a model."
++ },
++ "item_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Every member item, duplicates and items removed upstream included."
++ },
++ "item_id": {
++ "type": "string",
++ "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "created by ingestion and item_added: the item that started or joined the cluster."
++ },
++ "rule": {
++ "enum": [
++ "duplicate_of",
++ "shared_terms"
++ ],
++ "description": "item_added only: why the item joined."
++ },
++ "score": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "item_added with rule shared_terms only: 2 × shared entities + shared terms."
++ },
++ "split_from": {
++ "type": "string",
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "created by a split only: the cluster the items came from."
++ },
++ "audit_id": {
++ "type": "string",
++ "pattern": "^cla_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Every editor action (and created by a split): the news_cluster_audit row."
++ },
++ "absorbed": {
++ "type": "string",
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "merged only: the cluster merged into this one (now status merged)."
++ },
++ "moved": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "merged and split only: how many items moved."
++ },
++ "new_cluster": {
++ "type": "string",
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "split only: the cluster the items moved into (announced by its own created event)."
++ },
++ "restored": {
++ "type": "string",
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "merge_reversed only: the absorbed cluster, open again (announced by its own reopened event)."
++ },
++ "dissolved": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "split_reversed only: the split-off cluster when it was left empty and dissolved; null when items that joined it after the split keep it open."
++ }
++ }
++ }
++ },
++ "news.story.created": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.created.v1.json",
++ "title": "NewsStoryCreatedPayload",
++ "description": "news.story.created v1 (OpenVibe.News server/domain/stories.js create). An editor opened a draft story (news.story.create), in the transaction that created it; when it was opened from a cluster, that cluster's active items (not duplicates, not removed upstream) were attached as its first sources. A first text sent with the request becomes revision 1 in the same transaction and is not announced here. Deliberately left out: the working headline, slug and any text (drafts are not public). Envelope: subject { type: story, id: sty_… }, visibility internal, priority low, actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": [
++ "cluster_id",
++ "topic",
++ "sources"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "cluster_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The cluster it was opened from, followed through merges; null for a story opened without one."
++ },
++ "topic": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "minLength": 1,
++ "maxLength": 60,
++ "description": "Slug of the story's topic (news_topics); null when none was given."
++ },
++ "sources": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "How many source items were attached at creation."
++ }
++ }
++ }
++ },
++ "news.story.flagged": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.flagged.v1.json",
++ "title": "NewsStoryFlaggedPayload",
++ "description": "news.story.flagged v1 (OpenVibe.News server/domain/stories.js flagEvent, from onUpstreamChange). A source item a story cites changed (source_updated) or was removed (source_removed) upstream in OpenVibe.Sources, inside the ingest transaction that applied it: the story has an open editorial flag and, when it has text, a pending system-prepared revision (same paragraphs, refreshed source table) for an editor to check and publish. The published revision does not change. One event per affected story; an open source_updated flag is reused (its pending revision moves on) and announced again, an open source_removed flag is not. Editor flags (correction, update) and retraction flags do not emit this event. Envelope: subject { type: story, id: sty_…, revision: <pending revision, else the published revision, else 0> }, visibility internal, priority important for source_removed and normal for source_updated (normal is not an events.event-envelope@1 priority, so that envelope is refused until News sends low or important), actor service:news.",
++ "type": "object",
++ "required": [
++ "flag_id",
++ "kind",
++ "status",
++ "source_item_id",
++ "pending_revision",
++ "published_revision"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "flag_id": {
++ "type": "string",
++ "pattern": "^flg_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The news_editorial_flags row."
++ },
++ "kind": {
++ "enum": [
++ "source_updated",
++ "source_removed"
++ ]
++ },
++ "status": {
++ "const": "open"
++ },
++ "source_item_id": {
++ "type": "string",
++ "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The News source item that changed or was removed."
++ },
++ "pending_revision": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The revision News prepared for an editor; null when the story has no text yet or nothing new was prepared."
++ },
++ "published_revision": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The revision readers see; null when the story was never published."
++ }
++ }
++ }
++ },
++ "news.story.published": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.published.v1.json",
++ "title": "NewsStoryPublishedPayload",
++ "description": "news.story.published v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). An editor published a story that was a draft or unpublished (news.story.publish, stories.js publish), in the transaction of the change. For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets that from news.index_document.*). A story resting on fewer independent sources than NEWS_MIN_INDEPENDENT_SOURCES (default 2) is still published, with indexability noindex (unsourced). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate lets the story be listed, otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": [
++ "canonical_url",
++ "publication_state",
++ "indexability"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048
++ },
++ "publication_state": {
++ "const": "published"
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
++ }
++ }
++ },
++ "topic": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 60,
++ "description": "Slug of the story's topic (news_topics); absent when it has none."
++ }
++ }
++ }
++ },
++ "news.story.updated": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.updated.v1.json",
++ "title": "NewsStoryUpdatedPayload",
++ "description": "news.story.updated v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). A published story now shows a different published revision (an editor published another revision, news.story.publish, stories.js publish) or its canonical URL changed, in the transaction of the change. Republishing the same revision with a correction note, and topic or noindex changes, emit no product event (only news.index_document.* when Search's copy changes). For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets that from news.index_document.*). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate lets the story be listed, otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": [
++ "canonical_url",
++ "publication_state",
++ "indexability"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048
++ },
++ "publication_state": {
++ "const": "published"
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
++ }
++ }
++ },
++ "topic": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 60,
++ "description": "Slug of the story's topic (news_topics); absent when it has none."
++ }
++ }
++ }
++ },
++ "news.story.unpublished": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.unpublished.v1.json",
++ "title": "NewsStoryUnpublishedPayload",
++ "description": "news.story.unpublished v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). An editor unpublished a published or retracted story (news.story.publish, stories.js unpublish), in the transaction of the change: it leaves the site, feeds and Search. For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets the tombstone from news.index_document.deleted). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": [
++ "canonical_url",
++ "publication_state",
++ "indexability"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "null: the event carries a tombstone, which has no URL."
++ },
++ "publication_state": {
++ "const": "unpublished"
++ },
++ "indexability": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
++ }
++ },
++ "description": "The gate's decision for the unpublished story (noindex, not_published), or null when its published revision cannot be read."
++ },
++ "topic": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 60,
++ "description": "Slug of the story's topic (news_topics); absent when it has none."
++ }
++ }
++ }
++ },
++ "news.story.retracted": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.story.retracted.v1.json",
++ "title": "NewsStoryRetractedPayload",
++ "description": "news.story.retracted v1 (OpenVibe.News server/domain/publication.js afterChange; the envelope is built there, not by index-hooks publicationEvent). An editor retracted a published story (news.story.retract, stories.js retract), in the transaction that stored the published retraction flag: the story stays at its URL with the retraction notice, is noindex, and leaves sitemaps and Search (news.index_document.deleted follows when Search had it). A retracted story stays retracted; unpublishing it later is news.story.unpublished. note is the public retraction notice and flag_id the news_editorial_flags row that holds it. The payload carries no topic. Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate still lets the story be listed (a retraction is noindex, not hidden), otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.",
++ "type": "object",
++ "required": [
++ "canonical_url",
++ "publication_state",
++ "indexability",
++ "note",
++ "flag_id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Where the story, with its retraction notice, stays."
++ },
++ "publication_state": {
++ "const": "retracted"
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "description": "Search-shaped gate decision: always noindex, with retracted among the reasons.",
++ "properties": {
++ "decision": {
++ "const": "noindex"
++ },
++ "reasons": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 20,
++ "uniqueItems": true,
++ "contains": {
++ "const": "retracted"
++ },
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
++ }
++ }
++ },
++ "note": {
++ "type": "string",
++ "minLength": 10,
++ "maxLength": 2000,
++ "description": "The public retraction notice, whitespace collapsed."
++ },
++ "flag_id": {
++ "type": "string",
++ "pattern": "^flg_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The retraction's news_editorial_flags row (kind retraction, status published)."
++ }
++ }
++ }
++ },
++ "news.index_document.upserted": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.index_document.upserted.v1.json",
++ "title": "NewsIndexDocumentUpsertedPayload",
++ "description": "news.index_document.upserted v1 (OpenVibe.News server/domain/publication.js syncIndex, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a published (not retracted), public, listable story, sent whenever what Search should hold changed (the revision grows with every change): a publication or new published revision, a topic or noindex change, a person's review of an AI revision, or a cited source changing upstream. The body is the published revision's paragraphs as plain text; facets { topic?, outlets, sources }; provenance names the cited Sources items (and the AI run of an AI draft); language en. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: story, id, revision: <document revision> }, visibility internal, priority important, actor service:news.",
++ "allOf": [
++ {
++ "$ref": "../../search/index-document.v1.json"
++ },
++ {
++ "type": "object",
++ "required": [
++ "owner",
++ "type",
++ "deleted"
++ ],
++ "properties": {
++ "owner": {
++ "const": "news"
++ },
++ "type": {
++ "const": "story"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^sty_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "deleted": {
++ "const": false
++ }
++ }
++ }
++ ]
++ }
++ },
++ "news.index_document.deleted": {
++ "version": "1.0.0",
++ "owner": "news",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/news.index_document.deleted.v1.json",
++ "title": "NewsIndexDocumentDeletedPayload",
++ "description": "news.index_document.deleted v1 (OpenVibe.News server/domain/publication.js syncIndex, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the story is no longer published, public and listable (unpublished, retracted, or hidden by the gate, e.g. an AI revision no person has reviewed), so Search drops it at this revision or older. Only sent for a story Search was sent before. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: story, id, revision }, visibility internal, priority important, actor service:news.",
++ "type": "object",
++ "required": [
++ "type",
++ "id",
++ "revision"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "story"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^sty_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 9007199254740991,
++ "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ }
++ }
++ }
++ },
++ "reviews.entity.merged": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.entity.merged.v1.json",
++ "title": "ReviewsEntityMergedPayload",
++ "description": "reviews.entity.merged v1 (OpenVibe.Reviews server/reviews/service.js merge → entityEvent). An editor merged one entity into another (POST /api/v1/entities/:ref/merge, capability reviews.entity.merge, a person only), in the transaction of the merge. Nothing is rewritten: the merged entity keeps its aliases and its signals keep their attribution; it becomes state merged with an active merged_into link (link_id) and the target's aggregate is recomputed over both, which is why reviews.entity.split restores attribution exactly. The same transaction sends Search the merged entity's tombstone and the target's new document (reviews.index_document.*); a published summary of the merged entity keeps its state and no reviews.summary.unpublished is sent. The signal ids, aliases and aggregate are left out (they are in the entity's history, GET /api/v1/entities/:ref/history). Envelope: subject { type: entity, id: <merged entity ent_…> }, visibility public, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": [
++ "entity_id",
++ "into_entity_id",
++ "link_id",
++ "signal_count",
++ "url",
++ "into_url",
++ "note"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merged entity (subject.id); now state merged."
++ },
++ "into_entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The active entity it was merged into."
++ },
++ "link_id": {
++ "type": "string",
++ "pattern": "^lnk_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merged_into link; a later reviews.entity.split ends this link."
++ },
++ "signal_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Active signals of the merged entity and of entities merged into it, now counted in the target's aggregate."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical URL of the merged entity's page (/e/<slug>)."
++ },
++ "into_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical URL of the target entity's page."
++ },
++ "note": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 500,
++ "description": "The editor's note (clipped to 500 characters); null when none was given."
++ }
++ }
++ }
++ },
++ "reviews.entity.split": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.entity.split.v1.json",
++ "title": "ReviewsEntitySplitPayload",
++ "description": "reviews.entity.split v1 (OpenVibe.Reviews server/reviews/service.js split → entityEvent). An editor undid a merge (POST /api/v1/entities/:ref/split, capability reviews.entity.split, a person only), in the transaction of the split: the merged_into link (link_id) is ended, the entity is active again with exactly the aliases and signals it had before the merge, and both aggregates are recomputed. A summary of the former target that cites signals which went back with the split is flagged and gets a pending revision in the same transaction; Search gets both entities' documents again (reviews.index_document.upserted). The signal ids are left out (they are in the entity's history). Envelope: subject { type: entity, id: <split entity ent_…> }, visibility public, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": [
++ "entity_id",
++ "from_entity_id",
++ "link_id",
++ "signal_count",
++ "url",
++ "from_url",
++ "note"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity split off again (subject.id); now state active."
++ },
++ "from_entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity it had been merged into."
++ },
++ "link_id": {
++ "type": "string",
++ "pattern": "^lnk_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merged_into link this split ended (the link_id of the reviews.entity.merged event)."
++ },
++ "signal_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Active signals back with the entity (its own and those of entities merged into it)."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical URL of the split entity's page (/e/<slug>)."
++ },
++ "from_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical URL of the former target's page; null only if that entity no longer exists."
++ },
++ "note": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 500,
++ "description": "The editor's note (clipped to 500 characters); null when none was given."
++ }
++ }
++ }
++ },
++ "reviews.signal.added": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.signal.added.v1.json",
++ "title": "ReviewsSignalAddedPayload",
++ "description": "reviews.signal.added v1 (OpenVibe.Reviews server/reviews/service.js createSignal → signalEvent). A typed signal was recorded from one revision of an OpenVibe.Sources review item resolved to an entity (an import through POST /api/v1/signals/import or the Sources sync, a sources.item.created|updated event, or an editor resolving a queued item), in the transaction that stored it. Values and provenance are exactly what the source stated and never change afterwards. When the item already had an active signal (a newer item revision, or an editor reattributing the item), `replaces` names it and the reviews.signal.removed for it is written just before this event. Value fields depend on type: recommended (recommendation), positive_count and total_count (recommendation_tally), rating_value, rating_best, rating_worst and rating_count (rating, rating_aggregate); a scale the source did not state is null, never defaulted. Review text, the signal's trust metadata and the resulting aggregate are left out. Envelope: subject { type: signal, id: <signal_id> }, visibility public, priority important, actor the caller (the importing service, e.g. service:sources-sync, or the editor, user usr_…) or service:reviews for the Sources event consumer and sync worker.",
++ "type": "object",
++ "required": [
++ "signal_id",
++ "entity_id",
++ "canonical_entity_id",
++ "type",
++ "source_key",
++ "source_item_id",
++ "item_revision",
++ "observed_at",
++ "source_published_at",
++ "canonical_url",
++ "license_note"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "signal_id": {
++ "type": "string",
++ "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the signal is attributed to (never rewritten by a merge)."
++ },
++ "canonical_entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity it currently counts under: entity_id, or the entity it is merged into."
++ },
++ "type": {
++ "enum": [
++ "recommendation",
++ "recommendation_tally",
++ "rating",
++ "rating_aggregate"
++ ]
++ },
++ "source_key": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
++ "description": "OpenVibe.Sources source key."
++ },
++ "source_item_id": {
++ "type": "string",
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "item_revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The Sources item revision the signal was read from."
++ },
++ "observed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When Sources retrieved that item revision (provenance.retrieved_at)."
++ },
++ "source_published_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The item's published_at as Sources gave it; null when it gave none."
++ },
++ "canonical_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The item's canonical URL as Sources gave it; null when it has none."
++ },
++ "license_note": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Licence note recorded for the item in Sources; null when none."
++ },
++ "recommended": {
++ "type": "boolean",
++ "description": "recommendation only: whether the reviewer recommends it."
++ },
++ "positive_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "recommendation_tally only: positive reviews the source counts."
++ },
++ "total_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "recommendation_tally only: all reviews the source counts (≥ positive_count)."
++ },
++ "rating_value": {
++ "type": "number",
++ "description": "rating and rating_aggregate only: the value as stated."
++ },
++ "rating_best": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "description": "rating and rating_aggregate only: top of the stated scale; null when the source states none."
++ },
++ "rating_worst": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "description": "rating and rating_aggregate only: bottom of the stated scale; null when the source states none."
++ },
++ "rating_count": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "rating and rating_aggregate only: ratings behind the value (1 for a single review)."
++ },
++ "replaces": {
++ "type": "string",
++ "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Present when this signal replaced the item's previous active signal (see reviews.signal.removed)."
++ }
++ },
++ "if": {
++ "properties": {
++ "type": {
++ "const": "recommendation"
++ }
++ },
++ "required": [
++ "type"
++ ]
++ },
++ "then": {
++ "required": [
++ "recommended"
++ ]
++ },
++ "else": {
++ "if": {
++ "properties": {
++ "type": {
++ "const": "recommendation_tally"
++ }
++ },
++ "required": [
++ "type"
++ ]
++ },
++ "then": {
++ "required": [
++ "positive_count",
++ "total_count"
++ ]
++ },
++ "else": {
++ "required": [
++ "rating_value",
++ "rating_best",
++ "rating_worst",
++ "rating_count"
++ ]
++ }
++ }
++ }
++ },
++ "reviews.signal.removed": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.signal.removed.v1.json",
++ "title": "ReviewsSignalRemovedPayload",
++ "description": "reviews.signal.removed v1 (OpenVibe.Reviews server/reviews/service.js withdrawSignal → signalEvent). A signal stopped being active, in the transaction of the change. status superseded: a newer revision of the same item replaced it for the same entity (replaced_by names the new signal, whose reviews.signal.added follows). status withdrawn: its source removed the item (sources.item.removed or a removed item on import), the item no longer states a signal, or an editor reattributed or unresolved the item. The payload repeats the signal as recorded (values and provenance never change) plus status and reason; summaries citing it are flagged and get a pending revision, and the entity's aggregate and Search document are refreshed, in the same transaction. Envelope: subject { type: signal, id: <signal_id> }, visibility public, priority important, actor the caller (the importing service, e.g. service:sources-sync, or the editor, user usr_…) or service:reviews for the Sources event consumer and sync worker.",
++ "type": "object",
++ "required": [
++ "signal_id",
++ "entity_id",
++ "canonical_entity_id",
++ "type",
++ "source_key",
++ "source_item_id",
++ "item_revision",
++ "observed_at",
++ "source_published_at",
++ "canonical_url",
++ "license_note",
++ "status",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "signal_id": {
++ "type": "string",
++ "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the signal is attributed to (never rewritten by a merge)."
++ },
++ "canonical_entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity it counted under: entity_id, or the entity it is merged into."
++ },
++ "type": {
++ "enum": [
++ "recommendation",
++ "recommendation_tally",
++ "rating",
++ "rating_aggregate"
++ ]
++ },
++ "source_key": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
++ "description": "OpenVibe.Sources source key."
++ },
++ "source_item_id": {
++ "type": "string",
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "item_revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The Sources item revision the signal was read from."
++ },
++ "observed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When Sources retrieved that item revision (provenance.retrieved_at)."
++ },
++ "source_published_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The item's published_at as Sources gave it; null when it gave none."
++ },
++ "canonical_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The item's canonical URL as Sources gave it; null when it has none."
++ },
++ "license_note": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Licence note recorded for the item in Sources; null when none."
++ },
++ "recommended": {
++ "type": "boolean",
++ "description": "recommendation only: whether the reviewer recommends it."
++ },
++ "positive_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "recommendation_tally only: positive reviews the source counts."
++ },
++ "total_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "recommendation_tally only: all reviews the source counts (≥ positive_count)."
++ },
++ "rating_value": {
++ "type": "number",
++ "description": "rating and rating_aggregate only: the value as stated."
++ },
++ "rating_best": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "description": "rating and rating_aggregate only: top of the stated scale; null when the source states none."
++ },
++ "rating_worst": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "description": "rating and rating_aggregate only: bottom of the stated scale; null when the source states none."
++ },
++ "rating_count": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "rating and rating_aggregate only: ratings behind the value (1 for a single review)."
++ },
++ "status": {
++ "enum": [
++ "superseded",
++ "withdrawn"
++ ],
++ "description": "superseded: replaced by a newer revision of the same item; withdrawn: every other removal."
++ },
++ "reason": {
++ "type": "string",
++ "maxLength": 500,
++ "description": "Why, in plain words (e.g. \"removed by its source: <reason>\", \"source item revised (r1 → r2)\")."
++ },
++ "replaced_by": {
++ "type": "string",
++ "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "status superseded only: the signal that replaced it."
++ }
++ },
++ "if": {
++ "properties": {
++ "type": {
++ "const": "recommendation"
++ }
++ },
++ "required": [
++ "type"
++ ]
++ },
++ "then": {
++ "required": [
++ "recommended"
++ ]
++ },
++ "else": {
++ "if": {
++ "properties": {
++ "type": {
++ "const": "recommendation_tally"
++ }
++ },
++ "required": [
++ "type"
++ ]
++ },
++ "then": {
++ "required": [
++ "positive_count",
++ "total_count"
++ ]
++ },
++ "else": {
++ "required": [
++ "rating_value",
++ "rating_best",
++ "rating_worst",
++ "rating_count"
++ ]
++ }
++ },
++ "dependentSchemas": {
++ "status": {
++ "if": {
++ "properties": {
++ "status": {
++ "const": "superseded"
++ }
++ }
++ },
++ "then": {
++ "required": [
++ "replaced_by"
++ ]
++ },
++ "else": {
++ "not": {
++ "required": [
++ "replaced_by"
++ ]
++ }
++ }
++ }
++ }
++ }
++ },
++ "reviews.summary.published": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.summary.published.v1.json",
++ "title": "ReviewsSummaryPublishedPayload",
++ "description": "reviews.summary.published v1 (OpenVibe.Reviews server/reviews/service.js publishSummary, via openvibe-publishing/index-hooks publicationEvent). An entity's summary that was not published became published (an editor publishing a revision, or approving one with publish on, which is how an AI-drafted revision gets published), in the transaction of the change. For subscribers other than Search: the entity page's canonical URL, publication state and Search-shaped indexability, the entity id and slug, and the revision's authorship; never the summary text or its citations (Search gets those in reviews.index_document.upserted) and never a rating (a summary cannot carry one). correction appears only when the revision published is a correction revision (normally that is reviews.summary.updated). Envelope: subject { type: summary, id: sum_…, revision: <published summary revision> }, visibility public when the entity page is listable by the gate, otherwise internal, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": [
++ "canonical_url",
++ "publication_state",
++ "indexability",
++ "entity_id",
++ "entity_slug",
++ "authorship"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The entity page (/e/<slug>) that shows the summary."
++ },
++ "publication_state": {
++ "const": "published"
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
++ }
++ },
++ "description": "The gate's decision for the entity page after the change, in Search's vocabulary."
++ },
++ "entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the summary belongs to (one summary per entity)."
++ },
++ "entity_slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 90
++ },
++ "authorship": {
++ "enum": [
++ "human",
++ "ai_assisted",
++ "ai_generated",
++ null
++ ],
++ "description": "Authorship of the published revision: human (editor-written), ai_generated (an OpenVibe.AI draft a person approved), ai_assisted (an AI draft a person corrected); null only for a revision without an authorship record."
++ },
++ "correction": {
++ "type": "object",
++ "required": [
++ "note",
++ "corrects"
++ ],
++ "additionalProperties": false,
++ "description": "Present when the published revision is a correction: the public note and the revision it corrects.",
++ "properties": {
++ "note": {
++ "type": "string",
++ "minLength": 10,
++ "maxLength": 2000,
++ "description": "What was corrected, as readers see it."
++ },
++ "corrects": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The summary revision that was published when the correction was made."
++ }
++ }
++ }
++ }
++ }
++ },
++ "reviews.summary.updated": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.summary.updated.v1.json",
++ "title": "ReviewsSummaryUpdatedPayload",
++ "description": "reviews.summary.updated v1 (OpenVibe.Reviews server/reviews/service.js publishSummary, via openvibe-publishing/index-hooks publicationEvent). A published summary got a different published revision, in the transaction of the change: an editor published another revision, approved the pending revision prepared after a source change, or corrected the summary (POST …/summary/revisions with correction_note, or accepting a correction request), which publishes at once and carries correction. For subscribers other than Search: the entity page's canonical URL, publication state and Search-shaped indexability, the entity id and slug, and the revision's authorship; never the summary text or its citations (Search gets those in reviews.index_document.upserted) and never a rating. Envelope: subject { type: summary, id: sum_…, revision: <published summary revision> }, visibility public when the entity page is listable by the gate, otherwise internal, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": [
++ "canonical_url",
++ "publication_state",
++ "indexability",
++ "entity_id",
++ "entity_slug",
++ "authorship"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The entity page (/e/<slug>) that shows the summary."
++ },
++ "publication_state": {
++ "const": "published"
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
++ }
++ },
++ "description": "The gate's decision for the entity page after the change, in Search's vocabulary."
++ },
++ "entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the summary belongs to (one summary per entity)."
++ },
++ "entity_slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 90
++ },
++ "authorship": {
++ "enum": [
++ "human",
++ "ai_assisted",
++ "ai_generated",
++ null
++ ],
++ "description": "Authorship of the published revision: human (editor-written), ai_generated (an OpenVibe.AI draft a person approved), ai_assisted (an AI draft a person corrected); null only for a revision without an authorship record."
++ },
++ "correction": {
++ "type": "object",
++ "required": [
++ "note",
++ "corrects"
++ ],
++ "additionalProperties": false,
++ "description": "Present when the published revision is a correction: the public note and the revision it corrects.",
++ "properties": {
++ "note": {
++ "type": "string",
++ "minLength": 10,
++ "maxLength": 2000,
++ "description": "What was corrected, as readers see it."
++ },
++ "corrects": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The summary revision that was published when the correction was made."
++ }
++ }
++ }
++ }
++ }
++ },
++ "reviews.summary.unpublished": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.summary.unpublished.v1.json",
++ "title": "ReviewsSummaryUnpublishedPayload",
++ "description": "reviews.summary.unpublished v1 (OpenVibe.Reviews server/reviews/service.js unpublishSummary, via openvibe-publishing/index-hooks publicationEvent). An editor took an entity's published summary down (POST /api/v1/entities/:ref/summary/unpublish), in the transaction of the change. The entity page stays up without the summary; its new Search document goes out as reviews.index_document.upserted. Only this action sends it: merging or deleting an entity leaves its summary's state alone and Search gets the entity's tombstone instead. For subscribers other than Search: publication state, the entity page's indexability after the change, and the entity id and slug; canonical_url is always null from Reviews (the helper is given a tombstone), and authorship and correction are not sent. Envelope: subject { type: summary, id: sum_…, revision: <the revision that was published> }, visibility internal, priority important, actor the editor (user usr_…).",
++ "type": "object",
++ "required": [
++ "canonical_url",
++ "publication_state",
++ "indexability",
++ "entity_id",
++ "entity_slug"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "canonical_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "null: the event carries a tombstone, which has no URL."
++ },
++ "publication_state": {
++ "const": "unpublished"
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
++ }
++ },
++ "description": "The gate's decision for the entity page after the summary came down (Reviews always passes one, so it is never null here)."
++ },
++ "entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the summary belongs to."
++ },
++ "entity_slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 90
++ }
++ }
++ }
++ },
++ "reviews.index_document.upserted": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.index_document.upserted.v1.json",
++ "title": "ReviewsIndexDocumentUpsertedPayload",
++ "description": "reviews.index_document.upserted v1 (OpenVibe.Reviews server/reviews/service.js syncEntity, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of an active entity page: the entity name as title, the published summary (or the entity description) as summary, the summary text and the aggregate's computation lines as body, facets kind / has_aggregate / sources, the published summary's authorship, provenance of up to 40 cited Sources items (plus the AI run of an AI-drafted summary), and the gate's indexability (noindex thin_content / unsourced until signals and a summary back the page). Sent whenever what Search should hold changed (the revision grows with every change), in the transaction of the change; the reconciliation at every start (reconcileIndex) re-sends only documents that differ. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: entity, id, revision: <document revision> }, visibility internal, priority important, actor service:reviews.",
++ "allOf": [
++ {
++ "$ref": "../../search/index-document.v1.json"
++ },
++ {
++ "type": "object",
++ "required": [
++ "owner",
++ "type",
++ "deleted"
++ ],
++ "properties": {
++ "owner": {
++ "const": "reviews"
++ },
++ "type": {
++ "const": "entity"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "deleted": {
++ "const": false
++ }
++ }
++ }
++ ]
++ }
++ },
++ "reviews.index_document.deleted": {
++ "version": "1.0.0",
++ "owner": "reviews",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/reviews.index_document.deleted.v1.json",
++ "title": "ReviewsIndexDocumentDeletedPayload",
++ "description": "reviews.index_document.deleted v1 (OpenVibe.Reviews server/reviews/service.js syncEntity, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the entity was merged into another or deleted (or the gate stopped listing its page), so Search drops it at this revision or older. Only sent for an entity Search was sent before (every entity gets a document when it is created); a split sends the entity's document again. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: entity, id, revision }, visibility internal, priority important, actor service:reviews.",
++ "type": "object",
++ "required": [
++ "type",
++ "id",
++ "revision"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "entity"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 9007199254740991,
++ "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ }
++ }
++ }
++ },
++ "coupons.coupon.created": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.created.v1.json",
++ "title": "CouponsCouponCreatedPayload",
++ "description": "coupons.coupon.created v1 (OpenVibe.Coupons server/domain/coupons.js submit and importFromSource, payload from lifecyclePayload). A new code was recorded, in the transaction that inserted it: submitted by a member, by staff or by an AI extraction run (POST /api/v1/coupons/submit or the site form), or imported from an OpenVibe.Sources item (origin source). Submitting a code the merchant already has adds evidence and emits no created event. A new code always starts with status unknown and confidence null: a submission can never carry a status or a confidence. AI-extracted codes, imported codes (unless COUPONS_SOURCES_AUTO_PUBLISH is true and the merchant is active) and codes for a pending merchant start with review_state pending. Left out on purpose: the code text, title, description and restrictions (Search gets them from coupons.index_document.*) and who submitted it (submitters stay inside Coupons). Envelope: subject { type: coupon, id: cpn_… }, visibility public when the new code is publicly listed (published, merchant active, not expired), otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": [
++ "merchant_id",
++ "status",
++ "confidence",
++ "review_state",
++ "expires_at",
++ "canonical_url",
++ "origin"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": {
++ "type": "string",
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
++ },
++ "status": {
++ "const": "unknown",
++ "description": "Every new code starts unknown; only people's reports can move it to reported_working or reported_failed."
++ },
++ "confidence": {
++ "type": "null",
++ "description": "Always null at creation: no counted report yet."
++ },
++ "review_state": {
++ "enum": [
++ "pending",
++ "published"
++ ],
++ "description": "pending = waits for staff review (AI-extracted, imported without auto-publish, or merchant pending); published = eligible for active results."
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "Known expiry (UTC; a date-only expiry is 23:59:59.999Z of that day). null = expiry unknown, never defaulted."
++ },
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>)."
++ },
++ "origin": {
++ "enum": [
++ "member",
++ "staff",
++ "source",
++ "ai"
++ ],
++ "description": "How the code arrived: a member's or a staff member's submission, an OpenVibe.Sources import, or an AI extraction run (X-OV-Origin: ai)."
++ }
++ }
++ }
++ },
++ "coupons.coupon.updated": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.updated.v1.json",
++ "title": "CouponsCouponUpdatedPayload",
++ "description": "coupons.coupon.updated v1 (OpenVibe.Coupons server/domain/coupons.js recompute, approve and setExpiry, payload from lifecyclePayload). Emitted in the transaction of the change when (a) a code's status moved to unknown, reported_working or reported_failed: people's reports through the confidence formula (reason report), evidence added or withdrawn (evidence), old reports decaying (decay), or staff or a service re-enabling a disabled or expired code (staff, service:<client>[:<note>]); (b) staff published a code that waited for review (reason approved; status unchanged, so previous_status equals status and may be any status); or (c) the code's expiry was set or cleared (reason expiry_changed, no previous_status; setExpiry has no HTTP route yet). A move to expired or disabled is coupons.coupon.expired or coupons.coupon.disabled instead, and a confidence change without a status change emits only coupons.confidence.changed. Staff notes are reduced to reason staff; a service's note is copied into the reason. Never names a submitter, reporter or staff member. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code is publicly listed after the change (recompute: before or after), otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": [
++ "merchant_id",
++ "status",
++ "confidence",
++ "review_state",
++ "expires_at",
++ "canonical_url",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": {
++ "type": "string",
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
++ },
++ "status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired",
++ "disabled"
++ ],
++ "description": "Status after the change. expired and disabled occur only with reason approved or expiry_changed (neither moves the status)."
++ },
++ "confidence": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Report confidence after the change, rounded to 2 decimals; null = no counted report in the last 30 days."
++ },
++ "review_state": {
++ "enum": [
++ "pending",
++ "published"
++ ]
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "Known expiry (UTC); null = expiry unknown."
++ },
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>)."
++ },
++ "previous_status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired",
++ "disabled"
++ ],
++ "description": "Status before the change. Absent with reason expiry_changed."
++ },
++ "reason": {
++ "description": "Why it changed: report, evidence, decay, staff, approved, expiry_changed, or service:<client>[:<note>] when a service holding coupons.status.update re-enabled it.",
++ "anyOf": [
++ {
++ "enum": [
++ "report",
++ "evidence",
++ "decay",
++ "staff",
++ "approved",
++ "expiry_changed"
++ ]
++ },
++ {
++ "type": "string",
++ "pattern": "^service:[\\s\\S]+$",
++ "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one."
++ }
++ ]
++ }
++ }
++ }
++ },
++ "coupons.coupon.expired": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.expired.v1.json",
++ "title": "CouponsCouponExpiredPayload",
++ "description": "coupons.coupon.expired v1 (OpenVibe.Coupons server/domain/coupons.js recompute, payload from lifecyclePayload). A code's status became expired, in the transaction that recorded it: the sweep (server/worker.js, every COUPONS_SWEEP_INTERVAL_MS) saw its known expiry pass (reason expiry), a recompute for other work found it past its expiry first (evidence, report or decay), or staff or a service marked it expired (staff, service:<client>[:<note>]). Active lists do not wait for this event: they filter on expires_at at query time. When the code was in Search, a coupons.index_document.deleted tombstone and the merchant's updated document follow in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code was publicly listed before the change, otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": [
++ "merchant_id",
++ "status",
++ "confidence",
++ "review_state",
++ "expires_at",
++ "canonical_url",
++ "previous_status",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": {
++ "type": "string",
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
++ },
++ "status": {
++ "const": "expired"
++ },
++ "confidence": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Report confidence at the time (rounded to 2 decimals); null = no counted report in the last 30 days."
++ },
++ "review_state": {
++ "enum": [
++ "pending",
++ "published"
++ ]
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "The known expiry that passed; null when staff or a service marked a code of unknown expiry expired."
++ },
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>); it stays up, noindex."
++ },
++ "previous_status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed"
++ ],
++ "description": "Status before; a disabled code has to be re-enabled before it can be marked expired."
++ },
++ "reason": {
++ "description": "expiry, evidence, report, decay, staff, or service:<client>[:<note>].",
++ "anyOf": [
++ {
++ "enum": [
++ "expiry",
++ "evidence",
++ "report",
++ "decay",
++ "staff"
++ ]
++ },
++ {
++ "type": "string",
++ "pattern": "^service:[\\s\\S]+$",
++ "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one."
++ }
++ ]
++ }
++ }
++ }
++ },
++ "coupons.coupon.disabled": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.coupon.disabled.v1.json",
++ "title": "CouponsCouponDisabledPayload",
++ "description": "coupons.coupon.disabled v1 (OpenVibe.Coupons server/domain/coupons.js recompute via withdrawSourceItem and setStatus, payload from lifecyclePayload). A code was taken down, in the transaction that recorded it: the last OpenVibe.Sources item that evidenced it was removed at the source (reason source_removed), or staff or a service holding coupons.status.update disabled it (staff, service:<client>[:<note>]). Its page answers 410, it cannot be resubmitted and nothing is deleted. Disabling a merchant does not emit this per code (its codes only get Search tombstones). When the code was in Search, a coupons.index_document.deleted tombstone follows in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code was publicly listed before the change, otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": [
++ "merchant_id",
++ "status",
++ "confidence",
++ "review_state",
++ "expires_at",
++ "canonical_url",
++ "previous_status",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": {
++ "type": "string",
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
++ },
++ "status": {
++ "const": "disabled"
++ },
++ "confidence": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Report confidence at the time (rounded to 2 decimals); null = no counted report in the last 30 days."
++ },
++ "review_state": {
++ "enum": [
++ "pending",
++ "published"
++ ]
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "Known expiry (UTC); null = expiry unknown."
++ },
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>); it now answers 410."
++ },
++ "previous_status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired"
++ ],
++ "description": "Status before it was disabled."
++ },
++ "reason": {
++ "description": "source_removed, staff, or service:<client>[:<note>].",
++ "anyOf": [
++ {
++ "enum": [
++ "source_removed",
++ "staff"
++ ]
++ },
++ {
++ "type": "string",
++ "pattern": "^service:[\\s\\S]+$",
++ "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one."
++ }
++ ]
++ }
++ }
++ }
++ },
++ "coupons.report.created": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.report.created.v1.json",
++ "title": "CouponsReportCreatedPayload",
++ "description": "coupons.report.created v1 (OpenVibe.Coupons server/domain/reports.js report). A signed-in person (site form, a browser-helper install token with scope coupons.report, or a service acting for a person with X-OV-Subject) reported that an active code worked or failed, in the transaction that stored the report. One event per new (reporter, code, UTC day) row: a repeat the same day is deduplicated and a same-day switch to the other outcome corrects that row, and neither emits (both still recompute). The payload never identifies the reporter: not the subject, the HMAC reporter key or the install. reason is present only on failed reports that gave one. A coupons.confidence.changed and, when the status moved, a lifecycle event follow in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": [
++ "merchant_id",
++ "outcome",
++ "channel",
++ "day"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": {
++ "type": "string",
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant of the reported code."
++ },
++ "outcome": {
++ "enum": [
++ "worked",
++ "failed"
++ ]
++ },
++ "reason": {
++ "enum": [
++ "invalid",
++ "expired",
++ "min_spend_not_met",
++ "not_eligible",
++ "other"
++ ],
++ "description": "Why a failed code failed, when the reporter said. Never on a worked report."
++ },
++ "channel": {
++ "enum": [
++ "site",
++ "extension",
++ "api"
++ ],
++ "description": "How the report arrived: the site form, a browser-helper install token, or the API (a Network token or a service acting for a person)."
++ },
++ "day": {
++ "type": "string",
++ "format": "date",
++ "description": "The UTC day of the report (YYYY-MM-DD), the deduplication bucket."
++ }
++ },
++ "if": {
++ "properties": {
++ "outcome": {
++ "const": "worked"
++ }
++ },
++ "required": [
++ "outcome"
++ ]
++ },
++ "then": {
++ "not": {
++ "required": [
++ "reason"
++ ]
++ }
++ }
++ }
++ },
++ "coupons.confidence.changed": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.confidence.changed.v1.json",
++ "title": "CouponsConfidenceChangedPayload",
++ "description": "coupons.confidence.changed v1 (OpenVibe.Coupons server/domain/coupons.js recompute). A code's confidence moved, in the transaction of the recompute: a new or corrected report, evidence added or withdrawn (evidence on one of the merchant's own domains raises the prior), or the sweep letting old reports decay (to null once no report is younger than 30 days). confidence = (a0 + W) / (a0 + 1 + W + F), rounded to 2 decimals, over recency-weighted worked (W) and failed (F) reports (half-life 7 days, each reporter's latest report only; a0 = 1, or 1.5 with merchant evidence); null = no counted report in the 30-day window. from and to always differ. status is the code's status after the change; a status change also emits coupons.coupon.updated, .expired or .disabled. Never names a reporter. Envelope: subject { type: coupon, id: cpn_… }, visibility internal, priority important (not set: the Events default), actor service:coupons. No known consumers.",
++ "type": "object",
++ "required": [
++ "merchant_id",
++ "from",
++ "to",
++ "status"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": {
++ "type": "string",
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
++ },
++ "from": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Confidence before; null = no counted report."
++ },
++ "to": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Confidence after; null = no counted report left in the window."
++ },
++ "status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired",
++ "disabled"
++ ],
++ "description": "The code's status after the change (reported_working at confidence >= 0.6, reported_failed at <= 0.4, given enough recent reports)."
++ }
++ }
++ }
++ },
++ "coupons.index_document.upserted": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.index_document.upserted.v1.json",
++ "title": "CouponsIndexDocumentUpsertedPayload",
++ "description": "coupons.index_document.upserted v1 (OpenVibe.Coupons server/domain/publication.js sendDocument, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a code in active results (type coupon: published, merchant active, not expired or disabled; title '<code> — <title> (<merchant>)', status and expiry in the summary, description and restrictions in the body, facets merchant, status and expiry_known, Sources items in provenance) or of an active merchant (type merchant: facets active_codes; indexability noindex thin_content while it has no active code). Sent in the transaction of every change to what Search should hold (coupons.js syncIndex and syncMerchant); the revision (coupons_index_revisions) grows only when the document changed, and an unchanged document is not sent again. Visibility is always public. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: coupon | merchant, id, revision: <document revision> }, visibility internal, priority important, actor service:coupons.",
++ "allOf": [
++ {
++ "$ref": "../../search/index-document.v1.json"
++ },
++ {
++ "type": "object",
++ "required": [
++ "owner",
++ "type",
++ "deleted"
++ ],
++ "properties": {
++ "owner": {
++ "const": "coupons"
++ },
++ "type": {
++ "enum": [
++ "coupon",
++ "merchant"
++ ]
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "deleted": {
++ "const": false
++ }
++ }
++ }
++ ]
++ }
++ },
++ "coupons.index_document.deleted": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/coupons.index_document.deleted.v1.json",
++ "title": "CouponsIndexDocumentDeletedPayload",
++ "description": "coupons.index_document.deleted v1 (OpenVibe.Coupons server/domain/publication.js sendDocument, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the code left active results (expired, disabled, its merchant disabled) or the merchant is no longer active, so Search drops it at this revision or older. Only sent for a resource Search was sent before: a code or merchant that was never indexed gets no tombstone. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: coupon | merchant, id, revision }, visibility internal, priority important, actor service:coupons.",
++ "type": "object",
++ "required": [
++ "type",
++ "id",
++ "revision"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "enum": [
++ "coupon",
++ "merchant"
++ ]
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 9007199254740991,
++ "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ }
++ }
++ }
++ },
++ "deals.offer.created": {
++ "version": "1.0.0",
++ "owner": "deals",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.offer.created.v1.json",
++ "title": "DealsOfferCreatedPayload",
++ "description": "deals.offer.created v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js submit and createImported). A deal was posted: a person submitted a link (submit), or the Sources importer created an offer from a deals-category Sources item (createImported, the only caller that adds imported_from). Written in the transaction that inserted the offer and its first observation, next to deals.index_document.upserted. The payload describes the canonical offer as it now stands: canonical page URL, status, store domain, product, the latest observation (price text exactly as stated, never inferred; null where the source or person stated nothing) with its observed_at, the freshness verdict and the Search-shaped indexability decision. Deliberately left out: the description, the submitter's identity, vote counts and hotness. Envelope: subject { type: offer, id: <offer_id> }, visibility public when the offer is active and the SEO gate lets it be listed, otherwise internal, priority important (default), actor user:<submitter usr_…> for a submission, service:deals for an import. No consumer is built yet.",
++ "type": "object",
++ "required": [
++ "offer_id",
++ "slug",
++ "url",
++ "title",
++ "status",
++ "store",
++ "product_id",
++ "latest_observation",
++ "freshness",
++ "indexability"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The canonical (root) offer id."
++ },
++ "slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 80,
++ "description": "Page slug: the slugified title plus the last six characters of the id."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical offer page URL on Deals (/d/<slug>)."
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "status": {
++ "const": "active",
++ "description": "A new offer is always active."
++ },
++ "store": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "minLength": 1,
++ "description": "Store domain: the offer link's host without a leading www.; null when none could be derived."
++ },
++ "product_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^dpr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Deals product the offer is attached to, if any."
++ },
++ "latest_observation": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "price",
++ "currency",
++ "availability",
++ "observed_at"
++ ],
++ "additionalProperties": false,
++ "description": "The newest observation across the offer's merge group; null only when the group has none.",
++ "properties": {
++ "price": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 40,
++ "description": "Decimal text as stated; null when no price was stated (never 0)."
++ },
++ "currency": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[A-Z]{3}$",
++ "description": "ISO 4217; never assumed."
++ },
++ "availability": {
++ "enum": [
++ "in_stock",
++ "out_of_stock",
++ "preorder",
++ "discontinued",
++ "limited",
++ "sold_out",
++ "online_only",
++ "in_store_only",
++ null
++ ]
++ },
++ "observed_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ },
++ "freshness": {
++ "enum": [
++ "fresh",
++ "stale",
++ "unobserved"
++ ],
++ "description": "fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one."
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "description": "The SEO gate decision in Search's shape (index-hooks searchIndexability).",
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ },
++ "description": "Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review)."
++ }
++ }
++ },
++ "imported_from": {
++ "type": "object",
++ "required": [
++ "service",
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "description": "Only on imports: the OpenVibe.Sources item the offer was created from.",
++ "properties": {
++ "service": {
++ "const": "sources"
++ },
++ "type": {
++ "const": "item"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ }
++ }
++ },
++ "deals.offer.updated": {
++ "version": "1.0.0",
++ "owner": "deals",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.offer.updated.v1.json",
++ "title": "DealsOfferUpdatedPayload",
++ "description": "deals.offer.updated v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js and server/domain/importer.js). Something about a canonical offer changed; `changed` says what, and a few call sites add one reference: an edit (offers.js update: the edited columns, among title, description, category, expires_at, product_id, ai_summary, review_state, text_origin); a price/availability report by a person (observe) or a newer or re-fetched Sources item (importer importItem) ([observation] + observation_id); a second Sources item with the same link attached to an existing offer ([sources] + observation_id); a Sources item removed at the source ([sources] + source_removed; an imported offer left with no live source is disabled in the same transaction, so status is then disabled); moderators disabling it or re-activating a disabled or expired offer ([status] + moderation); a person reviewing imported or AI text ([review_state]); a merge ([merged] + merged_offer_id, sent for the merge target); an unmerge (two events: [unmerged] + unmerged_offer_id for the former target, and [unmerged] + unmerged_from for the restored offer). Written in the transaction that made the change, next to the offer's (and its product's) deals.index_document.* event. The rest of the payload is the offer as it now stands, as in deals.offer.created. Deliberately left out: old values, the description text, identities and vote counts (deals.vote.changed carries those counts). Envelope: subject { type: offer, id: <offer_id> }, visibility public when the offer is active and the SEO gate lets it be listed, otherwise internal, priority important (default), actor user:<the person who acted, usr_…>, service:<calling service> when a service acts without a person, service:deals for the importer. No consumer is built yet.",
++ "type": "object",
++ "required": [
++ "offer_id",
++ "slug",
++ "url",
++ "title",
++ "status",
++ "store",
++ "product_id",
++ "latest_observation",
++ "freshness",
++ "indexability",
++ "changed"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The canonical (root) offer id."
++ },
++ "slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 80,
++ "description": "Page slug: the slugified title plus the last six characters of the id."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical offer page URL on Deals (/d/<slug>)."
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "status": {
++ "enum": [
++ "active",
++ "expired",
++ "disabled"
++ ]
++ },
++ "store": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "minLength": 1,
++ "description": "Store domain: the offer link's host without a leading www.; null when none could be derived."
++ },
++ "product_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^dpr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Deals product the offer is attached to, if any."
++ },
++ "latest_observation": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "price",
++ "currency",
++ "availability",
++ "observed_at"
++ ],
++ "additionalProperties": false,
++ "description": "The newest observation across the offer's merge group; null only when the group has none.",
++ "properties": {
++ "price": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 40,
++ "description": "Decimal text as stated; null when no price was stated (never 0)."
++ },
++ "currency": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[A-Z]{3}$",
++ "description": "ISO 4217; never assumed."
++ },
++ "availability": {
++ "enum": [
++ "in_stock",
++ "out_of_stock",
++ "preorder",
++ "discontinued",
++ "limited",
++ "sold_out",
++ "online_only",
++ "in_store_only",
++ null
++ ]
++ },
++ "observed_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ },
++ "freshness": {
++ "enum": [
++ "fresh",
++ "stale",
++ "unobserved"
++ ],
++ "description": "fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one."
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "description": "The SEO gate decision in Search's shape (index-hooks searchIndexability).",
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ },
++ "description": "Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review)."
++ }
++ }
++ },
++ "changed": {
++ "type": "array",
++ "minItems": 1,
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "title",
++ "description",
++ "category",
++ "expires_at",
++ "product_id",
++ "ai_summary",
++ "review_state",
++ "text_origin",
++ "observation",
++ "sources",
++ "status",
++ "merged",
++ "unmerged"
++ ]
++ },
++ "description": "What changed: edited columns (update), or one of observation, sources, status, review_state, merged, unmerged."
++ },
++ "observation_id": {
++ "type": "string",
++ "pattern": "^dpo_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [observation] or [sources] from a report or an import: the observation just recorded."
++ },
++ "source_removed": {
++ "type": "string",
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [sources] after a removal: the OpenVibe.Sources item that was removed (takedown, licence)."
++ },
++ "moderation": {
++ "enum": [
++ "disabled",
++ "enabled"
++ ],
++ "description": "changed [status] by a moderator: disabled, or enabled (a disabled or expired offer made active again)."
++ },
++ "merged_offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [merged]: the duplicate now merged into this offer."
++ },
++ "unmerged_offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [unmerged], on the former target: the offer split back out."
++ },
++ "unmerged_from": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "changed [unmerged], on the restored offer: the offer it had been merged into."
++ }
++ }
++ }
++ },
++ "deals.offer.expired": {
++ "version": "1.0.0",
++ "owner": "deals",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.offer.expired.v1.json",
++ "title": "DealsOfferExpiredPayload",
++ "description": "deals.offer.expired v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js expireRow). An active offer became expired: its submitter or a moderator marked it expired (POST /offers/:id/expire; reason submitter, moderator or \"moderator: <note>\"), or the worker found that its STATED end time has passed (expireDue; reason stated_expiry). Expiry is never inferred from missing data. Written in the transaction that set the status and the moderation_log row, next to deals.index_document.upserted (the page stays in Search as a noindex document with reason expired). The rest of the payload is the offer as it now stands, as in deals.offer.created. Envelope: subject { type: offer, id: <offer_id> }, visibility internal (an expired offer is never public), priority important (default), actor user:<usr_…> who marked it, service:<calling service> when a service acts without a person, service:deals for the worker. No consumer is built yet.",
++ "type": "object",
++ "required": [
++ "offer_id",
++ "slug",
++ "url",
++ "title",
++ "status",
++ "store",
++ "product_id",
++ "latest_observation",
++ "freshness",
++ "indexability",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The canonical (root) offer id."
++ },
++ "slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 80,
++ "description": "Page slug: the slugified title plus the last six characters of the id."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical offer page URL on Deals (/d/<slug>)."
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "status": {
++ "const": "expired"
++ },
++ "store": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "minLength": 1,
++ "description": "Store domain: the offer link's host without a leading www.; null when none could be derived."
++ },
++ "product_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^dpr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Deals product the offer is attached to, if any."
++ },
++ "latest_observation": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "price",
++ "currency",
++ "availability",
++ "observed_at"
++ ],
++ "additionalProperties": false,
++ "description": "The newest observation across the offer's merge group; null only when the group has none.",
++ "properties": {
++ "price": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 40,
++ "description": "Decimal text as stated; null when no price was stated (never 0)."
++ },
++ "currency": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[A-Z]{3}$",
++ "description": "ISO 4217; never assumed."
++ },
++ "availability": {
++ "enum": [
++ "in_stock",
++ "out_of_stock",
++ "preorder",
++ "discontinued",
++ "limited",
++ "sold_out",
++ "online_only",
++ "in_store_only",
++ null
++ ]
++ },
++ "observed_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ },
++ "freshness": {
++ "enum": [
++ "fresh",
++ "stale",
++ "unobserved"
++ ],
++ "description": "fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one."
++ },
++ "indexability": {
++ "type": "object",
++ "required": [
++ "decision",
++ "reasons"
++ ],
++ "additionalProperties": false,
++ "description": "The SEO gate decision in Search's shape (index-hooks searchIndexability).",
++ "properties": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ },
++ "description": "Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review)."
++ }
++ }
++ },
++ "reason": {
++ "type": "string",
++ "pattern": "^(submitter|stated_expiry|moderator(: .{1,200})?)$",
++ "description": "submitter, stated_expiry, moderator, or \"moderator: <note>\" (the note as the moderator wrote it, whitespace collapsed)."
++ }
++ }
++ }
++ },
++ "deals.vote.changed": {
++ "version": "1.0.0",
++ "owner": "deals",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.vote.changed.v1.json",
++ "title": "DealsVoteChangedPayload",
++ "description": "deals.vote.changed v1 (OpenVibe.Deals server/domain/votes.js apply). A person's effective vote on an offer's merge group changed: set to +1 or -1 (PUT /offers/:id/vote, deals.vote.set) or removed, value 0 (DELETE /offers/:id/vote, deals.vote.remove). Nothing is sent when the vote did not change. Written in the transaction that stored the vote row and a hotness snapshot (reason vote). The payload carries counts and the recomputable hot@1 snapshot, not identities: the voter appears only as the envelope actor, and the IP hash and any vote-ring flags stay in Deals. Envelope: subject { type: offer, id: <canonical offer_id> }, visibility internal, priority important (default), actor user:<voter usr_…> (also when a service casts the vote for them). No consumer is built yet.",
++ "type": "object",
++ "required": [
++ "offer_id",
++ "listing_id",
++ "value",
++ "previous",
++ "weight",
++ "votes",
++ "hotness"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "offer_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The canonical (root) offer of the merge group; the vote row is stored on it."
++ },
++ "listing_id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The offer the request addressed: the root, or an offer merged into it."
++ },
++ "value": {
++ "enum": [
++ 1,
++ -1,
++ 0
++ ],
++ "description": "The new effective vote; 0 = removed."
++ },
++ "previous": {
++ "enum": [
++ 1,
++ -1,
++ 0
++ ],
++ "description": "The effective vote before; always different from value."
++ },
++ "weight": {
++ "type": "number",
++ "description": "Weight stored with this vote: 1, or DEALS_NEW_ACCOUNT_WEIGHT (default 0.25) for an account new to Deals."
++ },
++ "votes": {
++ "type": "object",
++ "required": [
++ "up",
++ "down",
++ "up_weight",
++ "down_weight"
++ ],
++ "additionalProperties": false,
++ "description": "Tally of effective votes across the merge group after the change (a voter counts once).",
++ "properties": {
++ "up": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "down": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "up_weight": {
++ "type": "number",
++ "description": "Sum of the up voters' weights, 4 decimals."
++ },
++ "down_weight": {
++ "type": "number",
++ "description": "Sum of the down voters' weights, 4 decimals."
++ }
++ }
++ },
++ "hotness": {
++ "type": "object",
++ "required": [
++ "formula",
++ "hot",
++ "score"
++ ],
++ "additionalProperties": false,
++ "description": "The snapshot taken with this vote; its inputs are kept in deal_hotness_snapshots (GET /api/v1/offers/:id/hotness).",
++ "properties": {
++ "formula": {
++ "type": "string",
++ "pattern": "^hot@[0-9]+$",
++ "description": "Currently hot@1: hot = round6(score / (ageHours + 2)^1.5)."
++ },
++ "hot": {
++ "type": "number"
++ },
++ "score": {
++ "type": "number",
++ "description": "up_weight - down_weight, 4 decimals."
++ }
++ }
++ }
++ }
++ }
++ },
++ "deals.index_document.upserted": {
++ "version": "1.0.0",
++ "owner": "deals",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.index_document.upserted.v1.json",
++ "title": "DealsIndexDocumentUpsertedPayload",
++ "description": "deals.index_document.upserted v1 (OpenVibe.Deals server/domain/indexing.js indexOffer and indexProduct, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a canonical offer page (type offer, /d/<slug>) or a product page (type product, /p/<slug>), sent whenever what Search should hold changed: after every offer write (offers.js, importer.js), a product resolved through the API, and the worker's re-index pass when freshness moves. The sequencer (deal_index_revisions) gives an unchanged document its old revision and nothing is sent. Always visibility public and publication_state published; expired offers, offers without a fresh observation and imported or AI text awaiting review stay in Search as noindex documents (reasons expired, stale_price, owner_decision). Offer facets: status, freshness, store, category, currency, product (slug); product facets: brand, category, offers (active offer count). Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: offer | product, id, revision: <document revision> }, visibility internal, priority important, actor service:deals.",
++ "allOf": [
++ {
++ "$ref": "../../search/index-document.v1.json"
++ },
++ {
++ "type": "object",
++ "required": [
++ "owner",
++ "type",
++ "deleted"
++ ],
++ "properties": {
++ "owner": {
++ "const": "deals"
++ },
++ "type": {
++ "enum": [
++ "offer",
++ "product"
++ ]
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^(dof|dpr)_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "deleted": {
++ "const": false
++ }
++ }
++ }
++ ]
++ }
++ },
++ "deals.index_document.deleted": {
++ "version": "1.0.0",
++ "owner": "deals",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/deals.index_document.deleted.v1.json",
++ "title": "DealsIndexDocumentDeletedPayload",
++ "description": "deals.index_document.deleted v1 (OpenVibe.Deals server/domain/indexing.js indexOffer, via openvibe-publishing/index-hooks indexEvent). A Search tombstone for an offer page: moderators disabled the offer (takedown, including an imported offer disabled because all its Sources items were removed), or the offer was merged into another one (the group is indexed under the target from then on). Sent when the tombstone's revision moved, so a repeat sends nothing. Product pages are never tombstoned: a product without a fresh offer stays in Search as a noindex document. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: offer, id, revision }, visibility internal, priority important, actor service:deals.",
++ "type": "object",
++ "required": [
++ "type",
++ "id",
++ "revision"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "offer"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^dof_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 9007199254740991,
++ "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ }
++ }
++ }
++ },
++ "trade.observation.created": {
++ "version": "1.0.0",
++ "owner": "trade",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.observation.created.v1.json",
++ "title": "TradeObservationCreatedPayload",
++ "description": "trade.observation.created v1 (OpenVibe.Trade server/domain/observations.js record). A market observation was recorded: one value a source stated for an instrument (metric, the decimal exactly as stated, unit, currency when monetary, period), when it was true per the source (observed_at), when the source was fetched (retrieved_at) and when Trade recorded it, with the source reference. Recorded by a feed service through POST /api/v1/observations (trade.observation.write) or by the Sources sync (server/domain/sync.js). Only on creation: a replay of the same (source_key, source_ref) sends nothing, and a different value under the same reference is refused. Written in the transaction that inserted the immutable row, together with the source's freshness update (trade.source.recovered|stale), alert evaluation (trade.alert.triggered) and the instrument's Search document. Leaves out the freshness verdict (computed at read time) and recorded_by (the envelope actor). Envelope: subject { type: observation, id }, visibility public, priority low, actor service:<recording service> (service:trade for the Sources sync). Information only, not investment advice.",
++ "type": "object",
++ "required": [
++ "instrument",
++ "id",
++ "metric",
++ "value",
++ "unit",
++ "currency",
++ "period",
++ "observed_at",
++ "retrieved_at",
++ "recorded_at",
++ "max_age_sec",
++ "source"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "instrument": {
++ "type": "object",
++ "required": [
++ "id",
++ "symbol"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^ins_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "symbol": {
++ "type": "string",
++ "pattern": "^[A-Z0-9][A-Z0-9.-]{0,15}$"
++ }
++ }
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^obs_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "metric": {
++ "type": "string",
++ "pattern": "^[A-Za-z][A-Za-z0-9_.:-]{0,79}$",
++ "description": "e.g. price.close, volume, us-gaap:Revenues."
++ },
++ "value": {
++ "type": "string",
++ "pattern": "^-?[0-9]+(\\.[0-9]+)?([eE][-+]?[0-9]+)?$",
++ "maxLength": 64,
++ "description": "The decimal exactly as the source stated it (text, never rounded)."
++ },
++ "unit": {
++ "type": "string",
++ "pattern": "^[A-Za-z][A-Za-z0-9/%._-]{0,19}$",
++ "description": "e.g. USD, shares, USD/shares."
++ },
++ "currency": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[A-Z]{3}$",
++ "description": "ISO 4217 when the value is monetary; null otherwise."
++ },
++ "period": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "The period the value covers when the source states one (e.g. a fiscal quarter)."
++ },
++ "observed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When the value was true, per the source."
++ },
++ "retrieved_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When the source was fetched."
++ },
++ "recorded_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When Trade recorded the row."
++ },
++ "max_age_sec": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 60,
++ "maximum": 315360000,
++ "description": "The value's own maximum age, when the recorder gave one."
++ },
++ "source": {
++ "type": "object",
++ "required": [
++ "key",
++ "item_id",
++ "url",
++ "ref"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
++ "description": "OpenVibe.Sources source key."
++ },
++ "item_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The Sources item the value came from, when there is one."
++ },
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^https?://",
++ "description": "The source's page for the value (absolute http(s) URL, normalised)."
++ },
++ "ref": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300,
++ "description": "The source's own reference for this datum; unique per source_key."
++ }
++ }
++ }
++ }
++ }
++ },
++ "trade.source.stale": {
++ "version": "1.0.0",
++ "owner": "trade",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.source.stale.v1.json",
++ "title": "TradeSourceStalePayload",
++ "description": "trade.source.stale v1 (OpenVibe.Trade server/domain/freshness.js evaluate). A source Trade shows data from went stale by Trade's own clock: its last successful fetch is older than its staleness window (Sources' stale_after_sec, else TRADE_DEFAULT_STALE_AFTER_SEC), or no success is known at all. Sent only on a transition (fresh to stale), or once when Trade first evaluates a source that is already stale; replays and further ticks send nothing. Evaluated when the Sources sync reports a source's health (sync.js reportHealth), when a recorded observation or filing proves a retrieval (noteRetrieval), and by the worker (evaluateAll). Sources' own health status is carried as upstream_status but is not the verdict. Envelope: subject { type: source, id: <source_key> }, visibility public, priority low, actor service:trade.",
++ "type": "object",
++ "required": [
++ "source_key",
++ "stale",
++ "stale_since",
++ "last_success_at",
++ "stale_after_sec",
++ "upstream_status",
++ "evaluated_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "source_key": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
++ "description": "OpenVibe.Sources source key."
++ },
++ "stale": {
++ "const": true
++ },
++ "stale_since": {
++ "type": "string",
++ "format": "date-time",
++ "description": "Last success + window, or when Trade first saw the source if it never succeeded."
++ },
++ "last_success_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "Last known successful fetch; null when none is known."
++ },
++ "stale_after_sec": {
++ "type": "integer",
++ "description": "The staleness window applied, in seconds."
++ },
++ "upstream_status": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Sources' own health status as last reported (healthy, stale, failing, never_fetched, disabled, manual); null if never reported."
++ },
++ "evaluated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "trade.source.recovered": {
++ "version": "1.0.0",
++ "owner": "trade",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.source.recovered.v1.json",
++ "title": "TradeSourceRecoveredPayload",
++ "description": "trade.source.recovered v1 (OpenVibe.Trade server/domain/freshness.js evaluate). A stale source is fresh again by Trade's own clock: a successful fetch younger than its staleness window is now known, reported through Sources' health during the sync (sync.js reportHealth) or proved by the retrieved_at of a newly recorded observation or filing (noteRetrieval). Sent only on a stale-to-fresh transition; a source that is already fresh the first time Trade evaluates it is not a recovery and sends nothing. Envelope: subject { type: source, id: <source_key> }, visibility public, priority low, actor service:trade.",
++ "type": "object",
++ "required": [
++ "source_key",
++ "stale",
++ "stale_since",
++ "last_success_at",
++ "stale_after_sec",
++ "upstream_status",
++ "evaluated_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "source_key": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
++ "description": "OpenVibe.Sources source key."
++ },
++ "stale": {
++ "const": false
++ },
++ "stale_since": {
++ "type": "null",
++ "description": "Always null: the source is fresh."
++ },
++ "last_success_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "The successful fetch that made it fresh (or a later one)."
++ },
++ "stale_after_sec": {
++ "type": "integer",
++ "description": "The staleness window applied, in seconds."
++ },
++ "upstream_status": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Sources' own health status as last reported (healthy, stale, failing, never_fetched, disabled, manual); null if never reported."
++ },
++ "evaluated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "trade.index_document.upserted": {
++ "version": "1.0.0",
++ "owner": "trade",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.index_document.upserted.v1.json",
++ "title": "TradeIndexDocumentUpsertedPayload",
++ "description": "trade.index_document.upserted v1 (OpenVibe.Trade server/domain/indexing.js refresh, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of an instrument page (/i/<symbol>), the only public indexable object Trade has (watchlists, alert rules and deliveries never reach Search). Sent while the instrument is active and the SEO gate lets the page be listed (financial context is sensitive: the page is hidden until a person-reviewed context is published), whenever the document changed: after a recorded observation, a context publish, review or retraction, an instrument edit, and the worker's refresh pass. The sequencer (trade_index_revisions) gives an unchanged document its old revision and nothing is sent. A page whose newest monetary observation is too old stays as a noindex document (reason stale_price). Title '<symbol> — <name>', language en, facets kind, symbol, exchange; provenance lists Trade's own cited records. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: instrument, id, revision: <document revision> }, visibility internal, priority important, actor service:trade.",
++ "allOf": [
++ {
++ "$ref": "../../search/index-document.v1.json"
++ },
++ {
++ "type": "object",
++ "required": [
++ "owner",
++ "type",
++ "deleted"
++ ],
++ "properties": {
++ "owner": {
++ "const": "trade"
++ },
++ "type": {
++ "const": "instrument"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^ins_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "deleted": {
++ "const": false
++ }
++ }
++ }
++ ]
++ }
++ },
++ "trade.index_document.deleted": {
++ "version": "1.0.0",
++ "owner": "trade",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/trade.index_document.deleted.v1.json",
++ "title": "TradeIndexDocumentDeletedPayload",
++ "description": "trade.index_document.deleted v1 (OpenVibe.Trade server/domain/indexing.js refresh, via openvibe-publishing/index-hooks indexEvent). A Search tombstone for an instrument page: the instrument was archived, or the page stopped being listable (e.g. its reviewed context was retracted). Only sent for a page Search was sent before, and only when the tombstone's revision moved. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: instrument, id, revision }, visibility internal, priority important, actor service:trade.",
++ "type": "object",
++ "required": [
++ "type",
++ "id",
++ "revision"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "instrument"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^ins_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 9007199254740991,
++ "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ }
++ }
++ }
++ },
++ "games.player.joined": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.player.joined.v1.json",
++ "title": "GamesPlayerJoinedPayload",
++ "description": "games.player.joined v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts playerJoinedEvent, via GameEventRecorder.recordJoin from game/gameServer.ts handleHello). A character entered the world: written to Games' event_outbox in a transaction of its own once the session exists and the welcome was sent (a new character's row is first written by its next save). restored is true when the character already existed in the database, false for a new one. Only emitted while Events publishing is configured. No position, appearance, inventory, rank or connection data. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "restored"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "restored": {
++ "type": "boolean",
++ "description": "The character was loaded from the database (false: created by this join)."
++ }
++ }
++ }
++ },
++ "games.player.left": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.player.left.v1.json",
++ "title": "GamesPlayerLeftPayload",
++ "description": "games.player.left v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts playerLeftEvent, via GameEventRecorder.recordLeave from game/gameServer.ts onDisconnect -> savePlayer(session, true)). A character's connection closed: written in the same transaction as its final save, after any games.skill.leveled / games.blueprint.unlocked that save produced. session_seconds is the time since its games.player.joined in this process, rounded to whole seconds; 0 when the join was not tracked. No position, appearance, inventory, rank or connection data. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "session_seconds"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "session_seconds": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++ },
++ "games.skill.leveled": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.skill.leveled.v1.json",
++ "title": "GamesSkillLeveledPayload",
++ "description": "games.skill.leveled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts progressEvents, via GameEventRecorder.recordPlayersSaved from game/gameServer.ts flush and savePlayer). A persisted skill level went up: derived from what a save actually wrote, compared with the last committed save of that character (for a restored character first the database state at join, for a new one level 1), in the same transaction. One event per raised skill per save, so level can be more than previous_level + 1; never emitted for a decrease, and not re-emitted once the baseline advances on commit. Only for characters that joined while events were on. XP totals are left out. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "skill",
++ "level",
++ "previous_level"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "skill": {
++ "type": "string",
++ "pattern": "^[a-z0-9_]+$",
++ "description": "Content skill id (e.g. mining, woodcutting)."
++ },
++ "level": {
++ "type": "integer",
++ "minimum": 2,
++ "description": "Level now persisted; always greater than previous_level."
++ },
++ "previous_level": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Level at the previous baseline (1 when the skill had none)."
++ }
++ }
++ }
++ },
++ "games.blueprint.unlocked": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.blueprint.unlocked.v1.json",
++ "title": "GamesBlueprintUnlockedPayload",
++ "description": "games.blueprint.unlocked v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts progressEvents, via GameEventRecorder.recordPlayersSaved from game/gameServer.ts flush and savePlayer). A blueprint recipe unlock (learned by consuming a blueprint item) was persisted: derived from the saved unlock set compared with the last committed save of that character, in the same transaction, one event per new recipe. Only for characters that joined while events were on. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "player",
++ "world",
++ "recipe"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "player": {
++ "description": "The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account",
++ "subject"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "network"
++ },
++ "subject": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "id",
++ "slot",
++ "name",
++ "account"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[0-9a-hjkmnp-tv-z]{20}$",
++ "description": "Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id."
++ },
++ "slot": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 2,
++ "description": "Character slot under the account (0-2; a local guest only has slot 0)."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 24,
++ "description": "Character name from the client hello (1-24 characters); not unique."
++ },
++ "account": {
++ "const": "guest"
++ }
++ }
++ }
++ ]
++ },
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "recipe": {
++ "type": "string",
++ "pattern": "^[a-z0-9_]+$",
++ "description": "Content recipe id of the blueprint recipe (e.g. craft_scrap_pistol)."
++ }
++ }
++ }
++ },
++ "games.world.saved": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.world.saved.v1.json",
++ "title": "GamesWorldSavedPayload",
++ "description": "games.world.saved v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts worldSavedEvent, via GameEventRecorder.recordWorldSaved from game/gameServer.ts flush). The authoritative world was written: a checkpoint at most once per WORLD_SAVED_EVENT_MINUTES (default 15) and only when something was written, and always on shutdown, in the transaction of the flush that triggers it. The counts are totals of the flushes since `since` (the previous event, or process start); players_written counts dirty characters saved by flushes, not the single-character saves on disconnect. Envelope: subject { type: world, id: <world> }, visibility internal, priority low, actor service:games. No consumer yet.",
++ "type": "object",
++ "required": [
++ "world",
++ "reason",
++ "entities_written",
++ "players_written",
++ "since"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "world": {
++ "type": "string",
++ "description": "World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved."
++ },
++ "reason": {
++ "enum": [
++ "checkpoint",
++ "shutdown"
++ ]
++ },
++ "entities_written": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "World entity rows written since `since`."
++ },
++ "players_written": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Character rows written by flushes since `since`."
++ },
++ "since": {
++ "type": "string",
++ "format": "date-time",
++ "description": "Start of the period these totals cover."
++ }
++ }
++ }
++ },
++ "games.mod.installed": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.installed.v1.json",
++ "title": "GamesModInstalledPayload",
++ "description": "games.mod.installed v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.install in mods/registry.ts; POST /api/v1/mods). A games-content@1 mod was installed after its manifest and content pack were validated. requested is the manifest's capability list, granted the approved subset (only capabilities the games-content@1 runtime binds: games.world.announce, games.prop.place), status whether it was installed enabled. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod",
++ "requested",
++ "granted",
++ "status"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ },
++ "requested": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "manifest.permissions.capabilities, sorted."
++ },
++ "granted": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "The approved subset of requested, sorted (may be empty)."
++ },
++ "status": {
++ "enum": [
++ "enabled",
++ "disabled"
++ ]
++ }
++ }
++ }
++ },
++ "games.mod.enabled": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.enabled.v1.json",
++ "title": "GamesModEnabledPayload",
++ "description": "games.mod.enabled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.enable in mods/registry.ts; POST /api/v1/mods/:id/enable). A disabled install was enabled; its granted capabilities take effect on the next runtime reconcile. Not emitted when it was already enabled; a revoked install cannot be enabled. The payload is the mod only. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ }
++ }
++ }
++ },
++ "games.mod.disabled": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.disabled.v1.json",
++ "title": "GamesModDisabledPayload",
++ "description": "games.mod.disabled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.disable in mods/registry.ts; POST /api/v1/mods/:id/disable). An enabled install was disabled: no capability check passes for it until it is enabled again, grants are kept. Not emitted when it was already disabled; a revoked install cannot change status. The payload is the mod only. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ }
++ }
++ }
++ },
++ "games.mod.grants_changed": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.grants_changed.v1.json",
++ "title": "GamesModGrantsChangedPayload",
++ "description": "games.mod.grants_changed v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.grant and ModRegistry.revokeGrant in mods/registry.ts; POST /api/v1/mods/:id/grants, DELETE /api/v1/mods/:id/grants/:capability). One capability of a non-revoked install was granted (granted: [capability], revoked: []) or one active grant was revoked (granted: [], revoked: [capability]); each call changes exactly one capability today. Not emitted when nothing changed. Revoking the whole install is games.mod.revoked instead. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod",
++ "granted",
++ "revoked"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ },
++ "granted": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "Capabilities newly granted by this change."
++ },
++ "revoked": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "Capabilities whose grant this change revoked."
++ }
++ }
++ }
++ },
++ "games.mod.revoked": {
++ "version": "1.0.0",
++ "owner": "games",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/games.mod.revoked.v1.json",
++ "title": "GamesModRevokedPayload",
++ "description": "games.mod.revoked v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.revoke in mods/registry.ts; POST /api/v1/mods/:id/revoke). Terminal: every active grant of the install was revoked and its status set to revoked; the runtime retracts the mod's announcements and props on the next tick. revoked_grants lists the grants that were active (may be empty); reason is the staff-supplied text (cut to 500 characters), absent when none was given. Not emitted again for an install that is already revoked. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority important, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).",
++ "type": "object",
++ "required": [
++ "mod",
++ "revoked_grants"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "mod": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "target",
++ "trust_tier"
++ ],
++ "additionalProperties": false,
++ "description": "The install, from its validated mods/mod-manifest.v1 manifest.",
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Mod id (manifest id). Also the envelope subject id."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of the installed release."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames)."
++ },
++ "trust_tier": {
++ "enum": [
++ "unreviewed",
++ "reviewed",
++ "first-party"
++ ],
++ "description": "Install metadata set at install; never consulted by a grant check."
++ }
++ }
++ },
++ "revoked_grants": {
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "description": "Grants active before the revoke, sorted."
++ },
++ "reason": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500
++ }
++ }
++ }
++ },
++ "media.object.deleted": {
++ "version": "1.0.0",
++ "owner": "media",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.object.deleted.v1.json",
++ "title": "MediaObjectDeletedPayload",
++ "description": "media.object.deleted v1 (OpenVibe.Media server/events.js objectChangeEvent and recordObjectChanges, commit 039dc48, from the media_object_changes rows that the AFTER UPDATE OF lifecycle_status trigger on media_objects writes). An object's lifecycle_status became deleted: a native object soft-deleted through DELETE /api/v2/:app/objects/:id, or an inherited vods/clips/files/pastes row removed, which marks its object deleted. Every write path counts (operator SQL included), and the event exists if and only if the change committed: the trigger row and the outbox envelope are written in the transaction of the change, or by the relay's drain for a change made outside Media's own transactions. Consumers drop any public copy, index entry or cache they hold for the object (roadmap §29.3). Deliberately minimal, identity and state only: never the title, description or metadata, the owner's ids or subject, storage keys, paths or providers, sizes or hashes. Developer-project sandbox tenants produce none. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "object_id",
++ "app_id",
++ "kind",
++ "legacy_ref",
++ "deleted_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "object_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "kind": {
++ "enum": [
++ "vod",
++ "clip",
++ "file",
++ "thumbnail",
++ "screenshot",
++ "avatar",
++ "asset"
++ ]
++ },
++ "legacy_ref": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300,
++ "description": "The inherited row the object projects (legacy:<app>:<kind>:<id>, a media.media-ref@1 legacy id); null for a native v2 object."
++ },
++ "deleted_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When the change committed, ISO 8601 UTC."
++ }
++ }
++ }
++ },
++ "media.object.visibility_changed": {
++ "version": "1.0.0",
++ "owner": "media",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.object.visibility_changed.v1.json",
++ "title": "MediaObjectVisibilityChangedPayload",
++ "description": "media.object.visibility_changed v1 (OpenVibe.Media server/events.js objectChangeEvent and recordObjectChanges, commit 039dc48, from the media_object_changes rows that the AFTER UPDATE OF visibility trigger on media_objects writes). An object's visibility changed to another value (public, unlisted or private), by any write path: the v2 objects API, the inherited vods/clips/files rows it projects, or operator SQL. Never emitted when the value did not change, nor for an object that is deleted at the time of the change (that object's last word is media.object.deleted). The event exists if and only if the change committed: the trigger row and the outbox envelope are written in the transaction of the change, or by the relay's drain for a change made outside Media's own transactions. Consumers that hold a public copy, index entry or cache of the object re-check it (roadmap §29.3); a move away from public means stop showing it. Deliberately minimal, identity and state only: never the title, description or metadata, the owner's ids or subject, storage keys, paths or providers, sizes or hashes. Developer-project sandbox tenants produce none. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "object_id",
++ "app_id",
++ "kind",
++ "legacy_ref",
++ "visibility",
++ "previous_visibility",
++ "changed_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "object_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "kind": {
++ "enum": [
++ "vod",
++ "clip",
++ "file",
++ "thumbnail",
++ "screenshot",
++ "avatar",
++ "asset"
++ ]
++ },
++ "legacy_ref": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300,
++ "description": "The inherited row the object projects (legacy:<app>:<kind>:<id>, a media.media-ref@1 legacy id); null for a native v2 object."
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "private"
++ ],
++ "description": "The new value."
++ },
++ "previous_visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "private"
++ ],
++ "description": "The value before the change; never equal to visibility."
++ },
++ "changed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When the change committed, ISO 8601 UTC."
++ }
++ },
++ "not": {
++ "anyOf": [
++ {
++ "required": [
++ "visibility",
++ "previous_visibility"
++ ],
++ "properties": {
++ "visibility": {
++ "const": "public"
++ },
++ "previous_visibility": {
++ "const": "public"
++ }
++ }
++ },
++ {
++ "required": [
++ "visibility",
++ "previous_visibility"
++ ],
++ "properties": {
++ "visibility": {
++ "const": "unlisted"
++ },
++ "previous_visibility": {
++ "const": "unlisted"
++ }
++ }
++ },
++ {
++ "required": [
++ "visibility",
++ "previous_visibility"
++ ],
++ "properties": {
++ "visibility": {
++ "const": "private"
++ },
++ "previous_visibility": {
++ "const": "private"
++ }
++ }
++ }
++ ]
++ }
++ }
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 7cd0a3f2..2b2331ae 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -8054,3 +8054,3226 @@ export interface ToolsJobRequest {
*/
idempotency_key?: string;
}
++
++/** [email protected] (owner: news) */
++/**
++ * news.source.ingested v1 (OpenVibe.News server/domain/ingest.js ingestedEvent, from apply and applyRemoval). An OpenVibe.Sources item of category news changed what News holds, in the transaction that applied it: created (a new news_source_items row, with its dedupe outcome and the cluster it joined; news.cluster.updated follows in the same transaction), updated (Sources sent a newer revision of an item News has; adds previous_revision) or removed (Sources removed the item: sticky, the stored summary is dropped; adds reason and origin). Items arrive through the Events webhook (sources.item.*) or the cursor pull; a replay, an older or equal revision, an item of another category, an update to a removed item and a removal of an item never ingested emit nothing, and an item without a title is news.source.failed (state rejected) instead. Deliberately left out: the headline, outlet, authors, published_at and licensed summary (GET /api/v1/source-items/:id with news.cluster.read) and the cluster_reason explanation. Envelope: subject { type: source_item, id: nsi_… }, visibility internal, priority low, actor service:news.
++ */
++export interface NewsSourceIngestedPayload {
++ /**
++ * News' own source item (news_source_items).
++ */
++ item_id: string;
++ action: "created" | "updated" | "removed";
++ /**
++ * The item after the change: active or duplicate for created and updated, removed exactly when action is removed.
++ */
++ status: "active" | "duplicate" | "removed";
++ /**
++ * Typed reference (common/entity-ref@1 shape) to the OpenVibe.Sources item and the Sources revision News now holds.
++ */
++ sources_item: {
++ service: "sources";
++ type: "item";
++ /**
++ * The Sources item id (itm_…), as Sources gave it.
++ */
++ id: string;
++ /**
++ * Sources revision the stored fields came from (1 when Sources sent none).
++ */
++ revision: number;
++ };
++ /**
++ * The Sources source the item came from, as Sources gave it ('unknown' when it gave none).
++ */
++ source_key: string;
++ /**
++ * The item's canonical URL as Sources gave it (not validated by News); null when it had none.
++ */
++ canonical_url: string | null;
++ /**
++ * The original item when status is duplicate (the root of any duplicate chain); null otherwise.
++ */
++ duplicate_of: string | null;
++ /**
++ * Which dedupe rule matched when the item was first ingested: same normalised URL, same Sources content hash, or a near-identical headline within 48 h; null when it is not a duplicate.
++ */
++ dedupe_rule: "canonical_url" | "content_hash" | "near_title" | null;
++ /**
++ * The cluster the item belongs to (every ingested item is placed in one).
++ */
++ cluster_id: string | null;
++ /**
++ * action updated only: the Sources revision News held before.
++ */
++ previous_revision?: number;
++ /**
++ * action removed only: why Sources removed it ('removed upstream' when it gave no reason).
++ */
++ reason?: string;
++ /**
++ * action removed only: whether the removal came as a sources.item.removed delivery or through the cursor pull.
++ */
++ origin?: "webhook" | "pull";
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.source.failed v1 (OpenVibe.News server/domain/ingest.js failedEvent). Ingestion of news items failed and nothing was made in its place (no source item, cluster or story text); the same transaction records it in news_ingest_runs. Four call sites, told apart by origin and state: origin sources (ingest.js upstreamFailure: OpenVibe.Sources reported sources.fetch.failed for a news source; state, error_code and http_status are Sources' own, and run_id and consecutive_failures are added); origin pull with state failed (the cursor pull could not read Sources; once per outage, not once per retry); origin webhook with state failed (server/http/webhook.js: a sources.item.created|updated delivery whose item could not be read from Sources, on the first delivery attempt only); origin pull or webhook with state rejected and error_code item.no_headline (a Sources item without a title). Envelope: subject { type: source, id: <source_key> } when the source is known, otherwise { type: ingest, id: <origin> }, visibility internal, priority important, actor service:news.
++ */
++export interface NewsSourceFailedPayload {
++ /**
++ * webhook: an Events delivery; pull: the cursor pull; sources: a failure Sources reported.
++ */
++ origin: "webhook" | "pull" | "sources";
++ /**
++ * The Sources source, as given; null for a pull failure or when the delivery named none.
++ */
++ source_key: string | null;
++ /**
++ * The Sources item (itm_…) as given, for a rejected item or an unreadable delivery; null otherwise.
++ */
++ sources_item_id: string | null;
++ /**
++ * rejected (an unusable item), failed (News could not read Sources), or for origin sources the fetch state Sources sent, copied as sent (robots_denied, http_error, rate_limited, parse_error…; failed when it sent none).
++ */
++ state: string;
++ /**
++ * item.no_headline for a rejected item; the Sources client's error code (e.g. sources.http_503), else pull.error or sources.error, when Sources could not be read; Sources' own error_code for origin sources (null when it sent none).
++ */
++ error_code: string | null;
++ /**
++ * The HTTP status Sources answered (or reported for its fetch); null when there was none.
++ */
++ http_status: number | null;
++ /**
++ * The error message, cut to 500 characters; null for rejected items and origin sources.
++ */
++ detail: string | null;
++ /**
++ * origin sources only: Sources' fetch run (frn_…), as sent.
++ */
++ run_id?: string | null;
++ /**
++ * origin sources only: the source's consecutive failed runs Sources reported, null when it reported none.
++ */
++ consecutive_failures?: number | null;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.cluster.updated v1 (OpenVibe.News server/domain/clusters.js emit, from assign, merge, split and reverse). A story cluster changed, in the transaction that changed it. action: created (a new item started it, adds item_id; or an editor's split made it, adds split_from and audit_id), item_added (an ingested item joined it, adds item_id and rule: duplicate_of when the item follows the item it duplicates, shared_terms with the score when it shares enough entities and terms), merged (an editor merged another cluster into it: absorbed, audit_id, moved), split (an editor moved items out of it into a new cluster: new_cluster, audit_id, moved), merge_reversed (a merge into it was undone: restored, audit_id), reopened (the cluster a reversed merge had absorbed is open again: audit_id) or split_reversed (a split off it was undone: dissolved, audit_id). audit_id is the news_cluster_audit row (cla_…) of the merge, split or reversal. label, status and item_count describe the cluster after the change. Deliberately left out: the key terms and entities, the time window and each member's cluster_reason (GET /api/v1/clusters/:id with news.cluster.read). Envelope: subject { type: cluster, id: clu_… }, visibility internal, priority low, actor service:news (also for an editor's merge, split or reversal; the editor is recorded on the audit row).
++ */
++export interface NewsClusterUpdatedPayload {
++ cluster_id: string;
++ action: "created" | "item_added" | "merged" | "split" | "merge_reversed" | "reopened" | "split_reversed";
++ /**
++ * The cluster's status after the change (in practice open: merged and dissolved clusters are not announced by their own event).
++ */
++ status: "open" | "merged" | "dissolved";
++ /**
++ * Derived from the members' top entities (up to 3) or terms (up to 4), joined with ' · '; 'unlabelled' when there are none. Never written by a person or a model.
++ */
++ label: string;
++ /**
++ * Every member item, duplicates and items removed upstream included.
++ */
++ item_count: number;
++ /**
++ * created by ingestion and item_added: the item that started or joined the cluster.
++ */
++ item_id?: string;
++ /**
++ * item_added only: why the item joined.
++ */
++ rule?: "duplicate_of" | "shared_terms";
++ /**
++ * item_added with rule shared_terms only: 2 × shared entities + shared terms.
++ */
++ score?: number;
++ /**
++ * created by a split only: the cluster the items came from.
++ */
++ split_from?: string;
++ /**
++ * Every editor action (and created by a split): the news_cluster_audit row.
++ */
++ audit_id?: string;
++ /**
++ * merged only: the cluster merged into this one (now status merged).
++ */
++ absorbed?: string;
++ /**
++ * merged and split only: how many items moved.
++ */
++ moved?: number;
++ /**
++ * split only: the cluster the items moved into (announced by its own created event).
++ */
++ new_cluster?: string;
++ /**
++ * merge_reversed only: the absorbed cluster, open again (announced by its own reopened event).
++ */
++ restored?: string;
++ /**
++ * split_reversed only: the split-off cluster when it was left empty and dissolved; null when items that joined it after the split keep it open.
++ */
++ dissolved?: string | null;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.story.created v1 (OpenVibe.News server/domain/stories.js create). An editor opened a draft story (news.story.create), in the transaction that created it; when it was opened from a cluster, that cluster's active items (not duplicates, not removed upstream) were attached as its first sources. A first text sent with the request becomes revision 1 in the same transaction and is not announced here. Deliberately left out: the working headline, slug and any text (drafts are not public). Envelope: subject { type: story, id: sty_… }, visibility internal, priority low, actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.
++ */
++export interface NewsStoryCreatedPayload {
++ /**
++ * The cluster it was opened from, followed through merges; null for a story opened without one.
++ */
++ cluster_id: string | null;
++ /**
++ * Slug of the story's topic (news_topics); null when none was given.
++ */
++ topic: string | null;
++ /**
++ * How many source items were attached at creation.
++ */
++ sources: number;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.story.flagged v1 (OpenVibe.News server/domain/stories.js flagEvent, from onUpstreamChange). A source item a story cites changed (source_updated) or was removed (source_removed) upstream in OpenVibe.Sources, inside the ingest transaction that applied it: the story has an open editorial flag and, when it has text, a pending system-prepared revision (same paragraphs, refreshed source table) for an editor to check and publish. The published revision does not change. One event per affected story; an open source_updated flag is reused (its pending revision moves on) and announced again, an open source_removed flag is not. Editor flags (correction, update) and retraction flags do not emit this event. Envelope: subject { type: story, id: sty_…, revision: <pending revision, else the published revision, else 0> }, visibility internal, priority important for source_removed and normal for source_updated (normal is not an events.event-envelope@1 priority, so that envelope is refused until News sends low or important), actor service:news.
++ */
++export interface NewsStoryFlaggedPayload {
++ /**
++ * The news_editorial_flags row.
++ */
++ flag_id: string;
++ kind: "source_updated" | "source_removed";
++ status: "open";
++ /**
++ * The News source item that changed or was removed.
++ */
++ source_item_id: string;
++ /**
++ * The revision News prepared for an editor; null when the story has no text yet or nothing new was prepared.
++ */
++ pending_revision: number | null;
++ /**
++ * The revision readers see; null when the story was never published.
++ */
++ published_revision: number | null;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.story.published v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). An editor published a story that was a draft or unpublished (news.story.publish, stories.js publish), in the transaction of the change. For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets that from news.index_document.*). A story resting on fewer independent sources than NEWS_MIN_INDEPENDENT_SOURCES (default 2) is still published, with indexability noindex (unsourced). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate lets the story be listed, otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.
++ */
++export interface NewsStoryPublishedPayload {
++ canonical_url: string;
++ publication_state: "published";
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * Slug of the story's topic (news_topics); absent when it has none.
++ */
++ topic?: string;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.story.updated v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). A published story now shows a different published revision (an editor published another revision, news.story.publish, stories.js publish) or its canonical URL changed, in the transaction of the change. Republishing the same revision with a correction note, and topic or noindex changes, emit no product event (only news.index_document.* when Search's copy changes). For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets that from news.index_document.*). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate lets the story be listed, otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.
++ */
++export interface NewsStoryUpdatedPayload {
++ canonical_url: string;
++ publication_state: "published";
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * Slug of the story's topic (news_topics); absent when it has none.
++ */
++ topic?: string;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.story.unpublished v1 (OpenVibe.News server/domain/publication.js afterChange, via openvibe-publishing/index-hooks publicationEvent). An editor unpublished a published or retracted story (news.story.publish, stories.js unpublish), in the transaction of the change: it leaves the site, feeds and Search. For subscribers other than Search: canonical URL, publication state and Search-shaped indexability, never the text (Search gets the tombstone from news.index_document.deleted). Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.
++ */
++export interface NewsStoryUnpublishedPayload {
++ /**
++ * null: the event carries a tombstone, which has no URL.
++ */
++ canonical_url: string | null;
++ publication_state: "unpublished";
++ /**
++ * The gate's decision for the unpublished story (noindex, not_published), or null when its published revision cannot be read.
++ */
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ } | null;
++ /**
++ * Slug of the story's topic (news_topics); absent when it has none.
++ */
++ topic?: string;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.story.retracted v1 (OpenVibe.News server/domain/publication.js afterChange; the envelope is built there, not by index-hooks publicationEvent). An editor retracted a published story (news.story.retract, stories.js retract), in the transaction that stored the published retraction flag: the story stays at its URL with the retraction notice, is noindex, and leaves sitemaps and Search (news.index_document.deleted follows when Search had it). A retracted story stays retracted; unpublishing it later is news.story.unpublished. note is the public retraction notice and flag_id the news_editorial_flags row that holds it. The payload carries no topic. Envelope: subject { type: story, id: sty_…, revision: <published revision> }, visibility public when the gate still lets the story be listed (a retraction is noindex, not hidden), otherwise internal, priority important (the envelope default; News sets none), actor the editor { type: user } (also when a service acts for a signed-in editor), otherwise the calling service.
++ */
++export interface NewsStoryRetractedPayload {
++ /**
++ * Where the story, with its retraction notice, stays.
++ */
++ canonical_url: string;
++ publication_state: "retracted";
++ /**
++ * Search-shaped gate decision: always noindex, with retracted among the reasons.
++ */
++ indexability: {
++ decision: "noindex";
++ /**
++ * @minItems 1
++ * @maxItems 20
++ */
++ reasons:
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * The public retraction notice, whitespace collapsed.
++ */
++ note: string;
++ /**
++ * The retraction's news_editorial_flags row (kind retraction, status published).
++ */
++ flag_id: string;
++}
++
++/** [email protected] (owner: news) */
++/**
++ * news.index_document.upserted v1 (OpenVibe.News server/domain/publication.js syncIndex, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a published (not retracted), public, listable story, sent whenever what Search should hold changed (the revision grows with every change): a publication or new published revision, a topic or noindex change, a person's review of an AI revision, or a cited source changing upstream. The body is the published revision's paragraphs as plain text; facets { topic?, outlets, sources }; provenance names the cited Sources items (and the AI run of an AI draft); language en. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: story, id, revision: <document revision> }, visibility internal, priority important, actor service:news.
++ */
++export type NewsIndexDocumentUpsertedPayload = IndexDocument & {
++ owner: "news";
++ type: "story";
++ id?: string;
++ deleted: false;
++};
++
++/** [email protected] (owner: news) */
++/**
++ * news.index_document.deleted v1 (OpenVibe.News server/domain/publication.js syncIndex, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the story is no longer published, public and listable (unpublished, retracted, or hidden by the gate, e.g. an AI revision no person has reviewed), so Search drops it at this revision or older. Only sent for a story Search was sent before. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: story, id, revision }, visibility internal, priority important, actor service:news.
++ */
++export interface NewsIndexDocumentDeletedPayload {
++ type: "story";
++ id: string;
++ /**
++ * Index revision of the tombstone; wins over any document at the same or an older revision.
++ */
++ revision: number;
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.entity.merged v1 (OpenVibe.Reviews server/reviews/service.js merge → entityEvent). An editor merged one entity into another (POST /api/v1/entities/:ref/merge, capability reviews.entity.merge, a person only), in the transaction of the merge. Nothing is rewritten: the merged entity keeps its aliases and its signals keep their attribution; it becomes state merged with an active merged_into link (link_id) and the target's aggregate is recomputed over both, which is why reviews.entity.split restores attribution exactly. The same transaction sends Search the merged entity's tombstone and the target's new document (reviews.index_document.*); a published summary of the merged entity keeps its state and no reviews.summary.unpublished is sent. The signal ids, aliases and aggregate are left out (they are in the entity's history, GET /api/v1/entities/:ref/history). Envelope: subject { type: entity, id: <merged entity ent_…> }, visibility public, priority important, actor the editor (user usr_…).
++ */
++export interface ReviewsEntityMergedPayload {
++ /**
++ * The merged entity (subject.id); now state merged.
++ */
++ entity_id: string;
++ /**
++ * The active entity it was merged into.
++ */
++ into_entity_id: string;
++ /**
++ * The merged_into link; a later reviews.entity.split ends this link.
++ */
++ link_id: string;
++ /**
++ * Active signals of the merged entity and of entities merged into it, now counted in the target's aggregate.
++ */
++ signal_count: number;
++ /**
++ * Canonical URL of the merged entity's page (/e/<slug>).
++ */
++ url: string;
++ /**
++ * Canonical URL of the target entity's page.
++ */
++ into_url: string;
++ /**
++ * The editor's note (clipped to 500 characters); null when none was given.
++ */
++ note: string | null;
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.entity.split v1 (OpenVibe.Reviews server/reviews/service.js split → entityEvent). An editor undid a merge (POST /api/v1/entities/:ref/split, capability reviews.entity.split, a person only), in the transaction of the split: the merged_into link (link_id) is ended, the entity is active again with exactly the aliases and signals it had before the merge, and both aggregates are recomputed. A summary of the former target that cites signals which went back with the split is flagged and gets a pending revision in the same transaction; Search gets both entities' documents again (reviews.index_document.upserted). The signal ids are left out (they are in the entity's history). Envelope: subject { type: entity, id: <split entity ent_…> }, visibility public, priority important, actor the editor (user usr_…).
++ */
++export interface ReviewsEntitySplitPayload {
++ /**
++ * The entity split off again (subject.id); now state active.
++ */
++ entity_id: string;
++ /**
++ * The entity it had been merged into.
++ */
++ from_entity_id: string;
++ /**
++ * The merged_into link this split ended (the link_id of the reviews.entity.merged event).
++ */
++ link_id: string;
++ /**
++ * Active signals back with the entity (its own and those of entities merged into it).
++ */
++ signal_count: number;
++ /**
++ * Canonical URL of the split entity's page (/e/<slug>).
++ */
++ url: string;
++ /**
++ * Canonical URL of the former target's page; null only if that entity no longer exists.
++ */
++ from_url: string | null;
++ /**
++ * The editor's note (clipped to 500 characters); null when none was given.
++ */
++ note: string | null;
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.signal.added v1 (OpenVibe.Reviews server/reviews/service.js createSignal → signalEvent). A typed signal was recorded from one revision of an OpenVibe.Sources review item resolved to an entity (an import through POST /api/v1/signals/import or the Sources sync, a sources.item.created|updated event, or an editor resolving a queued item), in the transaction that stored it. Values and provenance are exactly what the source stated and never change afterwards. When the item already had an active signal (a newer item revision, or an editor reattributing the item), `replaces` names it and the reviews.signal.removed for it is written just before this event. Value fields depend on type: recommended (recommendation), positive_count and total_count (recommendation_tally), rating_value, rating_best, rating_worst and rating_count (rating, rating_aggregate); a scale the source did not state is null, never defaulted. Review text, the signal's trust metadata and the resulting aggregate are left out. Envelope: subject { type: signal, id: <signal_id> }, visibility public, priority important, actor the caller (the importing service, e.g. service:sources-sync, or the editor, user usr_…) or service:reviews for the Sources event consumer and sync worker.
++ */
++export interface ReviewsSignalAddedPayload {
++ signal_id: string;
++ /**
++ * The entity the signal is attributed to (never rewritten by a merge).
++ */
++ entity_id: string;
++ /**
++ * The entity it currently counts under: entity_id, or the entity it is merged into.
++ */
++ canonical_entity_id: string;
++ type: "recommendation" | "recommendation_tally" | "rating" | "rating_aggregate";
++ /**
++ * OpenVibe.Sources source key.
++ */
++ source_key: string;
++ source_item_id: string;
++ /**
++ * The Sources item revision the signal was read from.
++ */
++ item_revision: number;
++ /**
++ * When Sources retrieved that item revision (provenance.retrieved_at).
++ */
++ observed_at: string;
++ /**
++ * The item's published_at as Sources gave it; null when it gave none.
++ */
++ source_published_at: string | null;
++ /**
++ * The item's canonical URL as Sources gave it; null when it has none.
++ */
++ canonical_url: string | null;
++ /**
++ * Licence note recorded for the item in Sources; null when none.
++ */
++ license_note: string | null;
++ /**
++ * recommendation only: whether the reviewer recommends it.
++ */
++ recommended?: boolean;
++ /**
++ * recommendation_tally only: positive reviews the source counts.
++ */
++ positive_count?: number;
++ /**
++ * recommendation_tally only: all reviews the source counts (≥ positive_count).
++ */
++ total_count?: number;
++ /**
++ * rating and rating_aggregate only: the value as stated.
++ */
++ rating_value?: number;
++ /**
++ * rating and rating_aggregate only: top of the stated scale; null when the source states none.
++ */
++ rating_best?: number | null;
++ /**
++ * rating and rating_aggregate only: bottom of the stated scale; null when the source states none.
++ */
++ rating_worst?: number | null;
++ /**
++ * rating and rating_aggregate only: ratings behind the value (1 for a single review).
++ */
++ rating_count?: number;
++ /**
++ * Present when this signal replaced the item's previous active signal (see reviews.signal.removed).
++ */
++ replaces?: string;
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.signal.removed v1 (OpenVibe.Reviews server/reviews/service.js withdrawSignal → signalEvent). A signal stopped being active, in the transaction of the change. status superseded: a newer revision of the same item replaced it for the same entity (replaced_by names the new signal, whose reviews.signal.added follows). status withdrawn: its source removed the item (sources.item.removed or a removed item on import), the item no longer states a signal, or an editor reattributed or unresolved the item. The payload repeats the signal as recorded (values and provenance never change) plus status and reason; summaries citing it are flagged and get a pending revision, and the entity's aggregate and Search document are refreshed, in the same transaction. Envelope: subject { type: signal, id: <signal_id> }, visibility public, priority important, actor the caller (the importing service, e.g. service:sources-sync, or the editor, user usr_…) or service:reviews for the Sources event consumer and sync worker.
++ */
++export interface ReviewsSignalRemovedPayload {
++ signal_id: string;
++ /**
++ * The entity the signal is attributed to (never rewritten by a merge).
++ */
++ entity_id: string;
++ /**
++ * The entity it counted under: entity_id, or the entity it is merged into.
++ */
++ canonical_entity_id: string;
++ type: "recommendation" | "recommendation_tally" | "rating" | "rating_aggregate";
++ /**
++ * OpenVibe.Sources source key.
++ */
++ source_key: string;
++ source_item_id: string;
++ /**
++ * The Sources item revision the signal was read from.
++ */
++ item_revision: number;
++ /**
++ * When Sources retrieved that item revision (provenance.retrieved_at).
++ */
++ observed_at: string;
++ /**
++ * The item's published_at as Sources gave it; null when it gave none.
++ */
++ source_published_at: string | null;
++ /**
++ * The item's canonical URL as Sources gave it; null when it has none.
++ */
++ canonical_url: string | null;
++ /**
++ * Licence note recorded for the item in Sources; null when none.
++ */
++ license_note: string | null;
++ /**
++ * recommendation only: whether the reviewer recommends it.
++ */
++ recommended?: boolean;
++ /**
++ * recommendation_tally only: positive reviews the source counts.
++ */
++ positive_count?: number;
++ /**
++ * recommendation_tally only: all reviews the source counts (≥ positive_count).
++ */
++ total_count?: number;
++ /**
++ * rating and rating_aggregate only: the value as stated.
++ */
++ rating_value?: number;
++ /**
++ * rating and rating_aggregate only: top of the stated scale; null when the source states none.
++ */
++ rating_best?: number | null;
++ /**
++ * rating and rating_aggregate only: bottom of the stated scale; null when the source states none.
++ */
++ rating_worst?: number | null;
++ /**
++ * rating and rating_aggregate only: ratings behind the value (1 for a single review).
++ */
++ rating_count?: number;
++ /**
++ * superseded: replaced by a newer revision of the same item; withdrawn: every other removal.
++ */
++ status: "superseded" | "withdrawn";
++ /**
++ * Why, in plain words (e.g. "removed by its source: <reason>", "source item revised (r1 → r2)").
++ */
++ reason: string;
++ /**
++ * status superseded only: the signal that replaced it.
++ */
++ replaced_by?: string;
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.summary.published v1 (OpenVibe.Reviews server/reviews/service.js publishSummary, via openvibe-publishing/index-hooks publicationEvent). An entity's summary that was not published became published (an editor publishing a revision, or approving one with publish on, which is how an AI-drafted revision gets published), in the transaction of the change. For subscribers other than Search: the entity page's canonical URL, publication state and Search-shaped indexability, the entity id and slug, and the revision's authorship; never the summary text or its citations (Search gets those in reviews.index_document.upserted) and never a rating (a summary cannot carry one). correction appears only when the revision published is a correction revision (normally that is reviews.summary.updated). Envelope: subject { type: summary, id: sum_…, revision: <published summary revision> }, visibility public when the entity page is listable by the gate, otherwise internal, priority important, actor the editor (user usr_…).
++ */
++export interface ReviewsSummaryPublishedPayload {
++ /**
++ * The entity page (/e/<slug>) that shows the summary.
++ */
++ canonical_url: string;
++ publication_state: "published";
++ /**
++ * The gate's decision for the entity page after the change, in Search's vocabulary.
++ */
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * The entity the summary belongs to (one summary per entity).
++ */
++ entity_id: string;
++ entity_slug: string;
++ /**
++ * Authorship of the published revision: human (editor-written), ai_generated (an OpenVibe.AI draft a person approved), ai_assisted (an AI draft a person corrected); null only for a revision without an authorship record.
++ */
++ authorship: "human" | "ai_assisted" | "ai_generated" | null;
++ /**
++ * Present when the published revision is a correction: the public note and the revision it corrects.
++ */
++ correction?: {
++ /**
++ * What was corrected, as readers see it.
++ */
++ note: string;
++ /**
++ * The summary revision that was published when the correction was made.
++ */
++ corrects: number;
++ };
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.summary.updated v1 (OpenVibe.Reviews server/reviews/service.js publishSummary, via openvibe-publishing/index-hooks publicationEvent). A published summary got a different published revision, in the transaction of the change: an editor published another revision, approved the pending revision prepared after a source change, or corrected the summary (POST …/summary/revisions with correction_note, or accepting a correction request), which publishes at once and carries correction. For subscribers other than Search: the entity page's canonical URL, publication state and Search-shaped indexability, the entity id and slug, and the revision's authorship; never the summary text or its citations (Search gets those in reviews.index_document.upserted) and never a rating. Envelope: subject { type: summary, id: sum_…, revision: <published summary revision> }, visibility public when the entity page is listable by the gate, otherwise internal, priority important, actor the editor (user usr_…).
++ */
++export interface ReviewsSummaryUpdatedPayload {
++ /**
++ * The entity page (/e/<slug>) that shows the summary.
++ */
++ canonical_url: string;
++ publication_state: "published";
++ /**
++ * The gate's decision for the entity page after the change, in Search's vocabulary.
++ */
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * The entity the summary belongs to (one summary per entity).
++ */
++ entity_id: string;
++ entity_slug: string;
++ /**
++ * Authorship of the published revision: human (editor-written), ai_generated (an OpenVibe.AI draft a person approved), ai_assisted (an AI draft a person corrected); null only for a revision without an authorship record.
++ */
++ authorship: "human" | "ai_assisted" | "ai_generated" | null;
++ /**
++ * Present when the published revision is a correction: the public note and the revision it corrects.
++ */
++ correction?: {
++ /**
++ * What was corrected, as readers see it.
++ */
++ note: string;
++ /**
++ * The summary revision that was published when the correction was made.
++ */
++ corrects: number;
++ };
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.summary.unpublished v1 (OpenVibe.Reviews server/reviews/service.js unpublishSummary, via openvibe-publishing/index-hooks publicationEvent). An editor took an entity's published summary down (POST /api/v1/entities/:ref/summary/unpublish), in the transaction of the change. The entity page stays up without the summary; its new Search document goes out as reviews.index_document.upserted. Only this action sends it: merging or deleting an entity leaves its summary's state alone and Search gets the entity's tombstone instead. For subscribers other than Search: publication state, the entity page's indexability after the change, and the entity id and slug; canonical_url is always null from Reviews (the helper is given a tombstone), and authorship and correction are not sent. Envelope: subject { type: summary, id: sum_…, revision: <the revision that was published> }, visibility internal, priority important, actor the editor (user usr_…).
++ */
++export interface ReviewsSummaryUnpublishedPayload {
++ /**
++ * null: the event carries a tombstone, which has no URL.
++ */
++ canonical_url: string | null;
++ publication_state: "unpublished";
++ /**
++ * The gate's decision for the entity page after the summary came down (Reviews always passes one, so it is never null here).
++ */
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * The entity the summary belongs to.
++ */
++ entity_id: string;
++ entity_slug: string;
++}
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.index_document.upserted v1 (OpenVibe.Reviews server/reviews/service.js syncEntity, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of an active entity page: the entity name as title, the published summary (or the entity description) as summary, the summary text and the aggregate's computation lines as body, facets kind / has_aggregate / sources, the published summary's authorship, provenance of up to 40 cited Sources items (plus the AI run of an AI-drafted summary), and the gate's indexability (noindex thin_content / unsourced until signals and a summary back the page). Sent whenever what Search should hold changed (the revision grows with every change), in the transaction of the change; the reconciliation at every start (reconcileIndex) re-sends only documents that differ. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: entity, id, revision: <document revision> }, visibility internal, priority important, actor service:reviews.
++ */
++export type ReviewsIndexDocumentUpsertedPayload = IndexDocument & {
++ owner: "reviews";
++ type: "entity";
++ id?: string;
++ deleted: false;
++};
++
++/** [email protected] (owner: reviews) */
++/**
++ * reviews.index_document.deleted v1 (OpenVibe.Reviews server/reviews/service.js syncEntity, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the entity was merged into another or deleted (or the gate stopped listing its page), so Search drops it at this revision or older. Only sent for an entity Search was sent before (every entity gets a document when it is created); a split sends the entity's document again. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: entity, id, revision }, visibility internal, priority important, actor service:reviews.
++ */
++export interface ReviewsIndexDocumentDeletedPayload {
++ type: "entity";
++ id: string;
++ /**
++ * Index revision of the tombstone; wins over any document at the same or an older revision.
++ */
++ revision: number;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.coupon.created v1 (OpenVibe.Coupons server/domain/coupons.js submit and importFromSource, payload from lifecyclePayload). A new code was recorded, in the transaction that inserted it: submitted by a member, by staff or by an AI extraction run (POST /api/v1/coupons/submit or the site form), or imported from an OpenVibe.Sources item (origin source). Submitting a code the merchant already has adds evidence and emits no created event. A new code always starts with status unknown and confidence null: a submission can never carry a status or a confidence. AI-extracted codes, imported codes (unless COUPONS_SOURCES_AUTO_PUBLISH is true and the merchant is active) and codes for a pending merchant start with review_state pending. Left out on purpose: the code text, title, description and restrictions (Search gets them from coupons.index_document.*) and who submitted it (submitters stay inside Coupons). Envelope: subject { type: coupon, id: cpn_… }, visibility public when the new code is publicly listed (published, merchant active, not expired), otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.
++ */
++export interface CouponsCouponCreatedPayload {
++ /**
++ * The merchant the code belongs to.
++ */
++ merchant_id: string;
++ /**
++ * Every new code starts unknown; only people's reports can move it to reported_working or reported_failed.
++ */
++ status: "unknown";
++ /**
++ * Always null at creation: no counted report yet.
++ */
++ confidence: null;
++ /**
++ * pending = waits for staff review (AI-extracted, imported without auto-publish, or merchant pending); published = eligible for active results.
++ */
++ review_state: "pending" | "published";
++ /**
++ * Known expiry (UTC; a date-only expiry is 23:59:59.999Z of that day). null = expiry unknown, never defaulted.
++ */
++ expires_at: string | null;
++ /**
++ * The code's page on Coupons (<origin>/c/<coupon id>).
++ */
++ canonical_url: string;
++ /**
++ * How the code arrived: a member's or a staff member's submission, an OpenVibe.Sources import, or an AI extraction run (X-OV-Origin: ai).
++ */
++ origin: "member" | "staff" | "source" | "ai";
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.coupon.updated v1 (OpenVibe.Coupons server/domain/coupons.js recompute, approve and setExpiry, payload from lifecyclePayload). Emitted in the transaction of the change when (a) a code's status moved to unknown, reported_working or reported_failed: people's reports through the confidence formula (reason report), evidence added or withdrawn (evidence), old reports decaying (decay), or staff or a service re-enabling a disabled or expired code (staff, service:<client>[:<note>]); (b) staff published a code that waited for review (reason approved; status unchanged, so previous_status equals status and may be any status); or (c) the code's expiry was set or cleared (reason expiry_changed, no previous_status; setExpiry has no HTTP route yet). A move to expired or disabled is coupons.coupon.expired or coupons.coupon.disabled instead, and a confidence change without a status change emits only coupons.confidence.changed. Staff notes are reduced to reason staff; a service's note is copied into the reason. Never names a submitter, reporter or staff member. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code is publicly listed after the change (recompute: before or after), otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.
++ */
++export interface CouponsCouponUpdatedPayload {
++ /**
++ * The merchant the code belongs to.
++ */
++ merchant_id: string;
++ /**
++ * Status after the change. expired and disabled occur only with reason approved or expiry_changed (neither moves the status).
++ */
++ status: "unknown" | "reported_working" | "reported_failed" | "expired" | "disabled";
++ /**
++ * Report confidence after the change, rounded to 2 decimals; null = no counted report in the last 30 days.
++ */
++ confidence: number | null;
++ review_state: "pending" | "published";
++ /**
++ * Known expiry (UTC); null = expiry unknown.
++ */
++ expires_at: string | null;
++ /**
++ * The code's page on Coupons (<origin>/c/<coupon id>).
++ */
++ canonical_url: string;
++ /**
++ * Status before the change. Absent with reason expiry_changed.
++ */
++ previous_status?: "unknown" | "reported_working" | "reported_failed" | "expired" | "disabled";
++ /**
++ * Why it changed: report, evidence, decay, staff, approved, expiry_changed, or service:<client>[:<note>] when a service holding coupons.status.update re-enabled it.
++ */
++ reason: ("report" | "evidence" | "decay" | "staff" | "approved" | "expiry_changed") | string;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.coupon.expired v1 (OpenVibe.Coupons server/domain/coupons.js recompute, payload from lifecyclePayload). A code's status became expired, in the transaction that recorded it: the sweep (server/worker.js, every COUPONS_SWEEP_INTERVAL_MS) saw its known expiry pass (reason expiry), a recompute for other work found it past its expiry first (evidence, report or decay), or staff or a service marked it expired (staff, service:<client>[:<note>]). Active lists do not wait for this event: they filter on expires_at at query time. When the code was in Search, a coupons.index_document.deleted tombstone and the merchant's updated document follow in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code was publicly listed before the change, otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.
++ */
++export interface CouponsCouponExpiredPayload {
++ /**
++ * The merchant the code belongs to.
++ */
++ merchant_id: string;
++ status: "expired";
++ /**
++ * Report confidence at the time (rounded to 2 decimals); null = no counted report in the last 30 days.
++ */
++ confidence: number | null;
++ review_state: "pending" | "published";
++ /**
++ * The known expiry that passed; null when staff or a service marked a code of unknown expiry expired.
++ */
++ expires_at: string | null;
++ /**
++ * The code's page on Coupons (<origin>/c/<coupon id>); it stays up, noindex.
++ */
++ canonical_url: string;
++ /**
++ * Status before; a disabled code has to be re-enabled before it can be marked expired.
++ */
++ previous_status: "unknown" | "reported_working" | "reported_failed";
++ /**
++ * expiry, evidence, report, decay, staff, or service:<client>[:<note>].
++ */
++ reason: ("expiry" | "evidence" | "report" | "decay" | "staff") | string;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.coupon.disabled v1 (OpenVibe.Coupons server/domain/coupons.js recompute via withdrawSourceItem and setStatus, payload from lifecyclePayload). A code was taken down, in the transaction that recorded it: the last OpenVibe.Sources item that evidenced it was removed at the source (reason source_removed), or staff or a service holding coupons.status.update disabled it (staff, service:<client>[:<note>]). Its page answers 410, it cannot be resubmitted and nothing is deleted. Disabling a merchant does not emit this per code (its codes only get Search tombstones). When the code was in Search, a coupons.index_document.deleted tombstone follows in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility public when the code was publicly listed before the change, otherwise internal, priority important (not set: the Events default), actor service:coupons. No known consumers.
++ */
++export interface CouponsCouponDisabledPayload {
++ /**
++ * The merchant the code belongs to.
++ */
++ merchant_id: string;
++ status: "disabled";
++ /**
++ * Report confidence at the time (rounded to 2 decimals); null = no counted report in the last 30 days.
++ */
++ confidence: number | null;
++ review_state: "pending" | "published";
++ /**
++ * Known expiry (UTC); null = expiry unknown.
++ */
++ expires_at: string | null;
++ /**
++ * The code's page on Coupons (<origin>/c/<coupon id>); it now answers 410.
++ */
++ canonical_url: string;
++ /**
++ * Status before it was disabled.
++ */
++ previous_status: "unknown" | "reported_working" | "reported_failed" | "expired";
++ /**
++ * source_removed, staff, or service:<client>[:<note>].
++ */
++ reason: ("source_removed" | "staff") | string;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.report.created v1 (OpenVibe.Coupons server/domain/reports.js report). A signed-in person (site form, a browser-helper install token with scope coupons.report, or a service acting for a person with X-OV-Subject) reported that an active code worked or failed, in the transaction that stored the report. One event per new (reporter, code, UTC day) row: a repeat the same day is deduplicated and a same-day switch to the other outcome corrects that row, and neither emits (both still recompute). The payload never identifies the reporter: not the subject, the HMAC reporter key or the install. reason is present only on failed reports that gave one. A coupons.confidence.changed and, when the status moved, a lifecycle event follow in the same transaction. Envelope: subject { type: coupon, id: cpn_… }, visibility internal, priority important (not set: the Events default), actor service:coupons. No known consumers.
++ */
++export interface CouponsReportCreatedPayload {
++ /**
++ * The merchant of the reported code.
++ */
++ merchant_id: string;
++ outcome: "worked" | "failed";
++ /**
++ * Why a failed code failed, when the reporter said. Never on a worked report.
++ */
++ reason?: "invalid" | "expired" | "min_spend_not_met" | "not_eligible" | "other";
++ /**
++ * How the report arrived: the site form, a browser-helper install token, or the API (a Network token or a service acting for a person).
++ */
++ channel: "site" | "extension" | "api";
++ /**
++ * The UTC day of the report (YYYY-MM-DD), the deduplication bucket.
++ */
++ day: string;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.confidence.changed v1 (OpenVibe.Coupons server/domain/coupons.js recompute). A code's confidence moved, in the transaction of the recompute: a new or corrected report, evidence added or withdrawn (evidence on one of the merchant's own domains raises the prior), or the sweep letting old reports decay (to null once no report is younger than 30 days). confidence = (a0 + W) / (a0 + 1 + W + F), rounded to 2 decimals, over recency-weighted worked (W) and failed (F) reports (half-life 7 days, each reporter's latest report only; a0 = 1, or 1.5 with merchant evidence); null = no counted report in the 30-day window. from and to always differ. status is the code's status after the change; a status change also emits coupons.coupon.updated, .expired or .disabled. Never names a reporter. Envelope: subject { type: coupon, id: cpn_… }, visibility internal, priority important (not set: the Events default), actor service:coupons. No known consumers.
++ */
++export interface CouponsConfidenceChangedPayload {
++ /**
++ * The merchant the code belongs to.
++ */
++ merchant_id: string;
++ /**
++ * Confidence before; null = no counted report.
++ */
++ from: number | null;
++ /**
++ * Confidence after; null = no counted report left in the window.
++ */
++ to: number | null;
++ /**
++ * The code's status after the change (reported_working at confidence >= 0.6, reported_failed at <= 0.4, given enough recent reports).
++ */
++ status: "unknown" | "reported_working" | "reported_failed" | "expired" | "disabled";
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.index_document.upserted v1 (OpenVibe.Coupons server/domain/publication.js sendDocument, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a code in active results (type coupon: published, merchant active, not expired or disabled; title '<code> — <title> (<merchant>)', status and expiry in the summary, description and restrictions in the body, facets merchant, status and expiry_known, Sources items in provenance) or of an active merchant (type merchant: facets active_codes; indexability noindex thin_content while it has no active code). Sent in the transaction of every change to what Search should hold (coupons.js syncIndex and syncMerchant); the revision (coupons_index_revisions) grows only when the document changed, and an unchanged document is not sent again. Visibility is always public. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: coupon | merchant, id, revision: <document revision> }, visibility internal, priority important, actor service:coupons.
++ */
++export type CouponsIndexDocumentUpsertedPayload = IndexDocument & {
++ owner: "coupons";
++ type: "coupon" | "merchant";
++ id?: string;
++ deleted: false;
++};
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.index_document.deleted v1 (OpenVibe.Coupons server/domain/publication.js sendDocument, via openvibe-publishing/index-hooks indexEvent). A Search tombstone: the code left active results (expired, disabled, its merchant disabled) or the merchant is no longer active, so Search drops it at this revision or older. Only sent for a resource Search was sent before: a code or merchant that was never indexed gets no tombstone. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: coupon | merchant, id, revision }, visibility internal, priority important, actor service:coupons.
++ */
++export interface CouponsIndexDocumentDeletedPayload {
++ type: "coupon" | "merchant";
++ id: string;
++ /**
++ * Index revision of the tombstone; wins over any document at the same or an older revision.
++ */
++ revision: number;
++}
++
++/** [email protected] (owner: deals) */
++/**
++ * deals.offer.created v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js submit and createImported). A deal was posted: a person submitted a link (submit), or the Sources importer created an offer from a deals-category Sources item (createImported, the only caller that adds imported_from). Written in the transaction that inserted the offer and its first observation, next to deals.index_document.upserted. The payload describes the canonical offer as it now stands: canonical page URL, status, store domain, product, the latest observation (price text exactly as stated, never inferred; null where the source or person stated nothing) with its observed_at, the freshness verdict and the Search-shaped indexability decision. Deliberately left out: the description, the submitter's identity, vote counts and hotness. Envelope: subject { type: offer, id: <offer_id> }, visibility public when the offer is active and the SEO gate lets it be listed, otherwise internal, priority important (default), actor user:<submitter usr_…> for a submission, service:deals for an import. No consumer is built yet.
++ */
++export interface DealsOfferCreatedPayload {
++ /**
++ * The canonical (root) offer id.
++ */
++ offer_id: string;
++ /**
++ * Page slug: the slugified title plus the last six characters of the id.
++ */
++ slug: string;
++ /**
++ * Canonical offer page URL on Deals (/d/<slug>).
++ */
++ url: string;
++ title: string;
++ /**
++ * A new offer is always active.
++ */
++ status: "active";
++ /**
++ * Store domain: the offer link's host without a leading www.; null when none could be derived.
++ */
++ store: string | null;
++ /**
++ * Deals product the offer is attached to, if any.
++ */
++ product_id: string | null;
++ /**
++ * The newest observation across the offer's merge group; null only when the group has none.
++ */
++ latest_observation: {
++ /**
++ * Decimal text as stated; null when no price was stated (never 0).
++ */
++ price: string | null;
++ /**
++ * ISO 4217; never assumed.
++ */
++ currency: string | null;
++ availability:
++ | "in_stock"
++ | "out_of_stock"
++ | "preorder"
++ | "discontinued"
++ | "limited"
++ | "sold_out"
++ | "online_only"
++ | "in_store_only"
++ | null;
++ observed_at: string;
++ } | null;
++ /**
++ * fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one.
++ */
++ freshness: "fresh" | "stale" | "unobserved";
++ /**
++ * The SEO gate decision in Search's shape (index-hooks searchIndexability).
++ */
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review).
++ *
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * Only on imports: the OpenVibe.Sources item the offer was created from.
++ */
++ imported_from?: {
++ service: "sources";
++ type: "item";
++ id: string;
++ };
++}
++
++/** [email protected] (owner: deals) */
++/**
++ * deals.offer.updated v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js and server/domain/importer.js). Something about a canonical offer changed; `changed` says what, and a few call sites add one reference: an edit (offers.js update: the edited columns, among title, description, category, expires_at, product_id, ai_summary, review_state, text_origin); a price/availability report by a person (observe) or a newer or re-fetched Sources item (importer importItem) ([observation] + observation_id); a second Sources item with the same link attached to an existing offer ([sources] + observation_id); a Sources item removed at the source ([sources] + source_removed; an imported offer left with no live source is disabled in the same transaction, so status is then disabled); moderators disabling it or re-activating a disabled or expired offer ([status] + moderation); a person reviewing imported or AI text ([review_state]); a merge ([merged] + merged_offer_id, sent for the merge target); an unmerge (two events: [unmerged] + unmerged_offer_id for the former target, and [unmerged] + unmerged_from for the restored offer). Written in the transaction that made the change, next to the offer's (and its product's) deals.index_document.* event. The rest of the payload is the offer as it now stands, as in deals.offer.created. Deliberately left out: old values, the description text, identities and vote counts (deals.vote.changed carries those counts). Envelope: subject { type: offer, id: <offer_id> }, visibility public when the offer is active and the SEO gate lets it be listed, otherwise internal, priority important (default), actor user:<the person who acted, usr_…>, service:<calling service> when a service acts without a person, service:deals for the importer. No consumer is built yet.
++ */
++export interface DealsOfferUpdatedPayload {
++ /**
++ * The canonical (root) offer id.
++ */
++ offer_id: string;
++ /**
++ * Page slug: the slugified title plus the last six characters of the id.
++ */
++ slug: string;
++ /**
++ * Canonical offer page URL on Deals (/d/<slug>).
++ */
++ url: string;
++ title: string;
++ status: "active" | "expired" | "disabled";
++ /**
++ * Store domain: the offer link's host without a leading www.; null when none could be derived.
++ */
++ store: string | null;
++ /**
++ * Deals product the offer is attached to, if any.
++ */
++ product_id: string | null;
++ /**
++ * The newest observation across the offer's merge group; null only when the group has none.
++ */
++ latest_observation: {
++ /**
++ * Decimal text as stated; null when no price was stated (never 0).
++ */
++ price: string | null;
++ /**
++ * ISO 4217; never assumed.
++ */
++ currency: string | null;
++ availability:
++ | "in_stock"
++ | "out_of_stock"
++ | "preorder"
++ | "discontinued"
++ | "limited"
++ | "sold_out"
++ | "online_only"
++ | "in_store_only"
++ | null;
++ observed_at: string;
++ } | null;
++ /**
++ * fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one.
++ */
++ freshness: "fresh" | "stale" | "unobserved";
++ /**
++ * The SEO gate decision in Search's shape (index-hooks searchIndexability).
++ */
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review).
++ *
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * What changed: edited columns (update), or one of observation, sources, status, review_state, merged, unmerged.
++ *
++ * @minItems 1
++ */
++ changed: [
++ (
++ | "title"
++ | "description"
++ | "category"
++ | "expires_at"
++ | "product_id"
++ | "ai_summary"
++ | "review_state"
++ | "text_origin"
++ | "observation"
++ | "sources"
++ | "status"
++ | "merged"
++ | "unmerged"
++ ),
++ ...(
++ | "title"
++ | "description"
++ | "category"
++ | "expires_at"
++ | "product_id"
++ | "ai_summary"
++ | "review_state"
++ | "text_origin"
++ | "observation"
++ | "sources"
++ | "status"
++ | "merged"
++ | "unmerged"
++ )[]
++ ];
++ /**
++ * changed [observation] or [sources] from a report or an import: the observation just recorded.
++ */
++ observation_id?: string;
++ /**
++ * changed [sources] after a removal: the OpenVibe.Sources item that was removed (takedown, licence).
++ */
++ source_removed?: string;
++ /**
++ * changed [status] by a moderator: disabled, or enabled (a disabled or expired offer made active again).
++ */
++ moderation?: "disabled" | "enabled";
++ /**
++ * changed [merged]: the duplicate now merged into this offer.
++ */
++ merged_offer_id?: string;
++ /**
++ * changed [unmerged], on the former target: the offer split back out.
++ */
++ unmerged_offer_id?: string;
++ /**
++ * changed [unmerged], on the restored offer: the offer it had been merged into.
++ */
++ unmerged_from?: string;
++}
++
++/** [email protected] (owner: deals) */
++/**
++ * deals.offer.expired v1 (OpenVibe.Deals server/domain/indexing.js emitOffer, called from server/domain/offers.js expireRow). An active offer became expired: its submitter or a moderator marked it expired (POST /offers/:id/expire; reason submitter, moderator or "moderator: <note>"), or the worker found that its STATED end time has passed (expireDue; reason stated_expiry). Expiry is never inferred from missing data. Written in the transaction that set the status and the moderation_log row, next to deals.index_document.upserted (the page stays in Search as a noindex document with reason expired). The rest of the payload is the offer as it now stands, as in deals.offer.created. Envelope: subject { type: offer, id: <offer_id> }, visibility internal (an expired offer is never public), priority important (default), actor user:<usr_…> who marked it, service:<calling service> when a service acts without a person, service:deals for the worker. No consumer is built yet.
++ */
++export interface DealsOfferExpiredPayload {
++ /**
++ * The canonical (root) offer id.
++ */
++ offer_id: string;
++ /**
++ * Page slug: the slugified title plus the last six characters of the id.
++ */
++ slug: string;
++ /**
++ * Canonical offer page URL on Deals (/d/<slug>).
++ */
++ url: string;
++ title: string;
++ status: "expired";
++ /**
++ * Store domain: the offer link's host without a leading www.; null when none could be derived.
++ */
++ store: string | null;
++ /**
++ * Deals product the offer is attached to, if any.
++ */
++ product_id: string | null;
++ /**
++ * The newest observation across the offer's merge group; null only when the group has none.
++ */
++ latest_observation: {
++ /**
++ * Decimal text as stated; null when no price was stated (never 0).
++ */
++ price: string | null;
++ /**
++ * ISO 4217; never assumed.
++ */
++ currency: string | null;
++ availability:
++ | "in_stock"
++ | "out_of_stock"
++ | "preorder"
++ | "discontinued"
++ | "limited"
++ | "sold_out"
++ | "online_only"
++ | "in_store_only"
++ | null;
++ observed_at: string;
++ } | null;
++ /**
++ * fresh while the latest observation is younger than DEALS_FRESHNESS_HOURS, stale after, unobserved without one.
++ */
++ freshness: "fresh" | "stale" | "unobserved";
++ /**
++ * The SEO gate decision in Search's shape (index-hooks searchIndexability).
++ */
++ indexability: {
++ decision: "index" | "noindex";
++ /**
++ * Search reason names, e.g. stale_price, expired, takedown, owner_decision (imported or AI text awaiting review).
++ *
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ /**
++ * submitter, stated_expiry, moderator, or "moderator: <note>" (the note as the moderator wrote it, whitespace collapsed).
++ */
++ reason: string;
++}
++
++/** [email protected] (owner: deals) */
++/**
++ * deals.vote.changed v1 (OpenVibe.Deals server/domain/votes.js apply). A person's effective vote on an offer's merge group changed: set to +1 or -1 (PUT /offers/:id/vote, deals.vote.set) or removed, value 0 (DELETE /offers/:id/vote, deals.vote.remove). Nothing is sent when the vote did not change. Written in the transaction that stored the vote row and a hotness snapshot (reason vote). The payload carries counts and the recomputable hot@1 snapshot, not identities: the voter appears only as the envelope actor, and the IP hash and any vote-ring flags stay in Deals. Envelope: subject { type: offer, id: <canonical offer_id> }, visibility internal, priority important (default), actor user:<voter usr_…> (also when a service casts the vote for them). No consumer is built yet.
++ */
++export interface DealsVoteChangedPayload {
++ /**
++ * The canonical (root) offer of the merge group; the vote row is stored on it.
++ */
++ offer_id: string;
++ /**
++ * The offer the request addressed: the root, or an offer merged into it.
++ */
++ listing_id: string;
++ /**
++ * The new effective vote; 0 = removed.
++ */
++ value: 1 | -1 | 0;
++ /**
++ * The effective vote before; always different from value.
++ */
++ previous: 1 | -1 | 0;
++ /**
++ * Weight stored with this vote: 1, or DEALS_NEW_ACCOUNT_WEIGHT (default 0.25) for an account new to Deals.
++ */
++ weight: number;
++ /**
++ * Tally of effective votes across the merge group after the change (a voter counts once).
++ */
++ votes: {
++ up: number;
++ down: number;
++ /**
++ * Sum of the up voters' weights, 4 decimals.
++ */
++ up_weight: number;
++ /**
++ * Sum of the down voters' weights, 4 decimals.
++ */
++ down_weight: number;
++ };
++ /**
++ * The snapshot taken with this vote; its inputs are kept in deal_hotness_snapshots (GET /api/v1/offers/:id/hotness).
++ */
++ hotness: {
++ /**
++ * Currently hot@1: hot = round6(score / (ageHours + 2)^1.5).
++ */
++ formula: string;
++ hot: number;
++ /**
++ * up_weight - down_weight, 4 decimals.
++ */
++ score: number;
++ };
++}
++
++/** [email protected] (owner: deals) */
++/**
++ * deals.index_document.upserted v1 (OpenVibe.Deals server/domain/indexing.js indexOffer and indexProduct, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of a canonical offer page (type offer, /d/<slug>) or a product page (type product, /p/<slug>), sent whenever what Search should hold changed: after every offer write (offers.js, importer.js), a product resolved through the API, and the worker's re-index pass when freshness moves. The sequencer (deal_index_revisions) gives an unchanged document its old revision and nothing is sent. Always visibility public and publication_state published; expired offers, offers without a fresh observation and imported or AI text awaiting review stay in Search as noindex documents (reasons expired, stale_price, owner_decision). Offer facets: status, freshness, store, category, currency, product (slug); product facets: brand, category, offers (active offer count). Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: offer | product, id, revision: <document revision> }, visibility internal, priority important, actor service:deals.
++ */
++export type DealsIndexDocumentUpsertedPayload = IndexDocument & {
++ owner: "deals";
++ type: "offer" | "product";
++ id?: string;
++ deleted: false;
++};
++
++/** [email protected] (owner: deals) */
++/**
++ * deals.index_document.deleted v1 (OpenVibe.Deals server/domain/indexing.js indexOffer, via openvibe-publishing/index-hooks indexEvent). A Search tombstone for an offer page: moderators disabled the offer (takedown, including an imported offer disabled because all its Sources items were removed), or the offer was merged into another one (the group is indexed under the target from then on). Sent when the tombstone's revision moved, so a repeat sends nothing. Product pages are never tombstoned: a product without a fresh offer stays in Search as a noindex document. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: offer, id, revision }, visibility internal, priority important, actor service:deals.
++ */
++export interface DealsIndexDocumentDeletedPayload {
++ type: "offer";
++ id: string;
++ /**
++ * Index revision of the tombstone; wins over any document at the same or an older revision.
++ */
++ revision: number;
++}
++
++/** [email protected] (owner: trade) */
++/**
++ * trade.observation.created v1 (OpenVibe.Trade server/domain/observations.js record). A market observation was recorded: one value a source stated for an instrument (metric, the decimal exactly as stated, unit, currency when monetary, period), when it was true per the source (observed_at), when the source was fetched (retrieved_at) and when Trade recorded it, with the source reference. Recorded by a feed service through POST /api/v1/observations (trade.observation.write) or by the Sources sync (server/domain/sync.js). Only on creation: a replay of the same (source_key, source_ref) sends nothing, and a different value under the same reference is refused. Written in the transaction that inserted the immutable row, together with the source's freshness update (trade.source.recovered|stale), alert evaluation (trade.alert.triggered) and the instrument's Search document. Leaves out the freshness verdict (computed at read time) and recorded_by (the envelope actor). Envelope: subject { type: observation, id }, visibility public, priority low, actor service:<recording service> (service:trade for the Sources sync). Information only, not investment advice.
++ */
++export interface TradeObservationCreatedPayload {
++ instrument: {
++ id: string;
++ symbol: string;
++ };
++ id: string;
++ /**
++ * e.g. price.close, volume, us-gaap:Revenues.
++ */
++ metric: string;
++ /**
++ * The decimal exactly as the source stated it (text, never rounded).
++ */
++ value: string;
++ /**
++ * e.g. USD, shares, USD/shares.
++ */
++ unit: string;
++ /**
++ * ISO 4217 when the value is monetary; null otherwise.
++ */
++ currency: string | null;
++ /**
++ * The period the value covers when the source states one (e.g. a fiscal quarter).
++ */
++ period: string | null;
++ /**
++ * When the value was true, per the source.
++ */
++ observed_at: string;
++ /**
++ * When the source was fetched.
++ */
++ retrieved_at: string;
++ /**
++ * When Trade recorded the row.
++ */
++ recorded_at: string;
++ /**
++ * The value's own maximum age, when the recorder gave one.
++ */
++ max_age_sec: number | null;
++ source: {
++ /**
++ * OpenVibe.Sources source key.
++ */
++ key: string;
++ /**
++ * The Sources item the value came from, when there is one.
++ */
++ item_id: string | null;
++ /**
++ * The source's page for the value (absolute http(s) URL, normalised).
++ */
++ url: string | null;
++ /**
++ * The source's own reference for this datum; unique per source_key.
++ */
++ ref: string;
++ };
++}
++
++/** [email protected] (owner: trade) */
++/**
++ * trade.source.stale v1 (OpenVibe.Trade server/domain/freshness.js evaluate). A source Trade shows data from went stale by Trade's own clock: its last successful fetch is older than its staleness window (Sources' stale_after_sec, else TRADE_DEFAULT_STALE_AFTER_SEC), or no success is known at all. Sent only on a transition (fresh to stale), or once when Trade first evaluates a source that is already stale; replays and further ticks send nothing. Evaluated when the Sources sync reports a source's health (sync.js reportHealth), when a recorded observation or filing proves a retrieval (noteRetrieval), and by the worker (evaluateAll). Sources' own health status is carried as upstream_status but is not the verdict. Envelope: subject { type: source, id: <source_key> }, visibility public, priority low, actor service:trade.
++ */
++export interface TradeSourceStalePayload {
++ /**
++ * OpenVibe.Sources source key.
++ */
++ source_key: string;
++ stale: true;
++ /**
++ * Last success + window, or when Trade first saw the source if it never succeeded.
++ */
++ stale_since: string;
++ /**
++ * Last known successful fetch; null when none is known.
++ */
++ last_success_at: string | null;
++ /**
++ * The staleness window applied, in seconds.
++ */
++ stale_after_sec: number;
++ /**
++ * Sources' own health status as last reported (healthy, stale, failing, never_fetched, disabled, manual); null if never reported.
++ */
++ upstream_status: string | null;
++ evaluated_at: string;
++}
++
++/** [email protected] (owner: trade) */
++/**
++ * trade.source.recovered v1 (OpenVibe.Trade server/domain/freshness.js evaluate). A stale source is fresh again by Trade's own clock: a successful fetch younger than its staleness window is now known, reported through Sources' health during the sync (sync.js reportHealth) or proved by the retrieved_at of a newly recorded observation or filing (noteRetrieval). Sent only on a stale-to-fresh transition; a source that is already fresh the first time Trade evaluates it is not a recovery and sends nothing. Envelope: subject { type: source, id: <source_key> }, visibility public, priority low, actor service:trade.
++ */
++export interface TradeSourceRecoveredPayload {
++ /**
++ * OpenVibe.Sources source key.
++ */
++ source_key: string;
++ stale: false;
++ /**
++ * Always null: the source is fresh.
++ */
++ stale_since: null;
++ /**
++ * The successful fetch that made it fresh (or a later one).
++ */
++ last_success_at: string;
++ /**
++ * The staleness window applied, in seconds.
++ */
++ stale_after_sec: number;
++ /**
++ * Sources' own health status as last reported (healthy, stale, failing, never_fetched, disabled, manual); null if never reported.
++ */
++ upstream_status: string | null;
++ evaluated_at: string;
++}
++
++/** [email protected] (owner: trade) */
++/**
++ * trade.index_document.upserted v1 (OpenVibe.Trade server/domain/indexing.js refresh, via openvibe-publishing/index-hooks indexEvent). The OpenVibe.Search document of an instrument page (/i/<symbol>), the only public indexable object Trade has (watchlists, alert rules and deliveries never reach Search). Sent while the instrument is active and the SEO gate lets the page be listed (financial context is sensitive: the page is hidden until a person-reviewed context is published), whenever the document changed: after a recorded observation, a context publish, review or retraction, an instrument edit, and the worker's refresh pass. The sequencer (trade_index_revisions) gives an unchanged document its old revision and nothing is sent. A page whose newest monetary observation is too old stays as a noindex document (reason stale_price). Title '<symbol> — <name>', language en, facets kind, symbol, exchange; provenance lists Trade's own cited records. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: instrument, id, revision: <document revision> }, visibility internal, priority important, actor service:trade.
++ */
++export type TradeIndexDocumentUpsertedPayload = IndexDocument & {
++ owner: "trade";
++ type: "instrument";
++ id?: string;
++ deleted: false;
++};
++
++/** [email protected] (owner: trade) */
++/**
++ * trade.index_document.deleted v1 (OpenVibe.Trade server/domain/indexing.js refresh, via openvibe-publishing/index-hooks indexEvent). A Search tombstone for an instrument page: the instrument was archived, or the page stopped being listable (e.g. its reviewed context was retracted). Only sent for a page Search was sent before, and only when the tombstone's revision moved. Consumed by OpenVibe.Search ('*.index_document.*'). Envelope: subject { type: instrument, id, revision }, visibility internal, priority important, actor service:trade.
++ */
++export interface TradeIndexDocumentDeletedPayload {
++ type: "instrument";
++ id: string;
++ /**
++ * Index revision of the tombstone; wins over any document at the same or an older revision.
++ */
++ revision: number;
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.player.joined v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts playerJoinedEvent, via GameEventRecorder.recordJoin from game/gameServer.ts handleHello). A character entered the world: written to Games' event_outbox in a transaction of its own once the session exists and the welcome was sent (a new character's row is first written by its next save). restored is true when the character already existed in the database, false for a new one. Only emitted while Events publishing is configured. No position, appearance, inventory, rank or connection data. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.
++ */
++export interface GamesPlayerJoinedPayload {
++ /**
++ * The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.
++ */
++ player:
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "network";
++ subject:
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ };
++ }
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "guest";
++ };
++ /**
++ * World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved.
++ */
++ world: string;
++ /**
++ * The character was loaded from the database (false: created by this join).
++ */
++ restored: boolean;
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.player.left v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts playerLeftEvent, via GameEventRecorder.recordLeave from game/gameServer.ts onDisconnect -> savePlayer(session, true)). A character's connection closed: written in the same transaction as its final save, after any games.skill.leveled / games.blueprint.unlocked that save produced. session_seconds is the time since its games.player.joined in this process, rounded to whole seconds; 0 when the join was not tracked. No position, appearance, inventory, rank or connection data. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.
++ */
++export interface GamesPlayerLeftPayload {
++ /**
++ * The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.
++ */
++ player:
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "network";
++ subject:
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ };
++ }
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "guest";
++ };
++ /**
++ * World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved.
++ */
++ world: string;
++ session_seconds: number;
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.skill.leveled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts progressEvents, via GameEventRecorder.recordPlayersSaved from game/gameServer.ts flush and savePlayer). A persisted skill level went up: derived from what a save actually wrote, compared with the last committed save of that character (for a restored character first the database state at join, for a new one level 1), in the same transaction. One event per raised skill per save, so level can be more than previous_level + 1; never emitted for a decrease, and not re-emitted once the baseline advances on commit. Only for characters that joined while events were on. XP totals are left out. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.
++ */
++export interface GamesSkillLeveledPayload {
++ /**
++ * The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.
++ */
++ player:
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "network";
++ subject:
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ };
++ }
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "guest";
++ };
++ /**
++ * World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved.
++ */
++ world: string;
++ /**
++ * Content skill id (e.g. mining, woodcutting).
++ */
++ skill: string;
++ /**
++ * Level now persisted; always greater than previous_level.
++ */
++ level: number;
++ /**
++ * Level at the previous baseline (1 when the skill had none).
++ */
++ previous_level: number;
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.blueprint.unlocked v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts progressEvents, via GameEventRecorder.recordPlayersSaved from game/gameServer.ts flush and savePlayer). A blueprint recipe unlock (learned by consuming a blueprint item) was persisted: derived from the saved unlock set compared with the last committed save of that character, in the same transaction, one event per new recipe. Only for characters that joined while events were on. Envelope: subject { type: player, id: <player.id> }, visibility subject when the player has a Network subject and internal otherwise, priority low, actor user:<usr_...> or guest:<gst_...> from player.subject, else service:games. No consumer yet.
++ */
++export interface GamesBlueprintUnlockedPayload {
++ /**
++ * The character. account network: a signed-in openvibe.network account, keyed by its canonical subject (user usr_..., or guest gst_... for a Network guest session). account guest: a local guest (browser token), or a Network sign-in whose token predates subject ids; no subject then.
++ */
++ player:
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "network";
++ subject:
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ };
++ }
++ | {
++ /**
++ * Games character id (packages/shared/src/ids.ts newPlayerId: 10 time + 10 random characters, lowercase Crockford base32). Also the envelope subject id.
++ */
++ id: string;
++ /**
++ * Character slot under the account (0-2; a local guest only has slot 0).
++ */
++ slot: number;
++ /**
++ * Character name from the client hello (1-24 characters); not unique.
++ */
++ name: string;
++ account: "guest";
++ };
++ /**
++ * World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved.
++ */
++ world: string;
++ /**
++ * Content recipe id of the blueprint recipe (e.g. craft_scrap_pistol).
++ */
++ recipe: string;
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.world.saved v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts worldSavedEvent, via GameEventRecorder.recordWorldSaved from game/gameServer.ts flush). The authoritative world was written: a checkpoint at most once per WORLD_SAVED_EVENT_MINUTES (default 15) and only when something was written, and always on shutdown, in the transaction of the flush that triggers it. The counts are totals of the flushes since `since` (the previous event, or process start); players_written counts dirty characters saved by flushes, not the single-character saves on disconnect. Envelope: subject { type: world, id: <world> }, visibility internal, priority low, actor service:games. No consumer yet.
++ */
++export interface GamesWorldSavedPayload {
++ /**
++ * World id (the loaded content world, e.g. openvibeville_v2). Also the envelope subject id of games.world.saved.
++ */
++ world: string;
++ reason: "checkpoint" | "shutdown";
++ /**
++ * World entity rows written since `since`.
++ */
++ entities_written: number;
++ /**
++ * Character rows written by flushes since `since`.
++ */
++ players_written: number;
++ /**
++ * Start of the period these totals cover.
++ */
++ since: string;
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.mod.installed v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.install in mods/registry.ts; POST /api/v1/mods). A games-content@1 mod was installed after its manifest and content pack were validated. requested is the manifest's capability list, granted the approved subset (only capabilities the games-content@1 runtime binds: games.world.announce, games.prop.place), status whether it was installed enabled. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).
++ */
++export interface GamesModInstalledPayload {
++ /**
++ * The install, from its validated mods/mod-manifest.v1 manifest.
++ */
++ mod: {
++ /**
++ * Mod id (manifest id). Also the envelope subject id.
++ */
++ id: string;
++ name: string;
++ /**
++ * Semantic version of the installed release.
++ */
++ version: string;
++ /**
++ * Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames).
++ */
++ target: string;
++ /**
++ * Install metadata set at install; never consulted by a grant check.
++ */
++ trust_tier: "unreviewed" | "reviewed" | "first-party";
++ };
++ /**
++ * manifest.permissions.capabilities, sorted.
++ *
++ * @maxItems 64
++ */
++ requested: string[];
++ /**
++ * The approved subset of requested, sorted (may be empty).
++ *
++ * @maxItems 64
++ */
++ granted: string[];
++ status: "enabled" | "disabled";
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.mod.enabled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.enable in mods/registry.ts; POST /api/v1/mods/:id/enable). A disabled install was enabled; its granted capabilities take effect on the next runtime reconcile. Not emitted when it was already enabled; a revoked install cannot be enabled. The payload is the mod only. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).
++ */
++export interface GamesModEnabledPayload {
++ /**
++ * The install, from its validated mods/mod-manifest.v1 manifest.
++ */
++ mod: {
++ /**
++ * Mod id (manifest id). Also the envelope subject id.
++ */
++ id: string;
++ name: string;
++ /**
++ * Semantic version of the installed release.
++ */
++ version: string;
++ /**
++ * Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames).
++ */
++ target: string;
++ /**
++ * Install metadata set at install; never consulted by a grant check.
++ */
++ trust_tier: "unreviewed" | "reviewed" | "first-party";
++ };
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.mod.disabled v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.disable in mods/registry.ts; POST /api/v1/mods/:id/disable). An enabled install was disabled: no capability check passes for it until it is enabled again, grants are kept. Not emitted when it was already disabled; a revoked install cannot change status. The payload is the mod only. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).
++ */
++export interface GamesModDisabledPayload {
++ /**
++ * The install, from its validated mods/mod-manifest.v1 manifest.
++ */
++ mod: {
++ /**
++ * Mod id (manifest id). Also the envelope subject id.
++ */
++ id: string;
++ name: string;
++ /**
++ * Semantic version of the installed release.
++ */
++ version: string;
++ /**
++ * Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames).
++ */
++ target: string;
++ /**
++ * Install metadata set at install; never consulted by a grant check.
++ */
++ trust_tier: "unreviewed" | "reviewed" | "first-party";
++ };
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.mod.grants_changed v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.grant and ModRegistry.revokeGrant in mods/registry.ts; POST /api/v1/mods/:id/grants, DELETE /api/v1/mods/:id/grants/:capability). One capability of a non-revoked install was granted (granted: [capability], revoked: []) or one active grant was revoked (granted: [], revoked: [capability]); each call changes exactly one capability today. Not emitted when nothing changed. Revoking the whole install is games.mod.revoked instead. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority low, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).
++ */
++export interface GamesModGrantsChangedPayload {
++ /**
++ * The install, from its validated mods/mod-manifest.v1 manifest.
++ */
++ mod: {
++ /**
++ * Mod id (manifest id). Also the envelope subject id.
++ */
++ id: string;
++ name: string;
++ /**
++ * Semantic version of the installed release.
++ */
++ version: string;
++ /**
++ * Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames).
++ */
++ target: string;
++ /**
++ * Install metadata set at install; never consulted by a grant check.
++ */
++ trust_tier: "unreviewed" | "reviewed" | "first-party";
++ };
++ /**
++ * Capabilities newly granted by this change.
++ *
++ * @maxItems 64
++ */
++ granted: string[];
++ /**
++ * Capabilities whose grant this change revoked.
++ *
++ * @maxItems 64
++ */
++ revoked: string[];
++}
++
++/** [email protected] (owner: games) */
++/**
++ * games.mod.revoked v1 (OpenVibe.Games apps/server/src/platform/gameEvents.ts modEvent, via ModRegistry.revoke in mods/registry.ts; POST /api/v1/mods/:id/revoke). Terminal: every active grant of the install was revoked and its status set to revoked; the runtime retracts the mod's announcements and props on the next tick. revoked_grants lists the grants that were active (may be empty); reason is the staff-supplied text (cut to 500 characters), absent when none was given. Not emitted again for an install that is already revoked. Written to Games' event_outbox in the same SQLite transaction as the registry and mod_audit rows (mods/registry.ts). The manifest, content pack, audit actor string and installer are left out. No consumer yet. Envelope: subject { type: mod, id: <mod.id> }, visibility internal, priority important, actor the staff member who made the change (user:<usr_...>), service:<client> for a principal token carrying games.mod.manage, or service:games (pre-subject staff token, editor key).
++ */
++export interface GamesModRevokedPayload {
++ /**
++ * The install, from its validated mods/mod-manifest.v1 manifest.
++ */
++ mod: {
++ /**
++ * Mod id (manifest id). Also the envelope subject id.
++ */
++ id: string;
++ name: string;
++ /**
++ * Semantic version of the installed release.
++ */
++ version: string;
++ /**
++ * Manifest target. Games only accepts games.browser today (mods/manifest.ts checkForGames).
++ */
++ target: string;
++ /**
++ * Install metadata set at install; never consulted by a grant check.
++ */
++ trust_tier: "unreviewed" | "reviewed" | "first-party";
++ };
++ /**
++ * Grants active before the revoke, sorted.
++ *
++ * @maxItems 64
++ */
++ revoked_grants: string[];
++ reason?: string;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.object.deleted v1 (OpenVibe.Media server/events.js objectChangeEvent and recordObjectChanges, commit 039dc48, from the media_object_changes rows that the AFTER UPDATE OF lifecycle_status trigger on media_objects writes). An object's lifecycle_status became deleted: a native object soft-deleted through DELETE /api/v2/:app/objects/:id, or an inherited vods/clips/files/pastes row removed, which marks its object deleted. Every write path counts (operator SQL included), and the event exists if and only if the change committed: the trigger row and the outbox envelope are written in the transaction of the change, or by the relay's drain for a change made outside Media's own transactions. Consumers drop any public copy, index entry or cache they hold for the object (roadmap §29.3). Deliberately minimal, identity and state only: never the title, description or metadata, the owner's ids or subject, storage keys, paths or providers, sizes or hashes. Developer-project sandbox tenants produce none. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority important, actor service:media.
++ */
++export interface MediaObjectDeletedPayload {
++ object_id: string;
++ /**
++ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ kind: "vod" | "clip" | "file" | "thumbnail" | "screenshot" | "avatar" | "asset";
++ /**
++ * The inherited row the object projects (legacy:<app>:<kind>:<id>, a media.media-ref@1 legacy id); null for a native v2 object.
++ */
++ legacy_ref: string | null;
++ /**
++ * When the change committed, ISO 8601 UTC.
++ */
++ deleted_at: string;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.object.visibility_changed v1 (OpenVibe.Media server/events.js objectChangeEvent and recordObjectChanges, commit 039dc48, from the media_object_changes rows that the AFTER UPDATE OF visibility trigger on media_objects writes). An object's visibility changed to another value (public, unlisted or private), by any write path: the v2 objects API, the inherited vods/clips/files rows it projects, or operator SQL. Never emitted when the value did not change, nor for an object that is deleted at the time of the change (that object's last word is media.object.deleted). The event exists if and only if the change committed: the trigger row and the outbox envelope are written in the transaction of the change, or by the relay's drain for a change made outside Media's own transactions. Consumers that hold a public copy, index entry or cache of the object re-check it (roadmap §29.3); a move away from public means stop showing it. Deliberately minimal, identity and state only: never the title, description or metadata, the owner's ids or subject, storage keys, paths or providers, sizes or hashes. Developer-project sandbox tenants produce none. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority important, actor service:media.
++ */
++export interface MediaObjectVisibilityChangedPayload {
++ object_id: string;
++ /**
++ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ kind: "vod" | "clip" | "file" | "thumbnail" | "screenshot" | "avatar" | "asset";
++ /**
++ * The inherited row the object projects (legacy:<app>:<kind>:<id>, a media.media-ref@1 legacy id); null for a native v2 object.
++ */
++ legacy_ref: string | null;
++ /**
++ * The new value.
++ */
++ visibility: "public" | "unlisted" | "private";
++ /**
++ * The value before the change; never equal to visibility.
++ */
++ previous_visibility: "public" | "unlisted" | "private";
++ /**
++ * When the change committed, ISO 8601 UTC.
++ */
++ changed_at: string;
++}
ddiff --git a/manifests/services/media.json b/manifests/services/media.json
iindex 18843c72..30b2ced4 100644
---- a/manifests/services/media.json
++++ b/manifests/services/media.json
@@@ -26,7 +26,9 @@
"media.job.retrying",
"media.job.started",
"media.job.succeeded",
++ "media.object.deleted",
"media.object.uploaded",
++ "media.object.visibility_changed",
"media.storage.alert",
"media.storage.recovered",
"media.vod.failed",
ddiff --git a/test/run.js b/test/run.js
iindex 21a07f73..6a659a86 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -305,6 +305,29 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
}
}
++// ── Payload contracts added in v0.34.0 (News, Reviews, Coupons, Deals, Trade, Games, Media) ──
++// Every event these producers list has a payload contract; Search documents are index-document@1
++// with the owner fixed; Media's object events carry identity and state only.
++{
++ for (const svc of ['news', 'reviews', 'coupons', 'deals', 'trade', 'games']) {
++ for (const t of services.get(svc).eventsProduced) ok(contracts.catalog.some(c => c.id === t && c.owner === svc), `${t} has a payload contract owned by ${svc}`);
++ }
++ for (const c of contracts.catalog.filter(x => /\.index_document\.upserted$/.test(x.id))) {
++ const s = contracts.schema(c.id);
++ if (s.$ref) ok(s.$ref === '../../search/index-document.v1.json', `${c.id} is a search.index-document@1`); // sources (v0.18): any owner
++ else ok(s.allOf && s.allOf[0].$ref === '../../search/index-document.v1.json' && s.allOf[1].properties.owner.const === c.owner && s.allOf[1].properties.deleted.const === false, `${c.id} is a search.index-document@1 owned by ${c.owner}`);
++ const del = contracts.schema(c.id.replace(/upserted$/, 'deleted'));
++ ok(JSON.stringify([...del.required].sort()) === '["id","revision","type"]' && del.additionalProperties === false, `${c.id.replace(/upserted$/, 'deleted')} is a { type, id, revision } tombstone`);
++ }
++ const objDel = contracts.schema('media.object.deleted'), objVis = contracts.schema('media.object.visibility_changed');
++ const leaks = ['title', 'description', 'metadata', 'owner_subject', 'owner_user_id', 'owner_app', 'namespace', 'key', 'storage_key', 'bucket', 'provider', 'size_bytes', 'content_hash', 'path'];
++ for (const s of [objDel, objVis]) ok(s.additionalProperties === false && leaks.every(k => !(k in s.properties)) && JSON.stringify([...s.required].sort()) === JSON.stringify(Object.keys(s.properties).sort()), `${s.title}: identity and state only, every field always present`);
++ ok(JSON.stringify(objVis.properties.visibility.enum) === JSON.stringify(objVis.properties.previous_visibility.enum), 'visibility and previous_visibility take the same values');
++ const vis = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/media.object.visibility_changed/valid/clip-made-private.json'), 'utf8'));
++ for (const v of objVis.properties.visibility.enum) ok(!contracts.validate('media.object.visibility_changed@1', { ...vis, visibility: v, previous_visibility: v }).valid, `visibility_changed never repeats the value (${v})`);
++ ok(services.get('media').eventsProduced.includes('media.object.deleted') && services.get('media').eventsProduced.includes('media.object.visibility_changed'), 'the media manifest lists its object events');
++}
++
// ── Mod manifest 1.1.0 (v0.34.0, ADR-013 amendment) ──────────────────────
// Read/write grants, billing hooks and dependencies are optional, so every 1.0.0 manifest stays
// valid; the split uses exactly the namespace patterns of the older lists; money goes only through