openvibers/openvibe.contracts Commits

Capability schemas (WS-C task 4): common.no-body@1 and common.binary@1 conventions, media.file-upload@1, Media and Tools capabilities complete; a ratchet fails on any active capability without schemas outside compatibility/capability-schema-gaps.json (162, only shrinking) (unreleased)

OP OpenVibers committed 25 Sept 2026 · 7aee059fc8 · 21 files, +1301 −6


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d9dacd18..0f621296 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,7 +6,7 @@ the notes were in the tag and commit messages (`git tag -n1`).
## Unreleased
--Capability schemas (roadmap WS-C task 4), starting with Search: **`search.query@1`** (the query string of `GET /api/v1/search` and `/suggest`: q, owner, type, lang, facets, limit, cursor, `facet.<key>`), **`search.query-result@1`** (a page of hits the caller may see, `next_cursor`, facet counts; never an ACL or a total) and **`search.write-result@1`** (`PUT /api/v1/documents/…`: applied or unchanged; stale and conflicting revisions are 409 problems). `search.query.run` and `search.query.delegate` name the first two; `search.document.write` names the third as its output. Fixtures for each. Additive.
++Capability schemas (roadmap WS-C task 4), starting with Search: **`search.query@1`** (the query string of `GET /api/v1/search` and `/suggest`: q, owner, type, lang, facets, limit, cursor, `facet.<key>`), **`search.query-result@1`** (a page of hits the caller may see, `next_cursor`, facet counts; never an ACL or a total) and **`search.write-result@1`** (`PUT /api/v1/documents/…`: applied or unchanged; stale and conflicting revisions are 409 problems). `search.query.run` and `search.query.delegate` name the first two; `search.document.write` names the third as its output. Fixtures for each. Two conventions so every capability can name schemas: **`common.no-body@1`** (a request whose parameters are the path and query) and **`common.binary@1`** (a response of bytes); **`media.file-upload@1`** is the multipart body of `POST /api/v1/:app/files`. The Media and Tools capabilities name them. **A ratchet**: `test/capability-schemas.test.js` fails on an active capability without an input or output schema unless it is in `compatibility/capability-schema-gaps.json` (162 today), and on a listed gap that has been filled; the list only shrinks. Additive.
## 0.49.0 — 2026-09-25
ddiff --git a/compatibility/capability-schema-gaps.json b/compatibility/capability-schema-gaps.json
nnew file mode 100644
iindex 00000000..1da77df0
---- /dev/null
++++ b/compatibility/capability-schema-gaps.json
@@@ -0,0 +1,1122 @@
++{
++ "_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": "ai.provider.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "ai.run.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "ai.usage.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "ai.workflow.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.balance.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.cashout.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.cashout.request",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.entitlement.check",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.intent.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.ledger.admin",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.subscription.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "billing.transfer.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "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": "chat.message.send",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "codes.release.manage",
++ "missing": [
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "codes.release.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "community.comment.moderate",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "community.paste.create",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "community.paste.moderate",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "community.paste.write",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "community.post.create",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "community.pulse.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "community.pulse.write",
++ "missing": [
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "community.space.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "community.thread.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "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": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.offer.expire",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.offer.merge",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.offer.moderate",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.offer.submit",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.offer.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.product.resolve",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.vote.remove",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.vote.set",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.watch.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.watch.delete",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "deals.watch.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "events.app.publish",
++ "missing": [
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "events.app.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "events.app.subscribe",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "events.delivery.admin",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "events.event.publish",
++ "missing": [
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "events.event.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "events.subscription.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "games.mod.manage",
++ "missing": [
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "games.mod.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "games.prop.place",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "games.world.announce",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "host.deploy.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "host.domain.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "host.site.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "live.channel.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "live.discovery.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "live.follower.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "live.stream.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "live.tips_delivery.write",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.blocks.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.coins.credit",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.coins.debit",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.coins.transfer",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.integration.github.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.modules.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "network.notifications.push",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.staff.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.cluster.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.cluster.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.perspective.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.source.attach",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.story.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.story.publish",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.story.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.story.retract",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.story.revise",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.timeline.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "news.topic.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "openre.key.rotate",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "openre.output.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "openre.output.write",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "openre.session.end",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "openre.session.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "openre.stream.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "openre.stream.write",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.correction.submit",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.entity.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.entity.merge",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.entity.resolve",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.entity.split",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.signal.import",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.summary.propose",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "reviews.summary.publish",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "sources.item.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "sources.source.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "sources.source.read",
++ "missing": [
++ "inputSchema"
++ ]
++ },
++ {
++ "id": "tips.checkout.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.goal.close",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.goal.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.goal.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.interaction.get",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.interaction.list",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.interaction.moderate",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.interaction.record",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.media_request.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.overlay.config.get",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.overlay.config.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.overlay.token.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.overlay.token.revoke",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.profile.get",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.profile.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.simulation.run",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.superchat.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "tips.tts.request",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.alert.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.alert.delete",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.alert.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.context.propose",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.context.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.instrument.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.instrument.resolve",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.observation.write",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.watchlist.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.watchlist.delete",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.watchlist.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "trade.watchlist.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.creator.members.list",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.entitlement.check",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.membership.checkout",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.membership.status",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.perk.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.perk.list",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.perk.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.plan.archive",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.plan.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.plan.list",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.plan.update",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.resource.policy.evaluate",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.resource.policy.get",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "vip.resource.policy.set",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.citation.attach",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.page.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.page.read",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.revision.propose",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.revision.publish",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.revision.revert",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.search.query",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "wiki.space.create",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ }
++ ]
++}
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 29c45514..eb882472 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -136,6 +136,33 @@
"status": "active",
"adr": "ADR-016"
},
++ {
++ "id": "common.no-body",
++ "version": "1.0.0",
++ "owner": "common",
++ "schema": "common/no-body.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "common.binary",
++ "version": "1.0.0",
++ "owner": "common",
++ "schema": "common/binary.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "media.file-upload",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "media/file-upload.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
{
"id": "search.index-document",
"version": "1.0.0",
ddiff --git a/contracts/common/binary.v1.json b/contracts/common/binary.v1.json
nnew file mode 100644
iindex 00000000..38333177
---- /dev/null
++++ b/contracts/common/binary.v1.json
@@@ -0,0 +1,8 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/common/binary.v1.json",
++ "title": "Binary",
++ "description": "common.binary@1: the response is the resource's bytes, not JSON — Content-Type names the media type, Content-Length (or Range with 206) the size. An error is still an application/problem+json body. A declaration for capabilities that serve files (roadmap WS-C task 4); there is nothing to validate but the headers.",
++ "type": "string",
++ "contentEncoding": "binary"
++}
ddiff --git a/contracts/common/no-body.v1.json b/contracts/common/no-body.v1.json
nnew file mode 100644
iindex 00000000..2faefe37
---- /dev/null
++++ b/contracts/common/no-body.v1.json
@@@ -0,0 +1,8 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/common/no-body.v1.json",
++ "title": "NoBody",
++ "description": "common.no-body@1: the capability's request carries no body; its parameters are the path and query of the routes in implementedBy (an id, a page cursor). An input schema that says so explicitly, so every active capability names one (roadmap WS-C task 4).",
++ "type": "object",
++ "maxProperties": 0
++}
ddiff --git a/contracts/media/file-upload.v1.json b/contracts/media/file-upload.v1.json
nnew file mode 100644
iindex 00000000..7316cce2
---- /dev/null
++++ b/contracts/media/file-upload.v1.json
@@@ -0,0 +1,12 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/media/file-upload.v1.json",
++ "title": "MediaFileUpload",
++ "description": "media.file-upload@1: the multipart/form-data body of POST /api/v1/:app/files (media.object.upload, namespace :app): one part named `file` with the bytes and its filename and type. The tenant's quota is checked against the bytes; the answer is media.media-ref@1.",
++ "type": "object",
++ "required": ["file"],
++ "additionalProperties": true,
++ "properties": {
++ "file": { "type": "string", "contentEncoding": "binary", "description": "The multipart part named file (filename and Content-Type from the part)." }
++ }
++}
ddiff --git a/fixtures/common.binary/invalid/json-object.json b/fixtures/common.binary/invalid/json-object.json
nnew file mode 100644
iindex 00000000..0ea2f495
---- /dev/null
++++ b/fixtures/common.binary/invalid/json-object.json
@@@ -0,0 +1 @@
++{ "data": "x" }
ddiff --git a/fixtures/common.binary/valid/bytes.json b/fixtures/common.binary/valid/bytes.json
nnew file mode 100644
iindex 00000000..00dc5ef2
---- /dev/null
++++ b/fixtures/common.binary/valid/bytes.json
@@@ -0,0 +1 @@
++"<bytes>"
ddiff --git a/fixtures/common.no-body/invalid/has-a-field.json b/fixtures/common.no-body/invalid/has-a-field.json
nnew file mode 100644
iindex 00000000..099ad2e0
---- /dev/null
++++ b/fixtures/common.no-body/invalid/has-a-field.json
@@@ -0,0 +1 @@
++{ "id": "x" }
ddiff --git a/fixtures/common.no-body/valid/empty.json b/fixtures/common.no-body/valid/empty.json
nnew file mode 100644
iindex 00000000..0967ef42
---- /dev/null
++++ b/fixtures/common.no-body/valid/empty.json
@@@ -0,0 +1 @@
++{}
ddiff --git a/fixtures/media.file-upload/invalid/no-file.json b/fixtures/media.file-upload/invalid/no-file.json
nnew file mode 100644
iindex 00000000..cc32e1c7
---- /dev/null
++++ b/fixtures/media.file-upload/invalid/no-file.json
@@@ -0,0 +1 @@
++{ "name": "x.png" }
ddiff --git a/fixtures/media.file-upload/valid/file.json b/fixtures/media.file-upload/valid/file.json
nnew file mode 100644
iindex 00000000..a12940b8
---- /dev/null
++++ b/fixtures/media.file-upload/valid/file.json
@@@ -0,0 +1 @@
++{ "file": "<bytes of screenshot.png>" }
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex b0fd814d..d69f5689 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -1584,6 +1584,55 @@
}
}
},
++ "common.no-body": {
++ "version": "1.0.0",
++ "owner": "common",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/common/no-body.v1.json",
++ "title": "NoBody",
++ "description": "common.no-body@1: the capability's request carries no body; its parameters are the path and query of the routes in implementedBy (an id, a page cursor). An input schema that says so explicitly, so every active capability names one (roadmap WS-C task 4).",
++ "type": "object",
++ "maxProperties": 0
++ }
++ },
++ "common.binary": {
++ "version": "1.0.0",
++ "owner": "common",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/common/binary.v1.json",
++ "title": "Binary",
++ "description": "common.binary@1: the response is the resource's bytes, not JSON — Content-Type names the media type, Content-Length (or Range with 206) the size. An error is still an application/problem+json body. A declaration for capabilities that serve files (roadmap WS-C task 4); there is nothing to validate but the headers.",
++ "type": "string",
++ "contentEncoding": "binary"
++ }
++ },
++ "media.file-upload": {
++ "version": "1.0.0",
++ "owner": "media",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/media/file-upload.v1.json",
++ "title": "MediaFileUpload",
++ "description": "media.file-upload@1: the multipart/form-data body of POST /api/v1/:app/files (media.object.upload, namespace :app): one part named `file` with the bytes and its filename and type. The tenant's quota is checked against the bytes; the answer is media.media-ref@1.",
++ "type": "object",
++ "required": [
++ "file"
++ ],
++ "additionalProperties": true,
++ "properties": {
++ "file": {
++ "type": "string",
++ "contentEncoding": "binary",
++ "description": "The multipart part named file (filename and Content-Type from the part)."
++ }
++ }
++ }
++ },
"search.index-document": {
"version": "1.0.0",
"owner": "search",
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex e2c233fc..8ddaf130 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -601,6 +601,30 @@ export interface ReleaseManifest {
metrics_url?: string | null;
}
++/** [email protected] (owner: common) */
++/**
++ * common.no-body@1: the capability's request carries no body; its parameters are the path and query of the routes in implementedBy (an id, a page cursor). An input schema that says so explicitly, so every active capability names one (roadmap WS-C task 4).
++ */
++export interface NoBody {}
++
++/** [email protected] (owner: common) */
++/**
++ * common.binary@1: the response is the resource's bytes, not JSON — Content-Type names the media type, Content-Length (or Range with 206) the size. An error is still an application/problem+json body. A declaration for capabilities that serve files (roadmap WS-C task 4); there is nothing to validate but the headers.
++ */
++export type Binary = string;
++
++/** [email protected] (owner: media) */
++/**
++ * media.file-upload@1: the multipart/form-data body of POST /api/v1/:app/files (media.object.upload, namespace :app): one part named `file` with the bytes and its filename and type. The tenant's quota is checked against the bytes; the answer is media.media-ref@1.
++ */
++export interface MediaFileUpload {
++ /**
++ * The multipart part named file (filename and Content-Type from the part).
++ */
++ file: string;
++ [k: string]: unknown | undefined;
++}
++
/** [email protected] (owner: search) */
/**
* What an owning service tells OpenVibe.Search about one of its resources (roadmap §15.12, §4.2 A). The owner stays the source of truth; Search keeps a revision-ordered, permission-aware copy for discovery. A document with deleted=true is a tombstone: it removes the resource from every result and wins over any upsert with the same or an older revision.
ddiff --git a/manifests/capabilities/media.object.read.json b/manifests/capabilities/media.object.read.json
iindex fbf06057..1bc24f8e 100644
---- a/manifests/capabilities/media.object.read.json
++++ b/manifests/capabilities/media.object.read.json
@@@ -17,5 +17,6 @@
"implementedBy": [
"GET /api/v1/:app/files/:key",
"GET /f/:key"
-- ]
++ ],
++ "outputSchema": "common.binary@1"
}
ddiff --git a/manifests/capabilities/media.object.upload.json b/manifests/capabilities/media.object.upload.json
iindex f4dd13d8..c6eb498d 100644
---- a/manifests/capabilities/media.object.upload.json
++++ b/manifests/capabilities/media.object.upload.json
@@@ -19,5 +19,6 @@
],
"implementedBy": [
"POST /api/v1/:app/files"
-- ]
++ ],
++ "inputSchema": "media.file-upload@1"
}
ddiff --git a/manifests/capabilities/tools.job.cancel.json b/manifests/capabilities/tools.job.cancel.json
iindex 0af9bfdc..1bd4fcaf 100644
---- a/manifests/capabilities/tools.job.cancel.json
++++ b/manifests/capabilities/tools.job.cancel.json
@@@ -16,5 +16,6 @@
"events": [],
"implementedBy": [
"DELETE /api/v1/jobs/:id"
-- ]
++ ],
++ "inputSchema": "common.no-body@1"
}
ddiff --git a/manifests/capabilities/tools.job.read.json b/manifests/capabilities/tools.job.read.json
iindex 3531d575..ed3e6087 100644
---- a/manifests/capabilities/tools.job.read.json
++++ b/manifests/capabilities/tools.job.read.json
@@@ -18,5 +18,6 @@
"GET /api/v1/jobs/:id",
"GET /api/v1/jobs/:id/events",
"GET /api/v1/jobs/:id/files/:n"
-- ]
++ ],
++ "inputSchema": "common.no-body@1"
}
ddiff --git a/manifests/capabilities/tools.tool.read.json b/manifests/capabilities/tools.tool.read.json
iindex aa799bf5..cacd4f28 100644
---- a/manifests/capabilities/tools.tool.read.json
++++ b/manifests/capabilities/tools.tool.read.json
@@@ -18,5 +18,6 @@
"GET /api/v1/tools",
"GET /api/v1/tools/:id",
"GET /api/v1/tools/:id/schema"
-- ]
++ ],
++ "inputSchema": "common.no-body@1"
}
ddiff --git a/test/capability-schemas.test.js b/test/capability-schemas.test.js
nnew file mode 100644
iindex 00000000..84bab887
---- /dev/null
++++ b/test/capability-schemas.test.js
@@@ -0,0 +1,31 @@
++'use strict';
++// Capability schemas (roadmap WS-C task 4): every active capability names an input and an output
++// schema that resolve in the catalog, except the recorded gaps in compatibility/capability-schema-gaps.json,
++// a list that only shrinks: a new active capability without schemas fails, and so does a listed gap that
++// has been filled (take it off the list).
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { schema } = require('..');
++
++const ROOT = path.join(__dirname, '..');
++const dir = path.join(ROOT, 'manifests', 'capabilities');
++const gaps = new Map(JSON.parse(fs.readFileSync(path.join(ROOT, 'compatibility', 'capability-schema-gaps.json'), 'utf8')).gaps.map((g) => [g.id, g.missing]));
++const problems = [];
++let active = 0, complete = 0;
++for (const f of fs.readdirSync(dir).sort()) {
++ const c = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
++ if (c.status !== 'active') continue;
++ active++;
++ const missing = ['inputSchema', 'outputSchema'].filter((k) => !c[k]);
++ for (const k of ['inputSchema', 'outputSchema']) {
++ if (c[k] && !schema(c[k])) problems.push(`${c.id}: ${k} ${c[k]} is not in the catalog`);
++ }
++ const listed = gaps.get(c.id);
++ if (!missing.length) { complete++; if (listed) problems.push(`${c.id}: has both schemas now — remove it from capability-schema-gaps.json`); continue; }
++ if (!listed) problems.push(`${c.id}: active without ${missing.join(' and ')} (add the schema, or record the gap)`);
++ else if (missing.some((k) => !listed.includes(k))) problems.push(`${c.id}: lost its ${missing.filter((k) => !listed.includes(k)).join(', ')}`);
++}
++for (const id of gaps.keys()) if (!fs.existsSync(path.join(dir, `${id}.json`))) problems.push(`${id}: listed as a gap but no such capability`);
++assert.deepStrictEqual(problems, []);
++console.log(`capability schemas: ${complete}/${active} active capabilities complete; ${gaps.size} recorded gap(s)`);
ddiff --git a/test/run.js b/test/run.js
iindex ca42d9cd..45b13066 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -632,6 +632,9 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);
fs.rmSync(tmp, { recursive: true, force: true });
}
++// ── Capability schemas (WS-C task 4): a ratchet over compatibility/capability-schema-gaps.json ──
++execFileSync(process.execPath, [path.join(__dirname, 'capability-schemas.test.js')], { stdio: 'inherit' });
++
// ── Generated output and compatibility gate ──────────────────────────────
execFileSync(process.execPath, [path.join(ROOT, 'scripts/generate.js'), '--check'], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/compat.js')], { stdio: 'inherit' });