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
| Field | Type | Required | Description | Constraints |
|---|---|---|---|---|
id | string | yes |
| |
name | string | |||
version | string | yes |
| |
status | enum | yes |
| |
repository | string | yes |
| |
domains | array of string | yes | ||
publicOrigin | string |
| ||
health | string |
| ||
ready | string |
| ||
internalOrigin | string | Where 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. |
| |
exposure | object | Where 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. |
| |
exposure.state | enum | yes |
| |
exposure.publicSite | enum | What the public domain answers today: the service itself, a placeholder page, or nothing (no public domain). |
| |
exposure.note | string |
| ||
exposure.package | string | For a library: the npm package name. |
| |
exposure.repo | string | For a library: the OpenVibers repository its release tags live in. |
| |
site | object | How 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. |
| |
site.name | string | yes | Short name after 'OpenVibe.' (or a full name containing a dot). |
|
site.icon | string | yes |
| |
site.tagline | string | yes |
| |
site.what | string | yes |
| |
site.legalProfile | enum | yes | Which legal wording its /terms, /privacy and /dmca use (openvibe-shared/legal). |
|
site.position | integer | Order in site lists. |
| |
site.host | string | Only while the manifest has no publicOrigin. |
| |
site.tld | https://openvibe.network/contracts/registry/product.v1.json#/$defs/tld | |||
site.accent | https://openvibe.network/contracts/registry/product.v1.json#/$defs/accent | |||
site.description | https://openvibe.network/contracts/registry/product.v1.json#/$defs/description | |||
site.pillars | https://openvibe.network/contracts/registry/product.v1.json#/$defs/pillars | |||
site.faq | https://openvibe.network/contracts/registry/product.v1.json#/$defs/faq | |||
site.keywords | https://openvibe.network/contracts/registry/product.v1.json#/$defs/keywords | |||
site.vision | https://openvibe.network/contracts/registry/product.v1.json#/$defs/vision | |||
site.highlight | https://openvibe.network/contracts/registry/product.v1.json#/$defs/highlight | |||
site.launch | https://openvibe.network/contracts/registry/product.v1.json#/$defs/launch | |||
site.relationships | https://openvibe.network/contracts/registry/product.v1.json#/$defs/relationships | |||
capabilities | array of string | yes | ||
eventsProduced | array of string | yes | ||
eventsConsumed | array of string | yes | ||
namespacesOwned | array of string | yes | ||
contractRanges | object | |||
lifecycle | object | How 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. |
| |
lifecycle.liveness | one of | yes | The endpoint that says the process is alive, and what a 200 from it proves. Readiness (can it serve?) is `ready`. | |
lifecycle.shutdown | one of | yes | What happens between the stop signal and the process exiting. | |
lifecycle.startupRecovery | one of | yes | Work a stop or a crash interrupted that the next start picks up. | |
lifecycle.rollback | one of | yes | When a release is rolled back, for how long that happens by itself, and what makes going back unsafe. | |
lifecycle.contracts | one of | yes | The openvibe-contracts versions the service accepts. | |
lifecycle.leases | one of | yes | What the service claims so that one owner acts at a time, and how a stale owner is fenced. | |
notes | string |
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 }] }