ServiceManifest registry.service-manifest@1

Generated at from openvibe-contracts v0.114.0 and openvibe-sdk v0.35.1.

Version
1.1.0
Owner
network
Visibility
public
Status
active
Compatibility
backward
Schema
https://openvibe.network/contracts/registry/service-manifest.v1.json

What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.

Fields

FieldTypeRequiredDescriptionConstraints
idstringyes
  • pattern ^[a-z][a-z0-9-]{1,39}$
namestring
versionstringyes
  • pattern ^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$
statusenumyes
  • one of "placeholder", "alpha", "beta", "stable", "degraded", "retired"
repositorystringyes
  • pattern ^OpenVibers/[A-Za-z0-9._-]+$
domainsarray of stringyes
publicOriginstring
  • format uri
healthstring
  • pattern ^/
readystring
  • pattern ^/
internalOriginstringWhere the service answers on its host (loopback only): the address the registry polls health and readiness from. A host inventory or an OV_<ID>_INTERNAL_URL loopback override may replace it; a service with nothing to run has none.
  • pattern ^http://(127\.0\.0\.1|localhost|\[::1\]):[0-9]{2,5}$
exposureobjectWhere the service can actually be reached today, which is separate from its maturity (status): live (its public domain serves it), internal (loopback only; the domain, if any, still serves a placeholder), library (a released package), repository (code with CI, nothing released or run), placeholder (charter only) or retired. When a service goes public, this changes in the same release that points its domain at it.
  • no other fields
exposure.stateenumyes
  • one of "live", "internal", "library", "repository", "placeholder", "retired"
exposure.publicSiteenumWhat the public domain answers today: the service itself, a placeholder page, or nothing (no public domain).
  • one of "service", "placeholder", null
exposure.notestring
  • maxLength 300
exposure.packagestringFor a library: the npm package name.
  • pattern ^[a-z0-9@/._-]+$
exposure.repostringFor a library: the OpenVibers repository its release tags live in.
  • pattern ^[A-Za-z0-9._-]+$
siteobjectHow the network presents the service as a site people visit (the navigation, the network home page, legal pages). A service without it is not a site. 1.1.0 (plan T11 lane D) adds the optional showcase fields copied from the OpenVibe.Sites catalog (tld, accent, description, pillars, faq, keywords, vision, highlight, launch, relationships; defined in registry.product@1): a site carrying tld is the catalog home of its domain (its publicOrigin host, or host), so that domain has no manifests/products file.
  • no other fields
site.namestringyesShort name after 'OpenVibe.' (or a full name containing a dot).
  • maxLength 40
site.iconstringyes
  • pattern ^[a-z0-9-]+$
site.taglinestringyes
  • maxLength 60
site.whatstringyes
  • maxLength 300
site.legalProfileenumyesWhich legal wording its /terms, /privacy and /dmca use (openvibe-shared/legal).
  • one of "streaming", "tools", "ugc", "games", "hosting", "account", "info"
site.positionintegerOrder in site lists.
  • minimum 0
site.hoststringOnly while the manifest has no publicOrigin.
  • pattern ^[a-z0-9.-]+$
site.tldhttps://openvibe.network/contracts/registry/product.v1.json#/$defs/tld
site.accenthttps://openvibe.network/contracts/registry/product.v1.json#/$defs/accent
site.descriptionhttps://openvibe.network/contracts/registry/product.v1.json#/$defs/description
site.pillarshttps://openvibe.network/contracts/registry/product.v1.json#/$defs/pillars
site.faqhttps://openvibe.network/contracts/registry/product.v1.json#/$defs/faq
site.keywordshttps://openvibe.network/contracts/registry/product.v1.json#/$defs/keywords
site.visionhttps://openvibe.network/contracts/registry/product.v1.json#/$defs/vision
site.highlighthttps://openvibe.network/contracts/registry/product.v1.json#/$defs/highlight
site.launchhttps://openvibe.network/contracts/registry/product.v1.json#/$defs/launch
site.relationshipshttps://openvibe.network/contracts/registry/product.v1.json#/$defs/relationships
capabilitiesarray of stringyes
eventsProducedarray of stringyes
eventsConsumedarray of stringyes
namespacesOwnedarray of stringyes
contractRangesobject
lifecycleobjectHow the service starts, stops, recovers and is rolled back (roadmap WS-P task 1), taken from its code and its systemd units, never from intent. A part that does not apply is { "none": "<why>" }. `ovhost validate` refuses a host service whose lifecycle lacks a field, whose shutdown deadline exceeds its unit's TimeoutStopSec, or whose installed openvibe-contracts is outside contracts.range.
  • no other fields
