Published in openvibe-contracts v0.114.0 (docs/adr/ADR-048-services-control-plane.md), rendered as is.

ADR-048: The Services control plane — authority aggregation, OVRN and one control-operation contract

Status: Accepted 2026-10-04 (plan track T13, step 1). Amended 2026-10-05: the resource index owns GET /api/v1/resources and OpenVibe.Network's T2 Fabric offer registry, whose public routes are currently at /api/v1/resources, moves them to /api/v1/offers in Network's release; the resource-kind catalog below records the id prefixes Events and Codes still owe, plus the prefixes not yet in lib/ids.js. Amended 2026-10-07 (step 8, the Contracts half): only Network lists projects, Media lists objects alone, Events waits for a stored queue row, Codes hosts no repositories, the person-owned kinds are step 8 phase 2, and the chosen prefixes joined lib/ids.js. No contract schema changes. Builds on ADR-034 §2 (one resource name) and §5 (control plane and data plane) and on ADR-046 §6 (the data plane keeps running without the control plane). Contracts: common.resource-name@1, common.resource-control-request@1, common.resource-control-result@1; helpers in contracts.resources (lib/resources.js).

Evidence

Decision

  1. Services aggregates authorities and owns no other service's rows. It reads each authority's resource index and events and keeps at most a read model it can rebuild from them. It never opens, writes or migrates another service's database, and its outage leaves every authority serving its own API.
  2. Every control operation is a call to the owning authority. Create, update, delete, start, stop, suspend, resume, resize, rotate, pair, grant, revoke and archive are sent as a common.resource-control-request@1 to the control API of the service named by the resource, and answered with a common.resource-control-result@1. The authority checks the caller's grant, decides, applies and emits its own events; Services only shows the answer. Actor's Console and any other operator surface use the same contract.
  3. OVRN is the one cross-service resource name: ovrn:<service>:<project_id>:<type>/<id> (common.resource-name@1), for example ovrn:media:prj_01J…:object/med_01K….

Authority boundaries

| Concern | The authority (owning service) | OpenVibe.Services | |---|---|---| | Rows and bytes | owns, writes, migrates, exports and erases them | never holds them; at most a rebuildable read model | | Grants and policy | checks the caller's capability and the on_behalf_of subject's grant on every control call | asks; never decides for an authority | | Sensitive-action gate | decides which actions need confirmation and refuses until one is approved | shows the confirmation, collects the owner's approval, retries | | Events and audit | emits the resource's events and audit rows | consumes them for its index and activity views | | Resource index | owns GET /api/v1/resources, answering common.resource-summary@1 for every resource it holds; no other public catalog is to mount that path once Network's offers move to /api/v1/offers (today they still share /api/v1/resources) | fans out, merges and paginates | | Resource offers (T2 Fabric) | OpenVibe.Network's offer registry (server/registry/offers.js), currently public at GET /api/v1/resources and moving to GET /api/v1/offers (/:offer_id for detail, /:offer_id/beacon for the probe) in Network's release; reported at POST /internal/resources/report | shows and calls Network's control API like any other authority | | Projects, apps, keys, nodes | OpenVibe.Network | shows and calls Network's control API like any other authority |

The project segment of every OVRN is the tenancy boundary: a control request's resource must be a resource of its project_id, and contracts.resources.checkControlRequest refuses one that is not. Authorities other than Network never list projects; every summary carries project_id.

The index owns /api/v1/resources; the offers move to /api/v1/offers in Network's release. That path belongs to the resource index above, so OpenVibe.Network's T2 Fabric offer registry (server/registry/offers.js, mounted in server/index.js:484-485, designed in docs/t2-resource-registry.md) currently serves its public routes at GET /api/v1/resources, GET /api/v1/resources/:offer_id and GET /api/v1/resources/:offer_id/beacon, and moves them in a Network release to GET /api/v1/offers, GET /api/v1/offers/:offer_id and GET /api/v1/offers/:offer_id/beacon. Until that release lands, the claim that no other public catalog mounts /api/v1/resources is not yet true — Network's offers still share the path. The machine report keeps POST /internal/resources/report, and the internal reads keep their /internal/resources paths and the network.resource.report guard. Moving a public route is a release-note change: OpenVibe.Network's release must name the old and new paths.

A merged page never fails for one bad authority: a slow or failing authority is omitted from the page and named, with the problem code it answered, in common.resource-list-result@1's partial (added 0.109.0).

The move has consumers outside server/registry/offers.js itself, which the Network release note must update with it:

Resource kinds and three-letter id prefixes. Every summary's kind is <service>.<type> and its id carries the same three-letter prefix its OVRN uses (lib/ids.js). The catalog the step-8 index sweep starts from; every prefix below is in lib/ids.js except act, run and zon, which are proposed and not yet chosen there:

| Service | Kind | Id prefix | |---|---|---| | Actor | actor.actor | act (proposed, not in lib/ids.js) | | Events | events.queue | future: when Events stores queues | | Events | events.subscription | sub | | Media | media.object | med | | Services | services.manifest | mfs (was codes.manifest until 0.113.0, when the developer portal moved from Codes to Services) | | Services | services.release | rel (was codes.release until 0.113.0) | | Run | run.sandbox | run (proposed, not in lib/ids.js; collides with the existing run_ AI run ids, contracts/ai/run.v1.json) | | Watch | watch.watch | wch | | Zone | zone.object-zone | zon (proposed, not in lib/ids.js; today only a usage-recorded description field <zon_id>) |

Codes hosts no repositories, so codes.repo is gone. Media's index lists only its objects; its v1 vods and clips are projections over objects (bigint ids) and are never listed. The person-owned resources — Bot robots and devices, Chat rooms, Community spaces, OpenRe.Stream streams and Games characters — are step 8 phase 2, served like Network's user-owned node principals: no OVRN, owner = user.

Events' queue is future until Events stores a queue row; act, run and zon are proposed but are not in lib/ids.js either. run cannot simply be added: run_ is already OpenVibe.AI's run-id pattern (contracts/ai/run.v1.json), so a run.sandbox id would collide with an AI run id. The step-8 sweep chooses every missing prefix, resolves the run_ collision and adds them to lib/ids.js before those services answer GET /api/v1/resources. No contract schema carries this catalog: it is prose, and recording it changes no schema.

Idempotency and confirmations

confirmation_required appears only on refused, and done and pending carry no problem. contracts.resources.checkControlResult enforces these rules.

Out of scope

Consequences