openvibers/openvibe.contracts Commits

Capability schemas (WS-C task 4) for Blog and Coupons: blog and post records, blog create/configure/theme, members, post create/update/publish/schedule/delete, post reads and the JSON Feed; coupons merchant and coupon records, host resolve, lookups, submissions, reports, status changes and merchant management. 18 capabilities complete; gaps 64 -> 46 (unreleased)

OP OpenVibers committed 25 Sept 2026 · 4691e87c31 · 121 files, +10857 −5430


Changes

ddiff --git a/compatibility/capability-schema-gaps.json b/compatibility/capability-schema-gaps.json
iindex df29c26d..28e2499d 100644
---- a/compatibility/capability-schema-gaps.json
++++ b/compatibility/capability-schema-gaps.json
@@@ -1,132 +1,6 @@
{
"_about": "Active capabilities still without an input or output schema (roadmap WS-C task 4). test/capability-schemas.test.js fails on any active capability missing a schema that is not listed here, and on a listed one that has since gained it (remove it from this list). The list only shrinks: fill a capability's schemas, then delete its row. A request with no body names common.no-body@1; a response of bytes names common.binary@1.",
"gaps": [
-- {
-- "id": "blog.blog.configure",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.blog.create",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.feed.read",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.member.manage",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.post.create",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.post.delete",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.post.publish",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.post.read",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.post.schedule",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.post.unpublish",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.post.update",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "blog.theme.set",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "coupons.coupon.lookup",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "coupons.coupon.submit",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "coupons.merchant.manage",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "coupons.merchant.resolve",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "coupons.report.create",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
-- {
-- "id": "coupons.status.update",
-- "missing": [
-- "inputSchema",
-- "outputSchema"
-- ]
-- },
{
"id": "deals.flag.create",
"missing": [
ddiff --git a/contracts/blog/blog-configure-request.v1.json b/contracts/blog/blog-configure-request.v1.json
nnew file mode 100644
iindex 00000000..0f2a4292
---- /dev/null
++++ b/contracts/blog/blog-configure-request.v1.json
@@@ -0,0 +1,70 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog-configure-request.v1.json",
++ "title": "BlogBlogConfigureRequest",
++ "description": "blog.blog-configure-request@1: the body of PATCH /api/v1/blogs/:handle on OpenVibe.Blog (blog.blog.configure; an owner): title (at most 120 characters, not blank), description (500), language (a BCP 47 tag) and feeds { rss, atom, json, item_count (1-50), full_content }. Fields left out keep their value.",
++ "type": "object",
++ "properties": {
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "language": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$"
++ },
++ "feeds": {
++ "type": "object",
++ "properties": {
++ "rss": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "atom": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "json": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "item_count": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 1,
++ "maximum": 50,
++ "pattern": "^[0-9]+$"
++ },
++ "full_content": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/blog/blog-create-request.v1.json b/contracts/blog/blog-create-request.v1.json
nnew file mode 100644
iindex 00000000..fdf485ee
---- /dev/null
++++ b/contracts/blog/blog-create-request.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog-create-request.v1.json",
++ "title": "BlogBlogCreateRequest",
++ "description": "blog.blog-create-request@1: the body of POST /api/v1/blogs on OpenVibe.Blog (blog.blog.create): get or create the acting member's blog (one per usr_ subject). handle defaults to their Network username (2-40 of a-z, 0-9, _ and -, not a reserved word); title and description are optional.",
++ "type": "object",
++ "properties": {
++ "handle": {
++ "type": "string",
++ "pattern": "^[a-z0-9_-]{2,40}$"
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++}
ddiff --git a/contracts/blog/blog-create-result.v1.json b/contracts/blog/blog-create-result.v1.json
nnew file mode 100644
iindex 00000000..5478ef74
---- /dev/null
++++ b/contracts/blog/blog-create-result.v1.json
@@@ -0,0 +1,19 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog-create-result.v1.json",
++ "title": "BlogBlogCreateResult",
++ "description": "blog.blog-create-result@1: the answer of POST /api/v1/blogs on OpenVibe.Blog: { blog, created } — 201 when the blog was created, 200 when the member already had it.",
++ "type": "object",
++ "required": [
++ "blog",
++ "created"
++ ],
++ "properties": {
++ "blog": {
++ "$ref": "blog.v1.json"
++ },
++ "created": {
++ "type": "boolean"
++ }
++ }
++}
ddiff --git a/contracts/blog/blog-result.v1.json b/contracts/blog/blog-result.v1.json
nnew file mode 100644
iindex 00000000..21c9328a
---- /dev/null
++++ b/contracts/blog/blog-result.v1.json
@@@ -0,0 +1,21 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog-result.v1.json",
++ "title": "BlogBlogResult",
++ "description": "blog.blog-result@1: the answer of PATCH /api/v1/blogs/:handle (blog.blog.configure) and PUT /api/v1/blogs/:handle/theme (blog.theme.set) on OpenVibe.Blog: { blog } with the member view; the theme answer adds themes (the presets).",
++ "type": "object",
++ "required": [
++ "blog"
++ ],
++ "properties": {
++ "blog": {
++ "$ref": "blog.v1.json"
++ },
++ "themes": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ }
++}
ddiff --git a/contracts/blog/blog.v1.json b/contracts/blog/blog.v1.json
nnew file mode 100644
iindex 00000000..fd68c7e6
---- /dev/null
++++ b/contracts/blog/blog.v1.json
@@@ -0,0 +1,100 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog.v1.json",
++ "title": "BlogBlog",
++ "description": "blog.blog@1: one blog as OpenVibe.Blog's API shows it (server/http/api.js blogDto). Members of the blog (and staff) also see status, owner_subject and feed_settings.",
++ "type": "object",
++ "required": [
++ "id",
++ "kind",
++ "handle",
++ "title",
++ "url",
++ "theme",
++ "language",
++ "feeds"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string",
++ "description": "member, …"
++ },
++ "handle": {
++ "type": "string"
++ },
++ "title": {
++ "type": "string"
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "url": {
++ "type": "string"
++ },
++ "theme": {
++ "type": "string"
++ },
++ "language": {
++ "type": "string"
++ },
++ "feeds": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "type",
++ "url"
++ ],
++ "properties": {
++ "type": {
++ "enum": [
++ "rss",
++ "atom",
++ "json"
++ ]
++ },
++ "url": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "status": {
++ "type": "string"
++ },
++ "owner_subject": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "feed_settings": {
++ "type": "object",
++ "properties": {
++ "rss": {
++ "type": "integer"
++ },
++ "atom": {
++ "type": "integer"
++ },
++ "json": {
++ "type": "integer"
++ },
++ "item_count": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 50
++ },
++ "full_content": {
++ "type": "integer"
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/blog/delete-result.v1.json b/contracts/blog/delete-result.v1.json
nnew file mode 100644
iindex 00000000..531a1dfd
---- /dev/null
++++ b/contracts/blog/delete-result.v1.json
@@@ -0,0 +1,19 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/delete-result.v1.json",
++ "title": "BlogDeleteResult",
++ "description": "blog.delete-result@1: the answer of DELETE /api/v1/posts/:id on OpenVibe.Blog (blog.post.delete): { deleted: true, id }.",
++ "type": "object",
++ "required": [
++ "deleted",
++ "id"
++ ],
++ "properties": {
++ "deleted": {
++ "const": true
++ },
++ "id": {
++ "type": "string"
++ }
++ }
++}
ddiff --git a/contracts/blog/json-feed.v1.json b/contracts/blog/json-feed.v1.json
nnew file mode 100644
iindex 00000000..2fa949df
---- /dev/null
++++ b/contracts/blog/json-feed.v1.json
@@@ -0,0 +1,107 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/json-feed.v1.json",
++ "title": "BlogJsonFeed",
++ "description": "blog.json-feed@1: the answer of GET /api/v1/blogs/:handle/feed on OpenVibe.Blog (blog.feed.read): the blog's public posts as a JSON Feed 1.1 document (openvibe-publishing seo.jsonFeed), as many as the blog's feed item count, with full HTML content when the blog allows it and otherwise the summary as text. Empty fields are left out.",
++ "type": "object",
++ "required": [
++ "version",
++ "title",
++ "home_page_url",
++ "feed_url",
++ "items"
++ ],
++ "properties": {
++ "version": {
++ "const": "https://jsonfeed.org/version/1.1"
++ },
++ "title": {
++ "type": "string"
++ },
++ "home_page_url": {
++ "type": "string"
++ },
++ "feed_url": {
++ "type": "string"
++ },
++ "description": {
++ "type": "string"
++ },
++ "language": {
++ "type": "string"
++ },
++ "authors": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "name": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "items": {
++ "type": "array",
++ "maxItems": 50,
++ "items": {
++ "type": "object",
++ "required": [
++ "id"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ },
++ "title": {
++ "type": "string"
++ },
++ "content_html": {
++ "type": "string"
++ },
++ "content_text": {
++ "type": "string"
++ },
++ "summary": {
++ "type": "string"
++ },
++ "image": {
++ "type": "string"
++ },
++ "date_published": {
++ "type": "string"
++ },
++ "date_modified": {
++ "type": "string"
++ },
++ "authors": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "name": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "tags": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/blog/member-request.v1.json b/contracts/blog/member-request.v1.json
nnew file mode 100644
iindex 00000000..ac2c9a95
---- /dev/null
++++ b/contracts/blog/member-request.v1.json
@@@ -0,0 +1,26 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/member-request.v1.json",
++ "title": "BlogMemberRequest",
++ "description": "blog.member-request@1: bodies of blog.member.manage on OpenVibe.Blog (an owner). PUT /api/v1/blogs/:handle/members/:subject: { role: owner | editor | author } (members are usr_ subjects; a member blog keeps at least one owner). GET …/members and DELETE …/members/:subject take no body.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "role"
++ ],
++ "properties": {
++ "role": {
++ "enum": [
++ "owner",
++ "editor",
++ "author"
++ ]
++ }
++ }
++ },
++ {
++ "$ref": "../common/no-body.v1.json"
++ }
++ ]
++}
ddiff --git a/contracts/blog/member-result.v1.json b/contracts/blog/member-result.v1.json
nnew file mode 100644
iindex 00000000..e5ac6ddb
---- /dev/null
++++ b/contracts/blog/member-result.v1.json
@@@ -0,0 +1,87 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/member-result.v1.json",
++ "title": "BlogMemberResult",
++ "description": "blog.member-result@1: answers of blog.member.manage on OpenVibe.Blog. GET /api/v1/blogs/:handle/members → { members } (subject, role, name and username when Network knows them); PUT …/members/:subject → { member }; DELETE → { removed } (false when they were not a member).",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "members"
++ ],
++ "properties": {
++ "members": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "subject",
++ "role"
++ ],
++ "properties": {
++ "subject": {
++ "type": "string"
++ },
++ "role": {
++ "enum": [
++ "owner",
++ "editor",
++ "author"
++ ]
++ },
++ "name": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "username": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "member"
++ ],
++ "properties": {
++ "member": {
++ "type": "object",
++ "required": [
++ "role"
++ ],
++ "properties": {
++ "subject": {
++ "type": "string"
++ },
++ "role": {
++ "enum": [
++ "owner",
++ "editor",
++ "author"
++ ]
++ }
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "removed"
++ ],
++ "properties": {
++ "removed": {
++ "type": "boolean"
++ }
++ }
++ }
++ ]
++}
ddiff --git a/contracts/blog/post-create-request.v1.json b/contracts/blog/post-create-request.v1.json
nnew file mode 100644
iindex 00000000..025951fd
---- /dev/null
++++ b/contracts/blog/post-create-request.v1.json
@@@ -0,0 +1,158 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-create-request.v1.json",
++ "title": "BlogPostCreateRequest",
++ "description": "blog.post-create-request@1: the body of POST /api/v1/blogs/:handle/posts on OpenVibe.Blog (blog.post.create; a member who may write, or a service acting for one; X-OV-Origin: ai makes an AI draft that needs a person's review). A new draft at revision 1: title required. Unknown fields are ignored; refusals are problem+json (post.invalid_title, post.too_long, post.invalid_visibility, post.invalid_slug, post.too_many_tags, post.invalid_series, …).",
++ "type": "object",
++ "required": [
++ "title"
++ ],
++ "properties": {
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "Whitespace collapsed; required, at most 200 characters."
++ },
++ "body": {
++ "type": "string",
++ "maxLength": 200000,
++ "description": "Markdown."
++ },
++ "summary": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "members",
++ "private"
++ ],
++ "description": "Default public; members gates it behind the blog's VIP entitlement (OpenVibe.VIP decides)."
++ },
++ "entitlement_key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.:-]{0,127}$",
++ "description": "members only: informational, default vip:<handle>."
++ },
++ "slug": {
++ "type": "string",
++ "description": "Default from the title; lowercase letters, digits and dashes (a taken one gets a number)."
++ },
++ "allow_comments": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "noindex": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "tags": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "At most 20 tags of at most 50 characters."
++ },
++ "categories": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "Category paths (Parent > Child), at most 10."
++ },
++ "series": {
++ "description": "A series id (ser_…), a new series title, or { title }; null or empty leaves the series."
++ },
++ "series_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "series_position": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ]
++ },
++ "citations": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "Sources: a URL (with retrieved_at) or an OpenVibe.Sources item id; a quote may be text or { text }. At most 100 are kept.",
++ "items": {
++ "type": "object",
++ "properties": {
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "source_item_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "retrieved_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "quote": {},
++ "license_note": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ },
++ "message": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "authorship": {
++ "type": "object",
++ "description": "hybrid or imported authorship details; an AI service (X-OV-Origin: ai) writes AI drafts that need review."
++ }
++ }
++}
ddiff --git a/contracts/blog/post-create-result.v1.json b/contracts/blog/post-create-result.v1.json
nnew file mode 100644
iindex 00000000..beec7070
---- /dev/null
++++ b/contracts/blog/post-create-result.v1.json
@@@ -0,0 +1,20 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-create-result.v1.json",
++ "title": "BlogPostCreateResult",
++ "description": "blog.post-create-result@1: the answer of POST /api/v1/blogs/:handle/posts on OpenVibe.Blog: 201 { post, revision } (the editor view and the revision number, 1).",
++ "type": "object",
++ "required": [
++ "post",
++ "revision"
++ ],
++ "properties": {
++ "post": {
++ "$ref": "post.v1.json"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
++}
ddiff --git a/contracts/blog/post-read-result.v1.json b/contracts/blog/post-read-result.v1.json
nnew file mode 100644
iindex 00000000..11be483f
---- /dev/null
++++ b/contracts/blog/post-read-result.v1.json
@@@ -0,0 +1,98 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-read-result.v1.json",
++ "title": "BlogPostReadResult",
++ "description": "blog.post-read-result@1: answers of blog.post.read on OpenVibe.Blog. GET /api/v1/posts/:id → { post } (editors and staff get the full view; readers of a published post the public one; a members-only post someone may not open is 403 post.members_only with a teaser); GET /api/v1/posts/:id/revisions → { revisions } (each with its length instead of the content); GET …/revisions/:n → { revision, citations }; GET …/diff?from=&to=&mode= → { diff }; GET /api/v1/blogs/:handle/posts?all=1 → { posts } (drafts too, members only).",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "post"
++ ],
++ "properties": {
++ "post": {
++ "$ref": "post.v1.json"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "revisions"
++ ],
++ "properties": {
++ "revisions": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "number"
++ ],
++ "properties": {
++ "number": {
++ "type": "integer"
++ },
++ "length": {
++ "type": "integer"
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "revision",
++ "citations"
++ ],
++ "properties": {
++ "revision": {
++ "type": "object",
++ "required": [
++ "number"
++ ],
++ "properties": {
++ "number": {
++ "type": "integer"
++ },
++ "content": {
++ "type": "string"
++ },
++ "fields": {
++ "type": "object"
++ }
++ }
++ },
++ "citations": {
++ "type": "array"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "diff"
++ ],
++ "properties": {
++ "diff": {
++ "type": "object"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "posts"
++ ],
++ "properties": {
++ "posts": {
++ "type": "array",
++ "items": {
++ "$ref": "post.v1.json"
++ }
++ }
++ }
++ }
++ ]
++}
ddiff --git a/contracts/blog/post-update-request.v1.json b/contracts/blog/post-update-request.v1.json
nnew file mode 100644
iindex 00000000..1a4eaab1
---- /dev/null
++++ b/contracts/blog/post-update-request.v1.json
@@@ -0,0 +1,229 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-update-request.v1.json",
++ "title": "BlogPostUpdateRequest",
++ "description": "blog.post-update-request@1: bodies of blog.post.update on OpenVibe.Blog (editors of the post). PATCH /api/v1/posts/:id: any post fields; a change to title, body or summary makes a new revision and needs expected_revision (the revision you edited; 428 revision.expected_required without it); citations sent replace the revision's, otherwise they carry forward. POST /api/v1/posts/:id/revert: { to_revision, expected_revision }. POST /api/v1/posts/:id/attachments: { media_id (med_…), role?: cover | inline | gallery, alt?, caption?, position? }. DELETE /api/v1/posts/:id/attachments/:aid takes no body.",
++ "anyOf": [
++ {
++ "type": "object",
++ "properties": {
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "body": {
++ "type": "string",
++ "maxLength": 200000,
++ "description": "Markdown."
++ },
++ "summary": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "members",
++ "private"
++ ],
++ "description": "Default public; members gates it behind the blog's VIP entitlement (OpenVibe.VIP decides)."
++ },
++ "entitlement_key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.:-]{0,127}$",
++ "description": "members only: informational, default vip:<handle>."
++ },
++ "slug": {
++ "type": "string",
++ "description": "Default from the title; lowercase letters, digits and dashes (a taken one gets a number)."
++ },
++ "allow_comments": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "noindex": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "tags": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "At most 20 tags of at most 50 characters."
++ },
++ "categories": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "Category paths (Parent > Child), at most 10."
++ },
++ "series": {
++ "description": "A series id (ser_…), a new series title, or { title }; null or empty leaves the series."
++ },
++ "series_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "series_position": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ]
++ },
++ "citations": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "Sources: a URL (with retrieved_at) or an OpenVibe.Sources item id; a quote may be text or { text }. At most 100 are kept.",
++ "items": {
++ "type": "object",
++ "properties": {
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "source_item_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "retrieved_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "quote": {},
++ "license_note": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ },
++ "message": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "authorship": {
++ "type": "object",
++ "description": "hybrid or imported authorship details; an AI service (X-OV-Origin: ai) writes AI drafts that need review."
++ },
++ "expected_revision": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 0,
++ "pattern": "^[0-9]+$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "to_revision",
++ "expected_revision"
++ ],
++ "properties": {
++ "to_revision": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 1
++ },
++ "expected_revision": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 0
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "media_id"
++ ],
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "role": {
++ "enum": [
++ "cover",
++ "inline",
++ "gallery"
++ ]
++ },
++ "alt": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "caption": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "position": {
++ "type": [
++ "integer",
++ "string"
++ ]
++ }
++ }
++ },
++ {
++ "$ref": "../common/no-body.v1.json"
++ }
++ ]
++}
ddiff --git a/contracts/blog/post-update-result.v1.json b/contracts/blog/post-update-result.v1.json
nnew file mode 100644
iindex 00000000..0345b5eb
---- /dev/null
++++ b/contracts/blog/post-update-result.v1.json
@@@ -0,0 +1,47 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-update-result.v1.json",
++ "title": "BlogPostUpdateResult",
++ "description": "blog.post-update-result@1: answers of blog.post.update on OpenVibe.Blog. PATCH /api/v1/posts/:id → { post, revision, created } (created false when nothing changed); POST …/revert → 201 { revision, post }; POST …/attachments → 201 { attachment }; DELETE …/attachments/:aid → { removed }.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "post",
++ "revision"
++ ],
++ "properties": {
++ "post": {
++ "$ref": "post.v1.json"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "created": {
++ "type": "boolean"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "attachment"
++ ],
++ "properties": {
++ "attachment": {
++ "type": "object"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "removed"
++ ],
++ "properties": {
++ "removed": {}
++ }
++ }
++ ]
++}
ddiff --git a/contracts/blog/post.v1.json b/contracts/blog/post.v1.json
nnew file mode 100644
iindex 00000000..8ee087ec
---- /dev/null
++++ b/contracts/blog/post.v1.json
@@@ -0,0 +1,162 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post.v1.json",
++ "title": "BlogPost",
++ "description": "blog.post@1: one post as OpenVibe.Blog's API shows it (server/http/api.js postDto): its state, visibility, the fields of the revision shown (the published one, or the head for editors), terms, series and the indexability decision. Editors and staff also get the body, authorship, review, scheduled jobs, attachments and citations; readers of a members-only post they may not open get a 403 teaser instead.",
++ "type": "object",
++ "required": [
++ "id",
++ "blog",
++ "slug",
++ "url",
++ "state",
++ "visibility",
++ "title",
++ "revision",
++ "published_revision",
++ "tags",
++ "categories",
++ "allow_comments",
++ "noindex"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "blog": {
++ "type": "object",
++ "required": [
++ "id",
++ "handle"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "handle": {
++ "type": "string"
++ }
++ }
++ },
++ "slug": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ },
++ "state": {
++ "type": "string",
++ "description": "draft, scheduled, published, unpublished, deleted"
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "members",
++ "private"
++ ]
++ },
++ "author_subject": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "summary": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "revision": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "published_revision": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "first_published_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "published_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "tags": {
++ "type": "array"
++ },
++ "categories": {
++ "type": "array"
++ },
++ "series": {
++ "type": [
++ "object",
++ "null"
++ ]
++ },
++ "allow_comments": {
++ "type": "boolean"
++ },
++ "noindex": {
++ "type": "boolean"
++ },
++ "indexability": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "properties": {
++ "indexable": {
++ "type": "boolean"
++ },
++ "robots": {
++ "type": "string"
++ },
++ "reasons": {
++ "type": "array"
++ }
++ }
++ },
++ "entitlement_key": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "body": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "authorship": {},
++ "review": {},
++ "scheduled": {
++ "type": "array"
++ },
++ "attachments": {
++ "type": "array"
++ },
++ "citations": {
++ "type": "array"
++ }
++ }
++}
ddiff --git a/contracts/blog/publication-result.v1.json b/contracts/blog/publication-result.v1.json
nnew file mode 100644
iindex 00000000..4482da6d
---- /dev/null
++++ b/contracts/blog/publication-result.v1.json
@@@ -0,0 +1,19 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/publication-result.v1.json",
++ "title": "BlogPublicationResult",
++ "description": "blog.publication-result@1: the answer of POST /api/v1/posts/:id/publish (blog.post.publish) and POST /api/v1/posts/:id/unpublish (blog.post.unpublish) on OpenVibe.Blog: { post, changed } (changed false when it already was in that state).",
++ "type": "object",
++ "required": [
++ "post",
++ "changed"
++ ],
++ "properties": {
++ "post": {
++ "$ref": "post.v1.json"
++ },
++ "changed": {
++ "type": "boolean"
++ }
++ }
++}
ddiff --git a/contracts/blog/publish-request.v1.json b/contracts/blog/publish-request.v1.json
nnew file mode 100644
iindex 00000000..6f9270d4
---- /dev/null
++++ b/contracts/blog/publish-request.v1.json
@@@ -0,0 +1,17 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/publish-request.v1.json",
++ "title": "BlogPublishRequest",
++ "description": "blog.publish-request@1: the body of POST /api/v1/posts/:id/publish on OpenVibe.Blog (blog.post.publish): { revision? } (default the head; an AI draft is published only after a person reviewed it: 409 post.review_required).",
++ "type": "object",
++ "properties": {
++ "revision": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ],
++ "minimum": 1
++ }
++ }
++}
ddiff --git a/contracts/blog/schedule-request.v1.json b/contracts/blog/schedule-request.v1.json
nnew file mode 100644
iindex 00000000..883c1c2a
---- /dev/null
++++ b/contracts/blog/schedule-request.v1.json
@@@ -0,0 +1,38 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/schedule-request.v1.json",
++ "title": "BlogScheduleRequest",
++ "description": "blog.schedule-request@1: bodies of blog.post.schedule on OpenVibe.Blog. POST /api/v1/posts/:id/schedule: { at, revision?, action? } (at an ISO 8601 time in the future; action publish (default) or unpublish). DELETE /api/v1/posts/:id/schedule cancels pending jobs and takes no body.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "at"
++ ],
++ "properties": {
++ "at": {
++ "type": [
++ "string",
++ "integer"
++ ]
++ },
++ "revision": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ]
++ },
++ "action": {
++ "enum": [
++ "publish",
++ "unpublish"
++ ]
++ }
++ }
++ },
++ {
++ "$ref": "../common/no-body.v1.json"
++ }
++ ]
++}
ddiff --git a/contracts/blog/schedule-result.v1.json b/contracts/blog/schedule-result.v1.json
nnew file mode 100644
iindex 00000000..2339b2af
---- /dev/null
++++ b/contracts/blog/schedule-result.v1.json
@@@ -0,0 +1,43 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/schedule-result.v1.json",
++ "title": "BlogScheduleResult",
++ "description": "blog.schedule-result@1: answers of blog.post.schedule on OpenVibe.Blog: POST …/schedule → 201 { job, created, post }; DELETE …/schedule → { cancelled, post } (jobs cancelled; a scheduled post goes back to draft).",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "job",
++ "created",
++ "post"
++ ],
++ "properties": {
++ "job": {
++ "type": "object"
++ },
++ "created": {
++ "type": "boolean"
++ },
++ "post": {
++ "$ref": "post.v1.json"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "cancelled",
++ "post"
++ ],
++ "properties": {
++ "cancelled": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "post": {
++ "$ref": "post.v1.json"
++ }
++ }
++ }
++ ]
++}
ddiff --git a/contracts/blog/theme-request.v1.json b/contracts/blog/theme-request.v1.json
nnew file mode 100644
iindex 00000000..0cd6a741
---- /dev/null
++++ b/contracts/blog/theme-request.v1.json
@@@ -0,0 +1,22 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/theme-request.v1.json",
++ "title": "BlogThemeRequest",
++ "description": "blog.theme-request@1: the body of PUT /api/v1/blogs/:handle/theme on OpenVibe.Blog (blog.theme.set; an owner): one of the theme presets.",
++ "type": "object",
++ "required": [
++ "theme"
++ ],
++ "properties": {
++ "theme": {
++ "enum": [
++ "vibe",
++ "paper",
++ "slate",
++ "sand",
++ "nord",
++ "high-contrast"
++ ]
++ }
++ }
++}
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex bbb0938a..9a3edbc9 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -837,6 +837,186 @@
"compatibility": "backward",
"status": "active"
},
++ {
++ "id": "blog.blog-configure-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/blog-configure-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.blog-create-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/blog-create-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.blog-create-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/blog-create-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.blog-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/blog-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.blog",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/blog.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.delete-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/delete-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.json-feed",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/json-feed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.member-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/member-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.member-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/member-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.post-create-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/post-create-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.post-create-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/post-create-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.post-read-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/post-read-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.post-update-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/post-update-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.post-update-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/post-update-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.post",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/post.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.publication-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/publication-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.publish-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/publish-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.schedule-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/schedule-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.schedule-result",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/schedule-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "blog.theme-request",
++ "version": "1.0.0",
++ "owner": "blog",
++ "schema": "blog/theme-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
{
"id": "ai.run-request",
"version": "1.0.0",
@@@ -2382,6 +2562,114 @@
"compatibility": "backward",
"status": "active"
},
++ {
++ "id": "coupons.coupon",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/coupon.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.lookup-result",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/lookup-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.merchant-manage-request",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/merchant-manage-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.merchant-manage-result",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/merchant-manage-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.merchant",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/merchant.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.report-request",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/report-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.report-result",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/report-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.resolve-result",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/resolve-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.status-request",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/status-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.status-result",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/status-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.submit-request",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/submit-request.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "coupons.submit-result",
++ "version": "1.0.0",
++ "owner": "coupons",
++ "schema": "coupons/submit-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
{
"id": "deals.offer.created",
"version": "1.0.0",
ddiff --git a/contracts/coupons/coupon.v1.json b/contracts/coupons/coupon.v1.json
nnew file mode 100644
iindex 00000000..e051ba25
---- /dev/null
++++ b/contracts/coupons/coupon.v1.json
@@@ -0,0 +1,218 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/coupon.v1.json",
++ "title": "CouponsCoupon",
++ "description": "coupons.coupon@1: one code as OpenVibe.Coupons shows it (server/domain/coupons.js view): its status and confidence come only from people's reports (counts in a window, report times rounded to the hour), a known or unknown expiry, restrictions, hints and evidence. No submitter or reporter is ever named.",
++ "type": "object",
++ "required": [
++ "id",
++ "merchant_id",
++ "code",
++ "title",
++ "status",
++ "active",
++ "reports",
++ "expiry",
++ "restrictions",
++ "origin",
++ "url"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "merchant_id": {
++ "type": "string"
++ },
++ "code": {
++ "type": "string",
++ "pattern": "^[\\x21-\\x7E]{1,64}$"
++ },
++ "title": {
++ "type": "string"
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired",
++ "disabled"
++ ]
++ },
++ "active": {
++ "type": "boolean"
++ },
++ "confidence": {},
++ "reports": {
++ "type": "object",
++ "properties": {
++ "window_days": {
++ "type": "integer"
++ },
++ "last_report_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "last_worked_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "last_failed_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ }
++ },
++ "expiry": {
++ "type": "object",
++ "required": [
++ "known",
++ "expires_at"
++ ],
++ "properties": {
++ "known": {
++ "type": "boolean"
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "precision": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "basis": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ },
++ "restrictions": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "enum": [
++ "min_spend",
++ "category",
++ "new_customers_only",
++ "region",
++ "other"
++ ]
++ },
++ "value": {},
++ "amount": {
++ "type": "string"
++ },
++ "amount_minor": {
++ "type": "integer"
++ },
++ "currency": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "hints": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "text": {
++ "type": "string"
++ },
++ "scope": {
++ "enum": [
++ "code",
++ "merchant"
++ ]
++ }
++ }
++ }
++ },
++ "evidence": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "kind": {
++ "type": "string"
++ },
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "merchant_page": {
++ "type": "boolean"
++ },
++ "sources_item": {
++ "type": "string"
++ },
++ "retrieved_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "origin": {
++ "type": "string"
++ },
++ "created_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "updated_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "expired_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "url": {
++ "type": "string"
++ }
++ }
++}
ddiff --git a/contracts/coupons/lookup-result.v1.json b/contracts/coupons/lookup-result.v1.json
nnew file mode 100644
iindex 00000000..5b530e82
---- /dev/null
++++ b/contracts/coupons/lookup-result.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/lookup-result.v1.json",
++ "title": "CouponsLookupResult",
++ "description": "coupons.lookup-result@1: answers of coupons.coupon.lookup on OpenVibe.Coupons (public, anonymous allowed, cacheable: the same bytes for every caller). GET /api/v1/merchants/:id/coupons → { merchant, coupons } (the active codes); GET /api/v1/merchants/:id → { merchant }; GET /api/v1/coupons/:id → { coupon, merchant }. Inactive merchants and unpublished or disabled codes are 404.",
++ "type": "object",
++ "required": [
++ "merchant"
++ ],
++ "properties": {
++ "merchant": {
++ "$ref": "merchant.v1.json"
++ },
++ "coupons": {
++ "type": "array",
++ "maxItems": 200,
++ "items": {
++ "$ref": "coupon.v1.json"
++ }
++ },
++ "coupon": {
++ "$ref": "coupon.v1.json"
++ }
++ }
++}
ddiff --git a/contracts/coupons/merchant-manage-request.v1.json b/contracts/coupons/merchant-manage-request.v1.json
nnew file mode 100644
iindex 00000000..f7b13516
---- /dev/null
++++ b/contracts/coupons/merchant-manage-request.v1.json
@@@ -0,0 +1,109 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/merchant-manage-request.v1.json",
++ "title": "CouponsMerchantManageRequest",
++ "description": "coupons.merchant-manage-request@1: bodies of coupons.merchant.manage on OpenVibe.Coupons (staff or a service). POST /api/v1/merchants: { domains, name?, slug?, homepage_url?, description?, status? } (at least one domain rule; a rule on a public suffix is refused; homepage_url must be https; status pending, else active). POST /api/v1/merchants/:id/domains: one domain rule { host, include_subdomains?, path_prefix? } (a rule another merchant holds is 409). POST /api/v1/merchants/:id/status: { status: active | disabled }.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "domains"
++ ],
++ "properties": {
++ "domains": {
++ "type": "array",
++ "minItems": 1,
++ "items": {
++ "type": "object",
++ "required": [
++ "host"
++ ],
++ "properties": {
++ "host": {
++ "type": "string",
++ "minLength": 1
++ },
++ "include_subdomains": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ],
++ "description": "Default true."
++ },
++ "path_prefix": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "name": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "slug": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "homepage_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^https://"
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "status": {
++ "type": "string",
++ "description": "pending; anything else is active."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "host"
++ ],
++ "properties": {
++ "host": {
++ "type": "string",
++ "minLength": 1
++ },
++ "include_subdomains": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ],
++ "description": "Default true."
++ },
++ "path_prefix": {
++ "type": "string"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "status"
++ ],
++ "properties": {
++ "status": {
++ "enum": [
++ "active",
++ "disabled"
++ ]
++ }
++ }
++ }
++ ]
++}
ddiff --git a/contracts/coupons/merchant-manage-result.v1.json b/contracts/coupons/merchant-manage-result.v1.json
nnew file mode 100644
iindex 00000000..4d7a228c
---- /dev/null
++++ b/contracts/coupons/merchant-manage-result.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/merchant-manage-result.v1.json",
++ "title": "CouponsMerchantManageResult",
++ "description": "coupons.merchant-manage-result@1: the answer of every coupons.merchant.manage route on OpenVibe.Coupons: { merchant } with its status (201 for a new merchant or domain).",
++ "type": "object",
++ "required": [
++ "merchant"
++ ],
++ "properties": {
++ "merchant": {
++ "allOf": [
++ {
++ "$ref": "merchant.v1.json"
++ },
++ {
++ "type": "object",
++ "required": [
++ "status"
++ ]
++ }
++ ]
++ }
++ }
++}
ddiff --git a/contracts/coupons/merchant.v1.json b/contracts/coupons/merchant.v1.json
nnew file mode 100644
iindex 00000000..79486603
---- /dev/null
++++ b/contracts/coupons/merchant.v1.json
@@@ -0,0 +1,78 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/merchant.v1.json",
++ "title": "CouponsMerchant",
++ "description": "coupons.merchant@1: one merchant as OpenVibe.Coupons' lookup API shows it (server/http/api.js merchantView): the same bytes for every caller, nothing about any person. Staff and service answers of the merchant routes add its status.",
++ "type": "object",
++ "required": [
++ "id",
++ "slug",
++ "name",
++ "url",
++ "domains",
++ "active_codes",
++ "hints"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "slug": {
++ "type": "string"
++ },
++ "name": {
++ "type": "string"
++ },
++ "homepage_url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "url": {
++ "type": "string"
++ },
++ "domains": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "host",
++ "include_subdomains",
++ "path_prefix"
++ ],
++ "properties": {
++ "host": {
++ "type": "string"
++ },
++ "include_subdomains": {
++ "type": "boolean"
++ },
++ "path_prefix": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ },
++ "active_codes": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "hints": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "active",
++ "disabled"
++ ]
++ }
++ }
++}
ddiff --git a/contracts/coupons/report-request.v1.json b/contracts/coupons/report-request.v1.json
nnew file mode 100644
iindex 00000000..5e1e7475
---- /dev/null
++++ b/contracts/coupons/report-request.v1.json
@@@ -0,0 +1,47 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/report-request.v1.json",
++ "title": "CouponsReportRequest",
++ "description": "coupons.report-request@1: the body of POST /api/v1/coupons/:id/report on OpenVibe.Coupons (coupons.report.create; people, installs and services acting for a person): whether the code worked, and for a failure why. The code must be in active results; you cannot report your own submission. Reporting again changes your earlier report.",
++ "type": "object",
++ "required": [
++ "outcome"
++ ],
++ "properties": {
++ "outcome": {
++ "enum": [
++ "worked",
++ "failed"
++ ]
++ },
++ "reason": {
++ "enum": [
++ "invalid",
++ "expired",
++ "min_spend_not_met",
++ "not_eligible",
++ "other",
++ null,
++ ""
++ ],
++ "description": "Failed reports only."
++ }
++ },
++ "if": {
++ "properties": {
++ "outcome": {
++ "const": "worked"
++ }
++ }
++ },
++ "then": {
++ "properties": {
++ "reason": {
++ "enum": [
++ null,
++ ""
++ ]
++ }
++ }
++ }
++}
ddiff --git a/contracts/coupons/report-result.v1.json b/contracts/coupons/report-result.v1.json
nnew file mode 100644
iindex 00000000..fa2f41ff
---- /dev/null
++++ b/contracts/coupons/report-result.v1.json
@@@ -0,0 +1,27 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/report-result.v1.json",
++ "title": "CouponsReportResult",
++ "description": "coupons.report-result@1: the answer of POST /api/v1/coupons/:id/report on OpenVibe.Coupons: { accepted, deduplicated, corrected, coupon } — 201 for a new report, 200 when it repeated or corrected an earlier one.",
++ "type": "object",
++ "required": [
++ "accepted",
++ "deduplicated",
++ "corrected",
++ "coupon"
++ ],
++ "properties": {
++ "accepted": {
++ "const": true
++ },
++ "deduplicated": {
++ "type": "boolean"
++ },
++ "corrected": {
++ "type": "boolean"
++ },
++ "coupon": {
++ "$ref": "coupon.v1.json"
++ }
++ }
++}
ddiff --git a/contracts/coupons/resolve-result.v1.json b/contracts/coupons/resolve-result.v1.json
nnew file mode 100644
iindex 00000000..1e536325
---- /dev/null
++++ b/contracts/coupons/resolve-result.v1.json
@@@ -0,0 +1,46 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/resolve-result.v1.json",
++ "title": "CouponsResolveResult",
++ "description": "coupons.resolve-result@1: the answer of GET /api/v1/merchants/resolve?host= on OpenVibe.Coupons (coupons.merchant.resolve; public, anonymous allowed, rate-limited, CORS for the configured extension origins): the merchant whose domain rule matches the host. No match is 404 merchant.not_found with the normalised host and its registrable domain; a missing host is 400 host.required.",
++ "type": "object",
++ "required": [
++ "host",
++ "registrable_domain",
++ "matched_rule",
++ "merchant"
++ ],
++ "properties": {
++ "host": {
++ "type": "string"
++ },
++ "registrable_domain": {
++ "type": "string"
++ },
++ "matched_rule": {
++ "type": "object",
++ "required": [
++ "host",
++ "include_subdomains",
++ "path_prefix"
++ ],
++ "properties": {
++ "host": {
++ "type": "string"
++ },
++ "include_subdomains": {
++ "type": "boolean"
++ },
++ "path_prefix": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ },
++ "merchant": {
++ "$ref": "merchant.v1.json"
++ }
++ }
++}
ddiff --git a/contracts/coupons/status-request.v1.json b/contracts/coupons/status-request.v1.json
nnew file mode 100644
iindex 00000000..ad105f64
---- /dev/null
++++ b/contracts/coupons/status-request.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/status-request.v1.json",
++ "title": "CouponsStatusRequest",
++ "description": "coupons.status-request@1: the body of POST /api/v1/coupons/:id/status on OpenVibe.Coupons (coupons.status.update; staff or a service): disabled, expired (not while disabled) or active (re-enable: the status is recomputed from reports and time); reported_working and reported_failed cannot be set by anyone.",
++ "type": "object",
++ "required": [
++ "status"
++ ],
++ "properties": {
++ "status": {
++ "enum": [
++ "disabled",
++ "expired",
++ "active"
++ ]
++ },
++ "note": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++}
ddiff --git a/contracts/coupons/status-result.v1.json b/contracts/coupons/status-result.v1.json
nnew file mode 100644
iindex 00000000..14b4d5bc
---- /dev/null
++++ b/contracts/coupons/status-result.v1.json
@@@ -0,0 +1,15 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/status-result.v1.json",
++ "title": "CouponsStatusResult",
++ "description": "coupons.status-result@1: the answer of POST /api/v1/coupons/:id/status on OpenVibe.Coupons: { coupon } after the change.",
++ "type": "object",
++ "required": [
++ "coupon"
++ ],
++ "properties": {
++ "coupon": {
++ "$ref": "coupon.v1.json"
++ }
++ }
++}
ddiff --git a/contracts/coupons/submit-request.v1.json b/contracts/coupons/submit-request.v1.json
nnew file mode 100644
iindex 00000000..fd2f747d
---- /dev/null
++++ b/contracts/coupons/submit-request.v1.json
@@@ -0,0 +1,162 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/submit-request.v1.json",
++ "title": "CouponsSubmitRequest",
++ "description": "coupons.submit-request@1: the body of POST /api/v1/coupons/submit on OpenVibe.Coupons (coupons.coupon.submit; a signed-in person, or a service acting for one or as AI; installs cannot submit): a code and the merchant it is for (merchant_id, or the host or url of the shop: an unknown shop becomes a pending merchant). code is 1-64 printable characters without spaces; title (at least 3 characters: what the code gives) is required. status, confidence, verified, working, valid and validity are refused (422 coupon.status_not_accepted): a code's status comes only from people's reports. expires is a date (YYYY-MM-DD) or ISO time; expiry_basis evidence needs evidence_url. A duplicate answers the existing code (200).",
++ "type": "object",
++ "required": [
++ "code",
++ "title"
++ ],
++ "anyOf": [
++ {
++ "required": [
++ "merchant_id"
++ ]
++ },
++ {
++ "required": [
++ "host"
++ ]
++ },
++ {
++ "required": [
++ "url"
++ ]
++ }
++ ],
++ "not": {
++ "anyOf": [
++ {
++ "required": [
++ "status"
++ ]
++ },
++ {
++ "required": [
++ "confidence"
++ ]
++ },
++ {
++ "required": [
++ "verified"
++ ]
++ },
++ {
++ "required": [
++ "working"
++ ]
++ },
++ {
++ "required": [
++ "valid"
++ ]
++ },
++ {
++ "required": [
++ "validity"
++ ]
++ }
++ ]
++ },
++ "properties": {
++ "code": {
++ "type": "string",
++ "pattern": "^\\s*[\\x21-\\x7E]{1,64}\\s*$"
++ },
++ "title": {
++ "type": "string",
++ "minLength": 3
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "merchant_id": {
++ "type": "string"
++ },
++ "host": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string",
++ "pattern": "^https?://"
++ },
++ "expires": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "expiry_basis": {
++ "enum": [
++ "submitter",
++ "evidence"
++ ]
++ },
++ "evidence_url": {
++ "type": "string",
++ "maxLength": 2048,
++ "pattern": "^https?://"
++ },
++ "restrictions": {
++ "type": "object",
++ "properties": {
++ "min_spend": {
++ "type": "object",
++ "properties": {
++ "amount": {
++ "type": [
++ "number",
++ "string"
++ ]
++ },
++ "currency": {
++ "type": "string",
++ "description": "ISO 4217."
++ }
++ }
++ },
++ "categories": {
++ "type": [
++ "array",
++ "string"
++ ],
++ "description": "At most 10."
++ },
++ "new_customers_only": {
++ "type": [
++ "boolean",
++ "string"
++ ]
++ },
++ "regions": {
++ "type": [
++ "array",
++ "string"
++ ],
++ "description": "ISO 3166-1 alpha-2 codes, at most 50."
++ },
++ "other": {
++ "type": "string",
++ "maxLength": 300
++ }
++ }
++ },
++ "hint": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300
++ },
++ "ai_run_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++}
ddiff --git a/contracts/coupons/submit-result.v1.json b/contracts/coupons/submit-result.v1.json
nnew file mode 100644
iindex 00000000..1537211a
---- /dev/null
++++ b/contracts/coupons/submit-result.v1.json
@@@ -0,0 +1,51 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/submit-result.v1.json",
++ "title": "CouponsSubmitResult",
++ "description": "coupons.submit-result@1: the answer of POST /api/v1/coupons/submit on OpenVibe.Coupons: { coupon, merchant, duplicate, review_state } — 201 for a new code, 200 when the same code was already known (duplicate).",
++ "type": "object",
++ "required": [
++ "coupon",
++ "merchant",
++ "duplicate",
++ "review_state"
++ ],
++ "properties": {
++ "coupon": {
++ "$ref": "coupon.v1.json"
++ },
++ "merchant": {
++ "type": "object",
++ "required": [
++ "id",
++ "slug",
++ "name",
++ "status"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "slug": {
++ "type": "string"
++ },
++ "name": {
++ "type": "string"
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "active",
++ "disabled"
++ ]
++ }
++ }
++ },
++ "duplicate": {
++ "type": "boolean"
++ },
++ "review_state": {
++ "type": "string"
++ }
++ }
++}
ddiff --git a/fixtures/blog.blog-configure-request/invalid/bad-language.json b/fixtures/blog.blog-configure-request/invalid/bad-language.json
nnew file mode 100644
iindex 00000000..8706b5c0
---- /dev/null
++++ b/fixtures/blog.blog-configure-request/invalid/bad-language.json
@@@ -0,0 +1 @@
++{ "language": "English" }
ddiff --git a/fixtures/blog.blog-configure-request/valid/configure.json b/fixtures/blog.blog-configure-request/valid/configure.json
nnew file mode 100644
iindex 00000000..f7a843af
---- /dev/null
++++ b/fixtures/blog.blog-configure-request/valid/configure.json
@@@ -0,0 +1 @@
++{ "description": "Garage projects, stream tech and the odd recipe.", "language": "en-GB", "feeds": { "rss": true, "atom": false, "item_count": 30 } }
ddiff --git a/fixtures/blog.blog-create-request/invalid/bad-handle.json b/fixtures/blog.blog-create-request/invalid/bad-handle.json
nnew file mode 100644
iindex 00000000..eda20f57
---- /dev/null
++++ b/fixtures/blog.blog-create-request/invalid/bad-handle.json
@@@ -0,0 +1 @@
++{ "handle": "A" }
ddiff --git a/fixtures/blog.blog-create-request/valid/title.json b/fixtures/blog.blog-create-request/valid/title.json
nnew file mode 100644
iindex 00000000..a582f167
---- /dev/null
++++ b/fixtures/blog.blog-create-request/valid/title.json
@@@ -0,0 +1 @@
++{ "title": "Alice builds things" }
ddiff --git a/fixtures/blog.blog-create-result/invalid/no-created.json b/fixtures/blog.blog-create-result/invalid/no-created.json
nnew file mode 100644
iindex 00000000..0c0eac08
---- /dev/null
++++ b/fixtures/blog.blog-create-result/invalid/no-created.json
@@@ -0,0 +1 @@
++{ "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "kind": "member", "handle": "alice", "title": "Alice builds things", "description": "Garage projects and stream tech.", "url": "https://openvibe.blog/@alice", "theme": "paper", "language": "en", "feeds": [ { "type": "rss", "url": "https://openvibe.blog/@alice/feed.xml" }, { "type": "json", "url": "https://openvibe.blog/@alice/feed.json" } ] } }
ddiff --git a/fixtures/blog.blog-create-result/valid/created.json b/fixtures/blog.blog-create-result/valid/created.json
nnew file mode 100644
iindex 00000000..4cf32069
---- /dev/null
++++ b/fixtures/blog.blog-create-result/valid/created.json
@@@ -0,0 +1 @@
++{ "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "kind": "member", "handle": "alice", "title": "Alice builds things", "description": "Garage projects and stream tech.", "url": "https://openvibe.blog/@alice", "theme": "paper", "language": "en", "feeds": [ { "type": "rss", "url": "https://openvibe.blog/@alice/feed.xml" }, { "type": "json", "url": "https://openvibe.blog/@alice/feed.json" } ] }, "created": true }
ddiff --git a/fixtures/blog.blog-result/invalid/no-blog.json b/fixtures/blog.blog-result/invalid/no-blog.json
nnew file mode 100644
iindex 00000000..bf3d8887
---- /dev/null
++++ b/fixtures/blog.blog-result/invalid/no-blog.json
@@@ -0,0 +1 @@
++{ "themes": [] }
ddiff --git a/fixtures/blog.blog-result/valid/theme.json b/fixtures/blog.blog-result/valid/theme.json
nnew file mode 100644
iindex 00000000..3a2c49c8
---- /dev/null
++++ b/fixtures/blog.blog-result/valid/theme.json
@@@ -0,0 +1 @@
++{ "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "kind": "member", "handle": "alice", "title": "Alice builds things", "description": "Garage projects and stream tech.", "url": "https://openvibe.blog/@alice", "theme": "paper", "language": "en", "feeds": [ { "type": "rss", "url": "https://openvibe.blog/@alice/feed.xml" }, { "type": "json", "url": "https://openvibe.blog/@alice/feed.json" } ] }, "themes": ["vibe", "paper", "slate", "sand", "nord", "high-contrast"] }
ddiff --git a/fixtures/blog.blog/invalid/bad-feed-type.json b/fixtures/blog.blog/invalid/bad-feed-type.json
nnew file mode 100644
iindex 00000000..4ef7b62c
---- /dev/null
++++ b/fixtures/blog.blog/invalid/bad-feed-type.json
@@@ -0,0 +1 @@
++{ "id": "b", "kind": "member", "handle": "x", "title": "t", "url": "u", "theme": "paper", "language": "en", "feeds": [ { "type": "opml", "url": "u" } ] }
ddiff --git a/fixtures/blog.blog/valid/member.json b/fixtures/blog.blog/valid/member.json
nnew file mode 100644
iindex 00000000..885dc56f
---- /dev/null
++++ b/fixtures/blog.blog/valid/member.json
@@@ -0,0 +1 @@
++{ "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "kind": "member", "handle": "alice", "title": "Alice builds things", "description": "Garage projects and stream tech.", "url": "https://openvibe.blog/@alice", "theme": "paper", "language": "en", "feeds": [ { "type": "rss", "url": "https://openvibe.blog/@alice/feed.xml" }, { "type": "json", "url": "https://openvibe.blog/@alice/feed.json" } ] }
ddiff --git a/fixtures/blog.delete-result/invalid/not-deleted.json b/fixtures/blog.delete-result/invalid/not-deleted.json
nnew file mode 100644
iindex 00000000..3db7df2e
---- /dev/null
++++ b/fixtures/blog.delete-result/invalid/not-deleted.json
@@@ -0,0 +1 @@
++{ "deleted": false, "id": "x" }
ddiff --git a/fixtures/blog.delete-result/valid/deleted.json b/fixtures/blog.delete-result/valid/deleted.json
nnew file mode 100644
iindex 00000000..b6680953
---- /dev/null
++++ b/fixtures/blog.delete-result/valid/deleted.json
@@@ -0,0 +1 @@
++{ "deleted": true, "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ" }
ddiff --git a/fixtures/blog.json-feed/invalid/old-version.json b/fixtures/blog.json-feed/invalid/old-version.json
nnew file mode 100644
iindex 00000000..74d27a91
---- /dev/null
++++ b/fixtures/blog.json-feed/invalid/old-version.json
@@@ -0,0 +1 @@
++{ "version": "https://jsonfeed.org/version/1", "title": "x", "home_page_url": "u", "feed_url": "u", "items": [] }
ddiff --git a/fixtures/blog.json-feed/valid/feed.json b/fixtures/blog.json-feed/valid/feed.json
nnew file mode 100644
iindex 00000000..5f789581
---- /dev/null
++++ b/fixtures/blog.json-feed/valid/feed.json
@@@ -0,0 +1 @@
++{ "version": "https://jsonfeed.org/version/1.1", "title": "Alice builds things", "home_page_url": "https://openvibe.blog/@alice", "feed_url": "https://openvibe.blog/@alice/feed.json", "items": [ { "id": "https://openvibe.blog/@alice/my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "title": "My restream setup", "content_html": "<p>One encoder, three platforms.</p>", "date_published": "2026-09-24T10:00:00.000Z", "tags": ["obs", "restream"] } ] }
ddiff --git a/fixtures/blog.member-request/invalid/bad-role.json b/fixtures/blog.member-request/invalid/bad-role.json
nnew file mode 100644
iindex 00000000..582a812c
---- /dev/null
++++ b/fixtures/blog.member-request/invalid/bad-role.json
@@@ -0,0 +1 @@
++{ "role": "admin" }
ddiff --git a/fixtures/blog.member-request/valid/editor.json b/fixtures/blog.member-request/valid/editor.json
nnew file mode 100644
iindex 00000000..41714263
---- /dev/null
++++ b/fixtures/blog.member-request/valid/editor.json
@@@ -0,0 +1 @@
++{ "role": "editor" }
ddiff --git a/fixtures/blog.member-result/invalid/removed-text.json b/fixtures/blog.member-result/invalid/removed-text.json
nnew file mode 100644
iindex 00000000..a1eee0f9
---- /dev/null
++++ b/fixtures/blog.member-result/invalid/removed-text.json
@@@ -0,0 +1 @@
++{ "removed": "yes" }
ddiff --git a/fixtures/blog.member-result/valid/members.json b/fixtures/blog.member-result/valid/members.json
nnew file mode 100644
iindex 00000000..bf701ca5
---- /dev/null
++++ b/fixtures/blog.member-result/valid/members.json
@@@ -0,0 +1 @@
++{ "members": [ { "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "role": "owner", "name": "Alice", "username": "alice" } ] }
ddiff --git a/fixtures/blog.post-create-request/invalid/no-title.json b/fixtures/blog.post-create-request/invalid/no-title.json
nnew file mode 100644
iindex 00000000..3ea70e4b
---- /dev/null
++++ b/fixtures/blog.post-create-request/invalid/no-title.json
@@@ -0,0 +1 @@
++{ "body": "no title" }
ddiff --git a/fixtures/blog.post-create-request/valid/draft.json b/fixtures/blog.post-create-request/valid/draft.json
nnew file mode 100644
iindex 00000000..a0b712c4
---- /dev/null
++++ b/fixtures/blog.post-create-request/valid/draft.json
@@@ -0,0 +1 @@
++{ "title": "My restream setup", "body": "One encoder, three platforms. Here is how.", "summary": "One encoder, three platforms.", "tags": "obs, restream", "visibility": "public", "citations": [ { "url": "https://openre.stream/docs", "retrieved_at": "2026-09-24T09:00:00Z" } ] }
ddiff --git a/fixtures/blog.post-create-result/invalid/revision-zero.json b/fixtures/blog.post-create-result/invalid/revision-zero.json
nnew file mode 100644
iindex 00000000..e6342f4d
---- /dev/null
++++ b/fixtures/blog.post-create-result/invalid/revision-zero.json
@@@ -0,0 +1 @@
++{ "post": { "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } }, "revision": 0 }
ddiff --git a/fixtures/blog.post-create-result/valid/created.json b/fixtures/blog.post-create-result/valid/created.json
nnew file mode 100644
iindex 00000000..fe67c9c3
---- /dev/null
++++ b/fixtures/blog.post-create-result/valid/created.json
@@@ -0,0 +1 @@
++{ "post": { "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } }, "revision": 1 }
ddiff --git a/fixtures/blog.post-read-result/invalid/no-number.json b/fixtures/blog.post-read-result/invalid/no-number.json
nnew file mode 100644
iindex 00000000..e66126f0
---- /dev/null
++++ b/fixtures/blog.post-read-result/invalid/no-number.json
@@@ -0,0 +1 @@
++{ "revisions": [ { "length": 5 } ] }
ddiff --git a/fixtures/blog.post-read-result/valid/post.json b/fixtures/blog.post-read-result/valid/post.json
nnew file mode 100644
iindex 00000000..77ae6829
---- /dev/null
++++ b/fixtures/blog.post-read-result/valid/post.json
@@@ -0,0 +1 @@
++{ "post": { "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } } }
ddiff --git a/fixtures/blog.post-read-result/valid/revisions.json b/fixtures/blog.post-read-result/valid/revisions.json
nnew file mode 100644
iindex 00000000..a5e9c6c0
---- /dev/null
++++ b/fixtures/blog.post-read-result/valid/revisions.json
@@@ -0,0 +1 @@
++{ "revisions": [ { "number": 2, "length": 1840, "fields": { "title": "My restream setup" } } ] }
ddiff --git a/fixtures/blog.post-update-request/invalid/bad-values.json b/fixtures/blog.post-update-request/invalid/bad-values.json
nnew file mode 100644
iindex 00000000..c335f347
---- /dev/null
++++ b/fixtures/blog.post-update-request/invalid/bad-values.json
@@@ -0,0 +1 @@
++{ "visibility": "friends", "media_id": "https://example.com/x.png" }
ddiff --git a/fixtures/blog.post-update-request/valid/attach.json b/fixtures/blog.post-update-request/valid/attach.json
nnew file mode 100644
iindex 00000000..c450d369
---- /dev/null
++++ b/fixtures/blog.post-update-request/valid/attach.json
@@@ -0,0 +1 @@
++{ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "role": "cover", "alt": "OBS scene list" }
ddiff --git a/fixtures/blog.post-update-request/valid/edit.json b/fixtures/blog.post-update-request/valid/edit.json
nnew file mode 100644
iindex 00000000..1cee2e8f
---- /dev/null
++++ b/fixtures/blog.post-update-request/valid/edit.json
@@@ -0,0 +1 @@
++{ "expected_revision": 2, "body": "One encoder, three platforms, zero dropped frames." }
ddiff --git a/fixtures/blog.post-update-result/invalid/no-post.json b/fixtures/blog.post-update-result/invalid/no-post.json
nnew file mode 100644
iindex 00000000..db1aab5e
---- /dev/null
++++ b/fixtures/blog.post-update-result/invalid/no-post.json
@@@ -0,0 +1 @@
++{ "revision": 3 }
ddiff --git a/fixtures/blog.post-update-result/valid/edited.json b/fixtures/blog.post-update-result/valid/edited.json
nnew file mode 100644
iindex 00000000..dd1a2e54
---- /dev/null
++++ b/fixtures/blog.post-update-result/valid/edited.json
@@@ -0,0 +1 @@
++{ "post": { "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } }, "revision": 3, "created": true }
ddiff --git a/fixtures/blog.post/invalid/bad-visibility.json b/fixtures/blog.post/invalid/bad-visibility.json
nnew file mode 100644
iindex 00000000..1a49089f
---- /dev/null
++++ b/fixtures/blog.post/invalid/bad-visibility.json
@@@ -0,0 +1 @@
++{ "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "friends", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } }
ddiff --git a/fixtures/blog.post/valid/published.json b/fixtures/blog.post/valid/published.json
nnew file mode 100644
iindex 00000000..df2c89b2
---- /dev/null
++++ b/fixtures/blog.post/valid/published.json
@@@ -0,0 +1 @@
++{ "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } }
ddiff --git a/fixtures/blog.publication-result/invalid/no-changed.json b/fixtures/blog.publication-result/invalid/no-changed.json
nnew file mode 100644
iindex 00000000..77ae6829
---- /dev/null
++++ b/fixtures/blog.publication-result/invalid/no-changed.json
@@@ -0,0 +1 @@
++{ "post": { "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } } }
ddiff --git a/fixtures/blog.publication-result/valid/published.json b/fixtures/blog.publication-result/valid/published.json
nnew file mode 100644
iindex 00000000..f63cc0d8
---- /dev/null
++++ b/fixtures/blog.publication-result/valid/published.json
@@@ -0,0 +1 @@
++{ "post": { "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } }, "changed": true }
ddiff --git a/fixtures/blog.publish-request/invalid/zero.json b/fixtures/blog.publish-request/invalid/zero.json
nnew file mode 100644
iindex 00000000..b96ed8a0
---- /dev/null
++++ b/fixtures/blog.publish-request/invalid/zero.json
@@@ -0,0 +1 @@
++{ "revision": 0 }
ddiff --git a/fixtures/blog.publish-request/valid/revision.json b/fixtures/blog.publish-request/valid/revision.json
nnew file mode 100644
iindex 00000000..67bf8ca2
---- /dev/null
++++ b/fixtures/blog.publish-request/valid/revision.json
@@@ -0,0 +1 @@
++{ "revision": 2 }
ddiff --git a/fixtures/blog.schedule-request/invalid/bad-action.json b/fixtures/blog.schedule-request/invalid/bad-action.json
nnew file mode 100644
iindex 00000000..524a0347
---- /dev/null
++++ b/fixtures/blog.schedule-request/invalid/bad-action.json
@@@ -0,0 +1 @@
++{ "at": "2026-10-01T15:00:00Z", "action": "delete" }
ddiff --git a/fixtures/blog.schedule-request/valid/publish.json b/fixtures/blog.schedule-request/valid/publish.json
nnew file mode 100644
iindex 00000000..622c9499
---- /dev/null
++++ b/fixtures/blog.schedule-request/valid/publish.json
@@@ -0,0 +1 @@
++{ "at": "2026-10-01T15:00:00Z", "action": "publish" }
ddiff --git a/fixtures/blog.schedule-result/invalid/negative.json b/fixtures/blog.schedule-result/invalid/negative.json
nnew file mode 100644
iindex 00000000..be368384
---- /dev/null
++++ b/fixtures/blog.schedule-result/invalid/negative.json
@@@ -0,0 +1 @@
++{ "cancelled": -1 }
ddiff --git a/fixtures/blog.schedule-result/valid/cancelled.json b/fixtures/blog.schedule-result/valid/cancelled.json
nnew file mode 100644
iindex 00000000..1dc0472f
---- /dev/null
++++ b/fixtures/blog.schedule-result/valid/cancelled.json
@@@ -0,0 +1 @@
++{ "cancelled": 1, "post": { "id": "pst_01JAB2C3D4E5F6G7H8J9K0MNPQ", "blog": { "id": "blg_01JAB2C3D4E5F6G7H8J9K0MNPQ", "handle": "alice" }, "slug": "my-restream-setup", "url": "https://openvibe.blog/@alice/my-restream-setup", "state": "published", "visibility": "public", "author_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "title": "My restream setup", "summary": "One encoder, three platforms.", "revision": 2, "published_revision": 2, "first_published_at": "2026-09-24T10:00:00.000Z", "published_at": "2026-09-25T10:00:00.000Z", "tags": ["obs", "restream"], "categories": [], "series": null, "allow_comments": true, "noindex": false, "indexability": { "indexable": true, "robots": "index,follow", "reasons": [] } } }
ddiff --git a/fixtures/blog.theme-request/invalid/unknown.json b/fixtures/blog.theme-request/invalid/unknown.json
nnew file mode 100644
iindex 00000000..6aae7ca1
---- /dev/null
++++ b/fixtures/blog.theme-request/invalid/unknown.json
@@@ -0,0 +1 @@
++{ "theme": "neon" }
ddiff --git a/fixtures/blog.theme-request/valid/nord.json b/fixtures/blog.theme-request/valid/nord.json
nnew file mode 100644
iindex 00000000..63b59dae
---- /dev/null
++++ b/fixtures/blog.theme-request/valid/nord.json
@@@ -0,0 +1 @@
++{ "theme": "nord" }
ddiff --git a/fixtures/coupons.coupon/invalid/verified-status.json b/fixtures/coupons.coupon/invalid/verified-status.json
nnew file mode 100644
iindex 00000000..cc2ba9ef
---- /dev/null
++++ b/fixtures/coupons.coupon/invalid/verified-status.json
@@@ -0,0 +1 @@
++{ "id": "cpn_01JAB2C3D4E5F6G7H8J9K0MNPR", "merchant_id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "code": "FALL15", "title": "15% off sneakers", "description": null, "status": "verified", "active": true, "confidence": 0.82, "reports": { "worked": 9, "failed": 1, "window_days": 30, "last_report_at": "2026-09-25T17:00:00.000Z", "last_worked_at": "2026-09-25T17:00:00.000Z", "last_failed_at": "2026-09-20T09:00:00.000Z" }, "expiry": { "known": true, "expires_at": "2026-10-31T23:59:59.999Z", "precision": "date", "basis": "evidence" }, "restrictions": [ { "kind": "min_spend", "amount": "$50.00", "amount_minor": 5000, "currency": "USD" }, { "kind": "new_customers_only" } ], "hints": [], "evidence": [ { "kind": "page", "url": "https://shoes.example.com/fall-sale", "merchant_page": true } ], "origin": "member", "created_at": "2026-09-20T09:00:00.000Z", "updated_at": "2026-09-25T17:03:00.000Z", "expired_at": null, "url": "https://openvibe.coupons/c/cpn_01JAB2C3D4E5F6G7H8J9K0MNPR" }
ddiff --git a/fixtures/coupons.coupon/valid/working.json b/fixtures/coupons.coupon/valid/working.json
nnew file mode 100644
iindex 00000000..5cc96c72
---- /dev/null
++++ b/fixtures/coupons.coupon/valid/working.json
@@@ -0,0 +1 @@
++{ "id": "cpn_01JAB2C3D4E5F6G7H8J9K0MNPR", "merchant_id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "code": "FALL15", "title": "15% off sneakers", "description": null, "status": "reported_working", "active": true, "confidence": 0.82, "reports": { "worked": 9, "failed": 1, "window_days": 30, "last_report_at": "2026-09-25T17:00:00.000Z", "last_worked_at": "2026-09-25T17:00:00.000Z", "last_failed_at": "2026-09-20T09:00:00.000Z" }, "expiry": { "known": true, "expires_at": "2026-10-31T23:59:59.999Z", "precision": "date", "basis": "evidence" }, "restrictions": [ { "kind": "min_spend", "amount": "$50.00", "amount_minor": 5000, "currency": "USD" }, { "kind": "new_customers_only" } ], "hints": [], "evidence": [ { "kind": "page", "url": "https://shoes.example.com/fall-sale", "merchant_page": true } ], "origin": "member", "created_at": "2026-09-20T09:00:00.000Z", "updated_at": "2026-09-25T17:03:00.000Z", "expired_at": null, "url": "https://openvibe.coupons/c/cpn_01JAB2C3D4E5F6G7H8J9K0MNPR" }
ddiff --git a/fixtures/coupons.lookup-result/invalid/no-merchant.json b/fixtures/coupons.lookup-result/invalid/no-merchant.json
nnew file mode 100644
iindex 00000000..fa34a450
---- /dev/null
++++ b/fixtures/coupons.lookup-result/invalid/no-merchant.json
@@@ -0,0 +1 @@
++{ "coupons": [] }
ddiff --git a/fixtures/coupons.lookup-result/valid/merchant-codes.json b/fixtures/coupons.lookup-result/valid/merchant-codes.json
nnew file mode 100644
iindex 00000000..025c4690
---- /dev/null
++++ b/fixtures/coupons.lookup-result/valid/merchant-codes.json
@@@ -0,0 +1 @@
++{ "merchant": { "id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "slug": "example-shoes", "name": "Example Shoes", "homepage_url": "https://shoes.example.com/", "url": "https://openvibe.coupons/m/example-shoes", "domains": [ { "host": "shoes.example.com", "include_subdomains": true, "path_prefix": null } ], "active_codes": 2, "hints": ["Enter the code in the Promo field at checkout."] }, "coupons": [ { "id": "cpn_01JAB2C3D4E5F6G7H8J9K0MNPR", "merchant_id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "code": "FALL15", "title": "15% off sneakers", "description": null, "status": "reported_working", "active": true, "confidence": 0.82, "reports": { "worked": 9, "failed": 1, "window_days": 30, "last_report_at": "2026-09-25T17:00:00.000Z", "last_worked_at": "2026-09-25T17:00:00.000Z", "last_failed_at": "2026-09-20T09:00:00.000Z" }, "expiry": { "known": true, "expires_at": "2026-10-31T23:59:59.999Z", "precision": "date", "basis": "evidence" }, "restrictions": [ { "kind": "min_spend", "amount": "$50.00", "amount_minor": 5000, "currency": "USD" }, { "kind": "new_customers_only" } ], "hints": [], "evidence": [ { "kind": "page", "url": "https://shoes.example.com/fall-sale", "merchant_page": true } ], "origin": "member", "created_at": "2026-09-20T09:00:00.000Z", "updated_at": "2026-09-25T17:03:00.000Z", "expired_at": null, "url": "https://openvibe.coupons/c/cpn_01JAB2C3D4E5F6G7H8J9K0MNPR" } ] }
ddiff --git a/fixtures/coupons.merchant-manage-request/invalid/bad.json b/fixtures/coupons.merchant-manage-request/invalid/bad.json
nnew file mode 100644
iindex 00000000..03081da9
---- /dev/null
++++ b/fixtures/coupons.merchant-manage-request/invalid/bad.json
@@@ -0,0 +1 @@
++{ "status": "paused", "domains": [] }
ddiff --git a/fixtures/coupons.merchant-manage-request/valid/create.json b/fixtures/coupons.merchant-manage-request/valid/create.json
nnew file mode 100644
iindex 00000000..5a37b588
---- /dev/null
++++ b/fixtures/coupons.merchant-manage-request/valid/create.json
@@@ -0,0 +1 @@
++{ "name": "Example Shoes", "homepage_url": "https://shoes.example.com/", "domains": [ { "host": "shoes.example.com" } ] }
ddiff --git a/fixtures/coupons.merchant-manage-request/valid/disable.json b/fixtures/coupons.merchant-manage-request/valid/disable.json
nnew file mode 100644
iindex 00000000..c6177bd4
---- /dev/null
++++ b/fixtures/coupons.merchant-manage-request/valid/disable.json
@@@ -0,0 +1 @@
++{ "status": "disabled" }
ddiff --git a/fixtures/coupons.merchant-manage-result/invalid/no-status.json b/fixtures/coupons.merchant-manage-result/invalid/no-status.json
nnew file mode 100644
iindex 00000000..5a938105
---- /dev/null
++++ b/fixtures/coupons.merchant-manage-result/invalid/no-status.json
@@@ -0,0 +1 @@
++{ "merchant": { "id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "slug": "example-shoes", "name": "Example Shoes", "homepage_url": "https://shoes.example.com/", "url": "https://openvibe.coupons/m/example-shoes", "domains": [ { "host": "shoes.example.com", "include_subdomains": true, "path_prefix": null } ], "active_codes": 2, "hints": ["Enter the code in the Promo field at checkout."] } }
ddiff --git a/fixtures/coupons.merchant-manage-result/valid/created.json b/fixtures/coupons.merchant-manage-result/valid/created.json
nnew file mode 100644
iindex 00000000..a6aedb67
---- /dev/null
++++ b/fixtures/coupons.merchant-manage-result/valid/created.json
@@@ -0,0 +1 @@
++{ "merchant": { "id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "slug": "example-shoes", "name": "Example Shoes", "homepage_url": "https://shoes.example.com/", "url": "https://openvibe.coupons/m/example-shoes", "domains": [ { "host": "shoes.example.com", "include_subdomains": true, "path_prefix": null } ], "active_codes": 2, "hints": ["Enter the code in the Promo field at checkout."] , "status": "active" } }
ddiff --git a/fixtures/coupons.merchant/invalid/negative-count.json b/fixtures/coupons.merchant/invalid/negative-count.json
nnew file mode 100644
iindex 00000000..8e3e18b7
---- /dev/null
++++ b/fixtures/coupons.merchant/invalid/negative-count.json
@@@ -0,0 +1 @@
++{ "id": "m", "slug": "s", "name": "n", "url": "u", "domains": [], "active_codes": -1, "hints": [] }
ddiff --git a/fixtures/coupons.merchant/valid/shop.json b/fixtures/coupons.merchant/valid/shop.json
nnew file mode 100644
iindex 00000000..f5c85f0a
---- /dev/null
++++ b/fixtures/coupons.merchant/valid/shop.json
@@@ -0,0 +1 @@
++{ "id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "slug": "example-shoes", "name": "Example Shoes", "homepage_url": "https://shoes.example.com/", "url": "https://openvibe.coupons/m/example-shoes", "domains": [ { "host": "shoes.example.com", "include_subdomains": true, "path_prefix": null } ], "active_codes": 2, "hints": ["Enter the code in the Promo field at checkout."] }
ddiff --git a/fixtures/coupons.report-request/invalid/worked-with-reason.json b/fixtures/coupons.report-request/invalid/worked-with-reason.json
nnew file mode 100644
iindex 00000000..0d8715cf
---- /dev/null
++++ b/fixtures/coupons.report-request/invalid/worked-with-reason.json
@@@ -0,0 +1 @@
++{ "outcome": "worked", "reason": "expired" }
ddiff --git a/fixtures/coupons.report-request/valid/failed.json b/fixtures/coupons.report-request/valid/failed.json
nnew file mode 100644
iindex 00000000..3b10f100
---- /dev/null
++++ b/fixtures/coupons.report-request/valid/failed.json
@@@ -0,0 +1 @@
++{ "outcome": "failed", "reason": "min_spend_not_met" }
ddiff --git a/fixtures/coupons.report-result/invalid/not-accepted.json b/fixtures/coupons.report-result/invalid/not-accepted.json
nnew file mode 100644
iindex 00000000..3e518447
---- /dev/null
++++ b/fixtures/coupons.report-result/invalid/not-accepted.json
@@@ -0,0 +1 @@
++{ "accepted": false }
ddiff --git a/fixtures/coupons.report-result/valid/new.json b/fixtures/coupons.report-result/valid/new.json
nnew file mode 100644
iindex 00000000..715b0d44
---- /dev/null
++++ b/fixtures/coupons.report-result/valid/new.json
@@@ -0,0 +1 @@
++{ "accepted": true, "deduplicated": false, "corrected": false, "coupon": { "id": "cpn_01JAB2C3D4E5F6G7H8J9K0MNPR", "merchant_id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "code": "FALL15", "title": "15% off sneakers", "description": null, "status": "reported_working", "active": true, "confidence": 0.82, "reports": { "worked": 9, "failed": 1, "window_days": 30, "last_report_at": "2026-09-25T17:00:00.000Z", "last_worked_at": "2026-09-25T17:00:00.000Z", "last_failed_at": "2026-09-20T09:00:00.000Z" }, "expiry": { "known": true, "expires_at": "2026-10-31T23:59:59.999Z", "precision": "date", "basis": "evidence" }, "restrictions": [ { "kind": "min_spend", "amount": "$50.00", "amount_minor": 5000, "currency": "USD" }, { "kind": "new_customers_only" } ], "hints": [], "evidence": [ { "kind": "page", "url": "https://shoes.example.com/fall-sale", "merchant_page": true } ], "origin": "member", "created_at": "2026-09-20T09:00:00.000Z", "updated_at": "2026-09-25T17:03:00.000Z", "expired_at": null, "url": "https://openvibe.coupons/c/cpn_01JAB2C3D4E5F6G7H8J9K0MNPR" } }
ddiff --git a/fixtures/coupons.resolve-result/invalid/no-merchant.json b/fixtures/coupons.resolve-result/invalid/no-merchant.json
nnew file mode 100644
iindex 00000000..d50a9d1e
---- /dev/null
++++ b/fixtures/coupons.resolve-result/invalid/no-merchant.json
@@@ -0,0 +1 @@
++{ "host": "x.example.com", "registrable_domain": "example.com" }
ddiff --git a/fixtures/coupons.resolve-result/valid/match.json b/fixtures/coupons.resolve-result/valid/match.json
nnew file mode 100644
iindex 00000000..4f6f8943
---- /dev/null
++++ b/fixtures/coupons.resolve-result/valid/match.json
@@@ -0,0 +1 @@
++{ "host": "www.shoes.example.com", "registrable_domain": "example.com", "matched_rule": { "host": "shoes.example.com", "include_subdomains": true, "path_prefix": null }, "merchant": { "id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "slug": "example-shoes", "name": "Example Shoes", "homepage_url": "https://shoes.example.com/", "url": "https://openvibe.coupons/m/example-shoes", "domains": [ { "host": "shoes.example.com", "include_subdomains": true, "path_prefix": null } ], "active_codes": 2, "hints": ["Enter the code in the Promo field at checkout."] } }
ddiff --git a/fixtures/coupons.status-request/invalid/report-status.json b/fixtures/coupons.status-request/invalid/report-status.json
nnew file mode 100644
iindex 00000000..274906c3
---- /dev/null
++++ b/fixtures/coupons.status-request/invalid/report-status.json
@@@ -0,0 +1 @@
++{ "status": "reported_working" }
ddiff --git a/fixtures/coupons.status-request/valid/disable.json b/fixtures/coupons.status-request/valid/disable.json
nnew file mode 100644
iindex 00000000..6fe47674
---- /dev/null
++++ b/fixtures/coupons.status-request/valid/disable.json
@@@ -0,0 +1 @@
++{ "status": "disabled", "note": "merchant asked us to remove it" }
ddiff --git a/fixtures/coupons.status-result/invalid/empty.json b/fixtures/coupons.status-result/invalid/empty.json
nnew file mode 100644
iindex 00000000..0967ef42
---- /dev/null
++++ b/fixtures/coupons.status-result/invalid/empty.json
@@@ -0,0 +1 @@
++{}
ddiff --git a/fixtures/coupons.status-result/valid/coupon.json b/fixtures/coupons.status-result/valid/coupon.json
nnew file mode 100644
iindex 00000000..1019f7c5
---- /dev/null
++++ b/fixtures/coupons.status-result/valid/coupon.json
@@@ -0,0 +1 @@
++{ "coupon": { "id": "cpn_01JAB2C3D4E5F6G7H8J9K0MNPR", "merchant_id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "code": "FALL15", "title": "15% off sneakers", "description": null, "status": "reported_working", "active": true, "confidence": 0.82, "reports": { "worked": 9, "failed": 1, "window_days": 30, "last_report_at": "2026-09-25T17:00:00.000Z", "last_worked_at": "2026-09-25T17:00:00.000Z", "last_failed_at": "2026-09-20T09:00:00.000Z" }, "expiry": { "known": true, "expires_at": "2026-10-31T23:59:59.999Z", "precision": "date", "basis": "evidence" }, "restrictions": [ { "kind": "min_spend", "amount": "$50.00", "amount_minor": 5000, "currency": "USD" }, { "kind": "new_customers_only" } ], "hints": [], "evidence": [ { "kind": "page", "url": "https://shoes.example.com/fall-sale", "merchant_page": true } ], "origin": "member", "created_at": "2026-09-20T09:00:00.000Z", "updated_at": "2026-09-25T17:03:00.000Z", "expired_at": null, "url": "https://openvibe.coupons/c/cpn_01JAB2C3D4E5F6G7H8J9K0MNPR" } }
ddiff --git a/fixtures/coupons.submit-request/invalid/claims-status.json b/fixtures/coupons.submit-request/invalid/claims-status.json
nnew file mode 100644
iindex 00000000..0c4ef317
---- /dev/null
++++ b/fixtures/coupons.submit-request/invalid/claims-status.json
@@@ -0,0 +1 @@
++{ "code": "FALL15", "title": "15% off sneakers", "host": "shoes.example.com", "status": "reported_working" }
ddiff --git a/fixtures/coupons.submit-request/valid/with-evidence.json b/fixtures/coupons.submit-request/valid/with-evidence.json
nnew file mode 100644
iindex 00000000..19a621e1
---- /dev/null
++++ b/fixtures/coupons.submit-request/valid/with-evidence.json
@@@ -0,0 +1 @@
++{ "code": "FALL15", "title": "15% off sneakers", "url": "https://shoes.example.com/sneakers", "expires": "2026-10-31", "expiry_basis": "evidence", "evidence_url": "https://shoes.example.com/fall-sale", "restrictions": { "min_spend": { "amount": 50, "currency": "USD" }, "new_customers_only": true } }
ddiff --git a/fixtures/coupons.submit-result/invalid/partial.json b/fixtures/coupons.submit-result/invalid/partial.json
nnew file mode 100644
iindex 00000000..bc4a575e
---- /dev/null
++++ b/fixtures/coupons.submit-result/invalid/partial.json
@@@ -0,0 +1 @@
++{ "duplicate": false }
ddiff --git a/fixtures/coupons.submit-result/valid/new.json b/fixtures/coupons.submit-result/valid/new.json
nnew file mode 100644
iindex 00000000..9f4811af
---- /dev/null
++++ b/fixtures/coupons.submit-result/valid/new.json
@@@ -0,0 +1 @@
++{ "coupon": { "id": "cpn_01JAB2C3D4E5F6G7H8J9K0MNPR", "merchant_id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "code": "FALL15", "title": "15% off sneakers", "description": null, "status": "reported_working", "active": true, "confidence": 0.82, "reports": { "worked": 9, "failed": 1, "window_days": 30, "last_report_at": "2026-09-25T17:00:00.000Z", "last_worked_at": "2026-09-25T17:00:00.000Z", "last_failed_at": "2026-09-20T09:00:00.000Z" }, "expiry": { "known": true, "expires_at": "2026-10-31T23:59:59.999Z", "precision": "date", "basis": "evidence" }, "restrictions": [ { "kind": "min_spend", "amount": "$50.00", "amount_minor": 5000, "currency": "USD" }, { "kind": "new_customers_only" } ], "hints": [], "evidence": [ { "kind": "page", "url": "https://shoes.example.com/fall-sale", "merchant_page": true } ], "origin": "member", "created_at": "2026-09-20T09:00:00.000Z", "updated_at": "2026-09-25T17:03:00.000Z", "expired_at": null, "url": "https://openvibe.coupons/c/cpn_01JAB2C3D4E5F6G7H8J9K0MNPR" }, "merchant": { "id": "mer_01JAB2C3D4E5F6G7H8J9K0MNPQ", "slug": "example-shoes", "name": "Example Shoes", "status": "active" }, "duplicate": false, "review_state": "published" }
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 47314037..0a558cd6 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -8271,2749 +8271,4176 @@
}
}
},
-- "ai.run-request": {
++ "blog.blog-configure-request": {
"version": "1.0.0",
-- "owner": "ai",
++ "owner": "blog",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/run-request.v1.json",
-- "title": "AiRunRequest",
-- "description": "Body of POST /api/v1/runs on OpenVibe.AI (capability ai.run.create; ADR-015). Runs the newest active version of a workflow (or the given active or deprecated version) on input that must match that workflow version's own input schema; per-workflow input and output schemas live in AI's registry (GET /api/v1/workflows/:key), not here. The token's ns claim limits which workflow namespaces may run (403 capability.namespace_denied). ?wait=ms waits for the result: 201 finished or served from cache, 202 still queued/running (poll GET /api/v1/runs/:id), 200 an idempotent replay. Errors are problem+json: 404 workflow.not_found, 409 workflow.inactive, 409 idempotency.conflict, 413 input.too_large, 422 input.invalid, 429 quota.exceeded or queue.full with Retry-After. The direct operations POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed} take the same fields (except workflow and version) with the input fields at the top level and run workflow ai.<op>. target (EntityRef): what the output is about, part of the cache scope and a run filter. attribution (EntityRef): what the spend is attributed to for quotas and usage. on_behalf_of (SubjectRef): the person or actor the caller acts for, part of the cache scope and per-actor quotas.",
++ "$id": "https://openvibe.network/contracts/blog/blog-configure-request.v1.json",
++ "title": "BlogBlogConfigureRequest",
++ "description": "blog.blog-configure-request@1: the body of PATCH /api/v1/blogs/:handle on OpenVibe.Blog (blog.blog.configure; an owner): title (at most 120 characters, not blank), description (500), language (a BCP 47 tag) and feeds { rss, atom, json, item_count (1-50), full_content }. Fields left out keep their value.",
"type": "object",
-- "required": [
-- "workflow"
-- ],
-- "additionalProperties": false,
"properties": {
-- "workflow": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)+$",
-- "maxLength": 200,
-- "description": "Workflow key, <namespace>.<name> (live.chat_reply, news.summarize_story, network.site_copy…)."
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Pin a workflow version (active or deprecated). Omitted: the newest active version."
-- },
-- "input": {
-- "type": "object",
-- "description": "Workflow input; validated against the workflow version's input_schema. Default {}."
-- },
-- "target": {
-- "$ref": "../common/entity-ref.v1.json"
-- },
-- "attribution": {
-- "$ref": "../common/entity-ref.v1.json"
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
},
-- "on_behalf_of": {
-- "$ref": "../identity/subject-ref.v1.json"
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
},
-- "idempotency_key": {
-- "type": "string",
-- "pattern": "^[A-Za-z0-9._:-]{8,128}$",
-- "description": "Same (requester, key) returns the existing run; a different workflow or input under it is 409 idempotency.conflict. The Idempotency-Key header is used when this is absent."
++ "language": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$"
},
-- "options": {
++ "feeds": {
"type": "object",
-- "additionalProperties": false,
"properties": {
-- "cache": {
-- "type": "boolean",
-- "default": true,
-- "description": "false skips the scoped cache lookup and store."
++ "rss": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
},
-- "debug": {
-- "type": "boolean",
-- "default": false,
-- "description": "Keep raw prompts and responses in the request log, only when AI runs with raw debug logging on."
++ "atom": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "json": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "item_count": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 1,
++ "maximum": 50,
++ "pattern": "^[0-9]+$"
++ },
++ "full_content": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
}
}
}
}
}
},
-- "ai.run": {
++ "blog.blog-create-request": {
"version": "1.0.0",
-- "owner": "ai",
++ "owner": "blog",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/run.v1.json",
-- "title": "AiRun",
-- "description": "A workflow run on OpenVibe.AI (ADR-015; server/runs.js decode): the `run` member of every run response (POST /api/v1/runs and the direct operations, GET /api/v1/runs/:id, cancel, retry) and each item of GET /api/v1/runs { runs }. Output is attributable to workflow + version + template version + route version + provider/model + run id, never to a person. A draft stays a draft: products store it with ai/ai_assisted authorship and publish nothing until a person reviews it. input keeps no inline media (replaced by data_url_sha256 and data_url_bytes) and no passthrough prompt ({ not_retained }); output is null until the run succeeded or was served from cache and then matches the workflow version's output_schema. requester is the token principal (service, app or mod). A run interrupted by a restart or shutdown ends failed with error.code run.interrupted.",
++ "$id": "https://openvibe.network/contracts/blog/blog-create-request.v1.json",
++ "title": "BlogBlogCreateRequest",
++ "description": "blog.blog-create-request@1: the body of POST /api/v1/blogs on OpenVibe.Blog (blog.blog.create): get or create the acting member's blog (one per usr_ subject). handle defaults to their Network username (2-40 of a-z, 0-9, _ and -, not a reserved word); title and description are optional.",
++ "type": "object",
++ "properties": {
++ "handle": {
++ "type": "string",
++ "pattern": "^[a-z0-9_-]{2,40}$"
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ },
++ "blog.blog-create-result": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog-create-result.v1.json",
++ "title": "BlogBlogCreateResult",
++ "description": "blog.blog-create-result@1: the answer of POST /api/v1/blogs on OpenVibe.Blog: { blog, created } — 201 when the blog was created, 200 when the member already had it.",
++ "type": "object",
++ "required": [
++ "blog",
++ "created"
++ ],
++ "properties": {
++ "blog": {
++ "$ref": "blog.v1.json"
++ },
++ "created": {
++ "type": "boolean"
++ }
++ }
++ }
++ },
++ "blog.blog-result": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog-result.v1.json",
++ "title": "BlogBlogResult",
++ "description": "blog.blog-result@1: the answer of PATCH /api/v1/blogs/:handle (blog.blog.configure) and PUT /api/v1/blogs/:handle/theme (blog.theme.set) on OpenVibe.Blog: { blog } with the member view; the theme answer adds themes (the presets).",
++ "type": "object",
++ "required": [
++ "blog"
++ ],
++ "properties": {
++ "blog": {
++ "$ref": "blog.v1.json"
++ },
++ "themes": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ },
++ "blog.blog": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/blog.v1.json",
++ "title": "BlogBlog",
++ "description": "blog.blog@1: one blog as OpenVibe.Blog's API shows it (server/http/api.js blogDto). Members of the blog (and staff) also see status, owner_subject and feed_settings.",
"type": "object",
"required": [
"id",
-- "status",
-- "workflow",
-- "template",
-- "route",
-- "requester",
-- "on_behalf_of",
-- "attribution",
-- "source_service",
-- "target",
-- "input",
-- "output",
-- "error",
-- "synthetic",
-- "provenance",
-- "usage",
-- "citations_count",
-- "retry_of",
-- "idempotency_key",
-- "trace_id",
-- "created_at",
-- "started_at",
-- "finished_at"
++ "kind",
++ "handle",
++ "title",
++ "url",
++ "theme",
++ "language",
++ "feeds"
],
-- "additionalProperties": false,
"properties": {
"id": {
++ "type": "string"
++ },
++ "kind": {
"type": "string",
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ "description": "member, …"
},
-- "status": {
-- "enum": [
-- "queued",
-- "running",
-- "succeeded",
-- "failed",
-- "cancelled",
-- "cached"
-- ],
-- "description": "Terminal: succeeded, failed, cancelled, cached (a scoped cache hit, no provider call; provenance.cached_from names the run it reused)."
++ "handle": {
++ "type": "string"
},
-- "workflow": {
-- "type": "object",
-- "required": [
-- "key",
-- "version"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1
++ "title": {
++ "type": "string"
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "url": {
++ "type": "string"
++ },
++ "theme": {
++ "type": "string"
++ },
++ "language": {
++ "type": "string"
++ },
++ "feeds": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "type",
++ "url"
++ ],
++ "properties": {
++ "type": {
++ "enum": [
++ "rss",
++ "atom",
++ "json"
++ ]
++ },
++ "url": {
++ "type": "string"
++ }
}
}
},
-- "template": {
++ "status": {
++ "type": "string"
++ },
++ "owner_subject": {
"type": [
-- "object",
++ "string",
"null"
-- ],
-- "required": [
-- "key",
-- "version"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": [
-- "integer",
-- "null"
-- ]
-- }
-- },
-- "description": "The prompt template of the workflow's llm step; null for workflows without one."
++ ]
},
-- "route": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "required": [
-- "key",
-- "version"
-- ],
-- "additionalProperties": false,
++ "feed_settings": {
++ "type": "object",
"properties": {
-- "key": {
-- "type": "string"
++ "rss": {
++ "type": "integer"
},
-- "version": {
-- "type": [
-- "integer",
-- "null"
-- ]
++ "atom": {
++ "type": "integer"
++ },
++ "json": {
++ "type": "integer"
++ },
++ "item_count": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 50
++ },
++ "full_content": {
++ "type": "integer"
}
-- },
-- "description": "Provider route; version null while the route is unresolved or disabled."
++ }
++ }
++ }
++ }
++ },
++ "blog.delete-result": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/delete-result.v1.json",
++ "title": "BlogDeleteResult",
++ "description": "blog.delete-result@1: the answer of DELETE /api/v1/posts/:id on OpenVibe.Blog (blog.post.delete): { deleted: true, id }.",
++ "type": "object",
++ "required": [
++ "deleted",
++ "id"
++ ],
++ "properties": {
++ "deleted": {
++ "const": true
},
-- "requester": {
-- "$ref": "../identity/subject-ref.v1.json"
++ "id": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "blog.json-feed": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/json-feed.v1.json",
++ "title": "BlogJsonFeed",
++ "description": "blog.json-feed@1: the answer of GET /api/v1/blogs/:handle/feed on OpenVibe.Blog (blog.feed.read): the blog's public posts as a JSON Feed 1.1 document (openvibe-publishing seo.jsonFeed), as many as the blog's feed item count, with full HTML content when the blog allows it and otherwise the summary as text. Empty fields are left out.",
++ "type": "object",
++ "required": [
++ "version",
++ "title",
++ "home_page_url",
++ "feed_url",
++ "items"
++ ],
++ "properties": {
++ "version": {
++ "const": "https://jsonfeed.org/version/1.1"
},
-- "on_behalf_of": {
-- "anyOf": [
-- {
-- "$ref": "../identity/subject-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
++ "title": {
++ "type": "string"
},
-- "attribution": {
-- "anyOf": [
-- {
-- "$ref": "../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
++ "home_page_url": {
++ "type": "string"
},
-- "source_service": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "The requester's service id when the requester is a service."
++ "feed_url": {
++ "type": "string"
},
-- "target": {
-- "anyOf": [
-- {
-- "$ref": "../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
++ "description": {
++ "type": "string"
},
-- "input": {
-- "type": "object",
-- "description": "The retained input (see the description)."
++ "language": {
++ "type": "string"
},
-- "output": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "Workflow output, or null."
++ "authors": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "name": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ }
++ }
++ }
},
-- "error": {
-- "type": [
-- "object",
-- "null"
-- ],
++ "items": {
++ "type": "array",
++ "maxItems": 50,
++ "items": {
++ "type": "object",
++ "required": [
++ "id"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ },
++ "title": {
++ "type": "string"
++ },
++ "content_html": {
++ "type": "string"
++ },
++ "content_text": {
++ "type": "string"
++ },
++ "summary": {
++ "type": "string"
++ },
++ "image": {
++ "type": "string"
++ },
++ "date_published": {
++ "type": "string"
++ },
++ "date_modified": {
++ "type": "string"
++ },
++ "authors": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "name": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "tags": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "blog.member-request": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/member-request.v1.json",
++ "title": "BlogMemberRequest",
++ "description": "blog.member-request@1: bodies of blog.member.manage on OpenVibe.Blog (an owner). PUT /api/v1/blogs/:handle/members/:subject: { role: owner | editor | author } (members are usr_ subjects; a member blog keeps at least one owner). GET …/members and DELETE …/members/:subject take no body.",
++ "anyOf": [
++ {
++ "type": "object",
"required": [
-- "code",
-- "detail"
++ "role"
],
-- "additionalProperties": false,
"properties": {
-- "code": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
-- "description": "run.cancelled, run.interrupted, run.internal, output.invalid, output.empty, provider.unavailable, route.unavailable, source.unavailable, media.unresolvable…"
-- },
-- "detail": {
-- "type": [
-- "string",
-- "null"
++ "role": {
++ "enum": [
++ "owner",
++ "editor",
++ "author"
]
}
}
},
-- "synthetic": {
-- "type": "boolean",
-- "description": "Produced by the stub provider; never served to production callers and never cached."
-- },
-- "provenance": {
++ {
++ "$ref": "../common/no-body.v1.json"
++ }
++ ]
++ }
++ },
++ "blog.member-result": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/member-result.v1.json",
++ "title": "BlogMemberResult",
++ "description": "blog.member-result@1: answers of blog.member.manage on OpenVibe.Blog. GET /api/v1/blogs/:handle/members → { members } (subject, role, name and username when Network knows them); PUT …/members/:subject → { member }; DELETE → { removed } (false when they were not a member).",
++ "anyOf": [
++ {
"type": "object",
-- "additionalProperties": false,
"required": [
-- "origin",
-- "workflow",
-- "workflow_version",
-- "template_version",
-- "route",
-- "route_version",
-- "provider",
-- "model",
-- "fallback_used",
-- "run_id",
-- "cached_from",
-- "synthetic"
++ "members"
],
"properties": {
-- "origin": {
-- "const": "ai"
-- },
-- "workflow": {
-- "type": "string"
-- },
-- "workflow_version": {
-- "type": "integer",
-- "minimum": 1
-- },
-- "template_version": {
-- "type": [
-- "integer",
-- "null"
-- ]
-- },
-- "route": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "route_version": {
-- "type": [
-- "integer",
-- "null"
-- ]
-- },
-- "provider": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "model": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "fallback_used": {
-- "type": "boolean",
-- "description": "The primary provider was degraded and the run fell back (ADR-015: fallbacks are recorded)."
-- },
-- "run_id": {
-- "type": "string",
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "cached_from": {
-- "type": [
-- "string",
-- "null"
++ "members": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "subject",
++ "role"
++ ],
++ "properties": {
++ "subject": {
++ "type": "string"
++ },
++ "role": {
++ "enum": [
++ "owner",
++ "editor",
++ "author"
++ ]
++ },
++ "name": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "username": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "member"
++ ],
++ "properties": {
++ "member": {
++ "type": "object",
++ "required": [
++ "role"
],
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "synthetic": {
-- "type": "boolean"
++ "properties": {
++ "subject": {
++ "type": "string"
++ },
++ "role": {
++ "enum": [
++ "owner",
++ "editor",
++ "author"
++ ]
++ }
++ }
}
}
},
-- "usage": {
++ {
"type": "object",
"required": [
-- "tokens_in",
-- "tokens_out",
-- "cost_usd",
-- "attempts"
++ "removed"
],
-- "additionalProperties": false,
"properties": {
-- "tokens_in": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "tokens_out": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "cost_usd": {
-- "type": "number",
-- "minimum": 0
-- },
-- "attempts": {
-- "type": "integer",
-- "minimum": 0,
-- "description": "Provider calls made (skipped routes not counted)."
++ "removed": {
++ "type": "boolean"
}
}
++ }
++ ]
++ }
++ },
++ "blog.post-create-request": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-create-request.v1.json",
++ "title": "BlogPostCreateRequest",
++ "description": "blog.post-create-request@1: the body of POST /api/v1/blogs/:handle/posts on OpenVibe.Blog (blog.post.create; a member who may write, or a service acting for one; X-OV-Origin: ai makes an AI draft that needs a person's review). A new draft at revision 1: title required. Unknown fields are ignored; refusals are problem+json (post.invalid_title, post.too_long, post.invalid_visibility, post.invalid_slug, post.too_many_tags, post.invalid_series, …).",
++ "type": "object",
++ "required": [
++ "title"
++ ],
++ "properties": {
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "Whitespace collapsed; required, at most 200 characters."
},
-- "citations_count": {
-- "type": "integer",
-- "minimum": 0,
-- "description": "Citations stored for the run (GET /api/v1/runs/:id/citations): the sources the workflow was given, and any the requester attached later."
++ "body": {
++ "type": "string",
++ "maxLength": 200000,
++ "description": "Markdown."
},
-- "retry_of": {
++ "summary": {
"type": [
"string",
"null"
++ ]
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "members",
++ "private"
],
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ "description": "Default public; members gates it behind the blog's VIP entitlement (OpenVibe.VIP decides)."
},
-- "idempotency_key": {
++ "entitlement_key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.:-]{0,127}$",
++ "description": "members only: informational, default vip:<handle>."
++ },
++ "slug": {
++ "type": "string",
++ "description": "Default from the title; lowercase letters, digits and dashes (a taken one gets a number)."
++ },
++ "allow_comments": {
"type": [
-- "string",
-- "null"
++ "boolean",
++ "integer",
++ "string"
]
},
-- "trace_id": {
++ "noindex": {
"type": [
-- "string",
-- "null"
++ "boolean",
++ "integer",
++ "string"
]
},
-- "created_at": {
-- "type": "string",
-- "format": "date-time"
++ "tags": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "At most 20 tags of at most 50 characters."
},
-- "started_at": {
++ "categories": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "Category paths (Parent > Child), at most 10."
++ },
++ "series": {
++ "description": "A series id (ser_…), a new series title, or { title }; null or empty leaves the series."
++ },
++ "series_id": {
"type": [
"string",
"null"
-- ],
-- "format": "date-time"
++ ]
},
-- "finished_at": {
++ "series_position": {
"type": [
++ "integer",
"string",
"null"
-- ],
-- "format": "date-time"
-- }
-- }
-- }
-- },
-- "ai.run.queued": {
-- "version": "1.0.0",
-- "owner": "ai",
-- "status": "planned",
-- "schema": {
-- "$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/ai.run.queued.v1.json",
-- "title": "AiRunQueuedPayload",
-- "description": "ai.run.queued v1 (OpenVibe.AI server/runs.js create). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A run was stored queued (it then runs inline within ?wait or on the worker). Not published for a cache hit (ai.run.cached) or a create refused by quota or a full queue. A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority low, actor service:ai.",
-- "type": "object",
-- "required": [
-- "run_id",
-- "status",
-- "workflow",
-- "requester",
-- "on_behalf_of",
-- "target",
-- "attribution",
-- "provider",
-- "model",
-- "fallback_used",
-- "synthetic",
-- "cached_from",
-- "usage",
-- "error",
-- "citations_count",
-- "retry_of",
-- "created_at",
-- "finished_at"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "run_id": {
-- "type": "string",
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "status": {
-- "const": "queued"
-- },
-- "workflow": {
-- "type": "object",
-- "required": [
-- "key",
-- "version"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1
-- }
-- }
-- },
-- "requester": {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- "on_behalf_of": {
-- "anyOf": [
-- {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- },
-- "target": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
]
},
-- "attribution": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
++ "citations": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "Sources: a URL (with retrieved_at) or an OpenVibe.Sources item id; a quote may be text or { text }. At most 100 are kept.",
++ "items": {
++ "type": "object",
++ "properties": {
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "source_item_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "retrieved_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "quote": {},
++ "license_note": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
}
-- ]
-- },
-- "provider": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Provider that produced the output; null before a provider call and for cached runs."
++ }
},
-- "model": {
++ "message": {
"type": [
"string",
"null"
]
},
-- "fallback_used": {
-- "type": "boolean"
-- },
-- "synthetic": {
-- "type": "boolean",
-- "description": "Stub provider output."
-- },
-- "cached_from": {
-- "type": "null"
-- },
-- "usage": {
++ "authorship": {
"type": "object",
-- "required": [
-- "tokens_in",
-- "tokens_out",
-- "cost_usd",
-- "attempts"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "tokens_in": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "tokens_out": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "cost_usd": {
-- "type": "number",
-- "minimum": 0
-- },
-- "attempts": {
-- "type": "integer",
-- "minimum": 0
-- }
-- }
-- },
-- "error": {
-- "type": "null"
-- },
-- "citations_count": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "retry_of": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "created_at": {
-- "type": "string",
-- "format": "date-time"
-- },
-- "finished_at": {
-- "type": "null"
++ "description": "hybrid or imported authorship details; an AI service (X-OV-Origin: ai) writes AI drafts that need review."
}
}
}
},
-- "ai.run.succeeded": {
++ "blog.post-create-result": {
"version": "1.0.0",
-- "owner": "ai",
-- "status": "planned",
++ "owner": "blog",
++ "status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/ai.run.succeeded.v1.json",
-- "title": "AiRunSucceededPayload",
-- "description": "ai.run.succeeded v1 (OpenVibe.AI server/runs.js execute). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A run finished with output that matched the workflow output schema. The output itself is not in the event: a consumer that needs it reads GET /api/v1/runs/:id with ai.run.read (own runs only). A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority important, actor service:ai.",
++ "$id": "https://openvibe.network/contracts/blog/post-create-result.v1.json",
++ "title": "BlogPostCreateResult",
++ "description": "blog.post-create-result@1: the answer of POST /api/v1/blogs/:handle/posts on OpenVibe.Blog: 201 { post, revision } (the editor view and the revision number, 1).",
"type": "object",
"required": [
-- "run_id",
-- "status",
-- "workflow",
-- "requester",
-- "on_behalf_of",
-- "target",
-- "attribution",
-- "provider",
-- "model",
-- "fallback_used",
-- "synthetic",
-- "cached_from",
-- "usage",
-- "error",
-- "citations_count",
-- "retry_of",
-- "created_at",
-- "finished_at"
++ "post",
++ "revision"
],
-- "additionalProperties": false,
"properties": {
-- "run_id": {
-- "type": "string",
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ "post": {
++ "$ref": "post.v1.json"
},
-- "status": {
-- "const": "succeeded"
-- },
-- "workflow": {
++ "revision": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
++ }
++ },
++ "blog.post-read-result": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-read-result.v1.json",
++ "title": "BlogPostReadResult",
++ "description": "blog.post-read-result@1: answers of blog.post.read on OpenVibe.Blog. GET /api/v1/posts/:id → { post } (editors and staff get the full view; readers of a published post the public one; a members-only post someone may not open is 403 post.members_only with a teaser); GET /api/v1/posts/:id/revisions → { revisions } (each with its length instead of the content); GET …/revisions/:n → { revision, citations }; GET …/diff?from=&to=&mode= → { diff }; GET /api/v1/blogs/:handle/posts?all=1 → { posts } (drafts too, members only).",
++ "anyOf": [
++ {
"type": "object",
"required": [
-- "key",
-- "version"
++ "post"
],
-- "additionalProperties": false,
"properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1
++ "post": {
++ "$ref": "post.v1.json"
}
}
},
-- "requester": {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- "on_behalf_of": {
-- "anyOf": [
-- {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- {
-- "type": "null"
++ {
++ "type": "object",
++ "required": [
++ "revisions"
++ ],
++ "properties": {
++ "revisions": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "number"
++ ],
++ "properties": {
++ "number": {
++ "type": "integer"
++ },
++ "length": {
++ "type": "integer"
++ }
++ }
++ }
}
-- ]
++ }
},
-- "target": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- },
-- "attribution": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- },
-- "provider": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Provider that produced the output; null before a provider call and for cached runs."
-- },
-- "model": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "fallback_used": {
-- "type": "boolean"
-- },
-- "synthetic": {
-- "type": "boolean",
-- "description": "Stub provider output."
-- },
-- "cached_from": {
-- "type": "null"
-- },
-- "usage": {
++ {
"type": "object",
"required": [
-- "tokens_in",
-- "tokens_out",
-- "cost_usd",
-- "attempts"
++ "revision",
++ "citations"
],
-- "additionalProperties": false,
"properties": {
-- "tokens_in": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "tokens_out": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "cost_usd": {
-- "type": "number",
-- "minimum": 0
++ "revision": {
++ "type": "object",
++ "required": [
++ "number"
++ ],
++ "properties": {
++ "number": {
++ "type": "integer"
++ },
++ "content": {
++ "type": "string"
++ },
++ "fields": {
++ "type": "object"
++ }
++ }
},
-- "attempts": {
-- "type": "integer",
-- "minimum": 0
++ "citations": {
++ "type": "array"
}
}
},
-- "error": {
-- "type": "null"
-- },
-- "citations_count": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "retry_of": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "created_at": {
-- "type": "string",
-- "format": "date-time"
-- },
-- "finished_at": {
-- "type": "string",
-- "format": "date-time"
-- }
-- }
-- }
-- },
-- "ai.run.failed": {
-- "version": "1.0.0",
-- "owner": "ai",
-- "status": "planned",
-- "schema": {
-- "$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/ai.run.failed.v1.json",
-- "title": "AiRunFailedPayload",
-- "description": "ai.run.failed v1 (OpenVibe.AI server/runs.js execute / recoverInterrupted). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A run ended failed: a provider, route, source or output error, run.internal, or run.interrupted after a restart or shutdown (retryable with POST /api/v1/runs/:id/retry unless its input was not retained). A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority important, actor service:ai.",
-- "type": "object",
-- "required": [
-- "run_id",
-- "status",
-- "workflow",
-- "requester",
-- "on_behalf_of",
-- "target",
-- "attribution",
-- "provider",
-- "model",
-- "fallback_used",
-- "synthetic",
-- "cached_from",
-- "usage",
-- "error",
-- "citations_count",
-- "retry_of",
-- "created_at",
-- "finished_at"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "run_id": {
-- "type": "string",
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "status": {
-- "const": "failed"
-- },
-- "workflow": {
++ {
"type": "object",
"required": [
-- "key",
-- "version"
++ "diff"
],
-- "additionalProperties": false,
"properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1
++ "diff": {
++ "type": "object"
}
}
},
-- "requester": {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- "on_behalf_of": {
-- "anyOf": [
-- {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- },
-- "target": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- },
-- "attribution": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- },
-- "provider": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Provider that produced the output; null before a provider call and for cached runs."
-- },
-- "model": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "fallback_used": {
-- "type": "boolean"
-- },
-- "synthetic": {
-- "type": "boolean",
-- "description": "Stub provider output."
-- },
-- "cached_from": {
-- "type": "null"
-- },
-- "usage": {
++ {
"type": "object",
"required": [
-- "tokens_in",
-- "tokens_out",
-- "cost_usd",
-- "attempts"
++ "posts"
],
-- "additionalProperties": false,
"properties": {
-- "tokens_in": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "tokens_out": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "cost_usd": {
-- "type": "number",
-- "minimum": 0
-- },
-- "attempts": {
-- "type": "integer",
-- "minimum": 0
++ "posts": {
++ "type": "array",
++ "items": {
++ "$ref": "post.v1.json"
++ }
}
}
-- },
-- "error": {
++ }
++ ]
++ }
++ },
++ "blog.post-update-request": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post-update-request.v1.json",
++ "title": "BlogPostUpdateRequest",
++ "description": "blog.post-update-request@1: bodies of blog.post.update on OpenVibe.Blog (editors of the post). PATCH /api/v1/posts/:id: any post fields; a change to title, body or summary makes a new revision and needs expected_revision (the revision you edited; 428 revision.expected_required without it); citations sent replace the revision's, otherwise they carry forward. POST /api/v1/posts/:id/revert: { to_revision, expected_revision }. POST /api/v1/posts/:id/attachments: { media_id (med_…), role?: cover | inline | gallery, alt?, caption?, position? }. DELETE /api/v1/posts/:id/attachments/:aid takes no body.",
++ "anyOf": [
++ {
"type": "object",
-- "required": [
-- "code",
-- "detail"
-- ],
-- "additionalProperties": false,
"properties": {
-- "code": {
++ "title": {
"type": "string",
-- "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$"
++ "minLength": 1,
++ "maxLength": 200
},
-- "detail": {
++ "body": {
++ "type": "string",
++ "maxLength": 200000,
++ "description": "Markdown."
++ },
++ "summary": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "members",
++ "private"
++ ],
++ "description": "Default public; members gates it behind the blog's VIP entitlement (OpenVibe.VIP decides)."
++ },
++ "entitlement_key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.:-]{0,127}$",
++ "description": "members only: informational, default vip:<handle>."
++ },
++ "slug": {
++ "type": "string",
++ "description": "Default from the title; lowercase letters, digits and dashes (a taken one gets a number)."
++ },
++ "allow_comments": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "noindex": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ]
++ },
++ "tags": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "At most 20 tags of at most 50 characters."
++ },
++ "categories": {
++ "anyOf": [
++ {
++ "type": "string",
++ "description": "Comma-separated."
++ },
++ {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "Category paths (Parent > Child), at most 10."
++ },
++ "series": {
++ "description": "A series id (ser_…), a new series title, or { title }; null or empty leaves the series."
++ },
++ "series_id": {
"type": [
"string",
"null"
]
++ },
++ "series_position": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ]
++ },
++ "citations": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "Sources: a URL (with retrieved_at) or an OpenVibe.Sources item id; a quote may be text or { text }. At most 100 are kept.",
++ "items": {
++ "type": "object",
++ "properties": {
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "source_item_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "retrieved_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "quote": {},
++ "license_note": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ },
++ "message": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "authorship": {
++ "type": "object",
++ "description": "hybrid or imported authorship details; an AI service (X-OV-Origin: ai) writes AI drafts that need review."
++ },
++ "expected_revision": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 0,
++ "pattern": "^[0-9]+$"
}
}
},
-- "citations_count": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "retry_of": {
-- "type": [
-- "string",
-- "null"
++ {
++ "type": "object",
++ "required": [
++ "to_revision",
++ "expected_revision"
],
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ "properties": {
++ "to_revision": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 1
++ },
++ "expected_revision": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 0
++ }
++ }
},
-- "created_at": {
-- "type": "string",
-- "format": "date-time"
++ {
++ "type": "object",
++ "required": [
++ "media_id"
++ ],
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "role": {
++ "enum": [
++ "cover",
++ "inline",
++ "gallery"
++ ]
++ },
++ "alt": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "caption": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "position": {
++ "type": [
++ "integer",
++ "string"
++ ]
++ }
++ }
},
-- "finished_at": {
-- "type": "string",
-- "format": "date-time"
++ {
++ "$ref": "../common/no-body.v1.json"
}
-- }
++ ]
}
},
-- "ai.run.cached": {
++ "blog.post-update-result": {
"version": "1.0.0",
-- "owner": "ai",
-- "status": "planned",
++ "owner": "blog",
++ "status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/ai.run.cached.v1.json",
-- "title": "AiRunCachedPayload",
-- "description": "ai.run.cached v1 (OpenVibe.AI server/runs.js create). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A create was served from the scoped cache: no provider call, cached_from names the run whose output was reused. A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority low, actor service:ai.",
-- "type": "object",
-- "required": [
-- "run_id",
-- "status",
-- "workflow",
-- "requester",
-- "on_behalf_of",
-- "target",
-- "attribution",
-- "provider",
-- "model",
-- "fallback_used",
-- "synthetic",
-- "cached_from",
-- "usage",
-- "error",
-- "citations_count",
-- "retry_of",
-- "created_at",
-- "finished_at"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "run_id": {
-- "type": "string",
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "status": {
-- "const": "cached"
-- },
-- "workflow": {
++ "$id": "https://openvibe.network/contracts/blog/post-update-result.v1.json",
++ "title": "BlogPostUpdateResult",
++ "description": "blog.post-update-result@1: answers of blog.post.update on OpenVibe.Blog. PATCH /api/v1/posts/:id → { post, revision, created } (created false when nothing changed); POST …/revert → 201 { revision, post }; POST …/attachments → 201 { attachment }; DELETE …/attachments/:aid → { removed }.",
++ "anyOf": [
++ {
"type": "object",
"required": [
-- "key",
-- "version"
++ "post",
++ "revision"
],
-- "additionalProperties": false,
"properties": {
-- "key": {
-- "type": "string"
++ "post": {
++ "$ref": "post.v1.json"
},
-- "version": {
++ "revision": {
"type": "integer",
"minimum": 1
++ },
++ "created": {
++ "type": "boolean"
}
}
},
-- "requester": {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- "on_behalf_of": {
-- "anyOf": [
-- {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- {
-- "type": "null"
++ {
++ "type": "object",
++ "required": [
++ "attachment"
++ ],
++ "properties": {
++ "attachment": {
++ "type": "object"
}
-- ]
++ }
},
-- "target": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
++ {
++ "type": "object",
++ "required": [
++ "removed"
++ ],
++ "properties": {
++ "removed": {}
++ }
++ }
++ ]
++ }
++ },
++ "blog.post": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/post.v1.json",
++ "title": "BlogPost",
++ "description": "blog.post@1: one post as OpenVibe.Blog's API shows it (server/http/api.js postDto): its state, visibility, the fields of the revision shown (the published one, or the head for editors), terms, series and the indexability decision. Editors and staff also get the body, authorship, review, scheduled jobs, attachments and citations; readers of a members-only post they may not open get a 403 teaser instead.",
++ "type": "object",
++ "required": [
++ "id",
++ "blog",
++ "slug",
++ "url",
++ "state",
++ "visibility",
++ "title",
++ "revision",
++ "published_revision",
++ "tags",
++ "categories",
++ "allow_comments",
++ "noindex"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "blog": {
++ "type": "object",
++ "required": [
++ "id",
++ "handle"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
},
-- {
-- "type": "null"
++ "handle": {
++ "type": "string"
}
++ }
++ },
++ "slug": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string"
++ },
++ "state": {
++ "type": "string",
++ "description": "draft, scheduled, published, unpublished, deleted"
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "unlisted",
++ "members",
++ "private"
]
},
-- "attribution": {
-- "anyOf": [
-- {
-- "$ref": "../../common/entity-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
++ "author_subject": {
++ "type": [
++ "string",
++ "null"
]
},
-- "provider": {
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "summary": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "revision": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "published_revision": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "first_published_at": {
"type": [
"string",
"null"
],
-- "description": "Provider that produced the output; null before a provider call and for cached runs."
++ "format": "date-time"
},
-- "model": {
++ "published_at": {
"type": [
"string",
"null"
++ ],
++ "format": "date-time"
++ },
++ "tags": {
++ "type": "array"
++ },
++ "categories": {
++ "type": "array"
++ },
++ "series": {
++ "type": [
++ "object",
++ "null"
]
},
-- "fallback_used": {
++ "allow_comments": {
"type": "boolean"
},
-- "synthetic": {
-- "type": "boolean",
-- "description": "Stub provider output."
-- },
-- "cached_from": {
-- "type": "string",
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ "noindex": {
++ "type": "boolean"
},
-- "usage": {
-- "type": "object",
-- "required": [
-- "tokens_in",
-- "tokens_out",
-- "cost_usd",
-- "attempts"
++ "indexability": {
++ "type": [
++ "object",
++ "null"
],
-- "additionalProperties": false,
"properties": {
-- "tokens_in": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "tokens_out": {
-- "type": "integer",
-- "minimum": 0
++ "indexable": {
++ "type": "boolean"
},
-- "cost_usd": {
-- "type": "number",
-- "minimum": 0
++ "robots": {
++ "type": "string"
},
-- "attempts": {
-- "type": "integer",
-- "minimum": 0
++ "reasons": {
++ "type": "array"
}
}
},
-- "error": {
-- "type": "null"
-- },
-- "citations_count": {
-- "type": "integer",
-- "minimum": 0
++ "entitlement_key": {
++ "type": [
++ "string",
++ "null"
++ ]
},
-- "retry_of": {
++ "body": {
"type": [
"string",
"null"
-- ],
-- "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ ]
},
-- "created_at": {
-- "type": "string",
-- "format": "date-time"
++ "authorship": {},
++ "review": {},
++ "scheduled": {
++ "type": "array"
},
-- "finished_at": {
-- "type": "string",
-- "format": "date-time"
++ "attachments": {
++ "type": "array"
++ },
++ "citations": {
++ "type": "array"
}
}
}
},
-- "ai.definition-version-request": {
++ "blog.publication-result": {
"version": "1.0.0",
-- "owner": "ai",
++ "owner": "blog",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/definition-version-request.v1.json",
-- "title": "AiDefinitionVersionRequest",
-- "description": "ai.definition-version-request@1: bodies of ai.workflow.manage on OpenVibe.AI. An edit is always a new version (the previous one stays readable); fields left out are taken from the newest existing version. POST /api/v1/templates/:key/versions: a prompt template (input_schema and output_schema must compile as JSON Schema; visibility public, first-party or internal). POST /api/v1/workflows/:key/versions (key <namespace>.<name>): steps (at least one; kind llm (naming an existing template), passthrough, transcribe or embed), schemas, default route, cache mode. POST /api/v1/routes/:key/versions: primary { provider, model } and fallbacks (every provider must exist), or alias_of another route. POST …/:key/versions/:version/status: { status } (templates and workflows draft|active|deprecated|archived; routes active|disabled). Refusals are 422 ai.invalid.",
++ "$id": "https://openvibe.network/contracts/blog/publication-result.v1.json",
++ "title": "BlogPublicationResult",
++ "description": "blog.publication-result@1: the answer of POST /api/v1/posts/:id/publish (blog.post.publish) and POST /api/v1/posts/:id/unpublish (blog.post.unpublish) on OpenVibe.Blog: { post, changed } (changed false when it already was in that state).",
++ "type": "object",
++ "required": [
++ "post",
++ "changed"
++ ],
++ "properties": {
++ "post": {
++ "$ref": "post.v1.json"
++ },
++ "changed": {
++ "type": "boolean"
++ }
++ }
++ }
++ },
++ "blog.publish-request": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/publish-request.v1.json",
++ "title": "BlogPublishRequest",
++ "description": "blog.publish-request@1: the body of POST /api/v1/posts/:id/publish on OpenVibe.Blog (blog.post.publish): { revision? } (default the head; an AI draft is published only after a person reviewed it: 409 post.review_required).",
++ "type": "object",
++ "properties": {
++ "revision": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ],
++ "minimum": 1
++ }
++ }
++ }
++ },
++ "blog.schedule-request": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/schedule-request.v1.json",
++ "title": "BlogScheduleRequest",
++ "description": "blog.schedule-request@1: bodies of blog.post.schedule on OpenVibe.Blog. POST /api/v1/posts/:id/schedule: { at, revision?, action? } (at an ISO 8601 time in the future; action publish (default) or unpublish). DELETE /api/v1/posts/:id/schedule cancels pending jobs and takes no body.",
"anyOf": [
{
"type": "object",
++ "required": [
++ "at"
++ ],
"properties": {
-- "name": {
-- "type": "string"
++ "at": {
++ "type": [
++ "string",
++ "integer"
++ ]
},
-- "description": {
++ "revision": {
"type": [
++ "integer",
"string",
"null"
]
},
-- "input_schema": {
-- "type": "object",
-- "description": "A JSON Schema the run input must match."
-- },
-- "output_schema": {
-- "type": "object",
-- "description": "A JSON Schema the output must match."
-- },
-- "system_prompt": {
-- "type": "string"
-- },
-- "user_prompt": {
-- "type": "string"
-- },
-- "default_route": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "owner": {
-- "type": "string"
-- },
-- "visibility": {
-- "enum": [
-- "public",
-- "first-party",
-- "internal"
-- ]
-- },
-- "status": {
-- "enum": [
-- "draft",
-- "active",
-- "deprecated",
-- "archived"
-- ]
-- },
-- "metadata": {
-- "type": "object"
-- }
-- }
-- },
-- {
-- "type": "object",
-- "properties": {
-- "name": {
-- "type": "string"
-- },
-- "description": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "namespace": {
-- "type": "string"
-- },
-- "input_schema": {
-- "type": "object",
-- "description": "A JSON Schema."
-- },
-- "output_schema": {
-- "type": "object",
-- "description": "A JSON Schema."
-- },
-- "steps": {
-- "type": "array",
-- "minItems": 1,
-- "items": {
-- "type": "object",
-- "required": [
-- "kind"
-- ],
-- "properties": {
-- "kind": {
-- "enum": [
-- "llm",
-- "passthrough",
-- "transcribe",
-- "embed"
-- ]
-- },
-- "template": {
-- "type": "string"
-- }
-- }
-- }
-- },
-- "default_route": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "cache_mode": {
-- "enum": [
-- "none",
-- "private",
-- "service"
-- ]
-- },
-- "cache_ttl_sec": {
-- "type": [
-- "integer",
-- "null"
-- ]
-- },
-- "status": {
++ "action": {
"enum": [
-- "draft",
-- "active",
-- "deprecated",
-- "archived"
++ "publish",
++ "unpublish"
]
-- },
-- "metadata": {
-- "type": "object"
}
}
},
{
-- "type": "object",
-- "properties": {
-- "primary": {
-- "type": "object",
-- "required": [
-- "provider"
-- ],
-- "properties": {
-- "provider": {
-- "type": "string"
-- },
-- "model": {
-- "type": [
-- "string",
-- "null"
-- ]
-- }
-- }
-- },
-- "fallbacks": {
-- "type": "array",
-- "items": {
-- "type": "object",
-- "required": [
-- "provider"
-- ],
-- "properties": {
-- "provider": {
-- "type": "string"
-- },
-- "model": {
-- "type": [
-- "string",
-- "null"
-- ]
-- }
-- }
-- }
-- },
-- "options": {
-- "type": "object"
-- },
-- "max_output_tokens": {
-- "type": [
-- "integer",
-- "null"
-- ]
-- },
-- "response_format": {
-- "enum": [
-- "text",
-- "json"
-- ]
-- },
-- "timeout_ms": {
-- "type": [
-- "integer",
-- "null"
-- ]
-- },
-- "alias_of": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "status": {
-- "enum": [
-- "active",
-- "disabled"
-- ]
-- }
-- }
++ "$ref": "../common/no-body.v1.json"
}
]
}
},
-- "ai.definition-version-result": {
++ "blog.schedule-result": {
"version": "1.0.0",
-- "owner": "ai",
++ "owner": "blog",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/definition-version-result.v1.json",
-- "title": "AiDefinitionVersionResult",
-- "description": "ai.definition-version-result@1: answers of ai.workflow.manage on OpenVibe.AI. POST …/:key/versions → 201 { template | workflow | route } (the new version, whole: prompts and schemas for a template, steps for a workflow, primary/fallbacks for a route); POST …/versions/:version/status → { template | workflow | route: { key, version, status } }.",
++ "$id": "https://openvibe.network/contracts/blog/schedule-result.v1.json",
++ "title": "BlogScheduleResult",
++ "description": "blog.schedule-result@1: answers of blog.post.schedule on OpenVibe.Blog: POST …/schedule → 201 { job, created, post }; DELETE …/schedule → { cancelled, post } (jobs cancelled; a scheduled post goes back to draft).",
"anyOf": [
{
"type": "object",
"required": [
-- "template"
++ "job",
++ "created",
++ "post"
],
"properties": {
-- "template": {
-- "type": "object",
-- "required": [
-- "key",
-- "version",
-- "status"
-- ],
-- "properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1
-- },
-- "status": {
-- "type": "string"
-- }
-- }
++ "job": {
++ "type": "object"
++ },
++ "created": {
++ "type": "boolean"
++ },
++ "post": {
++ "$ref": "post.v1.json"
}
}
},
{
"type": "object",
"required": [
-- "workflow"
++ "cancelled",
++ "post"
],
"properties": {
-- "workflow": {
-- "type": "object",
-- "required": [
-- "key",
-- "version",
-- "status"
-- ],
-- "properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1
-- },
-- "status": {
-- "type": "string"
-- }
-- }
-- }
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "route"
-- ],
-- "properties": {
-- "route": {
-- "type": "object",
-- "required": [
-- "key",
-- "version",
-- "status"
-- ],
-- "properties": {
-- "key": {
-- "type": "string"
-- },
-- "version": {
-- "type": "integer",
-- "minimum": 1
-- },
-- "status": {
-- "type": "string"
-- }
-- }
++ "cancelled": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "post": {
++ "$ref": "post.v1.json"
}
}
}
]
}
},
-- "ai.model": {
++ "blog.theme-request": {
++ "version": "1.0.0",
++ "owner": "blog",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/blog/theme-request.v1.json",
++ "title": "BlogThemeRequest",
++ "description": "blog.theme-request@1: the body of PUT /api/v1/blogs/:handle/theme on OpenVibe.Blog (blog.theme.set; an owner): one of the theme presets.",
++ "type": "object",
++ "required": [
++ "theme"
++ ],
++ "properties": {
++ "theme": {
++ "enum": [
++ "vibe",
++ "paper",
++ "slate",
++ "sand",
++ "nord",
++ "high-contrast"
++ ]
++ }
++ }
++ }
++ },
++ "ai.run-request": {
"version": "1.0.0",
"owner": "ai",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/model.v1.json",
-- "title": "AiModel",
-- "description": "ai.model@1: one model of a provider in OpenVibe.AI's registry (server/registry.js decodeModel): type, status, limits, per-million-token costs and what it supports.",
++ "$id": "https://openvibe.network/contracts/ai/run-request.v1.json",
++ "title": "AiRunRequest",
++ "description": "Body of POST /api/v1/runs on OpenVibe.AI (capability ai.run.create; ADR-015). Runs the newest active version of a workflow (or the given active or deprecated version) on input that must match that workflow version's own input schema; per-workflow input and output schemas live in AI's registry (GET /api/v1/workflows/:key), not here. The token's ns claim limits which workflow namespaces may run (403 capability.namespace_denied). ?wait=ms waits for the result: 201 finished or served from cache, 202 still queued/running (poll GET /api/v1/runs/:id), 200 an idempotent replay. Errors are problem+json: 404 workflow.not_found, 409 workflow.inactive, 409 idempotency.conflict, 413 input.too_large, 422 input.invalid, 429 quota.exceeded or queue.full with Retry-After. The direct operations POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed} take the same fields (except workflow and version) with the input fields at the top level and run workflow ai.<op>. target (EntityRef): what the output is about, part of the cache scope and a run filter. attribution (EntityRef): what the spend is attributed to for quotas and usage. on_behalf_of (SubjectRef): the person or actor the caller acts for, part of the cache scope and per-actor quotas.",
"type": "object",
"required": [
-- "provider_key",
-- "model_key",
-- "type",
-- "status",
-- "cost",
-- "supports"
++ "workflow"
],
++ "additionalProperties": false,
"properties": {
-- "provider_key": {
-- "type": "string"
-- },
-- "model_key": {
++ "workflow": {
"type": "string",
-- "maxLength": 200
++ "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)+$",
++ "maxLength": 200,
++ "description": "Workflow key, <namespace>.<name> (live.chat_reply, news.summarize_story, network.site_copy…)."
},
-- "display_name": {
-- "type": [
-- "string",
-- "null"
-- ]
++ "version": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Pin a workflow version (active or deprecated). Omitted: the newest active version."
},
-- "type": {
-- "enum": [
-- "chat",
-- "vision",
-- "embedding",
-- "stt"
-- ]
++ "input": {
++ "type": "object",
++ "description": "Workflow input; validated against the workflow version's input_schema. Default {}."
},
-- "status": {
-- "enum": [
-- "active",
-- "disabled"
-- ]
++ "target": {
++ "$ref": "../common/entity-ref.v1.json"
},
-- "context_window": {
-- "type": [
-- "integer",
-- "null"
-- ]
++ "attribution": {
++ "$ref": "../common/entity-ref.v1.json"
},
-- "max_output": {
-- "type": [
-- "integer",
-- "null"
-- ]
++ "on_behalf_of": {
++ "$ref": "../identity/subject-ref.v1.json"
},
-- "cost": {
-- "type": "object",
-- "properties": {
-- "in_per_mtok": {
-- "type": [
-- "number",
-- "null"
-- ]
-- },
-- "out_per_mtok": {
-- "type": [
-- "number",
-- "null"
-- ]
-- },
-- "cached_per_mtok": {
-- "type": [
-- "number",
-- "null"
-- ]
-- }
-- }
++ "idempotency_key": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9._:-]{8,128}$",
++ "description": "Same (requester, key) returns the existing run; a different workflow or input under it is 409 idempotency.conflict. The Idempotency-Key header is used when this is absent."
},
-- "supports": {
++ "options": {
"type": "object",
++ "additionalProperties": false,
"properties": {
-- "json": {
-- "type": "boolean"
-- },
-- "tools": {
-- "type": "boolean"
-- },
-- "streaming": {
-- "type": "boolean"
++ "cache": {
++ "type": "boolean",
++ "default": true,
++ "description": "false skips the scoped cache lookup and store."
},
-- "vision": {
-- "type": "boolean"
++ "debug": {
++ "type": "boolean",
++ "default": false,
++ "description": "Keep raw prompts and responses in the request log, only when AI runs with raw debug logging on."
}
}
-- },
-- "metadata": {
-- "type": "object"
-- },
-- "created_at": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "updated_at": {
-- "type": [
-- "string",
-- "null"
-- ]
}
}
}
},
-- "ai.provider-manage-request": {
++ "ai.run": {
"version": "1.0.0",
"owner": "ai",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/provider-manage-request.v1.json",
-- "title": "AiProviderManageRequest",
-- "description": "ai.provider-manage-request@1: bodies of ai.provider.manage on OpenVibe.AI. POST /api/v1/providers creates or replaces a provider (key required) and PATCH /api/v1/providers/:key changes one (fields left out keep their value); a body carrying api_key or secret is refused (422: send secret_ref env:NAME, never a secret value). POST /api/v1/models (provider_key and model_key required) and PATCH /api/v1/models/:provider/:model upsert a model. POST /api/v1/quotas upserts a quota (scope_type and window required; scope_id too unless global; the same scope, window and workflow_prefix is one quota). POST /api/v1/providers/:key/disable|enable|reset and DELETE /api/v1/cache[?workflow=] take no body. Unknown fields are ignored.",
-- "anyOf": [
-- {
++ "$id": "https://openvibe.network/contracts/ai/run.v1.json",
++ "title": "AiRun",
++ "description": "A workflow run on OpenVibe.AI (ADR-015; server/runs.js decode): the `run` member of every run response (POST /api/v1/runs and the direct operations, GET /api/v1/runs/:id, cancel, retry) and each item of GET /api/v1/runs { runs }. Output is attributable to workflow + version + template version + route version + provider/model + run id, never to a person. A draft stays a draft: products store it with ai/ai_assisted authorship and publish nothing until a person reviews it. input keeps no inline media (replaced by data_url_sha256 and data_url_bytes) and no passthrough prompt ({ not_retained }); output is null until the run succeeded or was served from cache and then matches the workflow version's output_schema. requester is the token principal (service, app or mod). A run interrupted by a restart or shutdown ends failed with error.code run.interrupted.",
++ "type": "object",
++ "required": [
++ "id",
++ "status",
++ "workflow",
++ "template",
++ "route",
++ "requester",
++ "on_behalf_of",
++ "attribution",
++ "source_service",
++ "target",
++ "input",
++ "output",
++ "error",
++ "synthetic",
++ "provenance",
++ "usage",
++ "citations_count",
++ "retry_of",
++ "idempotency_key",
++ "trace_id",
++ "created_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "enum": [
++ "queued",
++ "running",
++ "succeeded",
++ "failed",
++ "cancelled",
++ "cached"
++ ],
++ "description": "Terminal: succeeded, failed, cancelled, cached (a scoped cache hit, no provider call; provenance.cached_from names the run it reused)."
++ },
++ "workflow": {
"type": "object",
-- "not": {
-- "anyOf": [
-- {
-- "required": [
-- "api_key"
-- ]
-- },
-- {
-- "required": [
-- "secret"
-- ]
-- }
-- ]
-- },
++ "required": [
++ "key",
++ "version"
++ ],
++ "additionalProperties": false,
"properties": {
"key": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$"
++ "type": "string"
},
-- "display_name": {
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
++ },
++ "template": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "key",
++ "version"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "key": {
"type": "string"
},
-- "kind": {
-- "enum": [
-- "stub",
-- "openai",
-- "anthropic",
-- "http",
-- "whisper"
-- ]
-- },
-- "status": {
-- "enum": [
-- "active",
-- "disabled"
-- ]
-- },
-- "base_url": {
++ "version": {
"type": [
-- "string",
++ "integer",
"null"
-- ],
-- "pattern": "^https?://\\S+$"
-- },
-- "auth_mode": {
-- "enum": [
-- "none",
-- "bearer",
-- "x-api-key"
]
++ }
++ },
++ "description": "The prompt template of the workflow's llm step; null for workflows without one."
++ },
++ "route": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "key",
++ "version"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "key": {
++ "type": "string"
},
-- "secret_ref": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^(env:[A-Z][A-Z0-9_]{0,63})?$"
-- },
-- "default_model": {
++ "version": {
"type": [
-- "string",
++ "integer",
"null"
]
++ }
++ },
++ "description": "Provider route; version null while the route is unresolved or disabled."
++ },
++ "requester": {
++ "$ref": "../identity/subject-ref.v1.json"
++ },
++ "on_behalf_of": {
++ "anyOf": [
++ {
++ "$ref": "../identity/subject-ref.v1.json"
},
-- "capabilities": {
-- "type": "array",
-- "items": {
-- "enum": [
-- "chat",
-- "generate",
-- "summarize",
-- "classify",
-- "extract",
-- "enrich",
-- "embed",
-- "vision",
-- "json",
-- "transcribe"
-- ]
-- }
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "attribution": {
++ "anyOf": [
++ {
++ "$ref": "../common/entity-ref.v1.json"
},
-- "timeout_ms": {
-- "type": [
-- "integer",
-- "string"
-- ],
-- "description": "Clamped to 1000..600000."
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "source_service": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The requester's service id when the requester is a service."
++ },
++ "target": {
++ "anyOf": [
++ {
++ "$ref": "../common/entity-ref.v1.json"
},
-- "priority": {
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "input": {
++ "type": "object",
++ "description": "The retained input (see the description)."
++ },
++ "output": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "Workflow output, or null."
++ },
++ "error": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "code",
++ "detail"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "code": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "description": "run.cancelled, run.interrupted, run.internal, output.invalid, output.empty, provider.unavailable, route.unavailable, source.unavailable, media.unresolvable…"
++ },
++ "detail": {
"type": [
-- "number",
-- "string"
++ "string",
++ "null"
]
-- },
-- "metadata": {
-- "type": "object"
}
}
},
-- {
++ "synthetic": {
++ "type": "boolean",
++ "description": "Produced by the stub provider; never served to production callers and never cached."
++ },
++ "provenance": {
"type": "object",
++ "additionalProperties": false,
++ "required": [
++ "origin",
++ "workflow",
++ "workflow_version",
++ "template_version",
++ "route",
++ "route_version",
++ "provider",
++ "model",
++ "fallback_used",
++ "run_id",
++ "cached_from",
++ "synthetic"
++ ],
"properties": {
-- "provider_key": {
-- "type": "string"
-- },
-- "model_key": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 200
++ "origin": {
++ "const": "ai"
},
-- "display_name": {
++ "workflow": {
"type": "string"
},
-- "type": {
-- "enum": [
-- "chat",
-- "vision",
-- "embedding",
-- "stt"
-- ]
++ "workflow_version": {
++ "type": "integer",
++ "minimum": 1
},
-- "status": {
-- "enum": [
-- "active",
-- "disabled"
++ "template_version": {
++ "type": [
++ "integer",
++ "null"
]
},
-- "context_window": {
++ "route": {
"type": [
-- "integer",
++ "string",
"null"
]
},
-- "max_output": {
++ "route_version": {
"type": [
"integer",
"null"
]
},
-- "cost": {
-- "type": "object",
-- "properties": {
-- "in_per_mtok": {
-- "type": [
-- "number",
-- "null"
-- ]
-- },
-- "out_per_mtok": {
-- "type": [
-- "number",
-- "null"
-- ]
-- },
-- "cached_per_mtok": {
-- "type": [
-- "number",
-- "null"
-- ]
-- }
-- }
++ "provider": {
++ "type": [
++ "string",
++ "null"
++ ]
},
-- "supports": {
-- "type": "object",
-- "properties": {
-- "json": {
-- "type": "boolean"
-- },
-- "tools": {
-- "type": "boolean"
-- },
-- "streaming": {
-- "type": "boolean"
-- },
-- "vision": {
-- "type": "boolean"
-- }
-- }
++ "model": {
++ "type": [
++ "string",
++ "null"
++ ]
},
-- "metadata": {
-- "type": "object"
-- }
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "scope_type",
-- "window"
-- ],
-- "properties": {
-- "scope_type": {
-- "enum": [
-- "global",
-- "service",
-- "actor",
-- "attribution"
-- ]
++ "fallback_used": {
++ "type": "boolean",
++ "description": "The primary provider was degraded and the run fell back (ADR-015: fallbacks are recorded)."
},
-- "scope_id": {
++ "run_id": {
"type": "string",
-- "maxLength": 200,
-- "description": "* on a non-global scope = each one separately."
-- },
-- "window": {
-- "enum": [
-- "minute",
-- "hour",
-- "day"
-- ]
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "max_requests": {
++ "cached_from": {
"type": [
-- "number",
"string",
"null"
],
-- "minimum": 0
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "max_tokens": {
-- "type": [
-- "number",
-- "string",
-- "null"
-- ],
++ "synthetic": {
++ "type": "boolean"
++ }
++ }
++ },
++ "usage": {
++ "type": "object",
++ "required": [
++ "tokens_in",
++ "tokens_out",
++ "cost_usd",
++ "attempts"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "tokens_in": {
++ "type": "integer",
"minimum": 0
},
-- "max_cost_usd": {
-- "type": [
-- "number",
-- "string",
-- "null"
-- ],
++ "tokens_out": {
++ "type": "integer",
"minimum": 0
},
-- "workflow_prefix": {
-- "type": [
-- "string",
-- "null"
-- ]
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
},
-- "status": {
-- "enum": [
-- "active",
-- "disabled"
-- ]
++ "attempts": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Provider calls made (skipped routes not counted)."
}
}
},
-- {
-- "$ref": "../common/no-body.v1.json"
++ "citations_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Citations stored for the run (GET /api/v1/runs/:id/citations): the sources the workflow was given, and any the requester attached later."
++ },
++ "retry_of": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "trace_id": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
}
-- ]
++ }
}
},
-- "ai.provider-manage-result": {
++ "ai.run.queued": {
"version": "1.0.0",
"owner": "ai",
-- "status": "active",
++ "status": "planned",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/provider-manage-result.v1.json",
-- "title": "AiProviderManageResult",
-- "description": "ai.provider-manage-result@1: answers of ai.provider.manage on OpenVibe.AI. Provider create/update/disable/enable → { provider } (201 on POST); POST …/reset → { health } (the circuit breaker, closed again); model upserts → { model }; POST /api/v1/quotas → 201 { quota } (the stored row); DELETE /api/v1/cache → { removed } (entries purged). Every change is audited.",
-- "anyOf": [
-- {
++ "$id": "https://openvibe.network/contracts/events/payloads/ai.run.queued.v1.json",
++ "title": "AiRunQueuedPayload",
++ "description": "ai.run.queued v1 (OpenVibe.AI server/runs.js create). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A run was stored queued (it then runs inline within ?wait or on the worker). Not published for a cache hit (ai.run.cached) or a create refused by quota or a full queue. A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority low, actor service:ai.",
++ "type": "object",
++ "required": [
++ "run_id",
++ "status",
++ "workflow",
++ "requester",
++ "on_behalf_of",
++ "target",
++ "attribution",
++ "provider",
++ "model",
++ "fallback_used",
++ "synthetic",
++ "cached_from",
++ "usage",
++ "error",
++ "citations_count",
++ "retry_of",
++ "created_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "run_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "const": "queued"
++ },
++ "workflow": {
"type": "object",
"required": [
-- "provider"
++ "key",
++ "version"
],
++ "additionalProperties": false,
"properties": {
-- "provider": {
-- "$ref": "provider.v1.json"
++ "key": {
++ "type": "string"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
}
}
},
-- {
-- "type": "object",
-- "required": [
-- "health"
-- ],
-- "properties": {
-- "health": {
-- "type": "object",
-- "required": [
-- "state"
-- ],
-- "properties": {
-- "provider_key": {
-- "type": "string"
-- },
-- "state": {
-- "enum": [
-- "closed",
-- "open",
-- "half_open"
-- ]
-- },
-- "consecutive_failures": {
-- "type": "integer"
-- }
-- }
++ "requester": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "on_behalf_of": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
}
-- }
++ ]
},
-- {
-- "type": "object",
-- "required": [
-- "model"
-- ],
-- "properties": {
-- "model": {
-- "$ref": "model.v1.json"
++ "target": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
++ },
++ {
++ "type": "null"
}
-- }
++ ]
},
-- {
-- "type": "object",
-- "required": [
-- "quota"
-- ],
-- "properties": {
-- "quota": {
-- "type": "object",
-- "required": [
-- "id",
-- "scope_type",
-- "scope_id",
-- "window",
-- "status"
-- ],
-- "properties": {
-- "id": {
-- "type": "integer"
-- },
-- "scope_type": {
-- "type": "string"
-- },
-- "scope_id": {
-- "type": "string"
-- },
-- "window": {
-- "type": "string"
-- },
-- "max_requests": {
-- "type": [
-- "number",
-- "null"
-- ]
-- },
-- "max_tokens": {
-- "type": [
-- "number",
-- "null"
-- ]
-- },
-- "max_cost_usd": {
-- "type": [
-- "number",
-- "null"
-- ]
-- },
-- "workflow_prefix": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "status": {
-- "type": "string"
-- }
-- }
++ "attribution": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
++ },
++ {
++ "type": "null"
}
-- }
++ ]
},
-- {
++ "provider": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Provider that produced the output; null before a provider call and for cached runs."
++ },
++ "model": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "fallback_used": {
++ "type": "boolean"
++ },
++ "synthetic": {
++ "type": "boolean",
++ "description": "Stub provider output."
++ },
++ "cached_from": {
++ "type": "null"
++ },
++ "usage": {
"type": "object",
"required": [
-- "removed"
++ "tokens_in",
++ "tokens_out",
++ "cost_usd",
++ "attempts"
],
++ "additionalProperties": false,
"properties": {
-- "removed": {
++ "tokens_in": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "tokens_out": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "attempts": {
"type": "integer",
"minimum": 0
}
}
++ },
++ "error": {
++ "type": "null"
++ },
++ "citations_count": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "retry_of": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": "null"
}
-- ]
++ }
}
},
-- "ai.provider": {
++ "ai.run.succeeded": {
"version": "1.0.0",
"owner": "ai",
-- "status": "active",
++ "status": "planned",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/provider.v1.json",
-- "title": "AiProvider",
-- "description": "ai.provider@1: one model provider as OpenVibe.AI's registry API shows it (server/registry.js publicProvider): never a secret value, only the secret reference name (env:NAME) and whether it resolves (credentials), plus the circuit-breaker health.",
++ "$id": "https://openvibe.network/contracts/events/payloads/ai.run.succeeded.v1.json",
++ "title": "AiRunSucceededPayload",
++ "description": "ai.run.succeeded v1 (OpenVibe.AI server/runs.js execute). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A run finished with output that matched the workflow output schema. The output itself is not in the event: a consumer that needs it reads GET /api/v1/runs/:id with ai.run.read (own runs only). A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority important, actor service:ai.",
"type": "object",
"required": [
-- "key",
-- "kind",
++ "run_id",
"status",
-- "auth_mode",
-- "capabilities",
-- "credentials"
++ "workflow",
++ "requester",
++ "on_behalf_of",
++ "target",
++ "attribution",
++ "provider",
++ "model",
++ "fallback_used",
++ "synthetic",
++ "cached_from",
++ "usage",
++ "error",
++ "citations_count",
++ "retry_of",
++ "created_at",
++ "finished_at"
],
++ "additionalProperties": false,
"properties": {
-- "key": {
++ "run_id": {
"type": "string",
-- "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$"
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "display_name": {
-- "type": [
-- "string",
-- "null"
-- ]
++ "status": {
++ "const": "succeeded"
},
-- "kind": {
-- "enum": [
-- "stub",
-- "openai",
-- "anthropic",
-- "http",
-- "whisper"
-- ]
++ "workflow": {
++ "type": "object",
++ "required": [
++ "key",
++ "version"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "key": {
++ "type": "string"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
},
-- "status": {
-- "enum": [
-- "active",
-- "disabled"
++ "requester": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "on_behalf_of": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
]
},
-- "base_url": {
-- "type": [
-- "string",
-- "null"
++ "target": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
]
},
-- "auth_mode": {
-- "enum": [
-- "none",
-- "bearer",
-- "x-api-key"
++ "attribution": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
]
},
-- "secret_ref": {
++ "provider": {
"type": [
"string",
"null"
],
-- "description": "env:NAME, a reference; never the secret."
++ "description": "Provider that produced the output; null before a provider call and for cached runs."
},
-- "default_model": {
++ "model": {
"type": [
"string",
"null"
]
},
-- "capabilities": {
-- "type": "array",
-- "items": {
-- "enum": [
-- "chat",
-- "generate",
-- "summarize",
-- "classify",
-- "extract",
-- "enrich",
-- "embed",
-- "vision",
-- "json",
-- "transcribe"
-- ]
-- }
-- },
-- "timeout_ms": {
-- "type": [
-- "integer",
-- "null"
-- ]
++ "fallback_used": {
++ "type": "boolean"
},
-- "priority": {
-- "type": [
-- "number",
-- "null"
-- ]
++ "synthetic": {
++ "type": "boolean",
++ "description": "Stub provider output."
},
-- "metadata": {
-- "type": "object"
++ "cached_from": {
++ "type": "null"
},
-- "origin": {
++ "usage": {
++ "type": "object",
++ "required": [
++ "tokens_in",
++ "tokens_out",
++ "cost_usd",
++ "attempts"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "tokens_in": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "tokens_out": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ },
++ "error": {
++ "type": "null"
++ },
++ "citations_count": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "retry_of": {
"type": [
"string",
"null"
-- ]
++ ],
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
},
"created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "ai.run.failed": {
++ "version": "1.0.0",
++ "owner": "ai",
++ "status": "planned",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/ai.run.failed.v1.json",
++ "title": "AiRunFailedPayload",
++ "description": "ai.run.failed v1 (OpenVibe.AI server/runs.js execute / recoverInterrupted). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A run ended failed: a provider, route, source or output error, run.internal, or run.interrupted after a restart or shutdown (retryable with POST /api/v1/runs/:id/retry unless its input was not retained). A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority important, actor service:ai.",
++ "type": "object",
++ "required": [
++ "run_id",
++ "status",
++ "workflow",
++ "requester",
++ "on_behalf_of",
++ "target",
++ "attribution",
++ "provider",
++ "model",
++ "fallback_used",
++ "synthetic",
++ "cached_from",
++ "usage",
++ "error",
++ "citations_count",
++ "retry_of",
++ "created_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "run_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "const": "failed"
++ },
++ "workflow": {
++ "type": "object",
++ "required": [
++ "key",
++ "version"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "key": {
++ "type": "string"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
++ },
++ "requester": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "on_behalf_of": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "target": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "attribution": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "provider": {
"type": [
"string",
"null"
-- ]
++ ],
++ "description": "Provider that produced the output; null before a provider call and for cached runs."
},
-- "updated_at": {
++ "model": {
"type": [
"string",
"null"
]
},
-- "credentials": {
-- "enum": [
-- "not_required",
-- "configured",
-- "missing"
-- ]
++ "fallback_used": {
++ "type": "boolean"
},
-- "health": {
-- "type": [
-- "object",
-- "null"
++ "synthetic": {
++ "type": "boolean",
++ "description": "Stub provider output."
++ },
++ "cached_from": {
++ "type": "null"
++ },
++ "usage": {
++ "type": "object",
++ "required": [
++ "tokens_in",
++ "tokens_out",
++ "cost_usd",
++ "attempts"
],
++ "additionalProperties": false,
"properties": {
-- "provider_key": {
-- "type": "string"
++ "tokens_in": {
++ "type": "integer",
++ "minimum": 0
},
-- "state": {
-- "enum": [
-- "closed",
-- "open",
-- "half_open"
-- ]
++ "tokens_out": {
++ "type": "integer",
++ "minimum": 0
},
-- "consecutive_failures": {
-- "type": "integer"
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ },
++ "error": {
++ "type": "object",
++ "required": [
++ "code",
++ "detail"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "code": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$"
++ },
++ "detail": {
++ "type": [
++ "string",
++ "null"
++ ]
}
}
++ },
++ "citations_count": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "retry_of": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": "string",
++ "format": "date-time"
}
}
}
},
-- "ai.usage-result": {
++ "ai.run.cached": {
"version": "1.0.0",
"owner": "ai",
-- "status": "active",
++ "status": "planned",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/ai/usage-result.v1.json",
-- "title": "AiUsageResult",
-- "description": "ai.usage-result@1: answers of ai.usage.read on OpenVibe.AI (all GETs, parameters in the query). GET /api/v1/status → { providers, runs, today, workflows, templates, routes, inflight, quotas }; GET /api/v1/usage?from=&to=&requester= → { usage } (daily rows); GET /api/v1/quotas → { quotas, current }; GET /api/v1/requests?provider=&status=&run=&fallback=&limit= → { requests } (request-log metadata: hashes, tokens, cost, latency; raw debug fields only for provider managers who ask); GET /api/v1/audit → { audit }; GET /api/v1/cache → { cache }; GET /api/v1/providers → { providers }; GET /api/v1/models?provider= → { models }. Never raw prompts, never secrets.",
-- "anyOf": [
-- {
-- "type": "object",
++ "$id": "https://openvibe.network/contracts/events/payloads/ai.run.cached.v1.json",
++ "title": "AiRunCachedPayload",
++ "description": "ai.run.cached v1 (OpenVibe.AI server/runs.js create). PLANNED: AI does not publish run events yet (its manifest notes list ai.run.queued|succeeded|failed|cached once the Events client is wired in); this is the shape it is to send. A create was served from the scoped cache: no provider call, cached_from names the run whose output was reused. A projection of ai.run@1 without input or output (ADR-015: request metadata, never raw prompts). requester is the token principal that created the run (service, app or mod). Envelope: subject { type: run, id: <run_id> }, visibility internal, priority low, actor service:ai.",
++ "type": "object",
++ "required": [
++ "run_id",
++ "status",
++ "workflow",
++ "requester",
++ "on_behalf_of",
++ "target",
++ "attribution",
++ "provider",
++ "model",
++ "fallback_used",
++ "synthetic",
++ "cached_from",
++ "usage",
++ "error",
++ "citations_count",
++ "retry_of",
++ "created_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "run_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "const": "cached"
++ },
++ "workflow": {
++ "type": "object",
"required": [
-- "providers",
-- "runs",
-- "today"
++ "key",
++ "version"
],
++ "additionalProperties": false,
"properties": {
-- "providers": {
-- "type": "array",
-- "items": {
-- "type": "object",
-- "required": [
-- "key"
-- ],
-- "properties": {
-- "key": {
-- "type": "string"
-- },
-- "kind": {
-- "type": "string"
-- },
-- "status": {
-- "type": "string"
-- },
-- "credentials": {
-- "type": "string"
-- },
-- "health": {
-- "type": "string"
-- }
-- }
-- }
-- },
-- "runs": {
-- "type": "object",
-- "additionalProperties": {
-- "type": "integer"
-- },
-- "description": "Run counts by status."
-- },
-- "today": {
-- "type": "object",
-- "required": [
-- "day"
-- ],
-- "properties": {
-- "day": {
-- "type": "string"
-- },
-- "requests": {
-- "type": "number"
-- },
-- "cost_usd": {
-- "type": "number"
-- }
-- }
-- },
-- "workflows": {
-- "type": "integer"
++ "key": {
++ "type": "string"
},
-- "templates": {
-- "type": "integer"
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
++ },
++ "requester": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "on_behalf_of": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
},
-- "routes": {
-- "type": "integer"
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "target": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
},
-- "inflight": {
-- "type": "integer"
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "attribution": {
++ "anyOf": [
++ {
++ "$ref": "../../common/entity-ref.v1.json"
},
-- "quotas": {
-- "type": "array"
++ {
++ "type": "null"
}
-- }
++ ]
},
-- {
-- "type": "object",
-- "required": [
-- "providers"
++ "provider": {
++ "type": [
++ "string",
++ "null"
],
-- "properties": {
-- "providers": {
-- "type": "array",
-- "items": {
-- "$ref": "provider.v1.json"
-- }
-- }
-- }
++ "description": "Provider that produced the output; null before a provider call and for cached runs."
},
-- {
++ "model": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "fallback_used": {
++ "type": "boolean"
++ },
++ "synthetic": {
++ "type": "boolean",
++ "description": "Stub provider output."
++ },
++ "cached_from": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "usage": {
"type": "object",
"required": [
-- "models"
++ "tokens_in",
++ "tokens_out",
++ "cost_usd",
++ "attempts"
],
++ "additionalProperties": false,
"properties": {
-- "models": {
-- "type": "array",
-- "items": {
-- "$ref": "model.v1.json"
-- }
++ "tokens_in": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "tokens_out": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
}
}
},
++ "error": {
++ "type": "null"
++ },
++ "citations_count": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "retry_of": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "ai.definition-version-request": {
++ "version": "1.0.0",
++ "owner": "ai",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ai/definition-version-request.v1.json",
++ "title": "AiDefinitionVersionRequest",
++ "description": "ai.definition-version-request@1: bodies of ai.workflow.manage on OpenVibe.AI. An edit is always a new version (the previous one stays readable); fields left out are taken from the newest existing version. POST /api/v1/templates/:key/versions: a prompt template (input_schema and output_schema must compile as JSON Schema; visibility public, first-party or internal). POST /api/v1/workflows/:key/versions (key <namespace>.<name>): steps (at least one; kind llm (naming an existing template), passthrough, transcribe or embed), schemas, default route, cache mode. POST /api/v1/routes/:key/versions: primary { provider, model } and fallbacks (every provider must exist), or alias_of another route. POST …/:key/versions/:version/status: { status } (templates and workflows draft|active|deprecated|archived; routes active|disabled). Refusals are 422 ai.invalid.",
++ "anyOf": [
{
"type": "object",
-- "required": [
-- "quotas",
-- "current"
-- ],
"properties": {
-- "quotas": {
-- "type": "array",
-- "items": {
-- "type": "object"
-- }
++ "name": {
++ "type": "string"
},
-- "current": {
-- "type": "array",
-- "items": {
-- "type": "object",
-- "properties": {
-- "quota": {
-- "type": "object"
-- },
-- "scope_id": {
-- "type": "string"
-- },
-- "window_start": {
-- "type": "string"
-- },
-- "resets_in_seconds": {
-- "type": "number"
-- },
-- "used": {
-- "type": "object"
-- }
-- }
-- }
-- }
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "usage"
-- ],
-- "properties": {
-- "usage": {
-- "type": "array",
-- "items": {
-- "type": "object"
-- }
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "input_schema": {
++ "type": "object",
++ "description": "A JSON Schema the run input must match."
++ },
++ "output_schema": {
++ "type": "object",
++ "description": "A JSON Schema the output must match."
++ },
++ "system_prompt": {
++ "type": "string"
++ },
++ "user_prompt": {
++ "type": "string"
++ },
++ "default_route": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "owner": {
++ "type": "string"
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "first-party",
++ "internal"
++ ]
++ },
++ "status": {
++ "enum": [
++ "draft",
++ "active",
++ "deprecated",
++ "archived"
++ ]
++ },
++ "metadata": {
++ "type": "object"
}
}
},
{
"type": "object",
-- "required": [
-- "requests"
-- ],
"properties": {
-- "requests": {
++ "name": {
++ "type": "string"
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "namespace": {
++ "type": "string"
++ },
++ "input_schema": {
++ "type": "object",
++ "description": "A JSON Schema."
++ },
++ "output_schema": {
++ "type": "object",
++ "description": "A JSON Schema."
++ },
++ "steps": {
"type": "array",
-- "maxItems": 500,
++ "minItems": 1,
"items": {
-- "type": "object"
++ "type": "object",
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "enum": [
++ "llm",
++ "passthrough",
++ "transcribe",
++ "embed"
++ ]
++ },
++ "template": {
++ "type": "string"
++ }
++ }
}
++ },
++ "default_route": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "cache_mode": {
++ "enum": [
++ "none",
++ "private",
++ "service"
++ ]
++ },
++ "cache_ttl_sec": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "status": {
++ "enum": [
++ "draft",
++ "active",
++ "deprecated",
++ "archived"
++ ]
++ },
++ "metadata": {
++ "type": "object"
}
}
},
{
"type": "object",
-- "required": [
-- "audit"
-- ],
"properties": {
-- "audit": {
-- "type": "array",
-- "items": {
-- "type": "object"
++ "primary": {
++ "type": "object",
++ "required": [
++ "provider"
++ ],
++ "properties": {
++ "provider": {
++ "type": "string"
++ },
++ "model": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
}
-- }
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "cache"
-- ],
-- "properties": {
-- "cache": {
++ },
++ "fallbacks": {
"type": "array",
"items": {
"type": "object",
++ "required": [
++ "provider"
++ ],
"properties": {
-- "workflow_key": {
-- "type": "string"
-- },
-- "privacy": {
++ "provider": {
"type": "string"
},
-- "entries": {
-- "type": "integer"
-- },
-- "hits": {
++ "model": {
"type": [
-- "integer",
++ "string",
"null"
]
}
}
}
++ },
++ "options": {
++ "type": "object"
++ },
++ "max_output_tokens": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "response_format": {
++ "enum": [
++ "text",
++ "json"
++ ]
++ },
++ "timeout_ms": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "alias_of": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "status": {
++ "enum": [
++ "active",
++ "disabled"
++ ]
}
}
}
]
}
},
-- "tips.interaction.ready": {
++ "ai.definition-version-result": {
"version": "1.0.0",
-- "owner": "tips",
++ "owner": "ai",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.ready.v1.json",
-- "title": "TipsInteractionReadyPayload",
-- "description": "tips.interaction.ready v1 (OpenVibe.Tips server/domain/interactions.js settle, summary()). The interaction settled: it is active and its effects (overlay alert, chat line, paid message, TTS, media request) are queued. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Never emitted for tests or imports.",
-- "type": "object",
-- "required": [
-- "interaction_id",
-- "creator",
-- "supporter",
-- "supporter_name",
-- "kind",
-- "amount",
-- "currency",
-- "settlement",
-- "payment_state",
-- "delivery_state",
-- "billing_txn_id",
-- "test"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "interaction_id": {
-- "type": "string",
-- "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "creator": {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- "supporter": {
-- "anyOf": [
-- {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
++ "$id": "https://openvibe.network/contracts/ai/definition-version-result.v1.json",
++ "title": "AiDefinitionVersionResult",
++ "description": "ai.definition-version-result@1: answers of ai.workflow.manage on OpenVibe.AI. POST …/:key/versions → 201 { template | workflow | route } (the new version, whole: prompts and schemas for a template, steps for a workflow, primary/fallbacks for a route); POST …/versions/:version/status → { template | workflow | route: { key, version, status } }.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "template"
],
-- "description": "The supporter, null when external, anonymous or unmapped."
++ "properties": {
++ "template": {
++ "type": "object",
++ "required": [
++ "key",
++ "version",
++ "status"
++ ],
++ "properties": {
++ "key": {
++ "type": "string"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "status": {
++ "type": "string"
++ }
++ }
++ }
++ }
},
-- "supporter_name": {
-- "type": [
-- "string",
-- "null"
++ {
++ "type": "object",
++ "required": [
++ "workflow"
],
-- "description": "Display name at the time."
-- },
-- "kind": {
-- "enum": [
-- "tip",
-- "paid_message",
-- "tts",
-- "media_request"
-- ]
-- },
-- "amount": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Integer amount in currency minor units (Vibes bits)."
-- },
-- "currency": {
-- "type": "string",
-- "description": "vibes-bits today."
-- },
-- "settlement": {
-- "enum": [
-- "billing",
-- "external",
-- "simulated",
-- "imported"
-- ]
-- },
-- "payment_state": {
-- "enum": [
-- "pending",
-- "settled",
-- "reversed",
-- "failed"
-- ]
-- },
-- "delivery_state": {
-- "enum": [
-- "awaiting_payment",
-- "queued",
-- "delivered",
-- "failed",
-- "cancelled"
-- ]
++ "properties": {
++ "workflow": {
++ "type": "object",
++ "required": [
++ "key",
++ "version",
++ "status"
++ ],
++ "properties": {
++ "key": {
++ "type": "string"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "status": {
++ "type": "string"
++ }
++ }
++ }
++ }
},
-- "billing_txn_id": {
-- "type": [
-- "string",
-- "null"
++ {
++ "type": "object",
++ "required": [
++ "route"
],
-- "description": "The Billing transaction (txn_…) that settled it."
-- },
-- "test": {
-- "type": "boolean",
-- "description": "Simulations never emit events, so false in practice."
++ "properties": {
++ "route": {
++ "type": "object",
++ "required": [
++ "key",
++ "version",
++ "status"
++ ],
++ "properties": {
++ "key": {
++ "type": "string"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "status": {
++ "type": "string"
++ }
++ }
++ }
++ }
}
-- }
++ ]
}
},
-- "tips.interaction.failed": {
++ "ai.model": {
"version": "1.0.0",
-- "owner": "tips",
++ "owner": "ai",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.failed.v1.json",
-- "title": "TipsInteractionFailedPayload",
-- "description": "tips.interaction.failed v1 (OpenVibe.Tips server/domain/interactions.js recomputeDelivery). A delivery effect gave up after its retries and none is still queued; the payment state is untouched. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Never emitted for tests or imports.",
++ "$id": "https://openvibe.network/contracts/ai/model.v1.json",
++ "title": "AiModel",
++ "description": "ai.model@1: one model of a provider in OpenVibe.AI's registry (server/registry.js decodeModel): type, status, limits, per-million-token costs and what it supports.",
"type": "object",
"required": [
-- "interaction_id",
-- "creator",
-- "supporter",
-- "supporter_name",
-- "kind",
-- "amount",
-- "currency",
-- "settlement",
-- "payment_state",
-- "delivery_state",
-- "billing_txn_id",
-- "test",
-- "failed_effects"
++ "provider_key",
++ "model_key",
++ "type",
++ "status",
++ "cost",
++ "supports"
],
-- "additionalProperties": false,
"properties": {
-- "interaction_id": {
-- "type": "string",
-- "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "creator": {
-- "$ref": "../../identity/subject-ref.v1.json"
++ "provider_key": {
++ "type": "string"
},
-- "supporter": {
-- "anyOf": [
-- {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ],
-- "description": "The supporter, null when external, anonymous or unmapped."
++ "model_key": {
++ "type": "string",
++ "maxLength": 200
},
-- "supporter_name": {
++ "display_name": {
"type": [
"string",
"null"
-- ],
-- "description": "Display name at the time."
-- },
-- "kind": {
-- "enum": [
-- "tip",
-- "paid_message",
-- "tts",
-- "media_request"
]
},
-- "amount": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Integer amount in currency minor units (Vibes bits)."
-- },
-- "currency": {
-- "type": "string",
-- "description": "vibes-bits today."
-- },
-- "settlement": {
++ "type": {
"enum": [
-- "billing",
-- "external",
-- "simulated",
-- "imported"
++ "chat",
++ "vision",
++ "embedding",
++ "stt"
]
},
-- "payment_state": {
++ "status": {
"enum": [
-- "pending",
-- "settled",
-- "reversed",
-- "failed"
++ "active",
++ "disabled"
]
},
-- "delivery_state": {
-- "enum": [
-- "awaiting_payment",
-- "queued",
-- "delivered",
-- "failed",
-- "cancelled"
++ "context_window": {
++ "type": [
++ "integer",
++ "null"
]
},
-- "billing_txn_id": {
++ "max_output": {
"type": [
-- "string",
++ "integer",
"null"
-- ],
-- "description": "The Billing transaction (txn_…) that settled it."
-- },
-- "test": {
-- "type": "boolean",
-- "description": "Simulations never emit events, so false in practice."
++ ]
},
-- "failed_effects": {
-- "type": "array",
-- "minItems": 1,
-- "items": {
-- "type": "object",
-- "required": [
-- "effect",
-- "adapter",
-- "last_error"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "effect": {
-- "enum": [
-- "chat_line",
-- "paid_message",
-- "tts",
-- "media_request",
-- "overlay_alert"
-- ]
-- },
-- "adapter": {
-- "type": "string"
-- },
-- "last_error": {
-- "type": [
-- "string",
-- "null"
-- ]
-- }
++ "cost": {
++ "type": "object",
++ "properties": {
++ "in_per_mtok": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "out_per_mtok": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "cached_per_mtok": {
++ "type": [
++ "number",
++ "null"
++ ]
}
}
-- }
-- }
-- }
-- },
-- "tips.interaction.cancelled": {
-- "version": "1.0.0",
-- "owner": "tips",
-- "status": "active",
-- "schema": {
-- "$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.cancelled.v1.json",
-- "title": "TipsInteractionCancelledPayload",
-- "description": "tips.interaction.cancelled v1 (OpenVibe.Tips server/domain/interactions.js failPayment, cancelQueued). Undelivered effects were cancelled: the payment failed before settling (reason payment_failed, with the Billing error code), Billing reversed it (payment_reversed) or an effect came up while the payment was no longer settled (payment_not_settled). Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Never emitted for tests or imports.",
-- "type": "object",
-- "required": [
-- "interaction_id",
-- "creator",
-- "supporter",
-- "supporter_name",
-- "kind",
-- "amount",
-- "currency",
-- "settlement",
-- "payment_state",
-- "delivery_state",
-- "billing_txn_id",
-- "test",
-- "reason"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "interaction_id": {
-- "type": "string",
-- "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "creator": {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- "supporter": {
-- "anyOf": [
-- {
-- "$ref": "../../identity/subject-ref.v1.json"
++ "supports": {
++ "type": "object",
++ "properties": {
++ "json": {
++ "type": "boolean"
},
-- {
-- "type": "null"
++ "tools": {
++ "type": "boolean"
++ },
++ "streaming": {
++ "type": "boolean"
++ },
++ "vision": {
++ "type": "boolean"
}
-- ],
-- "description": "The supporter, null when external, anonymous or unmapped."
++ }
},
-- "supporter_name": {
++ "metadata": {
++ "type": "object"
++ },
++ "created_at": {
"type": [
"string",
"null"
-- ],
-- "description": "Display name at the time."
-- },
-- "kind": {
-- "enum": [
-- "tip",
-- "paid_message",
-- "tts",
-- "media_request"
-- ]
-- },
-- "amount": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Integer amount in currency minor units (Vibes bits)."
-- },
-- "currency": {
-- "type": "string",
-- "description": "vibes-bits today."
-- },
-- "settlement": {
-- "enum": [
-- "billing",
-- "external",
-- "simulated",
-- "imported"
-- ]
-- },
-- "payment_state": {
-- "enum": [
-- "pending",
-- "settled",
-- "reversed",
-- "failed"
-- ]
-- },
-- "delivery_state": {
-- "enum": [
-- "awaiting_payment",
-- "queued",
-- "delivered",
-- "failed",
-- "cancelled"
]
},
-- "billing_txn_id": {
++ "updated_at": {
"type": [
"string",
"null"
-- ],
-- "description": "The Billing transaction (txn_…) that settled it."
-- },
-- "test": {
-- "type": "boolean",
-- "description": "Simulations never emit events, so false in practice."
-- },
-- "reason": {
-- "enum": [
-- "payment_failed",
-- "payment_reversed",
-- "payment_not_settled"
-- ]
-- },
-- "code": {
-- "type": "string",
-- "description": "Only with reason payment_failed: the Billing problem code (billing.insufficient_funds, billing.funding_reversed, billing.refused…)."
-- }
-- }
-- }
-- },
-- "tips.interaction.moderated": {
-- "version": "1.0.0",
-- "owner": "tips",
-- "status": "active",
-- "schema": {
-- "$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.moderated.v1.json",
-- "title": "TipsInteractionModeratedPayload",
-- "description": "tips.interaction.moderated v1 (OpenVibe.Tips server/domain/moderation.js record). A moderation outcome for a paid message (and the name that came with it): the creator's word filter starred words out at settlement (filtered) or held it for review before anything was shown (held); the creator, one of their moderators or a service hid it (hidden: queued chat, TTS and media deliveries were cancelled, the overlay alert retracted, public pages leave it out) or showed it again (restored; a held one is released then). The money is never touched: no refund, the goal and totals keep it. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter, message, reason text or moderator identity (Tips keeps those in its own moderation log). Never emitted for simulations.",
-- "type": "object",
-- "required": [
-- "interaction_id",
-- "creator",
-- "action",
-- "by",
-- "moderation_state",
-- "cancelled_effects"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "interaction_id": {
-- "type": "string",
-- "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "creator": {
-- "$ref": "../../identity/subject-ref.v1.json"
-- },
-- "action": {
-- "enum": [
-- "filtered",
-- "held",
-- "hidden",
-- "restored"
]
-- },
-- "by": {
-- "enum": [
-- "filter",
-- "creator",
-- "moderator",
-- "service"
-- ],
-- "description": "Who acted, by role."
-- },
-- "moderation_state": {
-- "enum": [
-- "visible",
-- "held",
-- "hidden"
-- ],
-- "description": "The interaction's moderation state after this outcome."
-- },
-- "cancelled_effects": {
-- "type": "array",
-- "uniqueItems": true,
-- "items": {
-- "enum": [
-- "chat_line",
-- "paid_message",
-- "tts",
-- "media_request"
-- ]
-- },
-- "description": "Deliveries this outcome cancelled before they happened (hidden only; empty otherwise). A consumer that queued the same paid message itself should drop it."
}
}
}
},
-- "tips.interaction.erased": {
++ "ai.provider-manage-request": {
"version": "1.0.0",
-- "owner": "tips",
++ "owner": "ai",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.erased.v1.json",
++ "$id": "https://openvibe.network/contracts/ai/provider-manage-request.v1.json",
++ "title": "AiProviderManageRequest",
++ "description": "ai.provider-manage-request@1: bodies of ai.provider.manage on OpenVibe.AI. POST /api/v1/providers creates or replaces a provider (key required) and PATCH /api/v1/providers/:key changes one (fields left out keep their value); a body carrying api_key or secret is refused (422: send secret_ref env:NAME, never a secret value). POST /api/v1/models (provider_key and model_key required) and PATCH /api/v1/models/:provider/:model upsert a model. POST /api/v1/quotas upserts a quota (scope_type and window required; scope_id too unless global; the same scope, window and workflow_prefix is one quota). POST /api/v1/providers/:key/disable|enable|reset and DELETE /api/v1/cache[?workflow=] take no body. Unknown fields are ignored.",
++ "anyOf": [
++ {
++ "type": "object",
++ "not": {
++ "anyOf": [
++ {
++ "required": [
++ "api_key"
++ ]
++ },
++ {
++ "required": [
++ "secret"
++ ]
++ }
++ ]
++ },
++ "properties": {
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$"
++ },
++ "display_name": {
++ "type": "string"
++ },
++ "kind": {
++ "enum": [
++ "stub",
++ "openai",
++ "anthropic",
++ "http",
++ "whisper"
++ ]
++ },
++ "status": {
++ "enum": [
++ "active",
++ "disabled"
++ ]
++ },
++ "base_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^https?://\\S+$"
++ },
++ "auth_mode": {
++ "enum": [
++ "none",
++ "bearer",
++ "x-api-key"
++ ]
++ },
++ "secret_ref": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^(env:[A-Z][A-Z0-9_]{0,63})?$"
++ },
++ "default_model": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "capabilities": {
++ "type": "array",
++ "items": {
++ "enum": [
++ "chat",
++ "generate",
++ "summarize",
++ "classify",
++ "extract",
++ "enrich",
++ "embed",
++ "vision",
++ "json",
++ "transcribe"
++ ]
++ }
++ },
++ "timeout_ms": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "description": "Clamped to 1000..600000."
++ },
++ "priority": {
++ "type": [
++ "number",
++ "string"
++ ]
++ },
++ "metadata": {
++ "type": "object"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "properties": {
++ "provider_key": {
++ "type": "string"
++ },
++ "model_key": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "display_name": {
++ "type": "string"
++ },
++ "type": {
++ "enum": [
++ "chat",
++ "vision",
++ "embedding",
++ "stt"
++ ]
++ },
++ "status": {
++ "enum": [
++ "active",
++ "disabled"
++ ]
++ },
++ "context_window": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "max_output": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "cost": {
++ "type": "object",
++ "properties": {
++ "in_per_mtok": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "out_per_mtok": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "cached_per_mtok": {
++ "type": [
++ "number",
++ "null"
++ ]
++ }
++ }
++ },
++ "supports": {
++ "type": "object",
++ "properties": {
++ "json": {
++ "type": "boolean"
++ },
++ "tools": {
++ "type": "boolean"
++ },
++ "streaming": {
++ "type": "boolean"
++ },
++ "vision": {
++ "type": "boolean"
++ }
++ }
++ },
++ "metadata": {
++ "type": "object"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "scope_type",
++ "window"
++ ],
++ "properties": {
++ "scope_type": {
++ "enum": [
++ "global",
++ "service",
++ "actor",
++ "attribution"
++ ]
++ },
++ "scope_id": {
++ "type": "string",
++ "maxLength": 200,
++ "description": "* on a non-global scope = each one separately."
++ },
++ "window": {
++ "enum": [
++ "minute",
++ "hour",
++ "day"
++ ]
++ },
++ "max_requests": {
++ "type": [
++ "number",
++ "string",
++ "null"
++ ],
++ "minimum": 0
++ },
++ "max_tokens": {
++ "type": [
++ "number",
++ "string",
++ "null"
++ ],
++ "minimum": 0
++ },
++ "max_cost_usd": {
++ "type": [
++ "number",
++ "string",
++ "null"
++ ],
++ "minimum": 0
++ },
++ "workflow_prefix": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "status": {
++ "enum": [
++ "active",
++ "disabled"
++ ]
++ }
++ }
++ },
++ {
++ "$ref": "../common/no-body.v1.json"
++ }
++ ]
++ }
++ },
++ "ai.provider-manage-result": {
++ "version": "1.0.0",
++ "owner": "ai",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ai/provider-manage-result.v1.json",
++ "title": "AiProviderManageResult",
++ "description": "ai.provider-manage-result@1: answers of ai.provider.manage on OpenVibe.AI. Provider create/update/disable/enable → { provider } (201 on POST); POST …/reset → { health } (the circuit breaker, closed again); model upserts → { model }; POST /api/v1/quotas → 201 { quota } (the stored row); DELETE /api/v1/cache → { removed } (entries purged). Every change is audited.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "provider"
++ ],
++ "properties": {
++ "provider": {
++ "$ref": "provider.v1.json"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "health"
++ ],
++ "properties": {
++ "health": {
++ "type": "object",
++ "required": [
++ "state"
++ ],
++ "properties": {
++ "provider_key": {
++ "type": "string"
++ },
++ "state": {
++ "enum": [
++ "closed",
++ "open",
++ "half_open"
++ ]
++ },
++ "consecutive_failures": {
++ "type": "integer"
++ }
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "model"
++ ],
++ "properties": {
++ "model": {
++ "$ref": "model.v1.json"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "quota"
++ ],
++ "properties": {
++ "quota": {
++ "type": "object",
++ "required": [
++ "id",
++ "scope_type",
++ "scope_id",
++ "window",
++ "status"
++ ],
++ "properties": {
++ "id": {
++ "type": "integer"
++ },
++ "scope_type": {
++ "type": "string"
++ },
++ "scope_id": {
++ "type": "string"
++ },
++ "window": {
++ "type": "string"
++ },
++ "max_requests": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "max_tokens": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "max_cost_usd": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "workflow_prefix": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "status": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "removed"
++ ],
++ "properties": {
++ "removed": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++ ]
++ }
++ },
++ "ai.provider": {
++ "version": "1.0.0",
++ "owner": "ai",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ai/provider.v1.json",
++ "title": "AiProvider",
++ "description": "ai.provider@1: one model provider as OpenVibe.AI's registry API shows it (server/registry.js publicProvider): never a secret value, only the secret reference name (env:NAME) and whether it resolves (credentials), plus the circuit-breaker health.",
++ "type": "object",
++ "required": [
++ "key",
++ "kind",
++ "status",
++ "auth_mode",
++ "capabilities",
++ "credentials"
++ ],
++ "properties": {
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$"
++ },
++ "display_name": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "kind": {
++ "enum": [
++ "stub",
++ "openai",
++ "anthropic",
++ "http",
++ "whisper"
++ ]
++ },
++ "status": {
++ "enum": [
++ "active",
++ "disabled"
++ ]
++ },
++ "base_url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "auth_mode": {
++ "enum": [
++ "none",
++ "bearer",
++ "x-api-key"
++ ]
++ },
++ "secret_ref": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "env:NAME, a reference; never the secret."
++ },
++ "default_model": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "capabilities": {
++ "type": "array",
++ "items": {
++ "enum": [
++ "chat",
++ "generate",
++ "summarize",
++ "classify",
++ "extract",
++ "enrich",
++ "embed",
++ "vision",
++ "json",
++ "transcribe"
++ ]
++ }
++ },
++ "timeout_ms": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ },
++ "priority": {
++ "type": [
++ "number",
++ "null"
++ ]
++ },
++ "metadata": {
++ "type": "object"
++ },
++ "origin": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "created_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "updated_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "credentials": {
++ "enum": [
++ "not_required",
++ "configured",
++ "missing"
++ ]
++ },
++ "health": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "properties": {
++ "provider_key": {
++ "type": "string"
++ },
++ "state": {
++ "enum": [
++ "closed",
++ "open",
++ "half_open"
++ ]
++ },
++ "consecutive_failures": {
++ "type": "integer"
++ }
++ }
++ }
++ }
++ }
++ },
++ "ai.usage-result": {
++ "version": "1.0.0",
++ "owner": "ai",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ai/usage-result.v1.json",
++ "title": "AiUsageResult",
++ "description": "ai.usage-result@1: answers of ai.usage.read on OpenVibe.AI (all GETs, parameters in the query). GET /api/v1/status → { providers, runs, today, workflows, templates, routes, inflight, quotas }; GET /api/v1/usage?from=&to=&requester= → { usage } (daily rows); GET /api/v1/quotas → { quotas, current }; GET /api/v1/requests?provider=&status=&run=&fallback=&limit= → { requests } (request-log metadata: hashes, tokens, cost, latency; raw debug fields only for provider managers who ask); GET /api/v1/audit → { audit }; GET /api/v1/cache → { cache }; GET /api/v1/providers → { providers }; GET /api/v1/models?provider= → { models }. Never raw prompts, never secrets.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "providers",
++ "runs",
++ "today"
++ ],
++ "properties": {
++ "providers": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "key"
++ ],
++ "properties": {
++ "key": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "status": {
++ "type": "string"
++ },
++ "credentials": {
++ "type": "string"
++ },
++ "health": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "runs": {
++ "type": "object",
++ "additionalProperties": {
++ "type": "integer"
++ },
++ "description": "Run counts by status."
++ },
++ "today": {
++ "type": "object",
++ "required": [
++ "day"
++ ],
++ "properties": {
++ "day": {
++ "type": "string"
++ },
++ "requests": {
++ "type": "number"
++ },
++ "cost_usd": {
++ "type": "number"
++ }
++ }
++ },
++ "workflows": {
++ "type": "integer"
++ },
++ "templates": {
++ "type": "integer"
++ },
++ "routes": {
++ "type": "integer"
++ },
++ "inflight": {
++ "type": "integer"
++ },
++ "quotas": {
++ "type": "array"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "providers"
++ ],
++ "properties": {
++ "providers": {
++ "type": "array",
++ "items": {
++ "$ref": "provider.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "models"
++ ],
++ "properties": {
++ "models": {
++ "type": "array",
++ "items": {
++ "$ref": "model.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "quotas",
++ "current"
++ ],
++ "properties": {
++ "quotas": {
++ "type": "array",
++ "items": {
++ "type": "object"
++ }
++ },
++ "current": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "quota": {
++ "type": "object"
++ },
++ "scope_id": {
++ "type": "string"
++ },
++ "window_start": {
++ "type": "string"
++ },
++ "resets_in_seconds": {
++ "type": "number"
++ },
++ "used": {
++ "type": "object"
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "usage"
++ ],
++ "properties": {
++ "usage": {
++ "type": "array",
++ "items": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "requests"
++ ],
++ "properties": {
++ "requests": {
++ "type": "array",
++ "maxItems": 500,
++ "items": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "audit"
++ ],
++ "properties": {
++ "audit": {
++ "type": "array",
++ "items": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "cache"
++ ],
++ "properties": {
++ "cache": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "workflow_key": {
++ "type": "string"
++ },
++ "privacy": {
++ "type": "string"
++ },
++ "entries": {
++ "type": "integer"
++ },
++ "hits": {
++ "type": [
++ "integer",
++ "null"
++ ]
++ }
++ }
++ }
++ }
++ }
++ }
++ ]
++ }
++ },
++ "tips.interaction.ready": {
++ "version": "1.0.0",
++ "owner": "tips",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.ready.v1.json",
++ "title": "TipsInteractionReadyPayload",
++ "description": "tips.interaction.ready v1 (OpenVibe.Tips server/domain/interactions.js settle, summary()). The interaction settled: it is active and its effects (overlay alert, chat line, paid message, TTS, media request) are queued. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Never emitted for tests or imports.",
++ "type": "object",
++ "required": [
++ "interaction_id",
++ "creator",
++ "supporter",
++ "supporter_name",
++ "kind",
++ "amount",
++ "currency",
++ "settlement",
++ "payment_state",
++ "delivery_state",
++ "billing_txn_id",
++ "test"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": {
++ "type": "string",
++ "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "creator": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "supporter": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "description": "The supporter, null when external, anonymous or unmapped."
++ },
++ "supporter_name": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Display name at the time."
++ },
++ "kind": {
++ "enum": [
++ "tip",
++ "paid_message",
++ "tts",
++ "media_request"
++ ]
++ },
++ "amount": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Integer amount in currency minor units (Vibes bits)."
++ },
++ "currency": {
++ "type": "string",
++ "description": "vibes-bits today."
++ },
++ "settlement": {
++ "enum": [
++ "billing",
++ "external",
++ "simulated",
++ "imported"
++ ]
++ },
++ "payment_state": {
++ "enum": [
++ "pending",
++ "settled",
++ "reversed",
++ "failed"
++ ]
++ },
++ "delivery_state": {
++ "enum": [
++ "awaiting_payment",
++ "queued",
++ "delivered",
++ "failed",
++ "cancelled"
++ ]
++ },
++ "billing_txn_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The Billing transaction (txn_…) that settled it."
++ },
++ "test": {
++ "type": "boolean",
++ "description": "Simulations never emit events, so false in practice."
++ }
++ }
++ }
++ },
++ "tips.interaction.failed": {
++ "version": "1.0.0",
++ "owner": "tips",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.failed.v1.json",
++ "title": "TipsInteractionFailedPayload",
++ "description": "tips.interaction.failed v1 (OpenVibe.Tips server/domain/interactions.js recomputeDelivery). A delivery effect gave up after its retries and none is still queued; the payment state is untouched. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Never emitted for tests or imports.",
++ "type": "object",
++ "required": [
++ "interaction_id",
++ "creator",
++ "supporter",
++ "supporter_name",
++ "kind",
++ "amount",
++ "currency",
++ "settlement",
++ "payment_state",
++ "delivery_state",
++ "billing_txn_id",
++ "test",
++ "failed_effects"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": {
++ "type": "string",
++ "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "creator": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "supporter": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "description": "The supporter, null when external, anonymous or unmapped."
++ },
++ "supporter_name": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Display name at the time."
++ },
++ "kind": {
++ "enum": [
++ "tip",
++ "paid_message",
++ "tts",
++ "media_request"
++ ]
++ },
++ "amount": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Integer amount in currency minor units (Vibes bits)."
++ },
++ "currency": {
++ "type": "string",
++ "description": "vibes-bits today."
++ },
++ "settlement": {
++ "enum": [
++ "billing",
++ "external",
++ "simulated",
++ "imported"
++ ]
++ },
++ "payment_state": {
++ "enum": [
++ "pending",
++ "settled",
++ "reversed",
++ "failed"
++ ]
++ },
++ "delivery_state": {
++ "enum": [
++ "awaiting_payment",
++ "queued",
++ "delivered",
++ "failed",
++ "cancelled"
++ ]
++ },
++ "billing_txn_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The Billing transaction (txn_…) that settled it."
++ },
++ "test": {
++ "type": "boolean",
++ "description": "Simulations never emit events, so false in practice."
++ },
++ "failed_effects": {
++ "type": "array",
++ "minItems": 1,
++ "items": {
++ "type": "object",
++ "required": [
++ "effect",
++ "adapter",
++ "last_error"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "effect": {
++ "enum": [
++ "chat_line",
++ "paid_message",
++ "tts",
++ "media_request",
++ "overlay_alert"
++ ]
++ },
++ "adapter": {
++ "type": "string"
++ },
++ "last_error": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "tips.interaction.cancelled": {
++ "version": "1.0.0",
++ "owner": "tips",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.cancelled.v1.json",
++ "title": "TipsInteractionCancelledPayload",
++ "description": "tips.interaction.cancelled v1 (OpenVibe.Tips server/domain/interactions.js failPayment, cancelQueued). Undelivered effects were cancelled: the payment failed before settling (reason payment_failed, with the Billing error code), Billing reversed it (payment_reversed) or an effect came up while the payment was no longer settled (payment_not_settled). Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Never emitted for tests or imports.",
++ "type": "object",
++ "required": [
++ "interaction_id",
++ "creator",
++ "supporter",
++ "supporter_name",
++ "kind",
++ "amount",
++ "currency",
++ "settlement",
++ "payment_state",
++ "delivery_state",
++ "billing_txn_id",
++ "test",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": {
++ "type": "string",
++ "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "creator": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "supporter": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "description": "The supporter, null when external, anonymous or unmapped."
++ },
++ "supporter_name": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Display name at the time."
++ },
++ "kind": {
++ "enum": [
++ "tip",
++ "paid_message",
++ "tts",
++ "media_request"
++ ]
++ },
++ "amount": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Integer amount in currency minor units (Vibes bits)."
++ },
++ "currency": {
++ "type": "string",
++ "description": "vibes-bits today."
++ },
++ "settlement": {
++ "enum": [
++ "billing",
++ "external",
++ "simulated",
++ "imported"
++ ]
++ },
++ "payment_state": {
++ "enum": [
++ "pending",
++ "settled",
++ "reversed",
++ "failed"
++ ]
++ },
++ "delivery_state": {
++ "enum": [
++ "awaiting_payment",
++ "queued",
++ "delivered",
++ "failed",
++ "cancelled"
++ ]
++ },
++ "billing_txn_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The Billing transaction (txn_…) that settled it."
++ },
++ "test": {
++ "type": "boolean",
++ "description": "Simulations never emit events, so false in practice."
++ },
++ "reason": {
++ "enum": [
++ "payment_failed",
++ "payment_reversed",
++ "payment_not_settled"
++ ]
++ },
++ "code": {
++ "type": "string",
++ "description": "Only with reason payment_failed: the Billing problem code (billing.insufficient_funds, billing.funding_reversed, billing.refused…)."
++ }
++ }
++ }
++ },
++ "tips.interaction.moderated": {
++ "version": "1.0.0",
++ "owner": "tips",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.moderated.v1.json",
++ "title": "TipsInteractionModeratedPayload",
++ "description": "tips.interaction.moderated v1 (OpenVibe.Tips server/domain/moderation.js record). A moderation outcome for a paid message (and the name that came with it): the creator's word filter starred words out at settlement (filtered) or held it for review before anything was shown (held); the creator, one of their moderators or a service hid it (hidden: queued chat, TTS and media deliveries were cancelled, the overlay alert retracted, public pages leave it out) or showed it again (restored; a held one is released then). The money is never touched: no refund, the goal and totals keep it. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter, message, reason text or moderator identity (Tips keeps those in its own moderation log). Never emitted for simulations.",
++ "type": "object",
++ "required": [
++ "interaction_id",
++ "creator",
++ "action",
++ "by",
++ "moderation_state",
++ "cancelled_effects"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": {
++ "type": "string",
++ "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "creator": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "action": {
++ "enum": [
++ "filtered",
++ "held",
++ "hidden",
++ "restored"
++ ]
++ },
++ "by": {
++ "enum": [
++ "filter",
++ "creator",
++ "moderator",
++ "service"
++ ],
++ "description": "Who acted, by role."
++ },
++ "moderation_state": {
++ "enum": [
++ "visible",
++ "held",
++ "hidden"
++ ],
++ "description": "The interaction's moderation state after this outcome."
++ },
++ "cancelled_effects": {
++ "type": "array",
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "chat_line",
++ "paid_message",
++ "tts",
++ "media_request"
++ ]
++ },
++ "description": "Deliveries this outcome cancelled before they happened (hidden only; empty otherwise). A consumer that queued the same paid message itself should drop it."
++ }
++ }
++ }
++ },
++ "tips.interaction.erased": {
++ "version": "1.0.0",
++ "owner": "tips",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.erased.v1.json",
"title": "TipsInteractionErasedPayload",
"description": "tips.interaction.erased v1 (OpenVibe.Tips server/domain/interactions.js erase). The supporter erased their data from this interaction: Tips no longer holds their subject, name, message, text-to-speech text, media link or checkout reference; the money record (amount, creator, Billing transaction, goal contribution) stays so Billing's books and the creator's totals still reconcile. payload.redacts names every earlier Tips event about the interaction, so OpenVibe.Events tombstones them (ADR-026). Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter data. Never emitted for simulations.",
"type": "object",
@@@ -21727,529 +23154,1291 @@
"minLength": 1,
"description": "Unique per mutation. A replay returns the first answer and changes nothing (400 missing_idempotency_key without it)."
}
-- }
++ }
++ }
++ },
++ "network.coins-transfer-request": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/network/coins-transfer-request.v1.json",
++ "title": "NetworkCoinsTransferRequest",
++ "description": "network.coins-transfer-request@1: the body of network.coins.transfer on OpenVibe.Network. POST /internal/coins/transfer: { from_user_id, to_user_id, app_id?, amount, reason?, ref?, idempotency_key } moves OpenCoins between two Network users atomically (the two ids are Network user ids and must differ). Refusals are { error } bodies: 400 invalid_from_user_id, invalid_to_user_id, invalid_transfer (the same person), invalid_amount or missing_idempotency_key; 404 user_not_found; 409 { error: insufficient_funds, balance } when the sender cannot cover it. Unknown fields are ignored.",
++ "type": "object",
++ "required": [
++ "from_user_id",
++ "to_user_id",
++ "amount",
++ "idempotency_key"
++ ],
++ "properties": {
++ "from_user_id": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 1,
++ "pattern": "^[1-9][0-9]*$",
++ "description": "The Network user id that pays."
++ },
++ "to_user_id": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "minimum": 1,
++ "pattern": "^[1-9][0-9]*$",
++ "description": "The Network user id that receives."
++ },
++ "app_id": {
++ "type": "string",
++ "description": "The app the coins move for. A service token may only name its own (svc:live → live); anything else is 403 capability.owner_denied. The shared internal key may name any."
++ },
++ "amount": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "OpenCoins, a positive whole number (a string is refused: 400 invalid_amount)."
++ },
++ "reason": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Why, kept on the ledger row."
++ },
++ "ref": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The caller's reference for the row (an order, a game, …)."
++ },
++ "idempotency_key": {
++ "type": "string",
++ "minLength": 1,
++ "description": "Unique per mutation. A replay returns the first answer and changes nothing (400 missing_idempotency_key without it)."
++ }
++ }
++ }
++ },
++ "network.coins-transfer-result": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/network/coins-transfer-result.v1.json",
++ "title": "NetworkCoinsTransferResult",
++ "description": "network.coins-transfer-result@1: the answer of network.coins.transfer on OpenVibe.Network. POST /internal/coins/transfer → { from_balance, to_balance }: both wallets right after the transfer (a replayed idempotency_key answers the balances of the original transfer). Refusals are listed on network.coins-transfer-request@1.",
++ "type": "object",
++ "required": [
++ "from_balance",
++ "to_balance"
++ ],
++ "properties": {
++ "from_balance": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "to_balance": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++ },
++ "network.github-token-result": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/network/github-token-result.v1.json",
++ "title": "NetworkGithubTokenResult",
++ "description": "network.github-token-result@1: the answer of network.integration.github.read on OpenVibe.Network. GET /internal/integrations/github-token (a service token holding the capability; the shared internal key is refused) → { token, source }: the network's read-only GitHub API token and where it is set (GITHUB_TOKEN in the environment, or the value the owner saved in admin). Answered with Cache-Control: no-store; 404 { error: not_configured, detail } when no token is set.",
++ "type": "object",
++ "required": [
++ "token",
++ "source"
++ ],
++ "properties": {
++ "token": {
++ "type": "string",
++ "minLength": 1,
++ "description": "A GitHub token (ghp_…, github_pat_…). A secret: never log or show it."
++ },
++ "source": {
++ "enum": [
++ "env",
++ "database",
++ "unset"
++ ],
++ "description": "env: GITHUB_TOKEN; database: saved in admin (Settings → GitHub)."
++ }
++ }
++ }
++ },
++ "network.notification-push-request": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/network/notification-push-request.v1.json",
++ "title": "NetworkNotificationPushRequest",
++ "description": "network.notification-push-request@1: bodies of network.notifications.push on OpenVibe.Network (a service token holding the capability, or the internal key). POST /internal/notifications/push: { user_id, type?, title?, message?, … } creates one notification (400 { error: user_id required } without user_id). POST /internal/notifications/push-bulk: { user_ids, …same fields } creates it for each recipient (400 without a non-empty user_ids, or with more than 1000). Recipients are Network user ids. Unknown fields are ignored; 503 when the notification service is not running. The other /internal/notifications/* routes (unread, mark-read, resolve-users) take the internal key only and are not part of this capability.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "user_id"
++ ],
++ "properties": {
++ "user_id": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "description": "A Network user id (already translated from the caller's own ids)."
++ },
++ "type": {
++ "type": "string",
++ "description": "A notification type from openvibe-shared/notifications (STREAM_LIVE, FOLLOW, MENTION, …); it supplies the default category, priority, icon and title. Stored as GENERIC when absent."
++ },
++ "title": {
++ "type": "string",
++ "description": "Default: the type's title, else \"Notification\"."
++ },
++ "message": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "icon": {
++ "type": "string",
++ "description": "Default: the type's icon."
++ },
++ "sender_id": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ],
++ "description": "The actor's Network user id; with no actor_subject, a recipient who blocked this person gets nothing."
++ },
++ "sender_name": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "sender_avatar": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "service": {
++ "type": "string",
++ "description": "The sending service. A service token may only name its own (svc:live → live); anything else is 403 capability.owner_denied."
++ },
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Where the notification leads."
++ },
++ "priority": {
++ "type": "string",
++ "description": "low, normal, high or critical (openvibe-shared PRIORITY); default the type's, else normal."
++ },
++ "category": {
++ "type": "string",
++ "description": "Default: the type's category, else system. A recipient who switched the category off gets nothing; moderation, system and admin notices are never hidden by a block."
++ },
++ "rich_content": {
++ "description": "Any JSON value, stored and returned as given."
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "When it may be deleted (SQLite datetime, UTC)."
++ },
++ "actor_subject": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^usr_",
++ "description": "The actor as a usr_ subject for the block check; null: no known person (no block check)."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "user_ids"
++ ],
++ "properties": {
++ "user_ids": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 1000,
++ "items": {
++ "type": [
++ "integer",
++ "string"
++ ],
++ "description": "A Network user id (already translated from the caller's own ids)."
++ }
++ },
++ "type": {
++ "type": "string",
++ "description": "A notification type from openvibe-shared/notifications (STREAM_LIVE, FOLLOW, MENTION, …); it supplies the default category, priority, icon and title. Stored as GENERIC when absent."
++ },
++ "title": {
++ "type": "string",
++ "description": "Default: the type's title, else \"Notification\"."
++ },
++ "message": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "icon": {
++ "type": "string",
++ "description": "Default: the type's icon."
++ },
++ "sender_id": {
++ "type": [
++ "integer",
++ "string",
++ "null"
++ ],
++ "description": "The actor's Network user id; with no actor_subject, a recipient who blocked this person gets nothing."
++ },
++ "sender_name": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "sender_avatar": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "service": {
++ "type": "string",
++ "description": "The sending service. A service token may only name its own (svc:live → live); anything else is 403 capability.owner_denied."
++ },
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Where the notification leads."
++ },
++ "priority": {
++ "type": "string",
++ "description": "low, normal, high or critical (openvibe-shared PRIORITY); default the type's, else normal."
++ },
++ "category": {
++ "type": "string",
++ "description": "Default: the type's category, else system. A recipient who switched the category off gets nothing; moderation, system and admin notices are never hidden by a block."
++ },
++ "rich_content": {
++ "description": "Any JSON value, stored and returned as given."
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "When it may be deleted (SQLite datetime, UTC)."
++ },
++ "actor_subject": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^usr_",
++ "description": "The actor as a usr_ subject for the block check; null: no known person (no block check)."
++ }
++ }
++ }
++ ]
}
},
-- "network.coins-transfer-request": {
++ "network.notification-push-result": {
"version": "1.0.0",
"owner": "network",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/network/coins-transfer-request.v1.json",
-- "title": "NetworkCoinsTransferRequest",
-- "description": "network.coins-transfer-request@1: the body of network.coins.transfer on OpenVibe.Network. POST /internal/coins/transfer: { from_user_id, to_user_id, app_id?, amount, reason?, ref?, idempotency_key } moves OpenCoins between two Network users atomically (the two ids are Network user ids and must differ). Refusals are { error } bodies: 400 invalid_from_user_id, invalid_to_user_id, invalid_transfer (the same person), invalid_amount or missing_idempotency_key; 404 user_not_found; 409 { error: insufficient_funds, balance } when the sender cannot cover it. Unknown fields are ignored.",
-- "type": "object",
-- "required": [
-- "from_user_id",
-- "to_user_id",
-- "amount",
-- "idempotency_key"
-- ],
-- "properties": {
-- "from_user_id": {
-- "type": [
-- "integer",
-- "string"
-- ],
-- "minimum": 1,
-- "pattern": "^[1-9][0-9]*$",
-- "description": "The Network user id that pays."
-- },
-- "to_user_id": {
-- "type": [
-- "integer",
-- "string"
++ "$id": "https://openvibe.network/contracts/network/notification-push-result.v1.json",
++ "title": "NetworkNotificationPushResult",
++ "description": "network.notification-push-result@1: answers of network.notifications.push on OpenVibe.Network. POST /internal/notifications/push → { ok: true, notification } (the stored notification), or { ok: true, skipped: true, reason } when nothing was created (the recipient switched the category off, blocked the actor, or already got a go-live notification from the same streamer within the hour). POST /internal/notifications/push-bulk → { ok: true, sent, total } (sent: notifications created; total: recipients named). Errors are { error } bodies (400, 500; 503 when the notification service is not running).",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "ok",
++ "notification"
],
-- "minimum": 1,
-- "pattern": "^[1-9][0-9]*$",
-- "description": "The Network user id that receives."
-- },
-- "app_id": {
-- "type": "string",
-- "description": "The app the coins move for. A service token may only name its own (svc:live → live); anything else is 403 capability.owner_denied. The shared internal key may name any."
-- },
-- "amount": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "OpenCoins, a positive whole number (a string is refused: 400 invalid_amount)."
++ "properties": {
++ "ok": {
++ "const": true
++ },
++ "notification": {
++ "type": "object",
++ "required": [
++ "id",
++ "user_id",
++ "category",
++ "priority",
++ "title",
++ "icon",
++ "is_read",
++ "created_at"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "format": "uuid"
++ },
++ "user_id": {
++ "type": [
++ "integer",
++ "string"
++ ]
++ },
++ "type": {
++ "type": "string"
++ },
++ "category": {
++ "type": "string"
++ },
++ "priority": {
++ "type": "string"
++ },
++ "title": {
++ "type": "string"
++ },
++ "message": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "icon": {
++ "type": "string"
++ },
++ "service": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "rich_content": {
++ "description": "As sent."
++ },
++ "is_read": {
++ "const": 0
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ }
},
-- "reason": {
-- "type": [
-- "string",
-- "null"
++ {
++ "type": "object",
++ "required": [
++ "ok",
++ "skipped",
++ "reason"
],
-- "description": "Why, kept on the ledger row."
++ "properties": {
++ "ok": {
++ "const": true
++ },
++ "skipped": {
++ "const": true
++ },
++ "reason": {
++ "type": "string"
++ }
++ }
},
-- "ref": {
-- "type": [
-- "string",
-- "null"
++ {
++ "type": "object",
++ "required": [
++ "ok",
++ "sent",
++ "total"
],
-- "description": "The caller's reference for the row (an order, a game, …)."
-- },
-- "idempotency_key": {
-- "type": "string",
-- "minLength": 1,
-- "description": "Unique per mutation. A replay returns the first answer and changes nothing (400 missing_idempotency_key without it)."
++ "properties": {
++ "ok": {
++ "const": true
++ },
++ "sent": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "total": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
}
-- }
++ ]
}
},
-- "network.coins-transfer-result": {
++ "network.staff-list-result": {
"version": "1.0.0",
"owner": "network",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/network/coins-transfer-result.v1.json",
-- "title": "NetworkCoinsTransferResult",
-- "description": "network.coins-transfer-result@1: the answer of network.coins.transfer on OpenVibe.Network. POST /internal/coins/transfer → { from_balance, to_balance }: both wallets right after the transfer (a replayed idempotency_key answers the balances of the original transfer). Refusals are listed on network.coins-transfer-request@1.",
++ "$id": "https://openvibe.network/contracts/network/staff-list-result.v1.json",
++ "title": "NetworkStaffListResult",
++ "description": "network.staff-list-result@1: the answer of network.staff.read on OpenVibe.Network. GET /api/v1/staff/moderators[?service=chat|live|community|pastes|calls] (a service token holding network.staff.read, or a signed-in person holding staff.moderation.logs) → { service, staff }: the network's global moderators, admins and the owner who are not banned, with the staff capabilities their role holds in the contracts staff map; ?service keeps those holding the capability that moderates that service (another service name keeps those holding staff.content.moderate; an invalid value is ignored and answered as null). Answered with Cache-Control: private, no-store; a person without staff.moderation.logs gets 403 { error: forbidden }.",
"type": "object",
"required": [
-- "from_balance",
-- "to_balance"
++ "service",
++ "staff"
],
"properties": {
-- "from_balance": {
-- "type": "integer",
-- "minimum": 0
++ "service": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The service filter applied, or null."
},
-- "to_balance": {
-- "type": "integer",
-- "minimum": 0
++ "staff": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "subject",
++ "username",
++ "display_name",
++ "role",
++ "is_owner",
++ "capabilities"
++ ],
++ "properties": {
++ "subject": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^usr_",
++ "description": "null for an account that has no subject yet."
++ },
++ "username": {
++ "type": "string"
++ },
++ "display_name": {
++ "type": "string"
++ },
++ "role": {
++ "enum": [
++ "global_mod",
++ "admin",
++ "owner"
++ ]
++ },
++ "is_owner": {
++ "type": "boolean"
++ },
++ "capabilities": {
++ "type": "array",
++ "items": {
++ "type": "string",
++ "pattern": "^staff\\."
++ },
++ "description": "Staff capability ids, sorted."
++ }
++ }
++ }
}
}
}
},
-- "network.github-token-result": {
-- "version": "1.0.0",
-- "owner": "network",
++ "tools.tool": {
++ "version": "1.1.0",
++ "owner": "tools",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/network/github-token-result.v1.json",
-- "title": "NetworkGithubTokenResult",
-- "description": "network.github-token-result@1: the answer of network.integration.github.read on OpenVibe.Network. GET /internal/integrations/github-token (a service token holding the capability; the shared internal key is refused) → { token, source }: the network's read-only GitHub API token and where it is set (GITHUB_TOKEN in the environment, or the value the owner saved in admin). Answered with Cache-Control: no-store; 404 { error: not_configured, detail } when no token is set.",
++ "$id": "https://openvibe.network/contracts/tools/tool.v1.json",
++ "title": "ToolDescriptor",
++ "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. When api is false, $defs.input is false (the schema that accepts nothing), since there is no run to take an input. OUTPUT.SCHEMA describes result.data for every execution: inline and job tools alike. KEYWORDS (the catalogue's search terms) and EXAMPLES (sample runs for the docs and the OpenAPI document) are optional. NOT A TOOL: there is no planned status. A planned catalogue entry has no descriptor and is not listed, and neither is a mirror (a second build of another tool). GET /api/v1/tools/:id and /schema answer 404 tools.tool.not_found for either, with a detail that says the id is planned or names the tool it mirrors, as for an id that does not exist. RULES this schema enforces: api false has no run and no examples, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets), files.min <= files.max, and each example: its input against an embedded input schema and limits.maxInputBytes, and its files against files (count and accept).",
"type": "object",
"required": [
-- "token",
-- "source"
++ "id",
++ "family",
++ "name",
++ "summary",
++ "status",
++ "execution",
++ "api",
++ "run",
++ "input",
++ "files",
++ "output",
++ "limits",
++ "auth",
++ "quotaClass",
++ "cost",
++ "egress",
++ "hosts",
++ "docs"
],
++ "additionalProperties": false,
"properties": {
-- "token": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "The tool's catalogue id, which is its subdomain label (png, jsminify, dns, pdf2jpg). It never changes."
++ },
++ "family": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "The catalogue family: net, dev, img, audio, docs, text, media, places, pastes…"
++ },
++ "name": {
"type": "string",
"minLength": 1,
-- "description": "A GitHub token (ghp_…, github_pat_…). A secret: never log or show it."
++ "maxLength": 80
},
-- "source": {
++ "summary": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "One plain sentence: what the tool does."
++ },
++ "keywords": {
++ "type": "array",
++ "uniqueItems": true,
++ "maxItems": 50,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 100,
++ "pattern": "^\\S(.*\\S)?$"
++ },
++ "description": "Optional. The catalogue's search terms for the tool (GET /api/catalog.json tools[].keywords): phrases people search for, such as 'heic to png' or 'yaml validator'. GET /api/v1/tools?q= matches every word of the query against the id, name, summary and these."
++ },
++ "status": {
"enum": [
-- "env",
-- "database",
-- "unset"
++ "stable",
++ "beta",
++ "preview",
++ "unavailable"
],
-- "description": "env: GITHUB_TOKEN; database: saved in admin (Settings → GitHub)."
-- }
-- }
-- }
-- },
-- "network.notification-push-request": {
-- "version": "1.0.0",
-- "owner": "network",
-- "status": "active",
-- "schema": {
-- "$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/network/notification-push-request.v1.json",
-- "title": "NetworkNotificationPushRequest",
-- "description": "network.notification-push-request@1: bodies of network.notifications.push on OpenVibe.Network (a service token holding the capability, or the internal key). POST /internal/notifications/push: { user_id, type?, title?, message?, … } creates one notification (400 { error: user_id required } without user_id). POST /internal/notifications/push-bulk: { user_ids, …same fields } creates it for each recipient (400 without a non-empty user_ids, or with more than 1000). Recipients are Network user ids. Unknown fields are ignored; 503 when the notification service is not running. The other /internal/notifications/* routes (unread, mark-read, resolve-users) take the internal key only and are not part of this capability.",
-- "anyOf": [
-- {
-- "type": "object",
-- "required": [
-- "user_id"
++ "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason); a program or upstream the engine needs is missing (qpdf, pdftoppm, heif-dec, ffmpeg…), and the engine's own routes answer 503 tools.unavailable while it is. There is no planned status: a planned catalogue entry is not a tool, so it has no descriptor and is not listed."
++ },
++ "statusReason": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300,
++ "description": "Why the status is what it is, for people (e.g. 'needs qpdf on the host'). Required when unavailable."
++ },
++ "execution": {
++ "enum": [
++ "client",
++ "sync",
++ "job"
],
-- "properties": {
-- "user_id": {
-- "type": [
-- "integer",
-- "string"
-- ],
-- "description": "A Network user id (already translated from the caller's own ids)."
-- },
-- "type": {
-- "type": "string",
-- "description": "A notification type from openvibe-shared/notifications (STREAM_LIVE, FOLLOW, MENTION, …); it supplies the default category, priority, icon and title. Stored as GENERIC when absent."
-- },
-- "title": {
-- "type": "string",
-- "description": "Default: the type's title, else \"Notification\"."
-- },
-- "message": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "icon": {
-- "type": "string",
-- "description": "Default: the type's icon."
-- },
-- "sender_id": {
-- "type": [
-- "integer",
-- "string",
-- "null"
-- ],
-- "description": "The actor's Network user id; with no actor_subject, a recipient who blocked this person gets nothing."
-- },
-- "sender_name": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "sender_avatar": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "service": {
-- "type": "string",
-- "description": "The sending service. A service token may only name its own (svc:live → live); anything else is 403 capability.owner_denied."
-- },
-- "url": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Where the notification leads."
-- },
-- "priority": {
-- "type": "string",
-- "description": "low, normal, high or critical (openvibe-shared PRIORITY); default the type's, else normal."
++ "description": "Where the engine runs for the tool's page: client (the browser), sync (a server request answered inline), job (an asynchronous job)."
++ },
++ "api": {
++ "type": "boolean",
++ "description": "POST /api/v1/tools/{id}/run exposes the tool. false for a client tool without a server engine and for page-only tools (yt)."
++ },
++ "run": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "method",
++ "path",
++ "job"
++ ],
++ "additionalProperties": false,
++ "description": "How to call it; null when api is false.",
++ "properties": {
++ "method": {
++ "const": "POST"
},
-- "category": {
++ "path": {
"type": "string",
-- "description": "Default: the type's category, else system. A recipient who switched the category off gets nothing; moderation, system and admin notices are never hidden by a block."
-- },
-- "rich_content": {
-- "description": "Any JSON value, stored and returned as given."
++ "pattern": "^/api/v1/tools/[a-z][a-z0-9-]{0,39}/run$",
++ "description": "/api/v1/tools/{id}/run on https://openvibe.tools; the body is tools.run-request@1, the answer tools.run@1."
},
-- "expires_at": {
++ "job": {
"type": [
-- "string",
++ "object",
"null"
],
-- "description": "When it may be deleted (SQLite datetime, UTC)."
-- },
-- "actor_subject": {
-- "type": [
-- "string",
-- "null"
++ "required": [
++ "type",
++ "operation"
],
-- "pattern": "^usr_",
-- "description": "The actor as a usr_ subject for the block check; null: no known person (no block check)."
++ "additionalProperties": false,
++ "description": "For a job tool: the job the run creates. null for tools answered inline.",
++ "properties": {
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type, e.g. img.process."
++ },
++ "operation": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]{0,39}$",
++ "description": "The job input's tool field: convert, compress, trim, mergepdf…"
++ },
++ "preset": {
++ "type": "object",
++ "description": "Input fields the tool itself fixes, applied over the caller's input (png: { format: png })."
++ }
++ }
++ },
++ "legacy": {
++ "type": "array",
++ "uniqueItems": true,
++ "maxItems": 32,
++ "items": {
++ "type": "string",
++ "pattern": "^(GET|POST|PUT|DELETE) /[^\\s]*$"
++ },
++ "description": "Older routes that run the same engine (GET /api/net/dns/:target, POST /api/process…). They keep working until their Sunset date and answer with Deprecation, Sunset and Link: rel=successor-version pointing at path."
}
}
},
-- {
-- "type": "object",
++ "input": {
++ "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API, whose $defs/input is false (it accepts nothing).",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "$ref"
++ ],
++ "maxProperties": 1,
++ "properties": {
++ "$ref": {
++ "type": "string",
++ "format": "uri"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type"
++ ],
++ "properties": {
++ "type": {
++ "const": "object"
++ }
++ }
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "files": {
++ "type": [
++ "object",
++ "null"
++ ],
"required": [
-- "user_ids"
++ "min",
++ "max",
++ "accept",
++ "maxBytes"
],
++ "additionalProperties": false,
++ "description": "The files a run takes (multipart parts, or tools.run-request@1 files references); null when it takes none.",
"properties": {
-- "user_ids": {
++ "min": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "max": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 100
++ },
++ "accept": {
"type": "array",
"minItems": 1,
-- "maxItems": 1000,
++ "uniqueItems": true,
"items": {
-- "type": [
-- "integer",
-- "string"
-- ],
-- "description": "A Network user id (already translated from the caller's own ids)."
-- }
-- },
-- "type": {
-- "type": "string",
-- "description": "A notification type from openvibe-shared/notifications (STREAM_LIVE, FOLLOW, MENTION, …); it supplies the default category, priority, icon and title. Stored as GENERIC when absent."
-- },
-- "title": {
-- "type": "string",
-- "description": "Default: the type's title, else \"Notification\"."
-- },
-- "message": {
-- "type": [
-- "string",
-- "null"
-- ]
-- },
-- "icon": {
-- "type": "string",
-- "description": "Default: the type's icon."
++ "type": "string",
++ "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$"
++ },
++ "description": "Accepted media types, checked against the bytes, not the client's Content-Type (image/png, image/*, application/pdf…)."
},
-- "sender_id": {
-- "type": [
-- "integer",
-- "string",
-- "null"
++ "maxBytes": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Largest accepted file."
++ }
++ }
++ },
++ "examples": {
++ "type": "array",
++ "maxItems": 50,
++ "description": "Optional. Sample runs, which the docs and the generated OpenAPI document publish. Each example's input is valid against the tool's input schema and fits limits.maxInputBytes, and its files fit files (count and accept); contracts.tools.checkDescriptor checks this whenever input is embedded. Only a tool with an API has examples.",
++ "items": {
++ "type": "object",
++ "required": [
++ "input"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 120,
++ "description": "What the example shows, for people."
++ },
++ "input": {
++ "type": "object",
++ "description": "A run request's input (tools.run-request@1 input), valid against the tool's input schema."
++ },
++ "files": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "The files the sample run takes (uploaded parts or files references), in order: what each one is, and where to download a sample.",
++ "items": {
++ "type": "object",
++ "required": [
++ "mime"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "A file name for the sample (photo.jpg)."
++ },
++ "mime": {
++ "type": "string",
++ "pattern": "^[a-z]+/[a-z0-9][a-z0-9.+-]*$",
++ "description": "Its media type, one that files.accept allows."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://",
++ "description": "A sample file to download."
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "output": {
++ "type": "object",
++ "required": [
++ "kind"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "kind": {
++ "enum": [
++ "json",
++ "file",
++ "files",
++ "text"
],
-- "description": "The actor's Network user id; with no actor_subject, a recipient who blocked this person gets nothing."
-- },
-- "sender_name": {
-- "type": [
-- "string",
-- "null"
-- ]
++ "description": "json: result.data; text: result.text; file or files: result.files (job result files)."
},
-- "sender_avatar": {
-- "type": [
-- "string",
-- "null"
++ "schema": {
++ "description": "JSON Schema of result.data, whatever the execution: for a job tool too, where it describes the data of the run's result and of its job's result (tools.job@1 result.data), which are the same object. Required when kind is json. Embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "$ref"
++ ],
++ "maxProperties": 1,
++ "properties": {
++ "$ref": {
++ "type": "string",
++ "format": "uri"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type"
++ ],
++ "properties": {
++ "type": {
++ "const": "object"
++ }
++ }
++ }
]
},
-- "service": {
-- "type": "string",
-- "description": "The sending service. A service token may only name its own (svc:live → live); anything else is 403 capability.owner_denied."
++ "mime": {
++ "type": "array",
++ "minItems": 1,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$"
++ },
++ "description": "Media types the result files can have (kind file or files)."
++ }
++ },
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "json"
++ }
},
-- "url": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Where the notification leads."
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "schema"
++ ]
++ }
++ },
++ "limits": {
++ "type": "object",
++ "required": [
++ "timeoutMs"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "timeoutMs": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 3600000,
++ "description": "A run (or its job) longer than this fails with tools.job.timeout or 504."
},
-- "priority": {
-- "type": "string",
-- "description": "low, normal, high or critical (openvibe-shared PRIORITY); default the type's, else normal."
++ "maxDurationSec": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Longest audio or video input."
},
-- "category": {
-- "type": "string",
-- "description": "Default: the type's category, else system. A recipient who switched the category off gets nothing; moderation, system and admin notices are never hidden by a block."
++ "maxPixels": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Largest image input (width × height)."
},
-- "rich_content": {
-- "description": "Any JSON value, stored and returned as given."
++ "maxPages": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Most PDF pages. A longer document fails with 413 tools.pdf.too_many_pages."
},
-- "expires_at": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "When it may be deleted (SQLite datetime, UTC)."
++ "maxInputBytes": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Largest input as JSON (text to transform, lists of hosts…)."
},
-- "actor_subject": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^usr_",
-- "description": "The actor as a usr_ subject for the block check; null: no known person (no block check)."
++ "perTargetPerMinute": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Egress tools: most runs per minute against one target host or address, across all callers."
}
}
-- }
-- ]
-- }
-- },
-- "network.notification-push-result": {
-- "version": "1.0.0",
-- "owner": "network",
-- "status": "active",
-- "schema": {
-- "$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/network/notification-push-result.v1.json",
-- "title": "NetworkNotificationPushResult",
-- "description": "network.notification-push-result@1: answers of network.notifications.push on OpenVibe.Network. POST /internal/notifications/push → { ok: true, notification } (the stored notification), or { ok: true, skipped: true, reason } when nothing was created (the recipient switched the category off, blocked the actor, or already got a go-live notification from the same streamer within the hour). POST /internal/notifications/push-bulk → { ok: true, sent, total } (sent: notifications created; total: recipients named). Errors are { error } bodies (400, 500; 503 when the notification service is not running).",
-- "anyOf": [
-- {
++ },
++ "auth": {
"type": "object",
"required": [
-- "ok",
-- "notification"
++ "anonymous",
++ "capability"
],
++ "additionalProperties": false,
++ "description": "Caller tiers: anonymous < session < user < app/service.",
"properties": {
-- "ok": {
-- "const": true
++ "anonymous": {
++ "type": "boolean",
++ "description": "true: a caller with no token and no browser session may run it (keyed by IP, IPv6 by /64). false: a browser session (the ov_tools_jobs cookie), a signed-in person or a token is needed."
},
-- "notification": {
-- "type": "object",
-- "required": [
-- "id",
-- "user_id",
-- "category",
-- "priority",
-- "title",
-- "icon",
-- "is_read",
-- "created_at"
++ "capability": {
++ "enum": [
++ "tools.tool.run",
++ "tools.net.probe"
],
++ "description": "What an app or service token must hold. tools.tool.run is public. tools.net.probe (partner) covers network probes: only principals holding it run these through the API; people use their pages, which have a per-target throttle."
++ }
++ }
++ },
++ "quotaClass": {
++ "type": "string",
++ "pattern": "^tools-[a-z0-9]+(-[a-z0-9]+)*$",
++ "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names. A caller whose allowance is spent gets 429 tools.quota.exceeded with Retry-After."
++ },
++ "cost": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 1000,
++ "description": "Relative weight of one run within its quota class (1 = a cheap lookup or transform). Quotas count weight, not requests."
++ },
++ "egress": {
++ "type": "boolean",
++ "description": "The server fetches a host or URL the caller chose (DNS, WHOIS, headers, Open Graph, ports…), always through the SSRF guard."
++ },
++ "hosts": {
++ "type": "array",
++ "uniqueItems": true,
++ "maxItems": 50,
++ "items": {
++ "type": "string",
++ "pattern": "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$"
++ },
++ "description": "The tool's public hostnames, canonical first (png.openvibe.tools, a custom domain…); empty for a tool that lives on another service."
++ },
++ "docs": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://",
++ "description": "Where a person reads about the tool."
++ }
++ },
++ "if": {
++ "required": [
++ "api"
++ ]
++ },
++ "then": {
++ "$comment": "The RULES in the description, as one conditional so generated types stay plain (every descriptor has api).",
++ "allOf": [
++ {
++ "if": {
"properties": {
-- "id": {
-- "type": "string",
-- "format": "uuid"
++ "api": {
++ "const": false
++ }
++ },
++ "required": [
++ "api"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
++ "type": "null"
},
-- "user_id": {
-- "type": [
-- "integer",
-- "string"
++ "examples": false
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "api": {
++ "const": true
++ }
++ },
++ "required": [
++ "api"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
++ "type": "object"
++ },
++ "input": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "api": {
++ "const": true
++ },
++ "execution": {
++ "const": "job"
++ }
++ },
++ "required": [
++ "api",
++ "execution"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
++ "type": "object",
++ "properties": {
++ "job": {
++ "type": "object"
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "execution": {
++ "enum": [
++ "client",
++ "sync"
]
-- },
-- "type": {
-- "type": "string"
-- },
-- "category": {
-- "type": "string"
-- },
-- "priority": {
-- "type": "string"
-- },
-- "title": {
-- "type": "string"
-- },
-- "message": {
++ }
++ },
++ "required": [
++ "execution"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
"type": [
-- "string",
++ "object",
"null"
-- ]
-- },
-- "icon": {
-- "type": "string"
++ ],
++ "properties": {
++ "job": {
++ "type": "null"
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "api": {
++ "const": true
},
-- "service": {
-- "type": [
-- "string",
-- "null"
++ "output": {
++ "type": "object",
++ "properties": {
++ "kind": {
++ "enum": [
++ "file",
++ "files"
++ ]
++ }
++ },
++ "required": [
++ "kind"
]
-- },
-- "url": {
-- "type": [
-- "string",
-- "null"
++ }
++ },
++ "required": [
++ "api",
++ "output"
++ ]
++ },
++ "then": {
++ "properties": {
++ "execution": {
++ "const": "job"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "auth": {
++ "type": "object",
++ "properties": {
++ "capability": {
++ "const": "tools.net.probe"
++ }
++ },
++ "required": [
++ "capability"
]
++ }
++ },
++ "required": [
++ "auth"
++ ]
++ },
++ "then": {
++ "properties": {
++ "egress": {
++ "const": true
},
-- "rich_content": {
-- "description": "As sent."
-- },
-- "is_read": {
-- "const": 0
-- },
-- "created_at": {
-- "type": "string",
-- "format": "date-time"
++ "auth": {
++ "type": "object",
++ "properties": {
++ "anonymous": {
++ "const": false
++ }
++ }
}
}
}
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "ok",
-- "skipped",
-- "reason"
-- ],
-- "properties": {
-- "ok": {
-- "const": true
-- },
-- "skipped": {
-- "const": true
++ },
++ {
++ "if": {
++ "properties": {
++ "egress": {
++ "const": true
++ },
++ "auth": {
++ "type": "object",
++ "properties": {
++ "anonymous": {
++ "const": true
++ }
++ },
++ "required": [
++ "anonymous"
++ ]
++ }
++ },
++ "required": [
++ "egress",
++ "auth"
++ ]
},
-- "reason": {
-- "type": "string"
++ "then": {
++ "properties": {
++ "limits": {
++ "type": "object",
++ "required": [
++ "perTargetPerMinute"
++ ]
++ }
++ }
}
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "ok",
-- "sent",
-- "total"
-- ],
-- "properties": {
-- "ok": {
-- "const": true
++ },
++ {
++ "if": {
++ "properties": {
++ "execution": {
++ "const": "client"
++ }
++ },
++ "required": [
++ "execution"
++ ]
},
-- "sent": {
-- "type": "integer",
-- "minimum": 0
++ "then": {
++ "properties": {
++ "egress": {
++ "const": false
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "status": {
++ "const": "unavailable"
++ }
++ },
++ "required": [
++ "status"
++ ]
},
-- "total": {
-- "type": "integer",
-- "minimum": 1
++ "then": {
++ "required": [
++ "statusReason"
++ ]
}
}
-- }
-- ]
++ ]
++ }
}
},
-- "network.staff-list-result": {
++ "tools.tool-list": {
"version": "1.0.0",
-- "owner": "network",
++ "owner": "tools",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/network/staff-list-result.v1.json",
-- "title": "NetworkStaffListResult",
-- "description": "network.staff-list-result@1: the answer of network.staff.read on OpenVibe.Network. GET /api/v1/staff/moderators[?service=chat|live|community|pastes|calls] (a service token holding network.staff.read, or a signed-in person holding staff.moderation.logs) → { service, staff }: the network's global moderators, admins and the owner who are not banned, with the staff capabilities their role holds in the contracts staff map; ?service keeps those holding the capability that moderates that service (another service name keeps those holding staff.content.moderate; an invalid value is ignored and answered as null). Answered with Cache-Control: private, no-store; a person without staff.moderation.logs gets 403 { error: forbidden }.",
++ "$id": "https://openvibe.network/contracts/tools/tool-list.v1.json",
++ "title": "ToolList",
++ "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (every word must appear in the id, name, summary or keywords); family, execution and status take comma lists. An unknown execution or status value, or an api that is not true or false, is 400 tools.query.invalid. Every listed tool has one of the descriptor's statuses: there is no planned status, so planned placeholders are not tools and are not listed, and neither are mirrors (GET /api/v1/tools/:id answers 404 tools.tool.not_found for both, with a detail saying why). Cacheable (ETag); updated_at changes when any descriptor does.",
"type": "object",
"required": [
-- "service",
-- "staff"
++ "tools",
++ "count",
++ "updated_at"
],
++ "additionalProperties": false,
"properties": {
-- "service": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "The service filter applied, or null."
++ "tools": {
++ "type": "array",
++ "items": {
++ "$ref": "tool.v1.json"
++ }
},
-- "staff": {
++ "count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "The number of tools in this answer."
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "families": {
"type": "array",
++ "description": "The families of the tools in this answer.",
"items": {
"type": "object",
"required": [
-- "subject",
-- "username",
-- "display_name",
-- "role",
-- "is_owner",
-- "capabilities"
++ "id",
++ "name",
++ "count"
],
++ "additionalProperties": false,
"properties": {
-- "subject": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^usr_",
-- "description": "null for an account that has no subject yet."
-- },
-- "username": {
-- "type": "string"
-- },
-- "display_name": {
-- "type": "string"
-- },
-- "role": {
-- "enum": [
-- "global_mod",
-- "admin",
-- "owner"
-- ]
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
},
-- "is_owner": {
-- "type": "boolean"
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
},
-- "capabilities": {
-- "type": "array",
-- "items": {
-- "type": "string",
-- "pattern": "^staff\\."
-- },
-- "description": "Staff capability ids, sorted."
++ "count": {
++ "type": "integer",
++ "minimum": 0
}
}
}
@@@ -22257,1889 +24446,1975 @@
}
}
},
-- "tools.tool": {
-- "version": "1.1.0",
++ "tools.run-request": {
++ "version": "1.0.0",
"owner": "tools",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/tools/tool.v1.json",
-- "title": "ToolDescriptor",
-- "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. When api is false, $defs.input is false (the schema that accepts nothing), since there is no run to take an input. OUTPUT.SCHEMA describes result.data for every execution: inline and job tools alike. KEYWORDS (the catalogue's search terms) and EXAMPLES (sample runs for the docs and the OpenAPI document) are optional. NOT A TOOL: there is no planned status. A planned catalogue entry has no descriptor and is not listed, and neither is a mirror (a second build of another tool). GET /api/v1/tools/:id and /schema answer 404 tools.tool.not_found for either, with a detail that says the id is planned or names the tool it mirrors, as for an id that does not exist. RULES this schema enforces: api false has no run and no examples, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets), files.min <= files.max, and each example: its input against an embedded input schema and limits.maxInputBytes, and its files against files (count and accept).",
++ "$id": "https://openvibe.network/contracts/tools/run-request.v1.json",
++ "title": "ToolsRunRequest",
++ "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 tools.quota.exceeded (Retry-After; the caller's allowance in the tool's quota class is spent), quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable (the descriptor's status is unavailable), 503 tools.unavailable (a program or upstream the tool needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A job tool's own refusals (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…) come back as a failed run (tools.run@1 error).",
"type": "object",
-- "required": [
-- "id",
-- "family",
-- "name",
-- "summary",
-- "status",
-- "execution",
-- "api",
-- "run",
-- "input",
-- "files",
-- "output",
-- "limits",
-- "auth",
-- "quotaClass",
-- "cost",
-- "egress",
-- "hosts",
-- "docs"
-- ],
"additionalProperties": false,
"properties": {
-- "id": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{0,39}$",
-- "description": "The tool's catalogue id, which is its subdomain label (png, jsminify, dns, pdf2jpg). It never changes."
-- },
-- "family": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{1,39}$",
-- "description": "The catalogue family: net, dev, img, audio, docs, text, media, places, pastes…"
-- },
-- "name": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 80
-- },
-- "summary": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 200,
-- "description": "One plain sentence: what the tool does."
++ "input": {
++ "type": "object",
++ "description": "The tool's parameters, matching its input schema. Default {}."
},
-- "keywords": {
++ "files": {
"type": "array",
-- "uniqueItems": true,
"maxItems": 50,
++ "description": "Files that are already somewhere, instead of or besides uploaded parts.",
"items": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 100,
-- "pattern": "^\\S(.*\\S)?$"
-- },
-- "description": "Optional. The catalogue's search terms for the tool (GET /api/catalog.json tools[].keywords): phrases people search for, such as 'heic to png' or 'yaml validator'. GET /api/v1/tools?q= matches every word of the query against the id, name, summary and these."
-- },
-- "status": {
-- "enum": [
-- "stable",
-- "beta",
-- "preview",
-- "unavailable"
-- ],
-- "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason); a program or upstream the engine needs is missing (qpdf, pdftoppm, heif-dec, ffmpeg…), and the engine's own routes answer 503 tools.unavailable while it is. There is no planned status: a planned catalogue entry is not a tool, so it has no descriptor and is not listed."
-- },
-- "statusReason": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 300,
-- "description": "Why the status is what it is, for people (e.g. 'needs qpdf on the host'). Required when unavailable."
-- },
-- "execution": {
-- "enum": [
-- "client",
-- "sync",
-- "job"
-- ],
-- "description": "Where the engine runs for the tool's page: client (the browser), sync (a server request answered inline), job (an asynchronous job)."
-- },
-- "api": {
-- "type": "boolean",
-- "description": "POST /api/v1/tools/{id}/run exposes the tool. false for a client tool without a server engine and for page-only tools (yt)."
-- },
-- "run": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "required": [
-- "method",
-- "path",
-- "job"
-- ],
-- "additionalProperties": false,
-- "description": "How to call it; null when api is false.",
-- "properties": {
-- "method": {
-- "const": "POST"
-- },
-- "path": {
-- "type": "string",
-- "pattern": "^/api/v1/tools/[a-z][a-z0-9-]{0,39}/run$",
-- "description": "/api/v1/tools/{id}/run on https://openvibe.tools; the body is tools.run-request@1, the answer tools.run@1."
-- },
-- "job": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "required": [
-- "type",
-- "operation"
-- ],
-- "additionalProperties": false,
-- "description": "For a job tool: the job the run creates. null for tools answered inline.",
-- "properties": {
-- "type": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
-- "maxLength": 80,
-- "description": "Job type, e.g. img.process."
-- },
-- "operation": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_-]{0,39}$",
-- "description": "The job input's tool field: convert, compress, trim, mergepdf…"
-- },
-- "preset": {
-- "type": "object",
-- "description": "Input fields the tool itself fixes, applied over the caller's input (png: { format: png })."
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "media_id"
++ ],
++ "additionalProperties": false,
++ "description": "A Media object the caller may read. Tools reads it for the caller.",
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
}
-- }
-- },
-- "legacy": {
-- "type": "array",
-- "uniqueItems": true,
-- "maxItems": 32,
-- "items": {
-- "type": "string",
-- "pattern": "^(GET|POST|PUT|DELETE) /[^\\s]*$"
},
-- "description": "Older routes that run the same engine (GET /api/net/dns/:target, POST /api/process…). They keep working until their Sunset date and answer with Deprecation, Sunset and Link: rel=successor-version pointing at path."
-- }
-- }
-- },
-- "input": {
-- "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API, whose $defs/input is false (it accepts nothing).",
-- "anyOf": [
-- {
-- "type": "object",
-- "required": [
-- "$ref"
-- ],
-- "maxProperties": 1,
-- "properties": {
-- "$ref": {
-- "type": "string",
-- "format": "uri"
-- }
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "type"
-- ],
-- "properties": {
-- "type": {
-- "const": "object"
++ {
++ "type": "object",
++ "required": [
++ "job_id",
++ "index"
++ ],
++ "additionalProperties": false,
++ "description": "Result file index of a job the same caller owns (GET /api/v1/jobs/:id/files/:n), so one tool's output feeds the next.",
++ "properties": {
++ "job_id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "index": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 99
++ }
}
}
-- },
-- {
-- "type": "null"
-- }
-- ]
++ ]
++ }
},
-- "files": {
-- "type": [
-- "object",
-- "null"
-- ],
++ "wait_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 60000,
++ "description": "Job tools: wait up to this long for the job to finish before answering (200 finished, else 202 with the job). Default 0. Inline tools always answer finished."
++ },
++ "idempotency_key": {
++ "type": "string",
++ "pattern": "^[!-~]{8,200}$",
++ "description": "Job tools: the same (caller, key) and request returns the same job; a different request under it is 409 tools.job.idempotency_conflict. Inline tools ignore it."
++ }
++ }
++ }
++ },
++ "tools.run": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/run.v1.json",
++ "title": "ToolsRun",
++ "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password; its status is the problem's, not the HTTP answer's. A request refused before the tool ran is a problem+json answer, not a run. Also: 413 tools.input.too_large (the input exceeded a worker's memory or size limit), 504 tools.run.timeout (the run passed the descriptor's timeoutMs), 403 tools.origin.refused (a cookie-authenticated mutation from a foreign Origin), 401 tools.session_required (the tool is not anonymous and the call has no browser session or token), 404 tools.run.file_not_found (a files reference names no result the caller may read), 500 tools.run.failed (the engine failed without a more specific code).",
++ "oneOf": [
++ {
++ "type": "object",
"required": [
-- "min",
-- "max",
-- "accept",
-- "maxBytes"
++ "state",
++ "tool",
++ "result",
++ "took_ms"
],
"additionalProperties": false,
-- "description": "The files a run takes (multipart parts, or tools.run-request@1 files references); null when it takes none.",
"properties": {
-- "min": {
-- "type": "integer",
-- "minimum": 0,
-- "maximum": 100
-- },
-- "max": {
-- "type": "integer",
-- "minimum": 1,
-- "maximum": 100
++ "state": {
++ "const": "succeeded"
},
-- "accept": {
-- "type": "array",
-- "minItems": 1,
-- "uniqueItems": true,
-- "items": {
-- "type": "string",
-- "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$"
-- },
-- "description": "Accepted media types, checked against the bytes, not the client's Content-Type (image/png, image/*, application/pdf…)."
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
},
-- "maxBytes": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Largest accepted file."
-- }
-- }
-- },
-- "examples": {
-- "type": "array",
-- "maxItems": 50,
-- "description": "Optional. Sample runs, which the docs and the generated OpenAPI document publish. Each example's input is valid against the tool's input schema and fits limits.maxInputBytes, and its files fit files (count and accept); contracts.tools.checkDescriptor checks this whenever input is embedded. Only a tool with an API has examples.",
-- "items": {
-- "type": "object",
-- "required": [
-- "input"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "title": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 120,
-- "description": "What the example shows, for people."
-- },
-- "input": {
-- "type": "object",
-- "description": "A run request's input (tools.run-request@1 input), valid against the tool's input schema."
-- },
-- "files": {
-- "type": "array",
-- "maxItems": 100,
-- "description": "The files the sample run takes (uploaded parts or files references), in order: what each one is, and where to download a sample.",
-- "items": {
++ "result": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "data": {
"type": "object",
-- "required": [
-- "mime"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "name": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 200,
-- "description": "A file name for the sample (photo.jpg)."
-- },
-- "mime": {
-- "type": "string",
-- "pattern": "^[a-z]+/[a-z0-9][a-z0-9.+-]*$",
-- "description": "Its media type, one that files.accept allows."
-- },
-- "url": {
-- "type": "string",
-- "format": "uri",
-- "pattern": "^https://",
-- "description": "A sample file to download."
++ "description": "Output data; matches the descriptor's output.schema when output.kind is json."
++ },
++ "text": {
++ "type": "string",
++ "description": "Output text (output.kind text)."
++ },
++ "files": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "Result files (output.kind file or files), exactly as the job lists them.",
++ "items": {
++ "type": "object",
++ "required": [
++ "name",
++ "mime",
++ "size",
++ "sha256",
++ "storage",
++ "media",
++ "url"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "mime": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "size": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "sha256": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "storage": {
++ "enum": [
++ "local",
++ "media"
++ ],
++ "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires."
++ },
++ "media": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "media_id"
++ ],
++ "additionalProperties": false,
++ "description": "The Media object, when storage is media.",
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "role": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$"
++ },
++ "namespace": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,63}$"
++ },
++ "size_bytes": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "content_hash": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "mime_type": {
++ "type": "string",
++ "maxLength": 200
++ },
++ "status": {
++ "type": "string",
++ "maxLength": 40,
++ "description": "The object's Media lifecycle status when it was stored (ready)."
++ }
++ }
++ },
++ "media_error": {
++ "type": "string",
++ "maxLength": 500,
++ "description": "Present when storing the file in Media failed; the file stayed local."
++ },
++ "url": {
++ "type": "string",
++ "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$",
++ "description": "Where the owner downloads it."
++ }
}
}
}
}
++ },
++ "took_ms": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "job": {
++ "oneOf": [
++ {
++ "$ref": "job.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
}
}
},
-- "output": {
++ {
"type": "object",
"required": [
-- "kind"
++ "state",
++ "tool",
++ "error",
++ "took_ms"
],
"additionalProperties": false,
"properties": {
-- "kind": {
++ "state": {
"enum": [
-- "json",
-- "file",
-- "files",
-- "text"
-- ],
-- "description": "json: result.data; text: result.text; file or files: result.files (job result files)."
++ "failed",
++ "cancelled"
++ ]
},
-- "schema": {
-- "description": "JSON Schema of result.data, whatever the execution: for a job tool too, where it describes the data of the run's result and of its job's result (tools.job@1 result.data), which are the same object. Required when kind is json. Embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
-- "anyOf": [
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "error": {
++ "$ref": "../errors/problem.v1.json"
++ },
++ "took_ms": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "job": {
++ "oneOf": [
{
-- "type": "object",
-- "required": [
-- "$ref"
-- ],
-- "maxProperties": 1,
-- "properties": {
-- "$ref": {
-- "type": "string",
-- "format": "uri"
-- }
-- }
++ "$ref": "job.v1.json"
},
{
-- "type": "object",
-- "required": [
-- "type"
-- ],
-- "properties": {
-- "type": {
-- "const": "object"
-- }
-- }
++ "type": "null"
}
]
-- },
-- "mime": {
-- "type": "array",
-- "minItems": 1,
-- "uniqueItems": true,
-- "items": {
-- "type": "string",
-- "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$"
-- },
-- "description": "Media types the result files can have (kind file or files)."
}
-- },
-- "if": {
-- "properties": {
-- "kind": {
-- "const": "json"
-- }
-- },
-- "required": [
-- "kind"
-- ]
-- },
-- "then": {
-- "required": [
-- "schema"
-- ]
}
},
-- "limits": {
++ {
"type": "object",
"required": [
-- "timeoutMs"
++ "state",
++ "tool",
++ "job",
++ "location"
],
"additionalProperties": false,
"properties": {
-- "timeoutMs": {
-- "type": "integer",
-- "minimum": 1,
-- "maximum": 3600000,
-- "description": "A run (or its job) longer than this fails with tools.job.timeout or 504."
-- },
-- "maxDurationSec": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Longest audio or video input."
-- },
-- "maxPixels": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Largest image input (width × height)."
++ "state": {
++ "enum": [
++ "queued",
++ "running"
++ ]
},
-- "maxPages": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Most PDF pages. A longer document fails with 413 tools.pdf.too_many_pages."
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
},
-- "maxInputBytes": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Largest input as JSON (text to transform, lists of hosts…)."
++ "job": {
++ "$ref": "job.v1.json"
},
-- "perTargetPerMinute": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "Egress tools: most runs per minute against one target host or address, across all callers."
++ "location": {
++ "type": "string",
++ "format": "uri-reference",
++ "pattern": "/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job (also the Location header): /api/v1/jobs/:id on the gateway."
}
}
++ }
++ ]
++ }
++ },
++ "tools.job": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/job.v1.json",
++ "title": "ToolsJob",
++ "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password, with server paths removed.",
++ "type": "object",
++ "required": [
++ "id",
++ "object",
++ "service",
++ "type",
++ "type_version",
++ "state",
++ "progress",
++ "attempts",
++ "max_attempts",
++ "cancel_requested",
++ "created_at",
++ "started_at",
++ "finished_at",
++ "expires_at",
++ "result",
++ "error",
++ "retryable",
++ "retry_of",
++ "retried_by",
++ "references",
++ "links"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "auth": {
++ "object": {
++ "const": "tools.job"
++ },
++ "service": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "The Tools satellite that runs the job (img, audio, docs…)."
++ },
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "The tool (tools.tool@1 id) whose run created the job, when it came through POST /api/v1/tools/{id}/run. Absent for a job submitted to POST /api/v1/jobs directly."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type, e.g. img.process."
++ },
++ "type_version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "state": {
++ "enum": [
++ "queued",
++ "running",
++ "succeeded",
++ "failed",
++ "cancelled"
++ ],
++ "description": "Terminal: succeeded, failed, cancelled."
++ },
++ "progress": {
"type": "object",
"required": [
-- "anonymous",
-- "capability"
++ "percent",
++ "message"
],
"additionalProperties": false,
-- "description": "Caller tiers: anonymous < session < user < app/service.",
"properties": {
-- "anonymous": {
-- "type": "boolean",
-- "description": "true: a caller with no token and no browser session may run it (keyed by IP, IPv6 by /64). false: a browser session (the ov_tools_jobs cookie), a signed-in person or a token is needed."
++ "percent": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "minimum": 0,
++ "maximum": 100
},
-- "capability": {
-- "enum": [
-- "tools.tool.run",
-- "tools.net.probe"
++ "message": {
++ "type": [
++ "string",
++ "null"
],
-- "description": "What an app or service token must hold. tools.tool.run is public. tools.net.probe (partner) covers network probes: only principals holding it run these through the API; people use their pages, which have a per-target throttle."
++ "maxLength": 500
}
}
},
-- "quotaClass": {
-- "type": "string",
-- "pattern": "^tools-[a-z0-9]+(-[a-z0-9]+)*$",
-- "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names. A caller whose allowance is spent gets 429 tools.quota.exceeded with Retry-After."
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
},
-- "cost": {
++ "max_attempts": {
"type": "integer",
-- "minimum": 1,
-- "maximum": 1000,
-- "description": "Relative weight of one run within its quota class (1 = a cheap lookup or transform). Quotas count weight, not requests."
++ "minimum": 1
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "DELETE asked a running job to stop; it ends cancelled."
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
},
-- "egress": {
-- "type": "boolean",
-- "description": "The server fetches a host or URL the caller chose (DNS, WHOIS, headers, Open Graph, ports…), always through the SSRF guard."
++ "finished_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
},
-- "hosts": {
-- "type": "array",
-- "uniqueItems": true,
-- "maxItems": 50,
-- "items": {
-- "type": "string",
-- "pattern": "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$"
-- },
-- "description": "The tool's public hostnames, canonical first (png.openvibe.tools, a custom domain…); empty for a tool that lives on another service."
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "When the job, its events and its result files are pruned; null while a reference keeps the result."
},
-- "docs": {
-- "type": "string",
-- "format": "uri",
-- "pattern": "^https://",
-- "description": "Where a person reads about the tool."
-- }
-- },
-- "if": {
-- "required": [
-- "api"
-- ]
-- },
-- "then": {
-- "$comment": "The RULES in the description, as one conditional so generated types stay plain (every descriptor has api).",
-- "allOf": [
-- {
-- "if": {
-- "properties": {
-- "api": {
-- "const": false
-- }
-- },
-- "required": [
-- "api"
-- ]
-- },
-- "then": {
-- "properties": {
-- "run": {
-- "type": "null"
-- },
-- "examples": false
-- }
-- }
-- },
-- {
-- "if": {
-- "properties": {
-- "api": {
-- "const": true
-- }
-- },
-- "required": [
-- "api"
-- ]
-- },
-- "then": {
-- "properties": {
-- "run": {
-- "type": "object"
-- },
-- "input": {
-- "type": "object"
-- }
-- }
-- }
-- },
-- {
-- "if": {
-- "properties": {
-- "api": {
-- "const": true
-- },
-- "execution": {
-- "const": "job"
-- }
-- },
-- "required": [
-- "api",
-- "execution"
-- ]
-- },
-- "then": {
-- "properties": {
-- "run": {
-- "type": "object",
-- "properties": {
-- "job": {
-- "type": "object"
-- }
-- }
-- }
-- }
-- }
-- },
-- {
-- "if": {
-- "properties": {
-- "execution": {
-- "enum": [
-- "client",
-- "sync"
-- ]
-- }
-- },
-- "required": [
-- "execution"
-- ]
-- },
-- "then": {
-- "properties": {
-- "run": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "properties": {
-- "job": {
-- "type": "null"
-- }
-- }
-- }
-- }
-- }
-- },
-- {
-- "if": {
-- "properties": {
-- "api": {
-- "const": true
-- },
-- "output": {
-- "type": "object",
-- "properties": {
-- "kind": {
-- "enum": [
-- "file",
-- "files"
-- ]
-- }
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "files",
++ "data"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "files": {
++ "type": "array",
++ "maxItems": 100,
++ "items": {
++ "type": "object",
++ "required": [
++ "name",
++ "mime",
++ "size",
++ "sha256",
++ "storage",
++ "media",
++ "url"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
},
-- "required": [
-- "kind"
-- ]
-- }
-- },
-- "required": [
-- "api",
-- "output"
-- ]
-- },
-- "then": {
-- "properties": {
-- "execution": {
-- "const": "job"
-- }
-- }
-- }
-- },
-- {
-- "if": {
-- "properties": {
-- "auth": {
-- "type": "object",
-- "properties": {
-- "capability": {
-- "const": "tools.net.probe"
++ "mime": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "size": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "sha256": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "storage": {
++ "enum": [
++ "local",
++ "media"
++ ],
++ "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires."
++ },
++ "media": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "media_id"
++ ],
++ "additionalProperties": false,
++ "description": "The Media object, when storage is media.",
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "role": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$"
++ },
++ "namespace": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,63}$"
++ },
++ "size_bytes": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "content_hash": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "mime_type": {
++ "type": "string",
++ "maxLength": 200
++ },
++ "status": {
++ "type": "string",
++ "maxLength": 40,
++ "description": "The object's Media lifecycle status when it was stored (ready)."
++ }
}
},
-- "required": [
-- "capability"
-- ]
-- }
-- },
-- "required": [
-- "auth"
-- ]
-- },
-- "then": {
-- "properties": {
-- "egress": {
-- "const": true
-- },
-- "auth": {
-- "type": "object",
-- "properties": {
-- "anonymous": {
-- "const": false
-- }
++ "media_error": {
++ "type": "string",
++ "maxLength": 500,
++ "description": "Present when storing the file in Media failed; the file stayed local."
++ },
++ "url": {
++ "type": "string",
++ "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$",
++ "description": "Where the owner downloads it."
}
}
}
++ },
++ "data": {
++ "type": "object",
++ "description": "The tool's output data (dimensions, durations, page counts…); {} when it has none."
}
-- },
-- {
-- "if": {
-- "properties": {
-- "egress": {
-- "const": true
-- },
-- "auth": {
-- "type": "object",
-- "properties": {
-- "anonymous": {
-- "const": true
-- }
-- },
-- "required": [
-- "anonymous"
-- ]
-- }
-- },
-- "required": [
-- "egress",
-- "auth"
-- ]
++ }
++ },
++ "error": {
++ "oneOf": [
++ {
++ "$ref": "../errors/problem.v1.json"
},
-- "then": {
-- "properties": {
-- "limits": {
-- "type": "object",
-- "required": [
-- "perTargetPerMinute"
-- ]
-- }
-- }
++ {
++ "type": "null"
}
-- },
-- {
-- "if": {
-- "properties": {
-- "execution": {
-- "const": "client"
-- }
++ ]
++ },
++ "retryable": {
++ "type": "boolean",
++ "description": "Retrying may succeed (a timeout, a restart); POST links.retry makes a new job."
++ },
++ "retry_of": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The failed job this one retries."
++ },
++ "retried_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job that retried this failed one; asking again returns it."
++ },
++ "references": {
++ "type": "array",
++ "maxItems": 50,
++ "description": "What keeps the result (PUT /api/v1/jobs/:id/references/:ref), e.g. community:paste:p_123.",
++ "items": {
++ "type": "object",
++ "required": [
++ "ref",
++ "created_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "ref": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]*(?::[A-Za-z0-9_.-]+){1,4}$",
++ "maxLength": 200
},
-- "required": [
-- "execution"
-- ]
-- },
-- "then": {
-- "properties": {
-- "egress": {
-- "const": false
-- }
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
}
}
-- },
-- {
-- "if": {
-- "properties": {
-- "status": {
-- "const": "unavailable"
-- }
-- },
-- "required": [
-- "status"
-- ]
++ }
++ },
++ "links": {
++ "type": "object",
++ "required": [
++ "self",
++ "events",
++ "cancel",
++ "retry",
++ "retried_by"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "self": {
++ "type": "string",
++ "format": "uri-reference"
},
-- "then": {
-- "required": [
-- "statusReason"
-- ]
++ "events": {
++ "type": "string",
++ "format": "uri-reference"
++ },
++ "cancel": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri-reference",
++ "description": "DELETE it while the job is queued or running."
++ },
++ "retry": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri-reference",
++ "description": "POST it when the job failed."
++ },
++ "retried_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri-reference"
}
}
-- ]
++ }
}
}
},
-- "tools.tool-list": {
++ "tools.job-request": {
"version": "1.0.0",
"owner": "tools",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/tools/tool-list.v1.json",
-- "title": "ToolList",
-- "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (every word must appear in the id, name, summary or keywords); family, execution and status take comma lists. An unknown execution or status value, or an api that is not true or false, is 400 tools.query.invalid. Every listed tool has one of the descriptor's statuses: there is no planned status, so planned placeholders are not tools and are not listed, and neither are mirrors (GET /api/v1/tools/:id answers 404 tools.tool.not_found for both, with a detail saying why). Cacheable (ETag); updated_at changes when any descriptor does.",
++ "$id": "https://openvibe.network/contracts/tools/job-request.v1.json",
++ "title": "ToolsJobRequest",
++ "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner), tools.quota.exceeded (Retry-After; the owner's allowance is spent) or a rate limit, 503 tools.job.unavailable, 503 tools.unavailable (a program or upstream the job type needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A tool that refuses its input after the job was accepted fails the job with its own code (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…; tools.job@1 error).",
"type": "object",
"required": [
-- "tools",
-- "count",
-- "updated_at"
++ "type"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: img.process, audio.process, docs.process."
++ },
++ "input": {
++ "type": "object",
++ "description": "The job type's input; { tool: <operation>, …options }. Default {}; at most 16 KB as JSON."
++ },
++ "idempotency_key": {
++ "type": "string",
++ "pattern": "^[!-~]{8,200}$",
++ "description": "8-200 printable ASCII characters. The Idempotency-Key header is used when both are present."
++ }
++ }
++ }
++ },
++ "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": {
-- "tools": {
-- "type": "array",
-- "items": {
-- "$ref": "tool.v1.json"
++ "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)."
++ }
}
},
-- "count": {
++ "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",
-- "minimum": 0,
-- "description": "The number of tools in this answer."
++ "description": "action updated only: the Sources revision News held before."
},
-- "updated_at": {
++ "reason": {
"type": "string",
-- "format": "date-time"
++ "minLength": 1,
++ "maxLength": 500,
++ "description": "action removed only: why Sources removed it ('removed upstream' when it gave no reason)."
},
-- "families": {
-- "type": "array",
-- "description": "The families of the tools in this answer.",
-- "items": {
-- "type": "object",
-- "required": [
-- "id",
-- "name",
-- "count"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "id": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{1,39}$"
-- },
-- "name": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 80
-- },
-- "count": {
-- "type": "integer",
-- "minimum": 0
-- }
-- }
-- }
++ "origin": {
++ "enum": [
++ "webhook",
++ "pull"
++ ],
++ "description": "action removed only: whether the removal came as a sources.item.removed delivery or through the cursor pull."
}
}
}
},
-- "tools.run-request": {
++ "news.source.failed": {
"version": "1.0.0",
-- "owner": "tools",
++ "owner": "news",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/tools/run-request.v1.json",
-- "title": "ToolsRunRequest",
-- "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 tools.quota.exceeded (Retry-After; the caller's allowance in the tool's quota class is spent), quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable (the descriptor's status is unavailable), 503 tools.unavailable (a program or upstream the tool needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A job tool's own refusals (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…) come back as a failed run (tools.run@1 error).",
++ "$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": {
-- "input": {
-- "type": "object",
-- "description": "The tool's parameters, matching its input schema. Default {}."
++ "origin": {
++ "enum": [
++ "webhook",
++ "pull",
++ "sources"
++ ],
++ "description": "webhook: an Events delivery; pull: the cursor pull; sources: a failure Sources reported."
},
-- "files": {
-- "type": "array",
-- "maxItems": 50,
-- "description": "Files that are already somewhere, instead of or besides uploaded parts.",
-- "items": {
-- "oneOf": [
-- {
-- "type": "object",
-- "required": [
-- "media_id"
-- ],
-- "additionalProperties": false,
-- "description": "A Media object the caller may read. Tools reads it for the caller.",
-- "properties": {
-- "media_id": {
-- "type": "string",
-- "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
-- }
-- }
-- },
-- {
-- "type": "object",
-- "required": [
-- "job_id",
-- "index"
-- ],
-- "additionalProperties": false,
-- "description": "Result file index of a job the same caller owns (GET /api/v1/jobs/:id/files/:n), so one tool's output feeds the next.",
-- "properties": {
-- "job_id": {
-- "type": "string",
-- "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "index": {
-- "type": "integer",
-- "minimum": 0,
-- "maximum": 99
-- }
-- }
-- }
-- ]
-- }
++ "source_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "The Sources source, as given; null for a pull failure or when the delivery named none."
},
-- "wait_ms": {
-- "type": "integer",
-- "minimum": 0,
-- "maximum": 60000,
-- "description": "Job tools: wait up to this long for the job to finish before answering (200 finished, else 202 with the job). Default 0. Inline tools always answer finished."
++ "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."
},
-- "idempotency_key": {
-- "type": "string",
-- "pattern": "^[!-~]{8,200}$",
-- "description": "Job tools: the same (caller, key) and request returns the same job; a different request under it is 409 tools.job.idempotency_conflict. Inline tools ignore it."
++ "consecutive_failures": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "description": "origin sources only: the source's consecutive failed runs Sources reported, null when it reported none."
}
}
}
},
-- "tools.run": {
++ "news.cluster.updated": {
"version": "1.0.0",
-- "owner": "tools",
++ "owner": "news",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/tools/run.v1.json",
-- "title": "ToolsRun",
-- "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password; its status is the problem's, not the HTTP answer's. A request refused before the tool ran is a problem+json answer, not a run. Also: 413 tools.input.too_large (the input exceeded a worker's memory or size limit), 504 tools.run.timeout (the run passed the descriptor's timeoutMs), 403 tools.origin.refused (a cookie-authenticated mutation from a foreign Origin), 401 tools.session_required (the tool is not anonymous and the call has no browser session or token), 404 tools.run.file_not_found (a files reference names no result the caller may read), 500 tools.run.failed (the engine failed without a more specific code).",
-- "oneOf": [
-- {
-- "type": "object",
-- "required": [
-- "state",
-- "tool",
-- "result",
-- "took_ms"
++ "$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"
],
-- "additionalProperties": false,
-- "properties": {
-- "state": {
-- "const": "succeeded"
-- },
-- "tool": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{0,39}$"
-- },
-- "result": {
-- "type": "object",
-- "additionalProperties": false,
-- "properties": {
-- "data": {
-- "type": "object",
-- "description": "Output data; matches the descriptor's output.schema when output.kind is json."
-- },
-- "text": {
-- "type": "string",
-- "description": "Output text (output.kind text)."
-- },
-- "files": {
-- "type": "array",
-- "maxItems": 100,
-- "description": "Result files (output.kind file or files), exactly as the job lists them.",
-- "items": {
-- "type": "object",
-- "required": [
-- "name",
-- "mime",
-- "size",
-- "sha256",
-- "storage",
-- "media",
-- "url"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "name": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 200
-- },
-- "mime": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 200
-- },
-- "size": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "sha256": {
-- "type": "string",
-- "pattern": "^[0-9a-f]{64}$"
-- },
-- "storage": {
-- "enum": [
-- "local",
-- "media"
-- ],
-- "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires."
-- },
-- "media": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "required": [
-- "media_id"
-- ],
-- "additionalProperties": false,
-- "description": "The Media object, when storage is media.",
-- "properties": {
-- "media_id": {
-- "type": "string",
-- "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "role": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_]{1,39}$"
-- },
-- "namespace": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_.-]{0,63}$"
-- },
-- "size_bytes": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "content_hash": {
-- "type": "string",
-- "pattern": "^[0-9a-f]{64}$"
-- },
-- "mime_type": {
-- "type": "string",
-- "maxLength": 200
-- },
-- "status": {
-- "type": "string",
-- "maxLength": 40,
-- "description": "The object's Media lifecycle status when it was stored (ready)."
-- }
-- }
-- },
-- "media_error": {
-- "type": "string",
-- "maxLength": 500,
-- "description": "Present when storing the file in Media failed; the file stayed local."
-- },
-- "url": {
-- "type": "string",
-- "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$",
-- "description": "Where the owner downloads it."
-- }
-- }
-- }
-- }
-- }
-- },
-- "took_ms": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "job": {
-- "oneOf": [
-- {
-- "$ref": "job.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- }
-- }
++ "description": "The cluster's status after the change (in practice open: merged and dissolved clusters are not announced by their own event)."
},
-- {
-- "type": "object",
-- "required": [
-- "state",
-- "tool",
-- "error",
-- "took_ms"
++ "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"
],
-- "additionalProperties": false,
-- "properties": {
-- "state": {
-- "enum": [
-- "failed",
-- "cancelled"
-- ]
-- },
-- "tool": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{0,39}$"
-- },
-- "error": {
-- "$ref": "../errors/problem.v1.json"
-- },
-- "took_ms": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "job": {
-- "oneOf": [
-- {
-- "$ref": "job.v1.json"
-- },
-- {
-- "type": "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."
},
-- {
-- "type": "object",
-- "required": [
-- "state",
-- "tool",
-- "job",
-- "location"
++ "topic": {
++ "type": [
++ "string",
++ "null"
],
-- "additionalProperties": false,
-- "properties": {
-- "state": {
-- "enum": [
-- "queued",
-- "running"
-- ]
-- },
-- "tool": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{0,39}$"
-- },
-- "job": {
-- "$ref": "job.v1.json"
-- },
-- "location": {
-- "type": "string",
-- "format": "uri-reference",
-- "pattern": "/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The job (also the Location header): /api/v1/jobs/:id on the gateway."
-- }
-- }
++ "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."
}
-- ]
++ }
}
},
-- "tools.job": {
++ "news.story.flagged": {
"version": "1.0.0",
-- "owner": "tools",
++ "owner": "news",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/tools/job.v1.json",
-- "title": "ToolsJob",
-- "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), such as 413 tools.pdf.too_many_pages or 422 tools.pdf.wrong_password, with server paths removed.",
++ "$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": [
-- "id",
-- "object",
-- "service",
-- "type",
-- "type_version",
-- "state",
-- "progress",
-- "attempts",
-- "max_attempts",
-- "cancel_requested",
-- "created_at",
-- "started_at",
-- "finished_at",
-- "expires_at",
-- "result",
-- "error",
-- "retryable",
-- "retry_of",
-- "retried_by",
-- "references",
-- "links"
++ "flag_id",
++ "kind",
++ "status",
++ "source_item_id",
++ "pending_revision",
++ "published_revision"
],
"additionalProperties": false,
"properties": {
-- "id": {
++ "flag_id": {
"type": "string",
-- "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^flg_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The news_editorial_flags row."
},
-- "object": {
-- "const": "tools.job"
++ "kind": {
++ "enum": [
++ "source_updated",
++ "source_removed"
++ ]
},
-- "service": {
++ "status": {
++ "const": "open"
++ },
++ "source_item_id": {
"type": "string",
-- "pattern": "^[a-z][a-z0-9-]{1,39}$",
-- "description": "The Tools satellite that runs the job (img, audio, docs…)."
++ "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The News source item that changed or was removed."
},
-- "tool": {
++ "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",
-- "pattern": "^[a-z][a-z0-9-]{0,39}$",
-- "description": "The tool (tools.tool@1 id) whose run created the job, when it came through POST /api/v1/tools/{id}/run. Absent for a job submitted to POST /api/v1/jobs directly."
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048
},
-- "type": {
++ "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",
-- "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
-- "maxLength": 80,
-- "description": "Job type, e.g. img.process."
-- },
-- "type_version": {
-- "type": "integer",
-- "minimum": 1
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048
},
-- "state": {
-- "enum": [
-- "queued",
-- "running",
-- "succeeded",
-- "failed",
-- "cancelled"
-- ],
-- "description": "Terminal: succeeded, failed, cancelled."
++ "publication_state": {
++ "const": "published"
},
-- "progress": {
++ "indexability": {
"type": "object",
"required": [
-- "percent",
-- "message"
++ "decision",
++ "reasons"
],
"additionalProperties": false,
"properties": {
-- "percent": {
-- "type": [
-- "number",
-- "null"
-- ],
-- "minimum": 0,
-- "maximum": 100
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
},
-- "message": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 500
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
++ }
}
}
},
-- "attempts": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "max_attempts": {
-- "type": "integer",
-- "minimum": 1
-- },
-- "cancel_requested": {
-- "type": "boolean",
-- "description": "DELETE asked a running job to stop; it ends cancelled."
-- },
-- "created_at": {
++ "topic": {
"type": "string",
-- "format": "date-time"
-- },
-- "started_at": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "format": "date-time"
-- },
-- "finished_at": {
++ "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"
],
-- "format": "date-time"
++ "description": "null: the event carries a tombstone, which has no URL."
},
-- "expires_at": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "format": "date-time",
-- "description": "When the job, its events and its result files are pruned; null while a reference keeps the result."
++ "publication_state": {
++ "const": "unpublished"
},
-- "result": {
++ "indexability": {
"type": [
"object",
"null"
],
"required": [
-- "files",
-- "data"
++ "decision",
++ "reasons"
],
"additionalProperties": false,
"properties": {
-- "files": {
++ "decision": {
++ "enum": [
++ "index",
++ "noindex"
++ ]
++ },
++ "reasons": {
"type": "array",
-- "maxItems": 100,
++ "maxItems": 20,
++ "uniqueItems": true,
"items": {
-- "type": "object",
-- "required": [
-- "name",
-- "mime",
-- "size",
-- "sha256",
-- "storage",
-- "media",
-- "url"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "name": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 200
-- },
-- "mime": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 200
-- },
-- "size": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "sha256": {
-- "type": "string",
-- "pattern": "^[0-9a-f]{64}$"
-- },
-- "storage": {
-- "enum": [
-- "local",
-- "media"
-- ],
-- "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires."
-- },
-- "media": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "required": [
-- "media_id"
-- ],
-- "additionalProperties": false,
-- "description": "The Media object, when storage is media.",
-- "properties": {
-- "media_id": {
-- "type": "string",
-- "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "role": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_]{1,39}$"
-- },
-- "namespace": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_.-]{0,63}$"
-- },
-- "size_bytes": {
-- "type": "integer",
-- "minimum": 0
-- },
-- "content_hash": {
-- "type": "string",
-- "pattern": "^[0-9a-f]{64}$"
-- },
-- "mime_type": {
-- "type": "string",
-- "maxLength": 200
-- },
-- "status": {
-- "type": "string",
-- "maxLength": 40,
-- "description": "The object's Media lifecycle status when it was stored (ready)."
-- }
-- }
-- },
-- "media_error": {
-- "type": "string",
-- "maxLength": 500,
-- "description": "Present when storing the file in Media failed; the file stayed local."
-- },
-- "url": {
-- "type": "string",
-- "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$",
-- "description": "Where the owner downloads it."
-- }
-- }
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
}
-- },
-- "data": {
-- "type": "object",
-- "description": "The tool's output data (dimensions, durations, page counts…); {} when it has none."
}
-- }
-- },
-- "error": {
-- "oneOf": [
-- {
-- "$ref": "../errors/problem.v1.json"
-- },
-- {
-- "type": "null"
-- }
-- ]
-- },
-- "retryable": {
-- "type": "boolean",
-- "description": "Retrying may succeed (a timeout, a restart); POST links.retry makes a new job."
++ },
++ "description": "The gate's decision for the unpublished story (noindex, not_published), or null when its published revision cannot be read."
},
-- "retry_of": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The failed job this one retries."
++ "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."
},
-- "retried_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The job that retried this failed one; asking again returns it."
++ "publication_state": {
++ "const": "retracted"
},
-- "references": {
-- "type": "array",
-- "maxItems": 50,
-- "description": "What keeps the result (PUT /api/v1/jobs/:id/references/:ref), e.g. community:paste:p_123.",
-- "items": {
-- "type": "object",
-- "required": [
-- "ref",
-- "created_at"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "ref": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_-]*(?::[A-Za-z0-9_.-]+){1,4}$",
-- "maxLength": 200
++ "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"
},
-- "created_at": {
++ "items": {
"type": "string",
-- "format": "date-time"
++ "pattern": "^[a-z][a-z0-9_]{1,63}$"
}
}
}
},
-- "links": {
++ "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": [
-- "self",
-- "events",
-- "cancel",
-- "retry",
-- "retried_by"
++ "owner",
++ "type",
++ "deleted"
],
-- "additionalProperties": false,
"properties": {
-- "self": {
-- "type": "string",
-- "format": "uri-reference"
-- },
-- "events": {
-- "type": "string",
-- "format": "uri-reference"
++ "owner": {
++ "const": "news"
},
-- "cancel": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "format": "uri-reference",
-- "description": "DELETE it while the job is queued or running."
++ "type": {
++ "const": "story"
},
-- "retry": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "format": "uri-reference",
-- "description": "POST it when the job failed."
++ "id": {
++ "type": "string",
++ "pattern": "^sty_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "retried_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "format": "uri-reference"
++ "deleted": {
++ "const": false
}
}
}
-- }
++ ]
}
},
-- "tools.job-request": {
++ "news.index_document.deleted": {
"version": "1.0.0",
-- "owner": "tools",
++ "owner": "news",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/tools/job-request.v1.json",
-- "title": "ToolsJobRequest",
-- "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner), tools.quota.exceeded (Retry-After; the owner's allowance is spent) or a rate limit, 503 tools.job.unavailable, 503 tools.unavailable (a program or upstream the job type needs is missing), 503 tools.busy (Retry-After; every slot for this kind of work is taken). A tool that refuses its input after the job was accepted fails the job with its own code (413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password…; tools.job@1 error).",
++ "$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"
++ "type",
++ "id",
++ "revision"
],
"additionalProperties": false,
"properties": {
"type": {
++ "const": "story"
++ },
++ "id": {
"type": "string",
-- "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
-- "maxLength": 80,
-- "description": "Job type: img.process, audio.process, docs.process."
++ "pattern": "^sty_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "input": {
-- "type": "object",
-- "description": "The job type's input; { tool: <operation>, …options }. Default {}; at most 16 KB as JSON."
++ "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."
},
-- "idempotency_key": {
++ "into_entity_id": {
"type": "string",
-- "pattern": "^[!-~]{8,200}$",
-- "description": "8-200 printable ASCII characters. The Idempotency-Key header is used when both are present."
++ "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."
}
}
}
},
-- "news.source.ingested": {
++ "reviews.entity.split": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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.",
++ "$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": [
-- "item_id",
-- "action",
-- "status",
-- "sources_item",
-- "source_key",
-- "canonical_url",
-- "duplicate_of",
-- "dedupe_rule",
-- "cluster_id"
++ "entity_id",
++ "from_entity_id",
++ "link_id",
++ "signal_count",
++ "url",
++ "from_url",
++ "note"
],
"additionalProperties": false,
"properties": {
-- "item_id": {
++ "entity_id": {
"type": "string",
-- "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "News' own source item (news_source_items)."
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity split off again (subject.id); now state active."
},
-- "action": {
-- "enum": [
-- "created",
-- "updated",
-- "removed"
-- ]
++ "from_entity_id": {
++ "type": "string",
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity it had been merged into."
},
-- "status": {
-- "enum": [
-- "active",
-- "duplicate",
-- "removed"
-- ],
-- "description": "The item after the change: active or duplicate for created and updated, removed exactly when action is removed."
++ "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)."
},
-- "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)."
-- }
-- }
++ "signal_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Active signals back with the entity (its own and those of entities merged into it)."
},
-- "source_key": {
++ "url": {
"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."
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical URL of the split entity's page (/e/<slug>)."
},
-- "duplicate_of": {
++ "from_url": {
"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."
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "Canonical URL of the former target's page; null only if that entity no longer exists."
},
-- "cluster_id": {
++ "note": {
"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."
++ "description": "The editor's note (clipped to 500 characters); null when none was given."
}
}
}
},
-- "news.source.failed": {
++ "reviews.signal.added": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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.",
++ "$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": [
-- "origin",
++ "signal_id",
++ "entity_id",
++ "canonical_entity_id",
++ "type",
"source_key",
-- "sources_item_id",
-- "state",
-- "error_code",
-- "http_status",
-- "detail"
++ "source_item_id",
++ "item_revision",
++ "observed_at",
++ "source_published_at",
++ "canonical_url",
++ "license_note"
],
"additionalProperties": false,
"properties": {
-- "origin": {
++ "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": [
-- "webhook",
-- "pull",
-- "sources"
-- ],
-- "description": "webhook: an Events delivery; pull: the cursor pull; sources: a failure Sources reported."
++ "recommendation",
++ "recommendation_tally",
++ "rating",
++ "rating_aggregate"
++ ]
},
"source_key": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "The Sources source, as given; null for a pull failure or when the delivery named none."
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
++ "description": "OpenVibe.Sources source key."
},
-- "sources_item_id": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "The Sources item (itm_…) as given, for a rejected item or an unreadable delivery; null otherwise."
++ "source_item_id": {
++ "type": "string",
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "state": {
++ "item_revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The Sources item revision the signal was read from."
++ },
++ "observed_at": {
"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)."
++ "format": "date-time",
++ "description": "When Sources retrieved that item revision (provenance.retrieved_at)."
},
-- "error_code": {
++ "source_published_at": {
"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)."
++ "description": "The item's published_at as Sources gave it; null when it gave none."
},
-- "http_status": {
++ "canonical_url": {
"type": [
-- "integer",
++ "string",
"null"
],
-- "description": "The HTTP status Sources answered (or reported for its fetch); null when there was none."
++ "description": "The item's canonical URL as Sources gave it; null when it has none."
},
-- "detail": {
++ "license_note": {
"type": [
"string",
"null"
],
-- "maxLength": 500,
-- "description": "The error message, cut to 500 characters; null for rejected items and origin sources."
++ "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."
},
-- "run_id": {
++ "rating_best": {
"type": [
-- "string",
++ "number",
"null"
],
-- "description": "origin sources only: Sources' fetch run (frn_…), as sent."
++ "description": "rating and rating_aggregate only: top of the stated scale; null when the source states none."
},
-- "consecutive_failures": {
++ "rating_worst": {
"type": [
-- "integer",
++ "number",
"null"
],
-- "description": "origin sources only: the source's consecutive failed runs Sources reported, null when it reported none."
++ "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"
++ ]
}
}
}
},
-- "news.cluster.updated": {
++ "reviews.signal.removed": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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).",
++ "$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": [
-- "cluster_id",
-- "action",
++ "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",
-- "label",
-- "item_count"
++ "reason"
],
"additionalProperties": false,
"properties": {
-- "cluster_id": {
++ "signal_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)."
++ "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "label": {
++ "entity_id": {
"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."
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the signal is attributed to (never rewritten by a merge)."
},
-- "item_id": {
++ "canonical_entity_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."
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity it counted under: entity_id, or the entity it is merged into."
},
-- "rule": {
++ "type": {
"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."
++ "recommendation",
++ "recommendation_tally",
++ "rating",
++ "rating_aggregate"
++ ]
},
-- "audit_id": {
++ "source_key": {
"type": "string",
-- "pattern": "^cla_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "Every editor action (and created by a split): the news_cluster_audit row."
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
++ "description": "OpenVibe.Sources source key."
},
-- "absorbed": {
++ "source_item_id": {
"type": "string",
-- "pattern": "^clu_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "merged only: the cluster merged into this one (now status merged)."
++ "pattern": "^itm_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- "moved": {
++ "item_revision": {
"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)."
++ "minimum": 1,
++ "description": "The Sources item revision the signal was read from."
},
-- "restored": {
++ "observed_at": {
"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)."
++ "format": "date-time",
++ "description": "When Sources retrieved that item revision (provenance.retrieved_at)."
},
-- "dissolved": {
++ "source_published_at": {
"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": {
++ "description": "The item's published_at as Sources gave it; null when it gave none."
++ },
++ "canonical_url": {
"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."
++ "description": "The item's canonical URL as Sources gave it; null when it has none."
},
-- "topic": {
++ "license_note": {
"type": [
"string",
"null"
],
-- "minLength": 1,
-- "maxLength": 60,
-- "description": "Slug of the story's topic (news_topics); null when none was given."
++ "description": "Licence note recorded for the item in Sources; null when none."
},
-- "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."
++ "recommended": {
++ "type": "boolean",
++ "description": "recommendation only: whether the reviewer recommends it."
},
-- "kind": {
-- "enum": [
-- "source_updated",
-- "source_removed"
-- ]
++ "positive_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "recommendation_tally only: positive reviews the source counts."
},
-- "status": {
-- "const": "open"
++ "total_count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "recommendation_tally only: all reviews the source counts (≥ positive_count)."
},
-- "source_item_id": {
-- "type": "string",
-- "pattern": "^nsi_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The News source item that changed or was removed."
++ "rating_value": {
++ "type": "number",
++ "description": "rating and rating_aggregate only: the value as stated."
},
-- "pending_revision": {
++ "rating_best": {
"type": [
-- "integer",
++ "number",
"null"
],
-- "minimum": 1,
-- "description": "The revision News prepared for an editor; null when the story has no text yet or nothing new was prepared."
++ "description": "rating and rating_aggregate only: top of the stated scale; null when the source states none."
},
-- "published_revision": {
++ "rating_worst": {
"type": [
-- "integer",
++ "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": "The revision readers see; null when the story was never published."
++ "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"
++ ]
++ }
++ }
}
}
}
},
-- "news.story.published": {
++ "reviews.summary.published": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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.",
++ "$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"
++ "indexability",
++ "entity_id",
++ "entity_slug",
++ "authorship"
],
"additionalProperties": false,
"properties": {
@@@ -24147,7 +26422,8 @@
"type": "string",
"format": "uri",
"pattern": "^https?://",
-- "maxLength": 2048
++ "maxLength": 2048,
++ "description": "The entity page (/e/<slug>) that shows the summary."
},
"publication_state": {
"const": "published"
@@@ -24175,31 +26451,70 @@
"pattern": "^[a-z][a-z0-9_]{1,63}$"
}
}
-- }
++ },
++ "description": "The gate's decision for the entity page after the change, in Search's vocabulary."
},
-- "topic": {
++ "entity_id": {
"type": "string",
-- "minLength": 1,
-- "maxLength": 60,
-- "description": "Slug of the story's topic (news_topics); absent when it has none."
++ "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."
++ }
++ }
}
}
}
},
-- "news.story.updated": {
++ "reviews.summary.updated": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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.",
++ "$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"
++ "indexability",
++ "entity_id",
++ "entity_slug",
++ "authorship"
],
"additionalProperties": false,
"properties": {
@@@ -24207,7 +26522,8 @@
"type": "string",
"format": "uri",
"pattern": "^https?://",
-- "maxLength": 2048
++ "maxLength": 2048,
++ "description": "The entity page (/e/<slug>) that shows the summary."
},
"publication_state": {
"const": "published"
@@@ -24235,110 +26551,81 @@
"pattern": "^[a-z][a-z0-9_]{1,63}$"
}
}
-- }
++ },
++ "description": "The gate's decision for the entity page after the change, in Search's vocabulary."
},
-- "topic": {
++ "entity_id": {
"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."
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the summary belongs to (one summary per entity)."
},
-- "publication_state": {
-- "const": "unpublished"
++ "entity_slug": {
++ "type": "string",
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 90
},
-- "indexability": {
-- "type": [
-- "object",
-- "null"
++ "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": [
-- "decision",
-- "reasons"
++ "note",
++ "corrects"
],
"additionalProperties": false,
++ "description": "Present when the published revision is a correction: the public note and the revision it corrects.",
"properties": {
-- "decision": {
-- "enum": [
-- "index",
-- "noindex"
-- ]
++ "note": {
++ "type": "string",
++ "minLength": 10,
++ "maxLength": 2000,
++ "description": "What was corrected, as readers see it."
},
-- "reasons": {
-- "type": "array",
-- "maxItems": 20,
-- "uniqueItems": true,
-- "items": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_]{1,63}$"
-- }
++ "corrects": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The summary revision that was published when the correction was made."
}
-- },
-- "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": {
++ "reviews.summary.unpublished": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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.",
++ "$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",
-- "note",
-- "flag_id"
++ "entity_id",
++ "entity_slug"
],
"additionalProperties": false,
"properties": {
"canonical_url": {
-- "type": "string",
-- "format": "uri",
-- "pattern": "^https?://",
-- "maxLength": 2048,
-- "description": "Where the story, with its retraction notice, stays."
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "null: the event carries a tombstone, which has no URL."
},
"publication_state": {
-- "const": "retracted"
++ "const": "unpublished"
},
"indexability": {
"type": "object",
@@@ -24347,49 +26634,47 @@
"reasons"
],
"additionalProperties": false,
-- "description": "Search-shaped gate decision: always noindex, with retracted among the reasons.",
"properties": {
"decision": {
-- "const": "noindex"
++ "enum": [
++ "index",
++ "noindex"
++ ]
},
"reasons": {
"type": "array",
-- "minItems": 1,
"maxItems": 20,
"uniqueItems": true,
-- "contains": {
-- "const": "retracted"
-- },
"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)."
},
-- "note": {
++ "entity_id": {
"type": "string",
-- "minLength": 10,
-- "maxLength": 2000,
-- "description": "The public retraction notice, whitespace collapsed."
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The entity the summary belongs to."
},
-- "flag_id": {
++ "entity_slug": {
"type": "string",
-- "pattern": "^flg_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The retraction's news_editorial_flags row (kind retraction, status published)."
++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
++ "maxLength": 90
}
}
}
},
-- "news.index_document.upserted": {
++ "reviews.index_document.upserted": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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.",
++ "$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"
@@@ -24403,14 +26688,14 @@
],
"properties": {
"owner": {
-- "const": "news"
++ "const": "reviews"
},
"type": {
-- "const": "story"
++ "const": "entity"
},
"id": {
"type": "string",
-- "pattern": "^sty_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$"
},
"deleted": {
"const": false
@@@ -24420,15 +26705,15 @@
]
}
},
-- "news.index_document.deleted": {
++ "reviews.index_document.deleted": {
"version": "1.0.0",
-- "owner": "news",
++ "owner": "reviews",
"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.",
++ "$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",
@@@ -24438,816 +26723,501 @@
"additionalProperties": false,
"properties": {
"type": {
-- "const": "story"
++ "const": "entity"
},
"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."
++ "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."
}
}
}
},
-- "reviews.signal.added": {
++ "coupons.coupon.created": {
"version": "1.0.0",
-- "owner": "reviews",
++ "owner": "coupons",
"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.",
++ "$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": [
-- "signal_id",
-- "entity_id",
-- "canonical_entity_id",
-- "type",
-- "source_key",
-- "source_item_id",
-- "item_revision",
-- "observed_at",
-- "source_published_at",
++ "merchant_id",
++ "status",
++ "confidence",
++ "review_state",
++ "expires_at",
"canonical_url",
-- "license_note"
++ "origin"
],
"additionalProperties": false,
"properties": {
-- "signal_id": {
++ "merchant_id": {
"type": "string",
-- "pattern": "^sig_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
},
-- "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)."
++ "status": {
++ "const": "unknown",
++ "description": "Every new code starts unknown; only people's reports can move it to reported_working or reported_failed."
},
-- "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."
++ "confidence": {
++ "type": "null",
++ "description": "Always null at creation: no counted report yet."
},
-- "type": {
++ "review_state": {
"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"
++ "pending",
++ "published"
],
-- "description": "The item's canonical URL as Sources gave it; null when it has none."
++ "description": "pending = waits for staff review (AI-extracted, imported without auto-publish, or merchant pending); published = eligible for active results."
},
-- "license_note": {
++ "expires_at": {
"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)."
++ "format": "date-time",
++ "description": "Known expiry (UTC; a date-only expiry is 23:59:59.999Z of that day). null = expiry unknown, never defaulted."
},
-- "replaces": {
++ "canonical_url": {
"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"
-- ]
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>)."
},
-- "else": {
-- "required": [
-- "rating_value",
-- "rating_best",
-- "rating_worst",
-- "rating_count"
-- ]
++ "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)."
}
}
}
},
-- "reviews.signal.removed": {
++ "coupons.coupon.updated": {
"version": "1.0.0",
-- "owner": "reviews",
++ "owner": "coupons",
"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.",
++ "$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": [
-- "signal_id",
-- "entity_id",
-- "canonical_entity_id",
-- "type",
-- "source_key",
-- "source_item_id",
-- "item_revision",
-- "observed_at",
-- "source_published_at",
-- "canonical_url",
-- "license_note",
++ "merchant_id",
"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": {
++ "confidence",
++ "review_state",
++ "expires_at",
++ "canonical_url",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merchant_id": {
"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."
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
},
-- "canonical_url": {
-- "type": [
-- "string",
-- "null"
++ "status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired",
++ "disabled"
],
-- "description": "The item's canonical URL as Sources gave it; null when it has none."
++ "description": "Status after the change. expired and disabled occur only with reason approved or expiry_changed (neither moves the status)."
},
-- "license_note": {
++ "confidence": {
"type": [
-- "string",
++ "number",
"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."
++ "maximum": 1,
++ "description": "Report confidence after the change, rounded to 2 decimals; null = no counted report in the last 30 days."
},
-- "rating_best": {
-- "type": [
-- "number",
-- "null"
-- ],
-- "description": "rating and rating_aggregate only: top of the stated scale; null when the source states none."
++ "review_state": {
++ "enum": [
++ "pending",
++ "published"
++ ]
},
-- "rating_worst": {
++ "expires_at": {
"type": [
-- "number",
++ "string",
"null"
],
-- "description": "rating and rating_aggregate only: bottom of the stated scale; null when the source states none."
++ "format": "date-time",
++ "description": "Known expiry (UTC); null = expiry unknown."
},
-- "rating_count": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "rating and rating_aggregate only: ratings behind the value (1 for a single review)."
++ "canonical_url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>)."
},
-- "status": {
++ "previous_status": {
"enum": [
-- "superseded",
-- "withdrawn"
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired",
++ "disabled"
],
-- "description": "superseded: replaced by a newer revision of the same item; withdrawn: every other removal."
++ "description": "Status before the change. Absent with reason expiry_changed."
},
"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"
++ "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."
}
-- }
++ ]
}
}
}
},
-- "reviews.summary.published": {
++ "coupons.coupon.expired": {
"version": "1.0.0",
-- "owner": "reviews",
++ "owner": "coupons",
"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_…).",
++ "$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",
-- "publication_state",
-- "indexability",
-- "entity_id",
-- "entity_slug",
-- "authorship"
++ "previous_status",
++ "reason"
],
"additionalProperties": false,
"properties": {
-- "canonical_url": {
++ "merchant_id": {
"type": "string",
-- "format": "uri",
-- "pattern": "^https?://",
-- "maxLength": 2048,
-- "description": "The entity page (/e/<slug>) that shows the summary."
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
},
-- "publication_state": {
-- "const": "published"
++ "status": {
++ "const": "expired"
},
-- "indexability": {
-- "type": "object",
-- "required": [
-- "decision",
-- "reasons"
++ "confidence": {
++ "type": [
++ "number",
++ "null"
],
-- "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."
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Report confidence at the time (rounded to 2 decimals); null = no counted report in the last 30 days."
},
-- "entity_id": {
-- "type": "string",
-- "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The entity the summary belongs to (one summary per entity)."
++ "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."
},
-- "entity_slug": {
++ "canonical_url": {
"type": "string",
-- "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
-- "maxLength": 90
++ "format": "uri",
++ "pattern": "^https?://",
++ "maxLength": 2048,
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>); it stays up, noindex."
},
-- "authorship": {
++ "previous_status": {
"enum": [
-- "human",
-- "ai_assisted",
-- "ai_generated",
-- null
++ "unknown",
++ "reported_working",
++ "reported_failed"
],
-- "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."
++ "description": "Status before; a disabled code has to be re-enabled before it can be marked expired."
},
-- "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."
++ "reason": {
++ "description": "expiry, evidence, report, decay, staff, or service:<client>[:<note>].",
++ "anyOf": [
++ {
++ "enum": [
++ "expiry",
++ "evidence",
++ "report",
++ "decay",
++ "staff"
++ ]
},
-- "corrects": {
-- "type": "integer",
-- "minimum": 1,
-- "description": "The summary revision that was published when the correction was made."
++ {
++ "type": "string",
++ "pattern": "^service:[\\s\\S]+$",
++ "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one."
}
-- }
++ ]
}
}
}
},
-- "reviews.summary.updated": {
++ "coupons.coupon.disabled": {
"version": "1.0.0",
-- "owner": "reviews",
++ "owner": "coupons",
"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_…).",
++ "$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",
-- "publication_state",
-- "indexability",
-- "entity_id",
-- "entity_slug",
-- "authorship"
++ "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 entity page (/e/<slug>) that shows the summary."
-- },
-- "publication_state": {
-- "const": "published"
++ "description": "The code's page on Coupons (<origin>/c/<coupon id>); it now answers 410."
},
-- "indexability": {
-- "type": "object",
-- "required": [
-- "decision",
-- "reasons"
++ "previous_status": {
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired"
],
-- "additionalProperties": false,
-- "properties": {
-- "decision": {
++ "description": "Status before it was disabled."
++ },
++ "reason": {
++ "description": "source_removed, staff, or service:<client>[:<note>].",
++ "anyOf": [
++ {
"enum": [
-- "index",
-- "noindex"
++ "source_removed",
++ "staff"
]
},
-- "reasons": {
-- "type": "array",
-- "maxItems": 20,
-- "uniqueItems": true,
-- "items": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9_]{1,63}$"
-- }
++ {
++ "type": "string",
++ "pattern": "^service:[\\s\\S]+$",
++ "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one."
}
-- },
-- "description": "The gate's decision for the entity page after the change, in Search's vocabulary."
-- },
-- "entity_id": {
++ ]
++ }
++ }
++ }
++ },
++ "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": "^ent_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The entity the summary belongs to (one summary per entity)."
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant of the reported code."
},
-- "entity_slug": {
-- "type": "string",
-- "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
-- "maxLength": 90
++ "outcome": {
++ "enum": [
++ "worked",
++ "failed"
++ ]
},
-- "authorship": {
++ "reason": {
"enum": [
-- "human",
-- "ai_assisted",
-- "ai_generated",
-- null
++ "invalid",
++ "expired",
++ "min_spend_not_met",
++ "not_eligible",
++ "other"
],
-- "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."
++ "description": "Why a failed code failed, when the reporter said. Never on a worked report."
},
-- "correction": {
-- "type": "object",
-- "required": [
-- "note",
-- "corrects"
++ "channel": {
++ "enum": [
++ "site",
++ "extension",
++ "api"
],
-- "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."
-- }
++ "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"
++ ]
}
}
}
},
-- "reviews.summary.unpublished": {
++ "coupons.confidence.changed": {
"version": "1.0.0",
-- "owner": "reviews",
++ "owner": "coupons",
"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_…).",
++ "$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": [
-- "canonical_url",
-- "publication_state",
-- "indexability",
-- "entity_id",
-- "entity_slug"
++ "merchant_id",
++ "from",
++ "to",
++ "status"
],
"additionalProperties": false,
"properties": {
-- "canonical_url": {
++ "merchant_id": {
++ "type": "string",
++ "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merchant the code belongs to."
++ },
++ "from": {
"type": [
-- "string",
++ "number",
"null"
],
-- "description": "null: the event carries a tombstone, which has no URL."
-- },
-- "publication_state": {
-- "const": "unpublished"
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Confidence before; null = no counted report."
},
-- "indexability": {
-- "type": "object",
-- "required": [
-- "decision",
-- "reasons"
++ "to": {
++ "type": [
++ "number",
++ "null"
],
-- "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."
++ "minimum": 0,
++ "maximum": 1,
++ "description": "Confidence after; null = no counted report left in the window."
},
-- "entity_slug": {
-- "type": "string",
-- "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
-- "maxLength": 90
++ "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)."
}
}
}
},
-- "reviews.index_document.upserted": {
++ "coupons.index_document.upserted": {
"version": "1.0.0",
-- "owner": "reviews",
++ "owner": "coupons",
"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.",
++ "$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"
@@@ -25261,14 +27231,17 @@
],
"properties": {
"owner": {
-- "const": "reviews"
++ "const": "coupons"
},
"type": {
-- "const": "entity"
++ "enum": [
++ "coupon",
++ "merchant"
++ ]
},
"id": {
"type": "string",
-- "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$"
},
"deleted": {
"const": false
@@@ -25278,15 +27251,15 @@
]
}
},
-- "reviews.index_document.deleted": {
++ "coupons.index_document.deleted": {
"version": "1.0.0",
-- "owner": "reviews",
++ "owner": "coupons",
"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.",
++ "$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",
@@@ -25296,11 +27269,14 @@
"additionalProperties": false,
"properties": {
"type": {
-- "const": "entity"
++ "enum": [
++ "coupon",
++ "merchant"
++ ]
},
"id": {
"type": "string",
-- "pattern": "^ent_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$"
},
"revision": {
"type": "integer",
@@@ -25311,374 +27287,500 @@
}
}
},
-- "coupons.coupon.created": {
++ "coupons.coupon": {
"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.",
++ "$id": "https://openvibe.network/contracts/coupons/coupon.v1.json",
++ "title": "CouponsCoupon",
++ "description": "coupons.coupon@1: one code as OpenVibe.Coupons shows it (server/domain/coupons.js view): its status and confidence come only from people's reports (counts in a window, report times rounded to the hour), a known or unknown expiry, restrictions, hints and evidence. No submitter or reporter is ever named.",
"type": "object",
"required": [
++ "id",
"merchant_id",
++ "code",
++ "title",
"status",
-- "confidence",
-- "review_state",
-- "expires_at",
-- "canonical_url",
-- "origin"
++ "active",
++ "reports",
++ "expiry",
++ "restrictions",
++ "origin",
++ "url"
],
-- "additionalProperties": false,
"properties": {
++ "id": {
++ "type": "string"
++ },
"merchant_id": {
++ "type": "string"
++ },
++ "code": {
"type": "string",
-- "pattern": "^mer_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The merchant the code belongs to."
++ "pattern": "^[\\x21-\\x7E]{1,64}$"
++ },
++ "title": {
++ "type": "string"
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
},
"status": {
-- "const": "unknown",
-- "description": "Every new code starts unknown; only people's reports can move it to reported_working or reported_failed."
++ "enum": [
++ "unknown",
++ "reported_working",
++ "reported_failed",
++ "expired",
++ "disabled"
++ ]
++ },
++ "active": {
++ "type": "boolean"
++ },
++ "confidence": {},
++ "reports": {
++ "type": "object",
++ "properties": {
++ "window_days": {
++ "type": "integer"
++ },
++ "last_report_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "last_worked_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "last_failed_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ }
++ },
++ "expiry": {
++ "type": "object",
++ "required": [
++ "known",
++ "expires_at"
++ ],
++ "properties": {
++ "known": {
++ "type": "boolean"
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "precision": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "basis": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
++ },
++ "restrictions": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "enum": [
++ "min_spend",
++ "category",
++ "new_customers_only",
++ "region",
++ "other"
++ ]
++ },
++ "value": {},
++ "amount": {
++ "type": "string"
++ },
++ "amount_minor": {
++ "type": "integer"
++ },
++ "currency": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "hints": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "text": {
++ "type": "string"
++ },
++ "scope": {
++ "enum": [
++ "code",
++ "merchant"
++ ]
++ }
++ }
++ }
++ },
++ "evidence": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "properties": {
++ "kind": {
++ "type": "string"
++ },
++ "url": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "merchant_page": {
++ "type": "boolean"
++ },
++ "sources_item": {
++ "type": "string"
++ },
++ "retrieved_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ }
++ }
},
-- "confidence": {
-- "type": "null",
-- "description": "Always null at creation: no counted report yet."
++ "origin": {
++ "type": "string"
},
-- "review_state": {
-- "enum": [
-- "pending",
-- "published"
++ "created_at": {
++ "type": [
++ "string",
++ "null"
],
-- "description": "pending = waits for staff review (AI-extracted, imported without auto-publish, or merchant pending); published = eligible for active results."
++ "format": "date-time"
},
-- "expires_at": {
++ "updated_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>)."
++ "format": "date-time"
},
-- "origin": {
-- "enum": [
-- "member",
-- "staff",
-- "source",
-- "ai"
++ "expired_at": {
++ "type": [
++ "string",
++ "null"
],
-- "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)."
++ "format": "date-time"
++ },
++ "url": {
++ "type": "string"
}
}
}
},
-- "coupons.coupon.updated": {
++ "coupons.lookup-result": {
"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.",
++ "$id": "https://openvibe.network/contracts/coupons/lookup-result.v1.json",
++ "title": "CouponsLookupResult",
++ "description": "coupons.lookup-result@1: answers of coupons.coupon.lookup on OpenVibe.Coupons (public, anonymous allowed, cacheable: the same bytes for every caller). GET /api/v1/merchants/:id/coupons → { merchant, coupons } (the active codes); GET /api/v1/merchants/:id → { merchant }; GET /api/v1/coupons/:id → { coupon, merchant }. Inactive merchants and unpublished or disabled codes are 404.",
"type": "object",
"required": [
-- "merchant_id",
-- "status",
-- "confidence",
-- "review_state",
-- "expires_at",
-- "canonical_url",
-- "reason"
++ "merchant"
],
-- "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>)."
++ "merchant": {
++ "$ref": "merchant.v1.json"
},
-- "previous_status": {
-- "enum": [
-- "unknown",
-- "reported_working",
-- "reported_failed",
-- "expired",
-- "disabled"
-- ],
-- "description": "Status before the change. Absent with reason expiry_changed."
++ "coupons": {
++ "type": "array",
++ "maxItems": 200,
++ "items": {
++ "$ref": "coupon.v1.json"
++ }
},
-- "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."
-- }
-- ]
++ "coupon": {
++ "$ref": "coupon.v1.json"
}
}
}
},
-- "coupons.coupon.expired": {
++ "coupons.merchant-manage-request": {
"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"
++ "$id": "https://openvibe.network/contracts/coupons/merchant-manage-request.v1.json",
++ "title": "CouponsMerchantManageRequest",
++ "description": "coupons.merchant-manage-request@1: bodies of coupons.merchant.manage on OpenVibe.Coupons (staff or a service). POST /api/v1/merchants: { domains, name?, slug?, homepage_url?, description?, status? } (at least one domain rule; a rule on a public suffix is refused; homepage_url must be https; status pending, else active). POST /api/v1/merchants/:id/domains: one domain rule { host, include_subdomains?, path_prefix? } (a rule another merchant holds is 409). POST /api/v1/merchants/:id/status: { status: active | disabled }.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "domains"
],
-- "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."
++ "properties": {
++ "domains": {
++ "type": "array",
++ "minItems": 1,
++ "items": {
++ "type": "object",
++ "required": [
++ "host"
++ ],
++ "properties": {
++ "host": {
++ "type": "string",
++ "minLength": 1
++ },
++ "include_subdomains": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ],
++ "description": "Default true."
++ },
++ "path_prefix": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "name": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "slug": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "homepage_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^https://"
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "status": {
++ "type": "string",
++ "description": "pending; anything else is active."
++ }
++ }
},
-- "previous_status": {
-- "enum": [
-- "unknown",
-- "reported_working",
-- "reported_failed"
++ {
++ "type": "object",
++ "required": [
++ "host"
],
-- "description": "Status before; a disabled code has to be re-enabled before it can be marked expired."
++ "properties": {
++ "host": {
++ "type": "string",
++ "minLength": 1
++ },
++ "include_subdomains": {
++ "type": [
++ "boolean",
++ "integer",
++ "string"
++ ],
++ "description": "Default true."
++ },
++ "path_prefix": {
++ "type": "string"
++ }
++ }
},
-- "reason": {
-- "description": "expiry, evidence, report, decay, staff, or service:<client>[:<note>].",
-- "anyOf": [
-- {
++ {
++ "type": "object",
++ "required": [
++ "status"
++ ],
++ "properties": {
++ "status": {
"enum": [
-- "expiry",
-- "evidence",
-- "report",
-- "decay",
-- "staff"
++ "active",
++ "disabled"
]
++ }
++ }
++ }
++ ]
++ }
++ },
++ "coupons.merchant-manage-result": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/merchant-manage-result.v1.json",
++ "title": "CouponsMerchantManageResult",
++ "description": "coupons.merchant-manage-result@1: the answer of every coupons.merchant.manage route on OpenVibe.Coupons: { merchant } with its status (201 for a new merchant or domain).",
++ "type": "object",
++ "required": [
++ "merchant"
++ ],
++ "properties": {
++ "merchant": {
++ "allOf": [
++ {
++ "$ref": "merchant.v1.json"
},
{
-- "type": "string",
-- "pattern": "^service:[\\s\\S]+$",
-- "description": "service:<client id>, plus :<note> (at most 200 characters, copied verbatim) when the service gave one."
++ "type": "object",
++ "required": [
++ "status"
++ ]
}
]
}
}
}
},
-- "coupons.coupon.disabled": {
++ "coupons.merchant": {
"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.",
++ "$id": "https://openvibe.network/contracts/coupons/merchant.v1.json",
++ "title": "CouponsMerchant",
++ "description": "coupons.merchant@1: one merchant as OpenVibe.Coupons' lookup API shows it (server/http/api.js merchantView): the same bytes for every caller, nothing about any person. Staff and service answers of the merchant routes add its status.",
"type": "object",
"required": [
-- "merchant_id",
-- "status",
-- "confidence",
-- "review_state",
-- "expires_at",
-- "canonical_url",
-- "previous_status",
-- "reason"
++ "id",
++ "slug",
++ "name",
++ "url",
++ "domains",
++ "active_codes",
++ "hints"
],
-- "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"
++ "id": {
++ "type": "string"
},
-- "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."
++ "slug": {
++ "type": "string"
},
-- "review_state": {
-- "enum": [
-- "pending",
-- "published"
-- ]
++ "name": {
++ "type": "string"
},
-- "expires_at": {
++ "homepage_url": {
"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."
++ "url": {
++ "type": "string"
},
-- "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."
++ "domains": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "host",
++ "include_subdomains",
++ "path_prefix"
++ ],
++ "properties": {
++ "host": {
++ "type": "string"
++ },
++ "include_subdomains": {
++ "type": "boolean"
++ },
++ "path_prefix": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
}
++ }
++ },
++ "active_codes": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "hints": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "active",
++ "disabled"
]
}
}
}
},
-- "coupons.report.created": {
++ "coupons.report-request": {
"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.",
++ "$id": "https://openvibe.network/contracts/coupons/report-request.v1.json",
++ "title": "CouponsReportRequest",
++ "description": "coupons.report-request@1: the body of POST /api/v1/coupons/:id/report on OpenVibe.Coupons (coupons.report.create; people, installs and services acting for a person): whether the code worked, and for a failure why. The code must be in active results; you cannot report your own submission. Reporting again changes your earlier report.",
"type": "object",
"required": [
-- "merchant_id",
-- "outcome",
-- "channel",
-- "day"
++ "outcome"
],
-- "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",
@@@ -25691,22 +27793,11 @@
"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"
++ "other",
++ null,
++ ""
],
-- "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."
++ "description": "Failed reports only."
}
},
"if": {
@@@ -25714,148 +27805,372 @@
"outcome": {
"const": "worked"
}
++ }
++ },
++ "then": {
++ "properties": {
++ "reason": {
++ "enum": [
++ null,
++ ""
++ ]
++ }
++ }
++ }
++ }
++ },
++ "coupons.report-result": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/report-result.v1.json",
++ "title": "CouponsReportResult",
++ "description": "coupons.report-result@1: the answer of POST /api/v1/coupons/:id/report on OpenVibe.Coupons: { accepted, deduplicated, corrected, coupon } — 201 for a new report, 200 when it repeated or corrected an earlier one.",
++ "type": "object",
++ "required": [
++ "accepted",
++ "deduplicated",
++ "corrected",
++ "coupon"
++ ],
++ "properties": {
++ "accepted": {
++ "const": true
++ },
++ "deduplicated": {
++ "type": "boolean"
++ },
++ "corrected": {
++ "type": "boolean"
++ },
++ "coupon": {
++ "$ref": "coupon.v1.json"
++ }
++ }
++ }
++ },
++ "coupons.resolve-result": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/resolve-result.v1.json",
++ "title": "CouponsResolveResult",
++ "description": "coupons.resolve-result@1: the answer of GET /api/v1/merchants/resolve?host= on OpenVibe.Coupons (coupons.merchant.resolve; public, anonymous allowed, rate-limited, CORS for the configured extension origins): the merchant whose domain rule matches the host. No match is 404 merchant.not_found with the normalised host and its registrable domain; a missing host is 400 host.required.",
++ "type": "object",
++ "required": [
++ "host",
++ "registrable_domain",
++ "matched_rule",
++ "merchant"
++ ],
++ "properties": {
++ "host": {
++ "type": "string"
++ },
++ "registrable_domain": {
++ "type": "string"
++ },
++ "matched_rule": {
++ "type": "object",
++ "required": [
++ "host",
++ "include_subdomains",
++ "path_prefix"
++ ],
++ "properties": {
++ "host": {
++ "type": "string"
++ },
++ "include_subdomains": {
++ "type": "boolean"
++ },
++ "path_prefix": {
++ "type": [
++ "string",
++ "null"
++ ]
++ }
++ }
},
-- "required": [
-- "outcome"
-- ]
-- },
-- "then": {
-- "not": {
-- "required": [
-- "reason"
-- ]
++ "merchant": {
++ "$ref": "merchant.v1.json"
}
}
}
},
-- "coupons.confidence.changed": {
++ "coupons.status-request": {
"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.",
++ "$id": "https://openvibe.network/contracts/coupons/status-request.v1.json",
++ "title": "CouponsStatusRequest",
++ "description": "coupons.status-request@1: the body of POST /api/v1/coupons/:id/status on OpenVibe.Coupons (coupons.status.update; staff or a service): disabled, expired (not while disabled) or active (re-enable: the status is recomputed from reports and time); reported_working and reported_failed cannot be set by anyone.",
"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",
++ "disabled",
"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)."
++ "active"
++ ]
++ },
++ "note": {
++ "type": [
++ "string",
++ "null"
++ ]
}
}
}
},
-- "coupons.index_document.upserted": {
++ "coupons.status-result": {
"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": [
++ "$id": "https://openvibe.network/contracts/coupons/status-result.v1.json",
++ "title": "CouponsStatusResult",
++ "description": "coupons.status-result@1: the answer of POST /api/v1/coupons/:id/status on OpenVibe.Coupons: { coupon } after the change.",
++ "type": "object",
++ "required": [
++ "coupon"
++ ],
++ "properties": {
++ "coupon": {
++ "$ref": "coupon.v1.json"
++ }
++ }
++ }
++ },
++ "coupons.submit-request": {
++ "version": "1.0.0",
++ "owner": "coupons",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/coupons/submit-request.v1.json",
++ "title": "CouponsSubmitRequest",
++ "description": "coupons.submit-request@1: the body of POST /api/v1/coupons/submit on OpenVibe.Coupons (coupons.coupon.submit; a signed-in person, or a service acting for one or as AI; installs cannot submit): a code and the merchant it is for (merchant_id, or the host or url of the shop: an unknown shop becomes a pending merchant). code is 1-64 printable characters without spaces; title (at least 3 characters: what the code gives) is required. status, confidence, verified, working, valid and validity are refused (422 coupon.status_not_accepted): a code's status comes only from people's reports. expires is a date (YYYY-MM-DD) or ISO time; expiry_basis evidence needs evidence_url. A duplicate answers the existing code (200).",
++ "type": "object",
++ "required": [
++ "code",
++ "title"
++ ],
++ "anyOf": [
{
-- "$ref": "../../search/index-document.v1.json"
++ "required": [
++ "merchant_id"
++ ]
},
{
-- "type": "object",
"required": [
-- "owner",
-- "type",
-- "deleted"
-- ],
++ "host"
++ ]
++ },
++ {
++ "required": [
++ "url"
++ ]
++ }
++ ],
++ "not": {
++ "anyOf": [
++ {
++ "required": [
++ "status"
++ ]
++ },
++ {
++ "required": [
++ "confidence"
++ ]
++ },
++ {
++ "required": [
++ "verified"
++ ]
++ },
++ {
++ "required": [
++ "working"
++ ]
++ },
++ {
++ "required": [
++ "valid"
++ ]
++ },
++ {
++ "required": [
++ "validity"
++ ]
++ }
++ ]
++ },
++ "properties": {
++ "code": {
++ "type": "string",
++ "pattern": "^\\s*[\\x21-\\x7E]{1,64}\\s*$"
++ },
++ "title": {
++ "type": "string",
++ "minLength": 3
++ },
++ "description": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "merchant_id": {
++ "type": "string"
++ },
++ "host": {
++ "type": "string"
++ },
++ "url": {
++ "type": "string",
++ "pattern": "^https?://"
++ },
++ "expires": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "expiry_basis": {
++ "enum": [
++ "submitter",
++ "evidence"
++ ]
++ },
++ "evidence_url": {
++ "type": "string",
++ "maxLength": 2048,
++ "pattern": "^https?://"
++ },
++ "restrictions": {
++ "type": "object",
"properties": {
-- "owner": {
-- "const": "coupons"
++ "min_spend": {
++ "type": "object",
++ "properties": {
++ "amount": {
++ "type": [
++ "number",
++ "string"
++ ]
++ },
++ "currency": {
++ "type": "string",
++ "description": "ISO 4217."
++ }
++ }
},
-- "type": {
-- "enum": [
-- "coupon",
-- "merchant"
++ "categories": {
++ "type": [
++ "array",
++ "string"
++ ],
++ "description": "At most 10."
++ },
++ "new_customers_only": {
++ "type": [
++ "boolean",
++ "string"
]
},
-- "id": {
-- "type": "string",
-- "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$"
++ "regions": {
++ "type": [
++ "array",
++ "string"
++ ],
++ "description": "ISO 3166-1 alpha-2 codes, at most 50."
},
-- "deleted": {
-- "const": false
++ "other": {
++ "type": "string",
++ "maxLength": 300
}
}
++ },
++ "hint": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300
++ },
++ "ai_run_id": {
++ "type": [
++ "string",
++ "null"
++ ]
}
-- ]
++ }
}
},
-- "coupons.index_document.deleted": {
++ "coupons.submit-result": {
"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.",
++ "$id": "https://openvibe.network/contracts/coupons/submit-result.v1.json",
++ "title": "CouponsSubmitResult",
++ "description": "coupons.submit-result@1: the answer of POST /api/v1/coupons/submit on OpenVibe.Coupons: { coupon, merchant, duplicate, review_state } — 201 for a new code, 200 when the same code was already known (duplicate).",
"type": "object",
"required": [
-- "type",
-- "id",
-- "revision"
++ "coupon",
++ "merchant",
++ "duplicate",
++ "review_state"
],
-- "additionalProperties": false,
"properties": {
-- "type": {
-- "enum": [
-- "coupon",
-- "merchant"
-- ]
++ "coupon": {
++ "$ref": "coupon.v1.json"
},
-- "id": {
-- "type": "string",
-- "pattern": "^(cpn|mer)_[0-9A-HJKMNP-TV-Z]{26}$"
++ "merchant": {
++ "type": "object",
++ "required": [
++ "id",
++ "slug",
++ "name",
++ "status"
++ ],
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "slug": {
++ "type": "string"
++ },
++ "name": {
++ "type": "string"
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "active",
++ "disabled"
++ ]
++ }
++ }
},
-- "revision": {
-- "type": "integer",
-- "minimum": 1,
-- "maximum": 9007199254740991,
-- "description": "Index revision of the tombstone; wins over any document at the same or an older revision."
++ "duplicate": {
++ "type": "boolean"
++ },
++ "review_state": {
++ "type": "string"
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex dd45f073..23e32788 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -11949,6 +11949,449 @@ export interface BlogIndexDocumentDeletedPayload {
revision: number;
}
++/** [email protected] (owner: blog) */
++/**
++ * blog.blog-configure-request@1: the body of PATCH /api/v1/blogs/:handle on OpenVibe.Blog (blog.blog.configure; an owner): title (at most 120 characters, not blank), description (500), language (a BCP 47 tag) and feeds { rss, atom, json, item_count (1-50), full_content }. Fields left out keep their value.
++ */
++export interface BlogBlogConfigureRequest {
++ title?: string | null;
++ description?: string | null;
++ language?: string | null;
++ feeds?: {
++ rss?: boolean | number | string;
++ atom?: boolean | number | string;
++ json?: boolean | number | string;
++ item_count?: number | string;
++ full_content?: boolean | number | string;
++ };
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.blog-create-request@1: the body of POST /api/v1/blogs on OpenVibe.Blog (blog.blog.create): get or create the acting member's blog (one per usr_ subject). handle defaults to their Network username (2-40 of a-z, 0-9, _ and -, not a reserved word); title and description are optional.
++ */
++export interface BlogBlogCreateRequest {
++ handle?: string;
++ title?: string | null;
++ description?: string | null;
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.blog-create-result@1: the answer of POST /api/v1/blogs on OpenVibe.Blog: { blog, created } — 201 when the blog was created, 200 when the member already had it.
++ */
++export interface BlogBlogCreateResult {
++ blog: BlogBlog;
++ created: boolean;
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.blog-result@1: the answer of PATCH /api/v1/blogs/:handle (blog.blog.configure) and PUT /api/v1/blogs/:handle/theme (blog.theme.set) on OpenVibe.Blog: { blog } with the member view; the theme answer adds themes (the presets).
++ */
++export interface BlogBlogResult {
++ blog: BlogBlog;
++ themes?: string[];
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.blog@1: one blog as OpenVibe.Blog's API shows it (server/http/api.js blogDto). Members of the blog (and staff) also see status, owner_subject and feed_settings.
++ */
++export interface BlogBlog {
++ id: string;
++ /**
++ * member, …
++ */
++ kind: string;
++ handle: string;
++ title: string;
++ description?: string | null;
++ url: string;
++ theme: string;
++ language: string;
++ feeds: {
++ type: "rss" | "atom" | "json";
++ url: string;
++ }[];
++ status?: string;
++ owner_subject?: string | null;
++ feed_settings?: {
++ rss?: number;
++ atom?: number;
++ json?: number;
++ item_count?: number;
++ full_content?: number;
++ };
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.delete-result@1: the answer of DELETE /api/v1/posts/:id on OpenVibe.Blog (blog.post.delete): { deleted: true, id }.
++ */
++export interface BlogDeleteResult {
++ deleted: true;
++ id: string;
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.json-feed@1: the answer of GET /api/v1/blogs/:handle/feed on OpenVibe.Blog (blog.feed.read): the blog's public posts as a JSON Feed 1.1 document (openvibe-publishing seo.jsonFeed), as many as the blog's feed item count, with full HTML content when the blog allows it and otherwise the summary as text. Empty fields are left out.
++ */
++export interface BlogJsonFeed {
++ version: "https://jsonfeed.org/version/1.1";
++ title: string;
++ home_page_url: string;
++ feed_url: string;
++ description?: string;
++ language?: string;
++ authors?: {
++ name?: string;
++ url?: string;
++ }[];
++ /**
++ * @maxItems 50
++ */
++ items: {
++ id: string;
++ url?: string;
++ title?: string;
++ content_html?: string;
++ content_text?: string;
++ summary?: string;
++ image?: string;
++ date_published?: string;
++ date_modified?: string;
++ authors?: {
++ name?: string;
++ url?: string;
++ }[];
++ tags?: string[];
++ }[];
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.member-request@1: bodies of blog.member.manage on OpenVibe.Blog (an owner). PUT /api/v1/blogs/:handle/members/:subject: { role: owner | editor | author } (members are usr_ subjects; a member blog keeps at least one owner). GET …/members and DELETE …/members/:subject take no body.
++ */
++export type BlogMemberRequest =
++ | {
++ role: "owner" | "editor" | "author";
++ }
++ | NoBody;
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.member-result@1: answers of blog.member.manage on OpenVibe.Blog. GET /api/v1/blogs/:handle/members → { members } (subject, role, name and username when Network knows them); PUT …/members/:subject → { member }; DELETE → { removed } (false when they were not a member).
++ */
++export type BlogMemberResult =
++ | {
++ members: {
++ subject: string;
++ role: "owner" | "editor" | "author";
++ name?: string | null;
++ username?: string | null;
++ }[];
++ }
++ | {
++ member: {
++ subject?: string;
++ role: "owner" | "editor" | "author";
++ };
++ }
++ | {
++ removed: boolean;
++ };
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.post-create-request@1: the body of POST /api/v1/blogs/:handle/posts on OpenVibe.Blog (blog.post.create; a member who may write, or a service acting for one; X-OV-Origin: ai makes an AI draft that needs a person's review). A new draft at revision 1: title required. Unknown fields are ignored; refusals are problem+json (post.invalid_title, post.too_long, post.invalid_visibility, post.invalid_slug, post.too_many_tags, post.invalid_series, …).
++ */
++export interface BlogPostCreateRequest {
++ /**
++ * Whitespace collapsed; required, at most 200 characters.
++ */
++ title: string;
++ /**
++ * Markdown.
++ */
++ body?: string;
++ summary?: string | null;
++ /**
++ * Default public; members gates it behind the blog's VIP entitlement (OpenVibe.VIP decides).
++ */
++ visibility?: "public" | "unlisted" | "members" | "private";
++ /**
++ * members only: informational, default vip:<handle>.
++ */
++ entitlement_key?: string;
++ /**
++ * Default from the title; lowercase letters, digits and dashes (a taken one gets a number).
++ */
++ slug?: string;
++ allow_comments?: boolean | number | string;
++ noindex?: boolean | number | string;
++ /**
++ * At most 20 tags of at most 50 characters.
++ */
++ tags?: string | string[];
++ /**
++ * Category paths (Parent > Child), at most 10.
++ */
++ categories?: string | string[];
++ /**
++ * A series id (ser_…), a new series title, or { title }; null or empty leaves the series.
++ */
++ series?: {
++ [k: string]: unknown | undefined;
++ };
++ series_id?: string | null;
++ series_position?: number | string | null;
++ /**
++ * Sources: a URL (with retrieved_at) or an OpenVibe.Sources item id; a quote may be text or { text }. At most 100 are kept.
++ *
++ * @maxItems 100
++ */
++ citations?: {
++ url?: string | null;
++ source_item_id?: string | null;
++ title?: string | null;
++ retrieved_at?: string | null;
++ quote?: unknown;
++ license_note?: string | null;
++ }[];
++ message?: string | null;
++ /**
++ * hybrid or imported authorship details; an AI service (X-OV-Origin: ai) writes AI drafts that need review.
++ */
++ authorship?: {};
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.post-create-result@1: the answer of POST /api/v1/blogs/:handle/posts on OpenVibe.Blog: 201 { post, revision } (the editor view and the revision number, 1).
++ */
++export interface BlogPostCreateResult {
++ post: BlogPost;
++ revision: number;
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.post-read-result@1: answers of blog.post.read on OpenVibe.Blog. GET /api/v1/posts/:id → { post } (editors and staff get the full view; readers of a published post the public one; a members-only post someone may not open is 403 post.members_only with a teaser); GET /api/v1/posts/:id/revisions → { revisions } (each with its length instead of the content); GET …/revisions/:n → { revision, citations }; GET …/diff?from=&to=&mode= → { diff }; GET /api/v1/blogs/:handle/posts?all=1 → { posts } (drafts too, members only).
++ */
++export type BlogPostReadResult =
++ | {
++ post: BlogPost;
++ }
++ | {
++ revisions: {
++ number: number;
++ length?: number;
++ }[];
++ }
++ | {
++ revision: {
++ number: number;
++ content?: string;
++ fields?: {};
++ };
++ citations: unknown[];
++ }
++ | {
++ diff: {};
++ }
++ | {
++ posts: BlogPost[];
++ };
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.post-update-request@1: bodies of blog.post.update on OpenVibe.Blog (editors of the post). PATCH /api/v1/posts/:id: any post fields; a change to title, body or summary makes a new revision and needs expected_revision (the revision you edited; 428 revision.expected_required without it); citations sent replace the revision's, otherwise they carry forward. POST /api/v1/posts/:id/revert: { to_revision, expected_revision }. POST /api/v1/posts/:id/attachments: { media_id (med_…), role?: cover | inline | gallery, alt?, caption?, position? }. DELETE /api/v1/posts/:id/attachments/:aid takes no body.
++ */
++export type BlogPostUpdateRequest =
++ | {
++ title?: string;
++ /**
++ * Markdown.
++ */
++ body?: string;
++ summary?: string | null;
++ /**
++ * Default public; members gates it behind the blog's VIP entitlement (OpenVibe.VIP decides).
++ */
++ visibility?: "public" | "unlisted" | "members" | "private";
++ /**
++ * members only: informational, default vip:<handle>.
++ */
++ entitlement_key?: string;
++ /**
++ * Default from the title; lowercase letters, digits and dashes (a taken one gets a number).
++ */
++ slug?: string;
++ allow_comments?: boolean | number | string;
++ noindex?: boolean | number | string;
++ /**
++ * At most 20 tags of at most 50 characters.
++ */
++ tags?: string | string[];
++ /**
++ * Category paths (Parent > Child), at most 10.
++ */
++ categories?: string | string[];
++ /**
++ * A series id (ser_…), a new series title, or { title }; null or empty leaves the series.
++ */
++ series?: {
++ [k: string]: unknown | undefined;
++ };
++ series_id?: string | null;
++ series_position?: number | string | null;
++ /**
++ * Sources: a URL (with retrieved_at) or an OpenVibe.Sources item id; a quote may be text or { text }. At most 100 are kept.
++ *
++ * @maxItems 100
++ */
++ citations?: {
++ url?: string | null;
++ source_item_id?: string | null;
++ title?: string | null;
++ retrieved_at?: string | null;
++ quote?: unknown;
++ license_note?: string | null;
++ }[];
++ message?: string | null;
++ /**
++ * hybrid or imported authorship details; an AI service (X-OV-Origin: ai) writes AI drafts that need review.
++ */
++ authorship?: {};
++ expected_revision?: number | string;
++ }
++ | {
++ to_revision: number | string;
++ expected_revision: number | string;
++ }
++ | {
++ media_id: string;
++ role?: "cover" | "inline" | "gallery";
++ alt?: string | null;
++ caption?: string | null;
++ position?: number | string;
++ }
++ | NoBody;
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.post-update-result@1: answers of blog.post.update on OpenVibe.Blog. PATCH /api/v1/posts/:id → { post, revision, created } (created false when nothing changed); POST …/revert → 201 { revision, post }; POST …/attachments → 201 { attachment }; DELETE …/attachments/:aid → { removed }.
++ */
++export type BlogPostUpdateResult =
++ | {
++ post: BlogPost;
++ revision: number;
++ created?: boolean;
++ }
++ | {
++ attachment: {};
++ }
++ | {
++ removed: unknown;
++ };
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.post@1: one post as OpenVibe.Blog's API shows it (server/http/api.js postDto): its state, visibility, the fields of the revision shown (the published one, or the head for editors), terms, series and the indexability decision. Editors and staff also get the body, authorship, review, scheduled jobs, attachments and citations; readers of a members-only post they may not open get a 403 teaser instead.
++ */
++export interface BlogPost {
++ id: string;
++ blog: {
++ id: string;
++ handle: string;
++ };
++ slug: string;
++ url: string;
++ /**
++ * draft, scheduled, published, unpublished, deleted
++ */
++ state: string;
++ visibility: "public" | "unlisted" | "members" | "private";
++ author_subject?: string | null;
++ title: string | null;
++ summary?: string | null;
++ revision: number | null;
++ published_revision: number | null;
++ first_published_at?: string | null;
++ published_at?: string | null;
++ tags: unknown[];
++ categories: unknown[];
++ series?: {} | null;
++ allow_comments: boolean;
++ noindex: boolean;
++ indexability?: {
++ indexable?: boolean;
++ robots?: string;
++ reasons?: unknown[];
++ } | null;
++ entitlement_key?: string | null;
++ body?: string | null;
++ authorship?: unknown;
++ review?: unknown;
++ scheduled?: unknown[];
++ attachments?: unknown[];
++ citations?: unknown[];
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.publication-result@1: the answer of POST /api/v1/posts/:id/publish (blog.post.publish) and POST /api/v1/posts/:id/unpublish (blog.post.unpublish) on OpenVibe.Blog: { post, changed } (changed false when it already was in that state).
++ */
++export interface BlogPublicationResult {
++ post: BlogPost;
++ changed: boolean;
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.publish-request@1: the body of POST /api/v1/posts/:id/publish on OpenVibe.Blog (blog.post.publish): { revision? } (default the head; an AI draft is published only after a person reviewed it: 409 post.review_required).
++ */
++export interface BlogPublishRequest {
++ revision?: number | string | null;
++}
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.schedule-request@1: bodies of blog.post.schedule on OpenVibe.Blog. POST /api/v1/posts/:id/schedule: { at, revision?, action? } (at an ISO 8601 time in the future; action publish (default) or unpublish). DELETE /api/v1/posts/:id/schedule cancels pending jobs and takes no body.
++ */
++export type BlogScheduleRequest =
++ | {
++ at: string | number;
++ revision?: number | string | null;
++ action?: "publish" | "unpublish";
++ }
++ | NoBody;
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.schedule-result@1: answers of blog.post.schedule on OpenVibe.Blog: POST …/schedule → 201 { job, created, post }; DELETE …/schedule → { cancelled, post } (jobs cancelled; a scheduled post goes back to draft).
++ */
++export type BlogScheduleResult =
++ | {
++ job: {};
++ created: boolean;
++ post: BlogPost;
++ }
++ | {
++ cancelled: number;
++ post: BlogPost;
++ };
++
++/** [email protected] (owner: blog) */
++/**
++ * blog.theme-request@1: the body of PUT /api/v1/blogs/:handle/theme on OpenVibe.Blog (blog.theme.set; an owner): one of the theme presets.
++ */
++export interface BlogThemeRequest {
++ theme: "vibe" | "paper" | "slate" | "sand" | "nord" | "high-contrast";
++}
++
/** [email protected] (owner: ai) */
/**
* Body of POST /api/v1/runs on OpenVibe.AI (capability ai.run.create; ADR-015). Runs the newest active version of a workflow (or the given active or deprecated version) on input that must match that workflow version's own input schema; per-workflow input and output schemas live in AI's registry (GET /api/v1/workflows/:key), not here. The token's ns claim limits which workflow namespaces may run (403 capability.namespace_denied). ?wait=ms waits for the result: 201 finished or served from cache, 202 still queued/running (poll GET /api/v1/runs/:id), 200 an idempotent replay. Errors are problem+json: 404 workflow.not_found, 409 workflow.inactive, 409 idempotency.conflict, 413 input.too_large, 422 input.invalid, 429 quota.exceeded or queue.full with Retry-After. The direct operations POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed} take the same fields (except workflow and version) with the input fields at the top level and run workflow ai.<op>. target (EntityRef): what the output is about, part of the cache scope and a run filter. attribution (EntityRef): what the spend is attributed to for quotas and usage. on_behalf_of (SubjectRef): the person or actor the caller acts for, part of the cache scope and per-actor quotas.
@@@ -25397,6 +25840,255 @@ export interface CouponsIndexDocumentDeletedPayload {
revision: number;
}
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.coupon@1: one code as OpenVibe.Coupons shows it (server/domain/coupons.js view): its status and confidence come only from people's reports (counts in a window, report times rounded to the hour), a known or unknown expiry, restrictions, hints and evidence. No submitter or reporter is ever named.
++ */
++export interface CouponsCoupon {
++ id: string;
++ merchant_id: string;
++ code: string;
++ title: string;
++ description?: string | null;
++ status: "unknown" | "reported_working" | "reported_failed" | "expired" | "disabled";
++ active: boolean;
++ confidence?: unknown;
++ reports: {
++ window_days?: number;
++ last_report_at?: string | null;
++ last_worked_at?: string | null;
++ last_failed_at?: string | null;
++ };
++ expiry: {
++ known: boolean;
++ expires_at: string | null;
++ precision?: string | null;
++ basis?: string | null;
++ };
++ restrictions: {
++ kind: "min_spend" | "category" | "new_customers_only" | "region" | "other";
++ value?: unknown;
++ amount?: string;
++ amount_minor?: number;
++ currency?: string;
++ }[];
++ hints?: {
++ text?: string;
++ scope?: "code" | "merchant";
++ }[];
++ evidence?: {
++ kind?: string;
++ url?: string | null;
++ merchant_page?: boolean;
++ sources_item?: string;
++ retrieved_at?: string | null;
++ }[];
++ origin: string;
++ created_at?: string | null;
++ updated_at?: string | null;
++ expired_at?: string | null;
++ url: string;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.lookup-result@1: answers of coupons.coupon.lookup on OpenVibe.Coupons (public, anonymous allowed, cacheable: the same bytes for every caller). GET /api/v1/merchants/:id/coupons → { merchant, coupons } (the active codes); GET /api/v1/merchants/:id → { merchant }; GET /api/v1/coupons/:id → { coupon, merchant }. Inactive merchants and unpublished or disabled codes are 404.
++ */
++export interface CouponsLookupResult {
++ merchant: CouponsMerchant;
++ /**
++ * @maxItems 200
++ */
++ coupons?: CouponsCoupon[];
++ coupon?: CouponsCoupon;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.merchant-manage-request@1: bodies of coupons.merchant.manage on OpenVibe.Coupons (staff or a service). POST /api/v1/merchants: { domains, name?, slug?, homepage_url?, description?, status? } (at least one domain rule; a rule on a public suffix is refused; homepage_url must be https; status pending, else active). POST /api/v1/merchants/:id/domains: one domain rule { host, include_subdomains?, path_prefix? } (a rule another merchant holds is 409). POST /api/v1/merchants/:id/status: { status: active | disabled }.
++ */
++export type CouponsMerchantManageRequest =
++ | {
++ /**
++ * @minItems 1
++ */
++ domains: [
++ {
++ host: string;
++ /**
++ * Default true.
++ */
++ include_subdomains?: boolean | number | string;
++ path_prefix?: string;
++ },
++ ...{
++ host: string;
++ /**
++ * Default true.
++ */
++ include_subdomains?: boolean | number | string;
++ path_prefix?: string;
++ }[]
++ ];
++ name?: string | null;
++ slug?: string | null;
++ homepage_url?: string | null;
++ description?: string | null;
++ /**
++ * pending; anything else is active.
++ */
++ status?: string;
++ }
++ | {
++ host: string;
++ /**
++ * Default true.
++ */
++ include_subdomains?: boolean | number | string;
++ path_prefix?: string;
++ }
++ | {
++ status: "active" | "disabled";
++ };
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.merchant-manage-result@1: the answer of every coupons.merchant.manage route on OpenVibe.Coupons: { merchant } with its status (201 for a new merchant or domain).
++ */
++export interface CouponsMerchantManageResult {
++ merchant: CouponsMerchant & {};
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.merchant@1: one merchant as OpenVibe.Coupons' lookup API shows it (server/http/api.js merchantView): the same bytes for every caller, nothing about any person. Staff and service answers of the merchant routes add its status.
++ */
++export interface CouponsMerchant {
++ id: string;
++ slug: string;
++ name: string;
++ homepage_url?: string | null;
++ url: string;
++ domains: {
++ host: string;
++ include_subdomains: boolean;
++ path_prefix: string | null;
++ }[];
++ active_codes: number;
++ hints: string[];
++ status?: "pending" | "active" | "disabled";
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.report-request@1: the body of POST /api/v1/coupons/:id/report on OpenVibe.Coupons (coupons.report.create; people, installs and services acting for a person): whether the code worked, and for a failure why. The code must be in active results; you cannot report your own submission. Reporting again changes your earlier report.
++ */
++export interface CouponsReportRequest {
++ outcome: "worked" | "failed";
++ /**
++ * Failed reports only.
++ */
++ reason?: "invalid" | "expired" | "min_spend_not_met" | "not_eligible" | "other" | null | "";
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.report-result@1: the answer of POST /api/v1/coupons/:id/report on OpenVibe.Coupons: { accepted, deduplicated, corrected, coupon } — 201 for a new report, 200 when it repeated or corrected an earlier one.
++ */
++export interface CouponsReportResult {
++ accepted: true;
++ deduplicated: boolean;
++ corrected: boolean;
++ coupon: CouponsCoupon;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.resolve-result@1: the answer of GET /api/v1/merchants/resolve?host= on OpenVibe.Coupons (coupons.merchant.resolve; public, anonymous allowed, rate-limited, CORS for the configured extension origins): the merchant whose domain rule matches the host. No match is 404 merchant.not_found with the normalised host and its registrable domain; a missing host is 400 host.required.
++ */
++export interface CouponsResolveResult {
++ host: string;
++ registrable_domain: string;
++ matched_rule: {
++ host: string;
++ include_subdomains: boolean;
++ path_prefix: string | null;
++ };
++ merchant: CouponsMerchant;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.status-request@1: the body of POST /api/v1/coupons/:id/status on OpenVibe.Coupons (coupons.status.update; staff or a service): disabled, expired (not while disabled) or active (re-enable: the status is recomputed from reports and time); reported_working and reported_failed cannot be set by anyone.
++ */
++export interface CouponsStatusRequest {
++ status: "disabled" | "expired" | "active";
++ note?: string | null;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.status-result@1: the answer of POST /api/v1/coupons/:id/status on OpenVibe.Coupons: { coupon } after the change.
++ */
++export interface CouponsStatusResult {
++ coupon: CouponsCoupon;
++}
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.submit-request@1: the body of POST /api/v1/coupons/submit on OpenVibe.Coupons (coupons.coupon.submit; a signed-in person, or a service acting for one or as AI; installs cannot submit): a code and the merchant it is for (merchant_id, or the host or url of the shop: an unknown shop becomes a pending merchant). code is 1-64 printable characters without spaces; title (at least 3 characters: what the code gives) is required. status, confidence, verified, working, valid and validity are refused (422 coupon.status_not_accepted): a code's status comes only from people's reports. expires is a date (YYYY-MM-DD) or ISO time; expiry_basis evidence needs evidence_url. A duplicate answers the existing code (200).
++ */
++export type CouponsSubmitRequest = {
++ [k: string]: unknown | undefined;
++} & {
++ code: string;
++ title: string;
++ description?: string | null;
++ merchant_id?: string;
++ host?: string;
++ url?: string;
++ expires?: string | null;
++ expiry_basis?: "submitter" | "evidence";
++ evidence_url?: string;
++ restrictions?: {
++ min_spend?: {
++ amount?: number | string;
++ /**
++ * ISO 4217.
++ */
++ currency?: string;
++ };
++ /**
++ * At most 10.
++ */
++ categories?: unknown[] | string;
++ new_customers_only?: boolean | string;
++ /**
++ * ISO 3166-1 alpha-2 codes, at most 50.
++ */
++ regions?: unknown[] | string;
++ other?: string;
++ };
++ hint?: string | null;
++ ai_run_id?: string | null;
++};
++
++/** [email protected] (owner: coupons) */
++/**
++ * coupons.submit-result@1: the answer of POST /api/v1/coupons/submit on OpenVibe.Coupons: { coupon, merchant, duplicate, review_state } — 201 for a new code, 200 when the same code was already known (duplicate).
++ */
++export interface CouponsSubmitResult {
++ coupon: CouponsCoupon;
++ merchant: {
++ id: string;
++ slug: string;
++ name: string;
++ status: "pending" | "active" | "disabled";
++ };
++ duplicate: boolean;
++ review_state: string;
++}
++
/** [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.
ddiff --git a/manifests/capabilities/blog.blog.configure.json b/manifests/capabilities/blog.blog.configure.json
iindex 26ad1a13..0f26e063 100644
---- a/manifests/capabilities/blog.blog.configure.json
++++ b/manifests/capabilities/blog.blog.configure.json
@@@ -15,5 +15,7 @@
"events": [],
"implementedBy": [
"PATCH /api/v1/blogs/:handle"
-- ]
++ ],
++ "inputSchema": "blog.blog-configure-request@1",
++ "outputSchema": "blog.blog-result@1"
}
ddiff --git a/manifests/capabilities/blog.blog.create.json b/manifests/capabilities/blog.blog.create.json
iindex d56af461..8aa8993d 100644
---- a/manifests/capabilities/blog.blog.create.json
++++ b/manifests/capabilities/blog.blog.create.json
@@@ -15,5 +15,7 @@
"events": [],
"implementedBy": [
"POST /api/v1/blogs"
-- ]
++ ],
++ "inputSchema": "blog.blog-create-request@1",
++ "outputSchema": "blog.blog-create-result@1"
}
ddiff --git a/manifests/capabilities/blog.feed.read.json b/manifests/capabilities/blog.feed.read.json
iindex e4b7f278..4099a552 100644
---- a/manifests/capabilities/blog.feed.read.json
++++ b/manifests/capabilities/blog.feed.read.json
@@@ -15,5 +15,7 @@
"events": [],
"implementedBy": [
"GET /api/v1/blogs/:handle/feed"
-- ]
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "blog.json-feed@1"
}
ddiff --git a/manifests/capabilities/blog.member.manage.json b/manifests/capabilities/blog.member.manage.json
iindex cc5325f3..61e0f7b8 100644
---- a/manifests/capabilities/blog.member.manage.json
++++ b/manifests/capabilities/blog.member.manage.json
@@@ -17,5 +17,7 @@
"GET /api/v1/blogs/:handle/members",
"PUT /api/v1/blogs/:handle/members/:subject",
"DELETE /api/v1/blogs/:handle/members/:subject"
-- ]
++ ],
++ "inputSchema": "blog.member-request@1",
++ "outputSchema": "blog.member-result@1"
}
ddiff --git a/manifests/capabilities/blog.post.create.json b/manifests/capabilities/blog.post.create.json
iindex 4b8e023e..d0851f86 100644
---- a/manifests/capabilities/blog.post.create.json
++++ b/manifests/capabilities/blog.post.create.json
@@@ -17,5 +17,7 @@
],
"implementedBy": [
"POST /api/v1/blogs/:handle/posts"
-- ]
++ ],
++ "inputSchema": "blog.post-create-request@1",
++ "outputSchema": "blog.post-create-result@1"
}
ddiff --git a/manifests/capabilities/blog.post.delete.json b/manifests/capabilities/blog.post.delete.json
iindex 1656d849..b78f5006 100644
---- a/manifests/capabilities/blog.post.delete.json
++++ b/manifests/capabilities/blog.post.delete.json
@@@ -19,5 +19,7 @@
],
"implementedBy": [
"DELETE /api/v1/posts/:id"
-- ]
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "blog.delete-result@1"
}
ddiff --git a/manifests/capabilities/blog.post.publish.json b/manifests/capabilities/blog.post.publish.json
iindex 0239c26c..960890e0 100644
---- a/manifests/capabilities/blog.post.publish.json
++++ b/manifests/capabilities/blog.post.publish.json
@@@ -20,5 +20,7 @@
],
"implementedBy": [
"POST /api/v1/posts/:id/publish"
-- ]
++ ],
++ "inputSchema": "blog.publish-request@1",
++ "outputSchema": "blog.publication-result@1"
}
ddiff --git a/manifests/capabilities/blog.post.read.json b/manifests/capabilities/blog.post.read.json
iindex 98d59b23..850a96c3 100644
---- a/manifests/capabilities/blog.post.read.json
++++ b/manifests/capabilities/blog.post.read.json
@@@ -19,5 +19,7 @@
"GET /api/v1/posts/:id/revisions/:n",
"GET /api/v1/posts/:id/diff",
"GET /api/v1/blogs/:handle/posts?all=1"
-- ]
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "blog.post-read-result@1"
}
ddiff --git a/manifests/capabilities/blog.post.schedule.json b/manifests/capabilities/blog.post.schedule.json
iindex f5488177..df5b5eb8 100644
---- a/manifests/capabilities/blog.post.schedule.json
++++ b/manifests/capabilities/blog.post.schedule.json
@@@ -21,5 +21,7 @@
"implementedBy": [
"POST /api/v1/posts/:id/schedule",
"DELETE /api/v1/posts/:id/schedule"
-- ]
++ ],
++ "inputSchema": "blog.schedule-request@1",
++ "outputSchema": "blog.schedule-result@1"
}
ddiff --git a/manifests/capabilities/blog.post.unpublish.json b/manifests/capabilities/blog.post.unpublish.json
iindex 19a13dea..5a66d6eb 100644
---- a/manifests/capabilities/blog.post.unpublish.json
++++ b/manifests/capabilities/blog.post.unpublish.json
@@@ -19,5 +19,7 @@
],
"implementedBy": [
"POST /api/v1/posts/:id/unpublish"
-- ]
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "blog.publication-result@1"
}
ddiff --git a/manifests/capabilities/blog.post.update.json b/manifests/capabilities/blog.post.update.json
iindex 2e8905f5..c5ae1156 100644
---- a/manifests/capabilities/blog.post.update.json
++++ b/manifests/capabilities/blog.post.update.json
@@@ -22,5 +22,7 @@
"POST /api/v1/posts/:id/revert",
"POST /api/v1/posts/:id/attachments",
"DELETE /api/v1/posts/:id/attachments/:aid"
-- ]
++ ],
++ "inputSchema": "blog.post-update-request@1",
++ "outputSchema": "blog.post-update-result@1"
}
ddiff --git a/manifests/capabilities/blog.theme.set.json b/manifests/capabilities/blog.theme.set.json
iindex cfde19aa..795730ed 100644
---- a/manifests/capabilities/blog.theme.set.json
++++ b/manifests/capabilities/blog.theme.set.json
@@@ -15,5 +15,7 @@
"events": [],
"implementedBy": [
"PUT /api/v1/blogs/:handle/theme"
-- ]
++ ],
++ "inputSchema": "blog.theme-request@1",
++ "outputSchema": "blog.blog-result@1"
}
ddiff --git a/manifests/capabilities/coupons.coupon.lookup.json b/manifests/capabilities/coupons.coupon.lookup.json
iindex 676be487..0208ee1a 100644
---- a/manifests/capabilities/coupons.coupon.lookup.json
++++ b/manifests/capabilities/coupons.coupon.lookup.json
@@@ -17,5 +17,7 @@
"GET /api/v1/merchants/:id/coupons",
"GET /api/v1/merchants/:id",
"GET /api/v1/coupons/:id"
-- ]
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "coupons.lookup-result@1"
}
ddiff --git a/manifests/capabilities/coupons.coupon.submit.json b/manifests/capabilities/coupons.coupon.submit.json
iindex 43095fc2..eb6580a8 100644
---- a/manifests/capabilities/coupons.coupon.submit.json
++++ b/manifests/capabilities/coupons.coupon.submit.json
@@@ -21,5 +21,7 @@
],
"implementedBy": [
"POST /api/v1/coupons/submit"
-- ]
++ ],
++ "inputSchema": "coupons.submit-request@1",
++ "outputSchema": "coupons.submit-result@1"
}
ddiff --git a/manifests/capabilities/coupons.merchant.manage.json b/manifests/capabilities/coupons.merchant.manage.json
iindex 6fcfd5f3..bd6ca9ab 100644
---- a/manifests/capabilities/coupons.merchant.manage.json
++++ b/manifests/capabilities/coupons.merchant.manage.json
@@@ -21,5 +21,7 @@
"POST /api/v1/merchants",
"POST /api/v1/merchants/:id/domains",
"POST /api/v1/merchants/:id/status"
-- ]
++ ],
++ "inputSchema": "coupons.merchant-manage-request@1",
++ "outputSchema": "coupons.merchant-manage-result@1"
}
ddiff --git a/manifests/capabilities/coupons.merchant.resolve.json b/manifests/capabilities/coupons.merchant.resolve.json
iindex 8c1b5f2d..d2b7c05b 100644
---- a/manifests/capabilities/coupons.merchant.resolve.json
++++ b/manifests/capabilities/coupons.merchant.resolve.json
@@@ -15,5 +15,7 @@
"events": [],
"implementedBy": [
"GET /api/v1/merchants/resolve?host="
-- ]
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "coupons.resolve-result@1"
}
ddiff --git a/manifests/capabilities/coupons.report.create.json b/manifests/capabilities/coupons.report.create.json
iindex d83fcbd7..dd1cb0d9 100644
---- a/manifests/capabilities/coupons.report.create.json
++++ b/manifests/capabilities/coupons.report.create.json
@@@ -20,5 +20,7 @@
],
"implementedBy": [
"POST /api/v1/coupons/:id/report"
-- ]
++ ],
++ "inputSchema": "coupons.report-request@1",
++ "outputSchema": "coupons.report-result@1"
}
ddiff --git a/manifests/capabilities/coupons.status.update.json b/manifests/capabilities/coupons.status.update.json
iindex 29fa410f..9cff218d 100644
---- a/manifests/capabilities/coupons.status.update.json
++++ b/manifests/capabilities/coupons.status.update.json
@@@ -22,5 +22,7 @@
],
"implementedBy": [
"POST /api/v1/coupons/:id/status"
-- ]
++ ],
++ "inputSchema": "coupons.status-request@1",
++ "outputSchema": "coupons.status-result@1"
}