lifecycle.livenessone ofyesThe endpoint that says the process is alive, and what a 200 from it proves. Readiness (can it serve?) is `ready`.
lifecycle.shutdownone ofyesWhat happens between the stop signal and the process exiting.
lifecycle.startupRecoveryone ofyesWork a stop or a crash interrupted that the next start picks up.
lifecycle.rollbackone ofyesWhen a release is rolled back, for how long that happens by itself, and what makes going back unsafe.
lifecycle.contractsone ofyesThe openvibe-contracts versions the service accepts.
lifecycle.leasesone ofyesWhat the service claims so that one owner acts at a time, and how a stale owner is fenced.
notesstring

Examples

From the contract's own test fixtures: valid ones validate, rejected ones must fail.

Valid: library-lifecycle
{
  "id": "sdk-like",
  "version": "0.1.0",
  "status": "alpha",
  "repository": "OpenVibers/OpenVibe.SDK",
  "domains": [],
  "capabilities": [],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [],
  "lifecycle": {
    "liveness": {
      "none": "a library; nothing runs"
    },
    "shutdown": {
      "none": "a library; nothing runs"
    },
    "startupRecovery": {
      "none": "a library; nothing runs"
    },
    "rollback": {
      "none": "consumers pin tags"
    },
    "contracts": {
      "none": "no manifest range"
    },
    "leases": {
      "none": "a library; nothing runs"
    }
  }
}
Valid: media
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": "^0.1.0"
  },
  "id": "media",
  "name": "OpenVibe.Media",
  "status": "beta",
  "repository": "OpenVibers/OpenVibe.Media",
  "domains": [
    "openvibe.media"
  ],
  "publicOrigin": "https://openvibe.media",
  "health": "/healthz",
  "capabilities": [
    "media.object.upload",
    "media.object.read"
  ],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [
    "media.*"
  ]
}
Valid: site-catalog
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": ">=0.19.0 <1.0.0"
  },
  "id": "news",
  "name": "OpenVibe.News",
  "status": "alpha",
  "repository": "OpenVibers/OpenVibe.News",
  "domains": [
    "openvibe.news"
  ],
  "publicOrigin": "https://openvibe.news",
  "health": "/api/health",
  "ready": "/api/ready",
  "internalOrigin": "http://127.0.0.1:4820",
  "exposure": {
    "state": "internal",
    "publicSite": "placeholder",
    "note": "the domain serves the OpenVibe.Sites placeholder page"
  },
  "site": {
    "name": "News",
    "icon": "news",
    "tagline": "Sourced, not spun",
    "what": "Source-backed stories: clustered coverage, cited summaries, multiple perspectives and timelines.",
    "legalProfile": "info",
    "position": 10,
    "tld": "news",
    "accent": "#fb923c",
    "description": "OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.",
    "pillars": [
      {
        "title": "Sources first",
        "text": "Every summary links to what it was built from."
      },
      {
        "title": "Many angles",
        "text": "Read the same story from several perspectives, side by side."
      },
      {
        "title": "Your framing",
        "text": "You choose the lens; nothing is chosen for you silently."
      },
      {
        "title": "Topics",
        "text": "Follow topics across sources, with your OpenVibe account."
      }
    ],
    "keywords": "openvibe news, transparent news, news aggregation, multiple perspectives",
    "launch": "a person to verify a news source's terms and an editor to publish the first reviewed story",
    "relationships": {
      "plannedRepo": "OpenVibe.News",
      "meanwhile": [
        {
          "label": "Network updates",
          "url": "https://openvibe.live/updates",
          "note": "What is happening across OpenVibe."
        }
      ]
    }
  },
  "capabilities": [
    "news.story.create",
    "news.story.read",
    "news.story.revise",
    "news.story.publish",
    "news.story.retract",
    "news.cluster.read",
    "news.cluster.manage",
    "news.source.attach",
    "news.timeline.update",
    "news.perspective.update",
    "news.topic.manage"
  ],
  "eventsProduced": [
    "news.source.ingested",
    "news.source.failed",
    "news.cluster.updated",
    "news.story.created",
    "news.story.flagged",
    "news.story.published",
    "news.story.updated",
    "news.story.unpublished",
    "news.story.retracted",
    "news.index_document.upserted",
    "news.index_document.deleted",
    "news.moderation.action"
  ],
  "eventsConsumed": [
    "sources.item.created",
    "sources.item.updated",
    "sources.item.removed",
    "sources.fetch.failed"
  ],
  "namespacesOwned": [
    "news.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/api/health",
      "means": "the process is up and answers HTTP; it checks nothing else (the database and dependencies are what ready checks)"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "deadlineSeconds": 5,
      "drains": [
        "worker stopped (Sources cursor pull)",
        "HTTP server closed",
        "outbox relay stopped (awaited)",
        "store closed"
      ]
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "consumer",
          "what": "the OpenVibe.Sources change feed is pulled again from the stored cursor, so items published during the stop arrive (idempotent per item revision)"
        },
        {
          "kind": "consumer",
          "what": "OpenVibe.Events redelivers sources.item.* and sources.fetch.failed that were pending during the stop (at least once, deduped by event_id)"
        },
        {
          "kind": "outbox",
          "what": "unsent events in the outbox table go to OpenVibe.Events once the relay starts (at least once; Events dedupes by event_id)"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: ovhost deploy puts the previous sha back and restarts again when the ready URL does not answer 2xx within ready.timeoutSeconds after the restart (exit 3)",
        "manual: ovhost rollback <service> --to <sha>"
      ],
      "window": "automatic only while ovhost waits for readiness after the restart (30 s in host.example.json); manual afterwards, as long as no blocker applies",
      "blockers": {
        "none": "the schema code only adds (CREATE … IF NOT EXISTS, ADD COLUMN; no DROP, RENAME or table rebuild), which an older release ignores"
      }
    },
    "contracts": {
      "range": ">=0.19.0 <1.0.0"
    },
    "leases": {
      "none": "no claims: one process owns news.db (better-sqlite3, single writer)"
    }
  },
  "notes": "Port 4820. Owns news_topics, news_source_items, news_story_clusters, news_stories, news_story_revisions, news_story_sources, news_perspectives, news_timeline_entries, news_editorial_flags in its own SQLite (openvibe-publishing packages, prefixes news_story and news). Consumes OpenVibe.Sources (sources.item.read, optionally sources.source.read) through an Events subscription (sources.item.*, sources.fetch.failed; events.subscription.manage) and a cursor pull; OpenVibe.Network (SSO, identity.subject.resolve); optionally OpenVibe.AI (ai.run.create, namespace news) for news.summarize_story and news.compare_perspectives drafts; publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads news.index_document.* from Events."
}
Valid: with-lifecycle
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": "^0.1.0"
  },
  "id": "media",
  "name": "OpenVibe.Media",
  "status": "beta",
  "repository": "OpenVibers/OpenVibe.Media",
  "domains": [
    "openvibe.media"
  ],
  "publicOrigin": "https://openvibe.media",
  "health": "/healthz",
  "capabilities": [
    "media.object.upload",
    "media.object.read"
  ],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [
    "media.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/healthz",
      "means": "the process is up and answers HTTP; it checks nothing else"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "deadlineSeconds": 70,
      "drains": [
        "recordings stopped",
        "HTTP server closed, then the database"
      ],
      "note": "forced exit after 70 s"
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "jobs",
          "what": "jobs left running are retried or failed"
        },
        {
          "kind": "outbox",
          "what": "unsent events go to OpenVibe.Events"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: readiness not reached after the restart"
      ],
      "window": "while the deploy waits for readiness",
      "blockers": {
        "none": "the schema code only adds"
      }
    },
    "contracts": {
      "range": "^0.1.0"
    },
    "leases": {
      "claims": [
        {
          "what": "a job row",
          "holder": "the Media process",
          "expires": "lease_until, renewed while running",
          "fencing": "status must still be running"
        }
      ]
    }
  }
}
Rejected: bad-status
{
  "id": "x1",
  "version": "1.0.0",
  "status": "live",
  "repository": "OpenVibers/X",
  "domains": [],
  "capabilities": [],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": []
}
Rejected: lifecycle-claim-without-fencing
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": "^0.1.0"
  },
  "id": "media",
  "name": "OpenVibe.Media",
  "status": "beta",
  "repository": "OpenVibers/OpenVibe.Media",
  "domains": [
    "openvibe.media"
  ],
  "publicOrigin": "https://openvibe.media",
  "health": "/healthz",
  "capabilities": [
    "media.object.upload",
    "media.object.read"
  ],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [
    "media.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/healthz",
      "means": "the process is up and answers HTTP; it checks nothing else"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "deadlineSeconds": 70,
      "drains": [
        "recordings stopped",
        "HTTP server closed, then the database"
      ],
      "note": "forced exit after 70 s"
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "jobs",
          "what": "jobs left running are retried or failed"
        },
        {
          "kind": "outbox",
          "what": "unsent events go to OpenVibe.Events"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: readiness not reached after the restart"
      ],
      "window": "while the deploy waits for readiness",
      "blockers": {
        "none": "the schema code only adds"
      }
    },
    "contracts": {
      "range": "^0.1.0"
    },
    "leases": {
      "claims": [
        {
          "what": "a job row",
          "holder": "the Media process",
          "expires": "lease_until, renewed while running"
        }
      ]
    }
  }
}
Rejected: lifecycle-no-deadline
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": "^0.1.0"
  },
  "id": "media",
  "name": "OpenVibe.Media",
  "status": "beta",
  "repository": "OpenVibers/OpenVibe.Media",
  "domains": [
    "openvibe.media"
  ],
  "publicOrigin": "https://openvibe.media",
  "health": "/healthz",
  "capabilities": [
    "media.object.upload",
    "media.object.read"
  ],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [
    "media.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/healthz",
      "means": "the process is up and answers HTTP; it checks nothing else"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "drains": [
        "recordings stopped",
        "HTTP server closed, then the database"
      ],
      "note": "forced exit after 70 s"
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "jobs",
          "what": "jobs left running are retried or failed"
        },
        {
          "kind": "outbox",
          "what": "unsent events go to OpenVibe.Events"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: readiness not reached after the restart"
      ],
      "window": "while the deploy waits for readiness",
      "blockers": {
        "none": "the schema code only adds"
      }
    },
    "contracts": {
      "range": "^0.1.0"
    },
    "leases": {
      "claims": [
        {
          "what": "a job row",
          "holder": "the Media process",
          "expires": "lease_until, renewed while running",
          "fencing": "status must still be running"
        }
      ]
    }
  }
}
Rejected: lifecycle-no-leases
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": "^0.1.0"
  },
  "id": "media",
  "name": "OpenVibe.Media",
  "status": "beta",
  "repository": "OpenVibers/OpenVibe.Media",
  "domains": [
    "openvibe.media"
  ],
  "publicOrigin": "https://openvibe.media",
  "health": "/healthz",
  "capabilities": [
    "media.object.upload",
    "media.object.read"
  ],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [
    "media.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/healthz",
      "means": "the process is up and answers HTTP; it checks nothing else"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "deadlineSeconds": 70,
      "drains": [
        "recordings stopped",
        "HTTP server closed, then the database"
      ],
      "note": "forced exit after 70 s"
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "jobs",
          "what": "jobs left running are retried or failed"
        },
        {
          "kind": "outbox",
          "what": "unsent events go to OpenVibe.Events"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: readiness not reached after the restart"
      ],
      "window": "while the deploy waits for readiness",
      "blockers": {
        "none": "the schema code only adds"
      }
    },
    "contracts": {
      "range": "^0.1.0"
    }
  }
}
Rejected: lifecycle-none-without-reason
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": "^0.1.0"
  },
  "id": "media",
  "name": "OpenVibe.Media",
  "status": "beta",
  "repository": "OpenVibers/OpenVibe.Media",
  "domains": [
    "openvibe.media"
  ],
  "publicOrigin": "https://openvibe.media",
  "health": "/healthz",
  "capabilities": [
    "media.object.upload",
    "media.object.read"
  ],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [
    "media.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/healthz",
      "means": "the process is up and answers HTTP; it checks nothing else"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "deadlineSeconds": 70,
      "drains": [
        "recordings stopped",
        "HTTP server closed, then the database"
      ],
      "note": "forced exit after 70 s"
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "jobs",
          "what": "jobs left running are retried or failed"
        },
        {
          "kind": "outbox",
          "what": "unsent events go to OpenVibe.Events"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: readiness not reached after the restart"
      ],
      "window": "while the deploy waits for readiness",
      "blockers": {
        "none": "the schema code only adds"
      }
    },
    "contracts": {
      "range": "^0.1.0"
    },
    "leases": {
      "none": ""
    }
  }
}
Rejected: lifecycle-unknown-recovery-kind
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": "^0.1.0"
  },
  "id": "media",
  "name": "OpenVibe.Media",
  "status": "beta",
  "repository": "OpenVibers/OpenVibe.Media",
  "domains": [
    "openvibe.media"
  ],
  "publicOrigin": "https://openvibe.media",
  "health": "/healthz",
  "capabilities": [
    "media.object.upload",
    "media.object.read"
  ],
  "eventsProduced": [],
  "eventsConsumed": [],
  "namespacesOwned": [
    "media.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/healthz",
      "means": "the process is up and answers HTTP; it checks nothing else"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "deadlineSeconds": 70,
      "drains": [
        "recordings stopped",
        "HTTP server closed, then the database"
      ],
      "note": "forced exit after 70 s"
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "magic",
          "what": "jobs left running are retried or failed"
        },
        {
          "kind": "outbox",
          "what": "unsent events go to OpenVibe.Events"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: readiness not reached after the restart"
      ],
      "window": "while the deploy waits for readiness",
      "blockers": {
        "none": "the schema code only adds"
      }
    },
    "contracts": {
      "range": "^0.1.0"
    },
    "leases": {
      "claims": [
        {
          "what": "a job row",
          "holder": "the Media process",
          "expires": "lease_until, renewed while running",
          "fencing": "status must still be running"
        }
      ]
    }
  }
}
Rejected: site-catalog-bad-accent
{
  "version": "0.1.0",
  "contractRanges": {
    "openvibe-contracts": ">=0.19.0 <1.0.0"
  },
  "id": "news",
  "name": "OpenVibe.News",
  "status": "alpha",
  "repository": "OpenVibers/OpenVibe.News",
  "domains": [
    "openvibe.news"
  ],
  "publicOrigin": "https://openvibe.news",
  "health": "/api/health",
  "ready": "/api/ready",
  "internalOrigin": "http://127.0.0.1:4820",
  "exposure": {
    "state": "internal",
    "publicSite": "placeholder",
    "note": "the domain serves the OpenVibe.Sites placeholder page"
  },
  "site": {
    "name": "News",
    "icon": "news",
    "tagline": "Sourced, not spun",
    "what": "Source-backed stories: clustered coverage, cited summaries, multiple perspectives and timelines.",
    "legalProfile": "info",
    "position": 10,
    "tld": "news",
    "accent": "#FB923C",
    "description": "OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.",
    "pillars": [
      {
        "title": "Sources first",
        "text": "Every summary links to what it was built from."
      },
      {
        "title": "Many angles",
        "text": "Read the same story from several perspectives, side by side."
      },
      {
        "title": "Your framing",
        "text": "You choose the lens; nothing is chosen for you silently."
      },
      {
        "title": "Topics",
        "text": "Follow topics across sources, with your OpenVibe account."
      }
    ],
    "keywords": "openvibe news, transparent news, news aggregation, multiple perspectives",
    "launch": "a person to verify a news source's terms and an editor to publish the first reviewed story",
    "relationships": {
      "plannedRepo": "OpenVibe.News",
      "meanwhile": [
        {
          "label": "Network updates",
          "url": "https://openvibe.live/updates",
          "note": "What is happening across OpenVibe."
        }
      ]
    }
  },
  "capabilities": [
    "news.story.create",
    "news.story.read",
    "news.story.revise",
    "news.story.publish",
    "news.story.retract",
    "news.cluster.read",
    "news.cluster.manage",
    "news.source.attach",
    "news.timeline.update",
    "news.perspective.update",
    "news.topic.manage"
  ],
  "eventsProduced": [
    "news.source.ingested",
    "news.source.failed",
    "news.cluster.updated",
    "news.story.created",
    "news.story.flagged",
    "news.story.published",
    "news.story.updated",
    "news.story.unpublished",
    "news.story.retracted",
    "news.index_document.upserted",
    "news.index_document.deleted",
    "news.moderation.action"
  ],
  "eventsConsumed": [
    "sources.item.created",
    "sources.item.updated",
    "sources.item.removed",
    "sources.fetch.failed"
  ],
  "namespacesOwned": [
    "news.*"
  ],
  "lifecycle": {
    "liveness": {
      "endpoint": "/api/health",
      "means": "the process is up and answers HTTP; it checks nothing else (the database and dependencies are what ready checks)"
    },
    "shutdown": {
      "signal": "SIGTERM",
      "deadlineSeconds": 5,
      "drains": [
        "worker stopped (Sources cursor pull)",
        "HTTP server closed",
        "outbox relay stopped (awaited)",
        "store closed"
      ]
    },
    "startupRecovery": {
      "resumes": [
        {
          "kind": "consumer",
          "what": "the OpenVibe.Sources change feed is pulled again from the stored cursor, so items published during the stop arrive (idempotent per item revision)"
        },
        {
          "kind": "consumer",
          "what": "OpenVibe.Events redelivers sources.item.* and sources.fetch.failed that were pending during the stop (at least once, deduped by event_id)"
        },
        {
          "kind": "outbox",
          "what": "unsent events in the outbox table go to OpenVibe.Events once the relay starts (at least once; Events dedupes by event_id)"
        }
      ]
    },
    "rollback": {
      "conditions": [
        "automatic: ovhost deploy puts the previous sha back and restarts again when the ready URL does not answer 2xx within ready.timeoutSeconds after the restart (exit 3)",
        "manual: ovhost rollback <service> --to <sha>"
      ],
      "window": "automatic only while ovhost waits for readiness after the restart (30 s in host.example.json); manual afterwards, as long as no blocker applies",
      "blockers": {
        "none": "the schema code only adds (CREATE … IF NOT EXISTS, ADD COLUMN; no DROP, RENAME or table rebuild), which an older release ignores"
      }
    },
    "contracts": {
      "range": ">=0.19.0 <1.0.0"
    },
    "leases": {
      "none": "no claims: one process owns news.db (better-sqlite3, single writer)"
    }
  },
  "notes": "Port 4820. Owns news_topics, news_source_items, news_story_clusters, news_stories, news_story_revisions, news_story_sources, news_perspectives, news_timeline_entries, news_editorial_flags in its own SQLite (openvibe-publishing packages, prefixes news_story and news). Consumes OpenVibe.Sources (sources.item.read, optionally sources.source.read) through an Events subscription (sources.item.*, sources.fetch.failed; events.subscription.manage) and a cursor pull; OpenVibe.Network (SSO, identity.subject.resolve); optionally OpenVibe.AI (ai.run.create, namespace news) for news.summarize_story and news.compare_perspectives drafts; publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads news.index_document.* from Events."
}

Validate

const contracts = require('openvibe-contracts');
contracts.validate('registry.service-manifest@1', value);   // { valid, errors: [{ path, message }] }