openvibers/openvibe.contracts Commits
Use a low-entropy sample idempotency key in the resize fixture
Changes
@@@ -57,3 +57,9 @@ description = "Sample realtime tickets in network.realtime-ticket-result fixturecondition = "AND"paths = ['''^fixtures/network\.realtime\-ticket\-result/(valid|invalid)/[a-z-]+\.json$''']regexes = ['''^eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9\.eyJ0eXAiOiJyZWFsdGltZSJ9\.c2lnbmF0dXJl$''']++++[[allowlists]]++description = "Sample Idempotency-Key value in a common.resource-control-request@1 fixture; a caller-chosen dedupe token, not a secret"++condition = "AND"++paths = ['''^fixtures/common\.resource-control-request/valid/dry-run-resize\.json$''']++regexes = ['''^ctl-01K6R3-resize$''']@@@ -6,6 +6,16 @@ the notes were in the tag and commit messages (`git tag -n1`).## 0.95.0 — 2026-10-04++**The Services control plane** (ADR-048, plan T13 step 1). Additive: three new catalog ids at 1.0.0, owner `contracts`.++`common.resource-name@1` is the OVRN `ovrn:<service>:<project_id>:<type>/<id>`, with the pattern `common.usage-recorded@1`++and `common.resource-summary@1` already use. `common.resource-control-request@1` is one control operation OpenVibe.Services++sends to the authority that owns a resource (`action`, `resource` or, for `create`, `resource_kind`, `project_id`,++`idempotency_key`, optional `params`, `on_behalf_of`, `confirmation_id`, `dry_run`, `trace_id`), and++`common.resource-control-result@1` the authority's answer (`state` done, pending, refused or failed, with `result`,++`confirmation_required` or an `errors.problem@1`). New `contracts.resources`: `parse`, `format`, `nameOf`, `checkName`,++`checkControlRequest` (the resource's project is `project_id`; `create` names a kind, every other action a resource) and++`checkControlResult` (`problem` and `confirmation_required` fit `state`). New test `test/resources.test.js`.++**OpenVibe.Chat's internal read API** (plan T3, J4b): OpenVibe.Live stops reading its mirrored copy of Chat's tables.Additive. Six new internal capabilities owned by chat (loopback, Chat service token, Live's principal today; nevergranted to an app or delegated), added to `manifests/services/chat.json`:@@@ -195,6 +195,12 @@ name the zone's own project and id, a name belongs to one zone of its environmenanswer's `ovrn` names its `zone_id`. `contracts.usage.checkUsageRecorded(u)` refuses a usage rollup whose`resource` names another project than its `project_id`.++`contracts.resources` holds the resource names and the control-operation contract of ADR-048 (plan T13):++`parse(ovrn)` and `format({ service, project_id, type, id })` are the one OVRN parser and formatter, `nameOf(summary)`++composes the name of a `common.resource-summary@1`, `checkControlRequest(q)` refuses a request whose `resource`++names another project than its `project_id` (and `create` names `resource_kind`, every other action a `resource`),++and `checkControlResult(r)` holds `problem` and `confirmation_required` to the result's `state`.++Tools' problem codes are listed with the routes that answer them (`tools.run-request@1`,`tools.job-request@1`, `tools.tool-list@1`, `tools.tool@1`). They include `400 tools.query.invalid`,`404 tools.tool.not_found`, `413 tools.pdf.too_many_pages`, `422 tools.pdf.wrong_password`,@@@ -6052,6 +6052,36 @@"compatibility": "backward","status": "planned","adr": "ADR-034"++ },++ {++ "id": "common.resource-name",++ "version": "1.0.0",++ "owner": "contracts",++ "schema": "common/resource-name.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-048"++ },++ {++ "id": "common.resource-control-request",++ "version": "1.0.0",++ "owner": "contracts",++ "schema": "common/resource-control-request.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-048"++ },++ {++ "id": "common.resource-control-result",++ "version": "1.0.0",++ "owner": "contracts",++ "schema": "common/resource-control-result.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-048"}]}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/common/resource-control-request.v1.json",++ "title": "ResourceControlRequest",++ "description": "common.resource-control-request@1: one control operation OpenVibe.Services (or Actor's Console) sends to the authority that owns a resource (ADR-048, plan T13). Services never mutates another service's database; every create, update, start, stop, delete, resize, rotate, pair, grant or revoke is this request to the owning authority's control API, which decides and answers with a common.resource-control-result@1. A request names exactly one of resource (every action but create) or resource_kind (create, before the name exists). project_id is the tenancy boundary and equals the project segment of resource. idempotency_key makes a retry safe: the authority answers a repeated key with the first result. A sensitive action (money, publishing, deleting, physical control) is refused with confirmation_required until its owner approves; the retry carries that confirmation_id (roadmap WS-Z2). The cross-field rules are checked by contracts.resources.checkControlRequest.",++ "type": "object",++ "additionalProperties": false,++ "required": ["action", "project_id", "idempotency_key"],++ "oneOf": [{ "required": ["resource"] }, { "required": ["resource_kind"] }],++ "properties": {++ "action": { "enum": ["create", "update", "delete", "start", "stop", "suspend", "resume", "resize", "rotate", "pair", "grant", "revoke", "archive"] },++ "resource": { "$ref": "resource-name.v1.json", "description": "The resource acted on; required for every action but create." },++ "resource_kind": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}\\.[a-z][a-z0-9-]{0,39}$", "description": "service.type (a common.resource-summary@1 kind), for create, before the resource name exists." },++ "project_id": { "type": "string", "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$" },++ "params": { "type": "object", "description": "Action-specific input, shaped by the authority's own contract (never redefined here)." },++ "idempotency_key": { "type": "string", "minLength": 8, "maxLength": 200 },++ "on_behalf_of": { "$ref": "../identity/subject-ref.v1.json", "description": "The subject whose grant the authority checks; the caller's own token says who sent it." },++ "confirmation_id": { "type": ["string", "null"], "maxLength": 80, "description": "The approved confirmation from an earlier confirmation_required answer." },++ "dry_run": { "type": "boolean", "default": false, "description": "Decide and explain without applying." },++ "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }++ }++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/common/resource-control-result.v1.json",++ "title": "ResourceControlResult",++ "description": "common.resource-control-result@1: the authority's answer to a common.resource-control-request@1 (ADR-048, plan T13). state is the outcome, not the resource's own state: done (applied), pending (accepted and asynchronous; the authority reports completion later on its events), refused (a policy or confirmation gate said no) or failed (an error). State rules: refused carries problem or confirmation_required; failed carries problem; done and pending carry neither; confirmation_required appears only on refused. result carries the authority's own response document and never another service's rows. The state rules are checked by contracts.resources.checkControlResult.",++ "type": "object",++ "additionalProperties": false,++ "required": ["action", "state", "at"],++ "properties": {++ "action": { "type": "string" },++ "resource": { "$ref": "resource-name.v1.json" },++ "state": { "enum": ["done", "pending", "refused", "failed"] },++ "result": { "type": "object" },++ "confirmation_required": {++ "type": "object",++ "additionalProperties": false,++ "required": ["confirmation_id", "reason"],++ "properties": { "confirmation_id": { "type": "string", "maxLength": 80 }, "reason": { "type": "string" } },++ "description": "The action needs its owner's approval; retry the same request with this confirmation_id once approved."++ },++ "problem": { "$ref": "../errors/problem.v1.json", "description": "RFC 9457 body when refused or failed." },++ "at": { "type": "string", "format": "date-time" }++ }++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/common/resource-name.v1.json",++ "title": "ResourceName",++ "description": "common.resource-name@1: the one stable, cross-service name of an OpenVibe resource (OVRN; ADR-034 section 2, ADR-048, plan T13): ovrn:<service>:<project_id>:<type>/<id>, for example ovrn:media:prj_01J0000000000000000000000A:object/med_01K0000000000000000000000B. The service segment is the authority that owns the resource; the project segment is its tenancy boundary; type is the second half of a common.resource-summary@1 kind and id is that summary's id, so the name composes from any resource summary. It is used in grant scopes, audit rows, event subjects, usage records, bill lines and console URLs. It never names a person, an app, a request, a session or an event. The pattern is the one common.usage-recorded@1 resource and common.resource-summary@1 ovrn already use.",++ "type": "string",++ "maxLength": 200,++ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",++ "not": { "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_" }++}nnew file mode 100644@@@ -0,0 +1,91 @@++# ADR-048: The Services control plane — authority aggregation, OVRN and one control-operation contract++++**Status:** Accepted 2026-10-04 (plan track T13, step 1). 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++++- **ADR-034 §2** decided that every resource belongs to a project and has one name,++ `ovrn:<service>:<project>:<type>/<id>`, used in grants, audit, events, usage records, bills and the console. The++ pattern already lives inline in `common.usage-recorded@1` `resource` and `common.resource-summary@1` `ovrn`, but no++ contract names it and no helper parses it.++- **ADR-034 §5** puts identity, projects, grants, registry, billing and deploy in a small control plane, and Media bytes,++ Events streams, AI runs and the rest in a data plane partitioned by project.++- **ADR-046 §6**: a control-plane outage stops change, never delivery. A console that writes into other services'++ databases would break that rule, because their data plane would depend on the console's writes.++- **Plan T13**: OpenVibe.Services (`openvibe.services`, reserved in `manifests/services/services.json`) is the developer++ console over every service: projects, apps, keys, grants, usage, runs, events, deploys and health. The resource index++ (`common.resource-summary@1`, `common.resource-list-result@1`, `GET /api/v1/resources`) is how it lists resources it++ does not own. Nothing yet says how it changes one.++++## 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…`.++ - `service` is the authority that owns the resource; `project_id` its tenancy boundary.++ - `type` is the second half of the resource summary's `kind` (`<service>.<type>`), `id` the summary's typed ULID,++ whose prefix is exactly three letters. So a name composes from any summary (`contracts.resources.nameOf`).++ - It names resources only: never a person, an app as an actor, a request, a session or an event.++ - `contracts.resources.parse` and `format` are the only parser and formatter; no service splits on `:` itself.++ - A project is addressed by its bare `prj_` id. It is not given a self-referential OVRN.++++## 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 | answers `GET /api/v1/resources` with `common.resource-summary@1` | fans out, merges and paginates |++| 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.++++## Idempotency and confirmations++++- Every control request carries an `idempotency_key` (8–200 characters). The authority stores the first answer per++ (caller, project, key) and returns it for a repeat, so a retry after a timeout never applies an action twice. A key++ reused with a different request body is refused.++- `create` names `resource_kind` (`<service>.<type>`), because the name does not exist yet. Every other action names++ `resource`. A request never carries both.++- `dry_run: true` asks the authority to decide and explain without applying.++- **Confirmations (roadmap WS-Z2).** A sensitive action (money, publishing, deleting, physical control) is answered++ `state: refused` with `confirmation_required: { confirmation_id, reason }`. Once the resource's owner approves, the++ caller repeats the request with that `confirmation_id`. The authority issues and checks confirmation ids; Services++ never mints one.++- **Result states** describe the outcome, not the resource's own state:++ - `done`: applied.++ - `pending`: accepted; completion follows on the authority's events.++ - `refused`: carries a `problem` or `confirmation_required`.++ - `failed`: carries a `problem` (`errors.problem@1`).++++ `confirmation_required` appears only on `refused`, and `done` and `pending` carry no problem.++ `contracts.resources.checkControlResult` enforces these rules.++++## Out of scope++++- The console surfaces, the Services repository and its deploy (T13 steps 3, 9, 15).++- The SDK client (`openvibe-sdk/resources`, step 2) and each service's resource index (steps 5 and 8).++- Each authority's action-specific `params` and `result` shapes. They stay in the authority's own contracts and are never++ redefined here.++- How a confirmation is approved (WS-Z2), billing of control calls, and moving `common.usage-recorded@1` `resource` to++ a `$ref` of `common.resource-name@1`.++- Three-letter id prefixes still to be chosen before the step-8 sweep (Events queues, Codes repositories).++++## Consequences++++- Additive only: three new catalog ids at 1.0.0. No existing schema changes.++- A Services outage stops console changes, never a service's own API or data plane.++- Rollback: consumers pin the previous release tag. Nothing reads these contracts until T13 step 2.@@@ -41,3 +41,4 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each| [ADR-035](ADR-035-postgresql-and-valkey.md) | PostgreSQL 18 as every service's system of record (database + roles per service, PgBouncer transaction pooling, pgBackRest PITR to B2) and Valkey as the shared non-authoritative store; the SDK data layer; services migrate one at a time, smallest first; supersedes ADR-007's trigger rule; amendment: PostgreSQL dialect only (PGlite in tests), a verified SQLite importer, every service async and migrated | Accepted 2026-09-28 (owner decision), amended the same day || [ADR-042](ADR-042-events-fabric.md) | The events fabric: the delivery-policy class is the one vocabulary and TOPIC/QUEUE/STREAM a derived carrier class; PostgreSQL stays the record, carriers are transports that fail open to `pg-v1`; a delivery lease before any second worker; per-key ordering; the SDK planner places deliveries and explains them; JetStream for STREAM only; opaque cursors replace the global seq after one overlap release; hot/replay/archive tiers; amends ADR-004's NATS rejection | Accepted 2026-09-29 (plan T7) || [ADR-046](ADR-046-universal-adaptive-fabric.md) | The universal adaptive fabric: offers, requirements, placement results and signed plans through one placer (`openvibe-sdk/placement`); health, cost, telemetry and usage samples; five trust classes, with `user-owned` eligible only when a workload names it; `worker:<class>` Node capabilities; the data plane keeps running on the last valid signed plan; T6 AI the reference consumer | Proposed 2026-10-03 (plan T1 step 3), for the owner's review |++| [ADR-048](ADR-048-services-control-plane.md) | The Services control plane: Services aggregates authorities and owns no other service's rows; every console action is a `common.resource-control-request@1` to the owning authority, answered with `common.resource-control-result@1` (idempotency keys, `confirmation_id` for sensitive actions); OVRN `ovrn:<service>:<project_id>:<type>/<id>` (`common.resource-name@1`) is the one cross-service resource name | Accepted 2026-10-04 (plan T13) |nnew file mode 100644@@@ -0,0 +1,7 @@++{++ "action": "stop",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "resource_kind": "run.sandbox",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "idempotency_key": "ctl-01K6R3-run-stop"++}nnew file mode 100644@@@ -0,0 +1,6 @@++{++ "action": "create",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "idempotency_key": "ctl-01K6R3-run-create"++}nnew file mode 100644@@@ -0,0 +1,6 @@++{++ "action": "delete",++ "resource_kind": "run.sandbox",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "idempotency_key": "ctl-01K6R3-run-delete"++}nnew file mode 100644@@@ -0,0 +1,5 @@++{++ "action": "stop",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4"++}nnew file mode 100644@@@ -0,0 +1,6 @@++{++ "action": "delete",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z5:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "idempotency_key": "ctl-01K6R3-run-delete"++}nnew file mode 100644@@@ -0,0 +1,6 @@++{++ "action": "explode",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "idempotency_key": "ctl-01K6R3-run-boom"++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "action": "create",++ "resource_kind": "events.queue",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "params": {++ "name": "orders"++ },++ "idempotency_key": "ctl-01K6R3-queue-create"++}nnew file mode 100644@@@ -0,0 +1,12 @@++{++ "action": "delete",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "idempotency_key": "ctl-01K6R3-run-delete",++ "on_behalf_of": {++ "type": "user",++ "id": "usr_01K6R2Z8C4V7M9Q3T5W1X2Y3Z6"++ },++ "confirmation_id": "cnf_01K6R3A1B2C3D4E5F6G7H8J9KE",++ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "action": "resize",++ "resource": "ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object/med_01K6R3A1B2C3D4E5F6G7H8J9KB",++ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",++ "params": {++ "bytes": 1073741824++ },++ "idempotency_key": "resize-01",++ "dry_run": true++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "action": "delete",++ "state": "done",++ "confirmation_required": {++ "confirmation_id": "cnf_01K6R3A1B2C3D4E5F6G7H8J9KE",++ "reason": "x"++ },++ "at": "2026-10-04T12:00:02Z"++}nnew file mode 100644@@@ -0,0 +1,5 @@++{++ "action": "start",++ "state": "failed",++ "at": "2026-10-04T12:00:03Z"++}nnew file mode 100644@@@ -0,0 +1,4 @@++{++ "action": "stop",++ "state": "done"++}nnew file mode 100644@@@ -0,0 +1,5 @@++{++ "action": "delete",++ "state": "refused",++ "at": "2026-10-04T12:00:02Z"++}nnew file mode 100644@@@ -0,0 +1,5 @@++{++ "action": "stop",++ "state": "maybe",++ "at": "2026-10-04T12:00:00Z"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "action": "create",++ "resource": "ovrn:events:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:queue/evq_01K6R3A1B2C3D4E5F6G7H8J9KD",++ "state": "done",++ "result": {++ "id": "evq_01K6R3A1B2C3D4E5F6G7H8J9KD",++ "name": "orders"++ },++ "at": "2026-10-04T12:00:01Z"++}nnew file mode 100644@@@ -0,0 +1,12 @@++{++ "action": "start",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "state": "failed",++ "problem": {++ "type": "https://openvibe.network/problems/run.capacity",++ "title": "No capacity",++ "status": 503,++ "code": "run.capacity"++ },++ "at": "2026-10-04T12:00:03Z"++}nnew file mode 100644@@@ -0,0 +1,6 @@++{++ "action": "stop",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "state": "pending",++ "at": "2026-10-04T12:00:00Z"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "action": "delete",++ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",++ "state": "refused",++ "confirmation_required": {++ "confirmation_id": "cnf_01K6R3A1B2C3D4E5F6G7H8J9KE",++ "reason": "Deleting a sandbox discards its files."++ },++ "at": "2026-10-04T12:00:02Z"++}nnew file mode 100644@@@ -0,0 +1 @@++"ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:media.object/med_01K6R3A1B2C3D4E5F6G7H8J9KB"nnew file mode 100644@@@ -0,0 +1 @@++"media/med_1"nnew file mode 100644@@@ -0,0 +1 @@++"ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object/me_01K6R3A1B2C3D4E5F6G7H8J9KB"nnew file mode 100644@@@ -0,0 +1 @@++"ovrn:identity:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:user/usr_01K6R3A1B2C3D4E5F6G7H8J9KB"nnew file mode 100644@@@ -0,0 +1 @@++"ovrn:events:usr_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:queue/evq_01K6R3A1B2C3D4E5F6G7H8J9KD"nnew file mode 100644@@@ -0,0 +1 @@++"ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object/med_01K6R3A1B2C3D4E5F6G7H8J9KB"nnew file mode 100644@@@ -0,0 +1 @@++"ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA"nnew file mode 100644@@@ -0,0 +1 @@++"ovrn:zone:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object-zone/zon_01K6R3A1B2C3D4E5F6G7H8J9KC"@@@ -69886,6 +69886,178 @@}}}++ },++ "common.resource-name": {++ "version": "1.0.0",++ "owner": "contracts",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/common/resource-name.v1.json",++ "title": "ResourceName",++ "description": "common.resource-name@1: the one stable, cross-service name of an OpenVibe resource (OVRN; ADR-034 section 2, ADR-048, plan T13): ovrn:<service>:<project_id>:<type>/<id>, for example ovrn:media:prj_01J0000000000000000000000A:object/med_01K0000000000000000000000B. The service segment is the authority that owns the resource; the project segment is its tenancy boundary; type is the second half of a common.resource-summary@1 kind and id is that summary's id, so the name composes from any resource summary. It is used in grant scopes, audit rows, event subjects, usage records, bill lines and console URLs. It never names a person, an app, a request, a session or an event. The pattern is the one common.usage-recorded@1 resource and common.resource-summary@1 ovrn already use.",++ "type": "string",++ "maxLength": 200,++ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",++ "not": {++ "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_"++ }++ }++ },++ "common.resource-control-request": {++ "version": "1.0.0",++ "owner": "contracts",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/common/resource-control-request.v1.json",++ "title": "ResourceControlRequest",++ "description": "common.resource-control-request@1: one control operation OpenVibe.Services (or Actor's Console) sends to the authority that owns a resource (ADR-048, plan T13). Services never mutates another service's database; every create, update, start, stop, delete, resize, rotate, pair, grant or revoke is this request to the owning authority's control API, which decides and answers with a common.resource-control-result@1. A request names exactly one of resource (every action but create) or resource_kind (create, before the name exists). project_id is the tenancy boundary and equals the project segment of resource. idempotency_key makes a retry safe: the authority answers a repeated key with the first result. A sensitive action (money, publishing, deleting, physical control) is refused with confirmation_required until its owner approves; the retry carries that confirmation_id (roadmap WS-Z2). The cross-field rules are checked by contracts.resources.checkControlRequest.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "action",++ "project_id",++ "idempotency_key"++ ],++ "oneOf": [++ {++ "required": [++ "resource"++ ]++ },++ {++ "required": [++ "resource_kind"++ ]++ }++ ],++ "properties": {++ "action": {++ "enum": [++ "create",++ "update",++ "delete",++ "start",++ "stop",++ "suspend",++ "resume",++ "resize",++ "rotate",++ "pair",++ "grant",++ "revoke",++ "archive"++ ]++ },++ "resource": {++ "$ref": "resource-name.v1.json",++ "description": "The resource acted on; required for every action but create."++ },++ "resource_kind": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9-]{0,31}\\.[a-z][a-z0-9-]{0,39}$",++ "description": "service.type (a common.resource-summary@1 kind), for create, before the resource name exists."++ },++ "project_id": {++ "type": "string",++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "params": {++ "type": "object",++ "description": "Action-specific input, shaped by the authority's own contract (never redefined here)."++ },++ "idempotency_key": {++ "type": "string",++ "minLength": 8,++ "maxLength": 200++ },++ "on_behalf_of": {++ "$ref": "../identity/subject-ref.v1.json",++ "description": "The subject whose grant the authority checks; the caller's own token says who sent it."++ },++ "confirmation_id": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 80,++ "description": "The approved confirmation from an earlier confirmation_required answer."++ },++ "dry_run": {++ "type": "boolean",++ "default": false,++ "description": "Decide and explain without applying."++ },++ "trace_id": {++ "type": "string",++ "pattern": "^[0-9a-f]{32}$"++ }++ }++ }++ },++ "common.resource-control-result": {++ "version": "1.0.0",++ "owner": "contracts",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/common/resource-control-result.v1.json",++ "title": "ResourceControlResult",++ "description": "common.resource-control-result@1: the authority's answer to a common.resource-control-request@1 (ADR-048, plan T13). state is the outcome, not the resource's own state: done (applied), pending (accepted and asynchronous; the authority reports completion later on its events), refused (a policy or confirmation gate said no) or failed (an error). State rules: refused carries problem or confirmation_required; failed carries problem; done and pending carry neither; confirmation_required appears only on refused. result carries the authority's own response document and never another service's rows. The state rules are checked by contracts.resources.checkControlResult.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "action",++ "state",++ "at"++ ],++ "properties": {++ "action": {++ "type": "string"++ },++ "resource": {++ "$ref": "resource-name.v1.json"++ },++ "state": {++ "enum": [++ "done",++ "pending",++ "refused",++ "failed"++ ]++ },++ "result": {++ "type": "object"++ },++ "confirmation_required": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "confirmation_id",++ "reason"++ ],++ "properties": {++ "confirmation_id": {++ "type": "string",++ "maxLength": 80++ },++ "reason": {++ "type": "string"++ }++ },++ "description": "The action needs its owner's approval; retry the same request with this confirmation_id once approved."++ },++ "problem": {++ "$ref": "../errors/problem.v1.json",++ "description": "RFC 9457 body when refused or failed."++ },++ "at": {++ "type": "string",++ "format": "date-time"++ }++ }++ }}}}@@@ -54291,3 +54291,73 @@ export interface RunJobExpiredPayload {detail: string | null;};}++++/** [email protected] (owner: contracts) */++/**++ * common.resource-name@1: the one stable, cross-service name of an OpenVibe resource (OVRN; ADR-034 section 2, ADR-048, plan T13): ovrn:<service>:<project_id>:<type>/<id>, for example ovrn:media:prj_01J0000000000000000000000A:object/med_01K0000000000000000000000B. The service segment is the authority that owns the resource; the project segment is its tenancy boundary; type is the second half of a common.resource-summary@1 kind and id is that summary's id, so the name composes from any resource summary. It is used in grant scopes, audit rows, event subjects, usage records, bill lines and console URLs. It never names a person, an app, a request, a session or an event. The pattern is the one common.usage-recorded@1 resource and common.resource-summary@1 ovrn already use.++ */++export type ResourceName = string;++++/** [email protected] (owner: contracts) */++/**++ * common.resource-control-request@1: one control operation OpenVibe.Services (or Actor's Console) sends to the authority that owns a resource (ADR-048, plan T13). Services never mutates another service's database; every create, update, start, stop, delete, resize, rotate, pair, grant or revoke is this request to the owning authority's control API, which decides and answers with a common.resource-control-result@1. A request names exactly one of resource (every action but create) or resource_kind (create, before the name exists). project_id is the tenancy boundary and equals the project segment of resource. idempotency_key makes a retry safe: the authority answers a repeated key with the first result. A sensitive action (money, publishing, deleting, physical control) is refused with confirmation_required until its owner approves; the retry carries that confirmation_id (roadmap WS-Z2). The cross-field rules are checked by contracts.resources.checkControlRequest.++ */++export type ResourceControlRequest = {++ action:++ | "create"++ | "update"++ | "delete"++ | "start"++ | "stop"++ | "suspend"++ | "resume"++ | "resize"++ | "rotate"++ | "pair"++ | "grant"++ | "revoke"++ | "archive";++ resource?: ResourceName;++ /**++ * service.type (a common.resource-summary@1 kind), for create, before the resource name exists.++ */++ resource_kind?: string;++ project_id: string;++ /**++ * Action-specific input, shaped by the authority's own contract (never redefined here).++ */++ params?: {};++ idempotency_key: string;++ on_behalf_of?: SubjectRef;++ /**++ * The approved confirmation from an earlier confirmation_required answer.++ */++ confirmation_id?: string | null;++ /**++ * Decide and explain without applying.++ */++ dry_run?: boolean;++ trace_id?: string;++} & {++ [k: string]: unknown | undefined;++};++++/** [email protected] (owner: contracts) */++/**++ * common.resource-control-result@1: the authority's answer to a common.resource-control-request@1 (ADR-048, plan T13). state is the outcome, not the resource's own state: done (applied), pending (accepted and asynchronous; the authority reports completion later on its events), refused (a policy or confirmation gate said no) or failed (an error). State rules: refused carries problem or confirmation_required; failed carries problem; done and pending carry neither; confirmation_required appears only on refused. result carries the authority's own response document and never another service's rows. The state rules are checked by contracts.resources.checkControlResult.++ */++export interface ResourceControlResult {++ action: string;++ resource?: ResourceName;++ state: "done" | "pending" | "refused" | "failed";++ result?: {};++ /**++ * The action needs its owner's approval; retry the same request with this confirmation_id once approved.++ */++ confirmation_required?: {++ confirmation_id: string;++ reason: string;++ };++ problem?: Problem;++ at: string;++}@@@ -8,6 +8,7 @@* contracts.http.problem(403, 'capability.denied'); // RFC 9457 body* contracts.capabilities.check(tokenClaims, 'media.object.read', { namespace: 'live' });* contracts.staff.can(userTokenClaims, 'staff.moderation.chat'); // staff roles → staff capabilities++ * contracts.resources.parse('ovrn:media:prj_…:object/med_…'); // { service, project_id, type, id }*/const registry = require('./lib/registry');@@@ -27,6 +28,7 @@ module.exports = {tools: require('./lib/tools'),zones: require('./lib/zones'),usage: require('./lib/usage'),++ resources: require('./lib/resources'),staff: require('./lib/staff'),openapi: require('./lib/openapi'),};nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/**++ * Resource-name and control helpers (ADR-048, plan T13). The common.resource-* schemas hold every rule++ * JSON Schema can express; these checks add the ones that compare fields, so OpenVibe.Services (before++ * it sends a control call) and the owning authority (before it acts on one) hold it to the same bar:++ *++ * contracts.resources.parse('ovrn:media:prj_…:object/med_…') -> { service, project_id, type, id } | null++ * contracts.resources.format({ service, project_id, type, id }) -> 'ovrn:…'++ * contracts.resources.nameOf(resourceSummary) -> the summary's ovrn, or null++ * contracts.resources.checkControlRequest(q) -> { valid, errors: [{ path, message }] }++ * create names resource_kind, every other action a resource,++ * and the resource's project is project_id++ * contracts.resources.checkControlResult(r) -> { valid, errors } and problem/confirmation_required fit state++ */++const registry = require('./registry');++++const OVRN = /^ovrn:([a-z][a-z0-9-]{0,31}):(prj_[0-9A-HJKMNP-TV-Z]{26}):([a-z][a-z0-9-]{0,39})\/((?!(?:usr|gst|app|mod|svc|job|evt|req)_)[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26})$/;++const KIND = /^([a-z][a-z0-9-]{0,31})\.([a-z][a-z0-9-]{0,39})$/;++++/** ovrn:<service>:<project_id>:<type>/<id> -> its four segments, or null when it is not a resource name. */++function parse(name) {++ const m = typeof name === 'string' ? OVRN.exec(name) : null;++ return m ? { service: m[1], project_id: m[2], type: m[3], id: m[4] } : null;++}++++/** The four segments -> a resource name; throws when they do not make a valid one. */++function format({ service, project_id, type, id }) {++ const name = `ovrn:${service}:${project_id}:${type}/${id}`;++ if (!OVRN.test(name)) throw new TypeError(`not a resource name: ${name}`);++ return name;++}++++/** The name of a common.resource-summary@1: its service, project_id, the second half of its kind and its id. */++function nameOf(summary) {++ const m = KIND.exec(String((summary && summary.kind) || ''));++ if (!m || m[1] !== summary.service) return null;++ const name = `ovrn:${summary.service}:${summary.project_id}:${m[2]}/${summary.id}`;++ return OVRN.test(name) ? name : null;++}++++function checkName(name) { return registry.validate('common.resource-name@1', name); }++++function checkControlRequest(q) {++ const r = registry.validate('common.resource-control-request@1', q);++ if (!r.valid) return r;++ const errors = [];++ if (q.action === 'create' && q.resource !== undefined) errors.push({ path: '/resource', message: 'create names resource_kind, not resource' });++ if (q.action !== 'create' && q.resource === undefined) errors.push({ path: '/resource', message: `required for ${q.action}` });++ // The project is the tenancy boundary: a call for project A never reaches a resource of project B.++ if (q.resource !== undefined && parse(q.resource).project_id !== q.project_id) errors.push({ path: '/resource', message: `must name a resource of project ${q.project_id}` });++ return { valid: errors.length === 0, errors };++}++++function checkControlResult(res) {++ const r = registry.validate('common.resource-control-result@1', res);++ if (!r.valid) return r;++ const errors = [];++ const has = (k) => res[k] !== undefined;++ if (res.state === 'refused' && !has('problem') && !has('confirmation_required')) errors.push({ path: '/state', message: 'refused carries problem or confirmation_required' });++ if (res.state === 'failed' && !has('problem')) errors.push({ path: '/problem', message: 'required when failed' });++ if ((res.state === 'done' || res.state === 'pending') && has('problem')) errors.push({ path: '/problem', message: `not allowed when ${res.state}` });++ if (res.state !== 'refused' && has('confirmation_required')) errors.push({ path: '/confirmation_required', message: 'only when refused' });++ return { valid: errors.length === 0, errors };++}++++module.exports = { parse, format, nameOf, checkName, checkControlRequest, checkControlResult };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++// ADR-048 (plan T13): resource names and the control-operation contract every console action uses.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const c = require('../index.js');++const resources = require('../lib/resources');++const fx = (id, kind, f) => JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'fixtures', id, kind, f), 'utf8'));++++const name = fx('common.resource-name', 'valid', 'media-object.json');++assert.strictEqual(c.resources, resources, 'index.js exports lib/resources.js');++const parts = c.resources.parse(name);++assert.deepStrictEqual(Object.keys(parts), ['service', 'project_id', 'type', 'id'], 'a name parses into its four segments');++assert.strictEqual(parts.service, 'media');++assert.strictEqual(c.resources.format(parts), name, 'format is the inverse of parse');++assert.strictEqual(c.resources.parse(fx('common.resource-name', 'invalid', 'not-an-ovrn.json')), null, 'a non-name parses to null');++assert.strictEqual(c.resources.parse(42), null);++assert.strictEqual(c.resources.parse(fx('common.resource-name', 'invalid', 'user-id.json')), null, 'a name whose id is a person parses to null');++assert.throws(() => c.resources.format({ ...parts, project_id: 'usr_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4' }), TypeError, 'format refuses segments that make no name');++for (const f of ['media-object.json', 'zone-object-zone.json', 'run-sandbox.json']) {++ const n = fx('common.resource-name', 'valid', f);++ assert.ok(c.resources.checkName(n).valid && c.resources.parse(n), `${f}: the schema and parse agree`);++}++++// A resource summary composes its own name.++const summary = { id: 'wch_01K6R3A1B2C3D4E5F6G7H8J9KA', kind: 'watch.watch', service: 'watch', project_id: 'prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4', state: 'active', created_at: '2026-10-04T12:00:00Z' };++assert.strictEqual(c.resources.nameOf(summary), 'ovrn:watch:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:watch/wch_01K6R3A1B2C3D4E5F6G7H8J9KA');++assert.strictEqual(c.resources.nameOf({ ...summary, service: 'media' }), null, 'a kind of another service names nothing');++assert.strictEqual(c.resources.nameOf({ ...summary, project_id: undefined }), null, 'a summary without a project names nothing');++++// Control requests: the schema passes these, the cross-field rules refuse them.++for (const f of ['other-project.json', 'create-with-resource.json']) {++ const q = fx('common.resource-control-request', 'invalid', f);++ assert.ok(c.validate('common.resource-control-request@1', q).valid, `${f} is schema-valid`);++ assert.ok(!c.resources.checkControlRequest(q).valid, `${f} is refused by the cross-field check`);++}++const other = c.resources.checkControlRequest(fx('common.resource-control-request', 'invalid', 'other-project.json'));++assert.ok(other.errors.some((e) => e.path === '/resource' && /must name a resource of project/.test(e.message)), 'a resource of another project is refused');++const del = c.resources.checkControlRequest(fx('common.resource-control-request', 'invalid', 'delete-without-resource.json'));++assert.ok(del.errors.some((e) => e.path === '/resource' && /required for delete/.test(e.message)), 'delete names its resource');++assert.ok(!c.resources.checkControlRequest(fx('common.resource-control-request', 'invalid', 'both-names.json')).valid, 'resource and resource_kind together are refused');++++// Control results: problem and confirmation_required fit the state.++for (const f of ['failed-without-problem.json', 'done-with-confirmation.json', 'refused-bare.json']) {++ const r = fx('common.resource-control-result', 'invalid', f);++ assert.ok(c.validate('common.resource-control-result@1', r).valid && !c.resources.checkControlResult(r).valid, `${f}: schema-valid, refused by the state rules`);++}++const pending = fx('common.resource-control-result', 'valid', 'pending.json');++assert.ok(!c.resources.checkControlResult({ ...pending, problem: fx('common.resource-control-result', 'valid', 'failed-problem.json').problem }).valid, 'pending carries no problem');@@@ -60,6 +60,8 @@ const FULL_CHECK = {'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v),'zone.object-zone': (v) => contracts.zones.checkObjectZone(v), 'zone.object-zone-list': (v) => contracts.zones.checkObjectZoneList(v),'zone.object-zone-usage': (v) => contracts.zones.checkObjectZoneUsage(v), 'common.usage-recorded': (v) => contracts.usage.checkUsageRecorded(v),++ 'common.resource-name': (v) => contracts.resources.checkName(v), 'common.resource-control-request': (v) => contracts.resources.checkControlRequest(v),++ 'common.resource-control-result': (v) => contracts.resources.checkControlResult(v),};for (const c of contracts.catalog) {for (const kind of ['valid', 'invalid']) {@@@ -908,6 +910,7 @@ sub(path.join(__dirname, 'zones.test.js'));// *.usage.recorded rollups are counts: only network, codes and zone may consume one, never billing (T5 step 8).sub(path.join(__dirname, 'usage-topics.test.js'));sub(path.join(__dirname, 'billing-grace.test.js'));++sub(path.join(__dirname, 'resources.test.js'));// ── Generated output and compatibility gate ──────────────────────────────sub(path.join(__dirname, 'platform-fabric.test.js'));
@@ -57,3 +57,9 @@ description = "Sample realtime tickets in network.realtime-ticket-result fixture
@@ -57,3 +57,9 @@ description = "Sample realtime tickets in network.realtime-ticket-result fixture
condition = "AND"
condition = "AND"
paths = ['''^fixtures/network\.realtime\-ticket\-result/(valid|invalid)/[a-z-]+\.json$''']
paths = ['''^fixtures/network\.realtime\-ticket\-result/(valid|invalid)/[a-z-]+\.json$''']
regexes = ['''^eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9\.eyJ0eXAiOiJyZWFsdGltZSJ9\.c2lnbmF0dXJl$''']
regexes = ['''^eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9\.eyJ0eXAiOiJyZWFsdGltZSJ9\.c2lnbmF0dXJl$''']
+
+[[allowlists]]
+description = "Sample Idempotency-Key value in a common.resource-control-request@1 fixture; a caller-chosen dedupe token, not a secret"
+condition = "AND"
+paths = ['''^fixtures/common\.resource-control-request/valid/dry-run-resize\.json$''']
+regexes = ['''^ctl-01K6R3-resize$''']
@@ -6,6 +6,16 @@ the notes were in the tag and commit messages (`git tag -n1`).
@@ -6,6 +6,16 @@ the notes were in the tag and commit messages (`git tag -n1`).
## 0.95.0 — 2026-10-04
## 0.95.0 — 2026-10-04
+**The Services control plane** (ADR-048, plan T13 step 1). Additive: three new catalog ids at 1.0.0, owner `contracts`.
+`common.resource-name@1` is the OVRN `ovrn:<service>:<project_id>:<type>/<id>`, with the pattern `common.usage-recorded@1`
+and `common.resource-summary@1` already use. `common.resource-control-request@1` is one control operation OpenVibe.Services
+sends to the authority that owns a resource (`action`, `resource` or, for `create`, `resource_kind`, `project_id`,
+`idempotency_key`, optional `params`, `on_behalf_of`, `confirmation_id`, `dry_run`, `trace_id`), and
+`common.resource-control-result@1` the authority's answer (`state` done, pending, refused or failed, with `result`,
+`confirmation_required` or an `errors.problem@1`). New `contracts.resources`: `parse`, `format`, `nameOf`, `checkName`,
+`checkControlRequest` (the resource's project is `project_id`; `create` names a kind, every other action a resource) and
+`checkControlResult` (`problem` and `confirmation_required` fit `state`). New test `test/resources.test.js`.
+
**OpenVibe.Chat's internal read API** (plan T3, J4b): OpenVibe.Live stops reading its mirrored copy of Chat's tables.
**OpenVibe.Chat's internal read API** (plan T3, J4b): OpenVibe.Live stops reading its mirrored copy of Chat's tables.
Additive. Six new internal capabilities owned by chat (loopback, Chat service token, Live's principal today; never
Additive. Six new internal capabilities owned by chat (loopback, Chat service token, Live's principal today; never
granted to an app or delegated), added to `manifests/services/chat.json`:
granted to an app or delegated), added to `manifests/services/chat.json`:
@@ -195,6 +195,12 @@ name the zone's own project and id, a name belongs to one zone of its environmen
@@ -195,6 +195,12 @@ name the zone's own project and id, a name belongs to one zone of its environmen
answer's `ovrn` names its `zone_id`. `contracts.usage.checkUsageRecorded(u)` refuses a usage rollup whose
answer's `ovrn` names its `zone_id`. `contracts.usage.checkUsageRecorded(u)` refuses a usage rollup whose
`resource` names another project than its `project_id`.
`resource` names another project than its `project_id`.
+`contracts.resources` holds the resource names and the control-operation contract of ADR-048 (plan T13):
+`parse(ovrn)` and `format({ service, project_id, type, id })` are the one OVRN parser and formatter, `nameOf(summary)`
+composes the name of a `common.resource-summary@1`, `checkControlRequest(q)` refuses a request whose `resource`
+names another project than its `project_id` (and `create` names `resource_kind`, every other action a `resource`),
+and `checkControlResult(r)` holds `problem` and `confirmation_required` to the result's `state`.
+
Tools' problem codes are listed with the routes that answer them (`tools.run-request@1`,
Tools' problem codes are listed with the routes that answer them (`tools.run-request@1`,
`tools.job-request@1`, `tools.tool-list@1`, `tools.tool@1`). They include `400 tools.query.invalid`,
`tools.job-request@1`, `tools.tool-list@1`, `tools.tool@1`). They include `400 tools.query.invalid`,
`404 tools.tool.not_found`, `413 tools.pdf.too_many_pages`, `422 tools.pdf.wrong_password`,
`404 tools.tool.not_found`, `413 tools.pdf.too_many_pages`, `422 tools.pdf.wrong_password`,
@@ -6052,6 +6052,36 @@
@@ -6052,6 +6052,36 @@
"compatibility": "backward",
"compatibility": "backward",
"status": "planned",
"status": "planned",
"adr": "ADR-034"
"adr": "ADR-034"
+ },
+ {
+ "id": "common.resource-name",
+ "version": "1.0.0",
+ "owner": "contracts",
+ "schema": "common/resource-name.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-048"
+ },
+ {
+ "id": "common.resource-control-request",
+ "version": "1.0.0",
+ "owner": "contracts",
+ "schema": "common/resource-control-request.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-048"
+ },
+ {
+ "id": "common.resource-control-result",
+ "version": "1.0.0",
+ "owner": "contracts",
+ "schema": "common/resource-control-result.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-048"
}
}
]
]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/common/resource-control-request.v1.json",
+ "title": "ResourceControlRequest",
+ "description": "common.resource-control-request@1: one control operation OpenVibe.Services (or Actor's Console) sends to the authority that owns a resource (ADR-048, plan T13). Services never mutates another service's database; every create, update, start, stop, delete, resize, rotate, pair, grant or revoke is this request to the owning authority's control API, which decides and answers with a common.resource-control-result@1. A request names exactly one of resource (every action but create) or resource_kind (create, before the name exists). project_id is the tenancy boundary and equals the project segment of resource. idempotency_key makes a retry safe: the authority answers a repeated key with the first result. A sensitive action (money, publishing, deleting, physical control) is refused with confirmation_required until its owner approves; the retry carries that confirmation_id (roadmap WS-Z2). The cross-field rules are checked by contracts.resources.checkControlRequest.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["action", "project_id", "idempotency_key"],
+ "oneOf": [{ "required": ["resource"] }, { "required": ["resource_kind"] }],
+ "properties": {
+ "action": { "enum": ["create", "update", "delete", "start", "stop", "suspend", "resume", "resize", "rotate", "pair", "grant", "revoke", "archive"] },
+ "resource": { "$ref": "resource-name.v1.json", "description": "The resource acted on; required for every action but create." },
+ "resource_kind": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}\\.[a-z][a-z0-9-]{0,39}$", "description": "service.type (a common.resource-summary@1 kind), for create, before the resource name exists." },
+ "project_id": { "type": "string", "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$" },
+ "params": { "type": "object", "description": "Action-specific input, shaped by the authority's own contract (never redefined here)." },
+ "idempotency_key": { "type": "string", "minLength": 8, "maxLength": 200 },
+ "on_behalf_of": { "$ref": "../identity/subject-ref.v1.json", "description": "The subject whose grant the authority checks; the caller's own token says who sent it." },
+ "confirmation_id": { "type": ["string", "null"], "maxLength": 80, "description": "The approved confirmation from an earlier confirmation_required answer." },
+ "dry_run": { "type": "boolean", "default": false, "description": "Decide and explain without applying." },
+ "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/common/resource-control-result.v1.json",
+ "title": "ResourceControlResult",
+ "description": "common.resource-control-result@1: the authority's answer to a common.resource-control-request@1 (ADR-048, plan T13). state is the outcome, not the resource's own state: done (applied), pending (accepted and asynchronous; the authority reports completion later on its events), refused (a policy or confirmation gate said no) or failed (an error). State rules: refused carries problem or confirmation_required; failed carries problem; done and pending carry neither; confirmation_required appears only on refused. result carries the authority's own response document and never another service's rows. The state rules are checked by contracts.resources.checkControlResult.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["action", "state", "at"],
+ "properties": {
+ "action": { "type": "string" },
+ "resource": { "$ref": "resource-name.v1.json" },
+ "state": { "enum": ["done", "pending", "refused", "failed"] },
+ "result": { "type": "object" },
+ "confirmation_required": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["confirmation_id", "reason"],
+ "properties": { "confirmation_id": { "type": "string", "maxLength": 80 }, "reason": { "type": "string" } },
+ "description": "The action needs its owner's approval; retry the same request with this confirmation_id once approved."
+ },
+ "problem": { "$ref": "../errors/problem.v1.json", "description": "RFC 9457 body when refused or failed." },
+ "at": { "type": "string", "format": "date-time" }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/common/resource-name.v1.json",
+ "title": "ResourceName",
+ "description": "common.resource-name@1: the one stable, cross-service name of an OpenVibe resource (OVRN; ADR-034 section 2, ADR-048, plan T13): ovrn:<service>:<project_id>:<type>/<id>, for example ovrn:media:prj_01J0000000000000000000000A:object/med_01K0000000000000000000000B. The service segment is the authority that owns the resource; the project segment is its tenancy boundary; type is the second half of a common.resource-summary@1 kind and id is that summary's id, so the name composes from any resource summary. It is used in grant scopes, audit rows, event subjects, usage records, bill lines and console URLs. It never names a person, an app, a request, a session or an event. The pattern is the one common.usage-recorded@1 resource and common.resource-summary@1 ovrn already use.",
+ "type": "string",
+ "maxLength": 200,
+ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",
+ "not": { "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+# ADR-048: The Services control plane — authority aggregation, OVRN and one control-operation contract
+
+**Status:** Accepted 2026-10-04 (plan track T13, step 1). 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
+
+- **ADR-034 §2** decided that every resource belongs to a project and has one name,
+ `ovrn:<service>:<project>:<type>/<id>`, used in grants, audit, events, usage records, bills and the console. The
+ pattern already lives inline in `common.usage-recorded@1` `resource` and `common.resource-summary@1` `ovrn`, but no
+ contract names it and no helper parses it.
+- **ADR-034 §5** puts identity, projects, grants, registry, billing and deploy in a small control plane, and Media bytes,
+ Events streams, AI runs and the rest in a data plane partitioned by project.
+- **ADR-046 §6**: a control-plane outage stops change, never delivery. A console that writes into other services'
+ databases would break that rule, because their data plane would depend on the console's writes.
+- **Plan T13**: OpenVibe.Services (`openvibe.services`, reserved in `manifests/services/services.json`) is the developer
+ console over every service: projects, apps, keys, grants, usage, runs, events, deploys and health. The resource index
+ (`common.resource-summary@1`, `common.resource-list-result@1`, `GET /api/v1/resources`) is how it lists resources it
+ does not own. Nothing yet says how it changes one.
+
+## 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…`.
+ - `service` is the authority that owns the resource; `project_id` its tenancy boundary.
+ - `type` is the second half of the resource summary's `kind` (`<service>.<type>`), `id` the summary's typed ULID,
+ whose prefix is exactly three letters. So a name composes from any summary (`contracts.resources.nameOf`).
+ - It names resources only: never a person, an app as an actor, a request, a session or an event.
+ - `contracts.resources.parse` and `format` are the only parser and formatter; no service splits on `:` itself.
+ - A project is addressed by its bare `prj_` id. It is not given a self-referential OVRN.
+
+## 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 | answers `GET /api/v1/resources` with `common.resource-summary@1` | fans out, merges and paginates |
+| 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.
+
+## Idempotency and confirmations
+
+- Every control request carries an `idempotency_key` (8–200 characters). The authority stores the first answer per
+ (caller, project, key) and returns it for a repeat, so a retry after a timeout never applies an action twice. A key
+ reused with a different request body is refused.
+- `create` names `resource_kind` (`<service>.<type>`), because the name does not exist yet. Every other action names
+ `resource`. A request never carries both.
+- `dry_run: true` asks the authority to decide and explain without applying.
+- **Confirmations (roadmap WS-Z2).** A sensitive action (money, publishing, deleting, physical control) is answered
+ `state: refused` with `confirmation_required: { confirmation_id, reason }`. Once the resource's owner approves, the
+ caller repeats the request with that `confirmation_id`. The authority issues and checks confirmation ids; Services
+ never mints one.
+- **Result states** describe the outcome, not the resource's own state:
+ - `done`: applied.
+ - `pending`: accepted; completion follows on the authority's events.
+ - `refused`: carries a `problem` or `confirmation_required`.
+ - `failed`: carries a `problem` (`errors.problem@1`).
+
+ `confirmation_required` appears only on `refused`, and `done` and `pending` carry no problem.
+ `contracts.resources.checkControlResult` enforces these rules.
+
+## Out of scope
+
+- The console surfaces, the Services repository and its deploy (T13 steps 3, 9, 15).
+- The SDK client (`openvibe-sdk/resources`, step 2) and each service's resource index (steps 5 and 8).
+- Each authority's action-specific `params` and `result` shapes. They stay in the authority's own contracts and are never
+ redefined here.
+- How a confirmation is approved (WS-Z2), billing of control calls, and moving `common.usage-recorded@1` `resource` to
+ a `$ref` of `common.resource-name@1`.
+- Three-letter id prefixes still to be chosen before the step-8 sweep (Events queues, Codes repositories).
+
+## Consequences
+
+- Additive only: three new catalog ids at 1.0.0. No existing schema changes.
+- A Services outage stops console changes, never a service's own API or data plane.
+- Rollback: consumers pin the previous release tag. Nothing reads these contracts until T13 step 2.
@@ -41,3 +41,4 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
@@ -41,3 +41,4 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
| [ADR-035](ADR-035-postgresql-and-valkey.md) | PostgreSQL 18 as every service's system of record (database + roles per service, PgBouncer transaction pooling, pgBackRest PITR to B2) and Valkey as the shared non-authoritative store; the SDK data layer; services migrate one at a time, smallest first; supersedes ADR-007's trigger rule; amendment: PostgreSQL dialect only (PGlite in tests), a verified SQLite importer, every service async and migrated | Accepted 2026-09-28 (owner decision), amended the same day |
| [ADR-035](ADR-035-postgresql-and-valkey.md) | PostgreSQL 18 as every service's system of record (database + roles per service, PgBouncer transaction pooling, pgBackRest PITR to B2) and Valkey as the shared non-authoritative store; the SDK data layer; services migrate one at a time, smallest first; supersedes ADR-007's trigger rule; amendment: PostgreSQL dialect only (PGlite in tests), a verified SQLite importer, every service async and migrated | Accepted 2026-09-28 (owner decision), amended the same day |
| [ADR-042](ADR-042-events-fabric.md) | The events fabric: the delivery-policy class is the one vocabulary and TOPIC/QUEUE/STREAM a derived carrier class; PostgreSQL stays the record, carriers are transports that fail open to `pg-v1`; a delivery lease before any second worker; per-key ordering; the SDK planner places deliveries and explains them; JetStream for STREAM only; opaque cursors replace the global seq after one overlap release; hot/replay/archive tiers; amends ADR-004's NATS rejection | Accepted 2026-09-29 (plan T7) |
| [ADR-042](ADR-042-events-fabric.md) | The events fabric: the delivery-policy class is the one vocabulary and TOPIC/QUEUE/STREAM a derived carrier class; PostgreSQL stays the record, carriers are transports that fail open to `pg-v1`; a delivery lease before any second worker; per-key ordering; the SDK planner places deliveries and explains them; JetStream for STREAM only; opaque cursors replace the global seq after one overlap release; hot/replay/archive tiers; amends ADR-004's NATS rejection | Accepted 2026-09-29 (plan T7) |
| [ADR-046](ADR-046-universal-adaptive-fabric.md) | The universal adaptive fabric: offers, requirements, placement results and signed plans through one placer (`openvibe-sdk/placement`); health, cost, telemetry and usage samples; five trust classes, with `user-owned` eligible only when a workload names it; `worker:<class>` Node capabilities; the data plane keeps running on the last valid signed plan; T6 AI the reference consumer | Proposed 2026-10-03 (plan T1 step 3), for the owner's review |
| [ADR-046](ADR-046-universal-adaptive-fabric.md) | The universal adaptive fabric: offers, requirements, placement results and signed plans through one placer (`openvibe-sdk/placement`); health, cost, telemetry and usage samples; five trust classes, with `user-owned` eligible only when a workload names it; `worker:<class>` Node capabilities; the data plane keeps running on the last valid signed plan; T6 AI the reference consumer | Proposed 2026-10-03 (plan T1 step 3), for the owner's review |
+| [ADR-048](ADR-048-services-control-plane.md) | The Services control plane: Services aggregates authorities and owns no other service's rows; every console action is a `common.resource-control-request@1` to the owning authority, answered with `common.resource-control-result@1` (idempotency keys, `confirmation_id` for sensitive actions); OVRN `ovrn:<service>:<project_id>:<type>/<id>` (`common.resource-name@1`) is the one cross-service resource name | Accepted 2026-10-04 (plan T13) |
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+{
+ "action": "stop",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "resource_kind": "run.sandbox",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "idempotency_key": "ctl-01K6R3-run-stop"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+{
+ "action": "create",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "idempotency_key": "ctl-01K6R3-run-create"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+{
+ "action": "delete",
+ "resource_kind": "run.sandbox",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "idempotency_key": "ctl-01K6R3-run-delete"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+{
+ "action": "stop",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+{
+ "action": "delete",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z5:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "idempotency_key": "ctl-01K6R3-run-delete"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+{
+ "action": "explode",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "idempotency_key": "ctl-01K6R3-run-boom"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "action": "create",
+ "resource_kind": "events.queue",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "params": {
+ "name": "orders"
+ },
+ "idempotency_key": "ctl-01K6R3-queue-create"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+{
+ "action": "delete",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "idempotency_key": "ctl-01K6R3-run-delete",
+ "on_behalf_of": {
+ "type": "user",
+ "id": "usr_01K6R2Z8C4V7M9Q3T5W1X2Y3Z6"
+ },
+ "confirmation_id": "cnf_01K6R3A1B2C3D4E5F6G7H8J9KE",
+ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "action": "resize",
+ "resource": "ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object/med_01K6R3A1B2C3D4E5F6G7H8J9KB",
+ "project_id": "prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4",
+ "params": {
+ "bytes": 1073741824
+ },
+ "idempotency_key": "resize-01",
+ "dry_run": true
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "action": "delete",
+ "state": "done",
+ "confirmation_required": {
+ "confirmation_id": "cnf_01K6R3A1B2C3D4E5F6G7H8J9KE",
+ "reason": "x"
+ },
+ "at": "2026-10-04T12:00:02Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+{
+ "action": "start",
+ "state": "failed",
+ "at": "2026-10-04T12:00:03Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+{
+ "action": "stop",
+ "state": "done"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+{
+ "action": "delete",
+ "state": "refused",
+ "at": "2026-10-04T12:00:02Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+{
+ "action": "stop",
+ "state": "maybe",
+ "at": "2026-10-04T12:00:00Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "action": "create",
+ "resource": "ovrn:events:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:queue/evq_01K6R3A1B2C3D4E5F6G7H8J9KD",
+ "state": "done",
+ "result": {
+ "id": "evq_01K6R3A1B2C3D4E5F6G7H8J9KD",
+ "name": "orders"
+ },
+ "at": "2026-10-04T12:00:01Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+{
+ "action": "start",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "state": "failed",
+ "problem": {
+ "type": "https://openvibe.network/problems/run.capacity",
+ "title": "No capacity",
+ "status": 503,
+ "code": "run.capacity"
+ },
+ "at": "2026-10-04T12:00:03Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+{
+ "action": "stop",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "state": "pending",
+ "at": "2026-10-04T12:00:00Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "action": "delete",
+ "resource": "ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA",
+ "state": "refused",
+ "confirmation_required": {
+ "confirmation_id": "cnf_01K6R3A1B2C3D4E5F6G7H8J9KE",
+ "reason": "Deleting a sandbox discards its files."
+ },
+ "at": "2026-10-04T12:00:02Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:media.object/med_01K6R3A1B2C3D4E5F6G7H8J9KB"
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"media/med_1"
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object/me_01K6R3A1B2C3D4E5F6G7H8J9KB"
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"ovrn:identity:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:user/usr_01K6R3A1B2C3D4E5F6G7H8J9KB"
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"ovrn:events:usr_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:queue/evq_01K6R3A1B2C3D4E5F6G7H8J9KD"
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"ovrn:media:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object/med_01K6R3A1B2C3D4E5F6G7H8J9KB"
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"ovrn:run:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:sandbox/run_01K6R3A1B2C3D4E5F6G7H8J9KA"
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+"ovrn:zone:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:object-zone/zon_01K6R3A1B2C3D4E5F6G7H8J9KC"
@@ -69886,6 +69886,178 @@
@@ -69886,6 +69886,178 @@
}
}
}
}
}
}
+ },
+ "common.resource-name": {
+ "version": "1.0.0",
+ "owner": "contracts",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/common/resource-name.v1.json",
+ "title": "ResourceName",
+ "description": "common.resource-name@1: the one stable, cross-service name of an OpenVibe resource (OVRN; ADR-034 section 2, ADR-048, plan T13): ovrn:<service>:<project_id>:<type>/<id>, for example ovrn:media:prj_01J0000000000000000000000A:object/med_01K0000000000000000000000B. The service segment is the authority that owns the resource; the project segment is its tenancy boundary; type is the second half of a common.resource-summary@1 kind and id is that summary's id, so the name composes from any resource summary. It is used in grant scopes, audit rows, event subjects, usage records, bill lines and console URLs. It never names a person, an app, a request, a session or an event. The pattern is the one common.usage-recorded@1 resource and common.resource-summary@1 ovrn already use.",
+ "type": "string",
+ "maxLength": 200,
+ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",
+ "not": {
+ "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_"
+ }
+ }
+ },
+ "common.resource-control-request": {
+ "version": "1.0.0",
+ "owner": "contracts",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/common/resource-control-request.v1.json",
+ "title": "ResourceControlRequest",
+ "description": "common.resource-control-request@1: one control operation OpenVibe.Services (or Actor's Console) sends to the authority that owns a resource (ADR-048, plan T13). Services never mutates another service's database; every create, update, start, stop, delete, resize, rotate, pair, grant or revoke is this request to the owning authority's control API, which decides and answers with a common.resource-control-result@1. A request names exactly one of resource (every action but create) or resource_kind (create, before the name exists). project_id is the tenancy boundary and equals the project segment of resource. idempotency_key makes a retry safe: the authority answers a repeated key with the first result. A sensitive action (money, publishing, deleting, physical control) is refused with confirmation_required until its owner approves; the retry carries that confirmation_id (roadmap WS-Z2). The cross-field rules are checked by contracts.resources.checkControlRequest.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "action",
+ "project_id",
+ "idempotency_key"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "resource"
+ ]
+ },
+ {
+ "required": [
+ "resource_kind"
+ ]
+ }
+ ],
+ "properties": {
+ "action": {
+ "enum": [
+ "create",
+ "update",
+ "delete",
+ "start",
+ "stop",
+ "suspend",
+ "resume",
+ "resize",
+ "rotate",
+ "pair",
+ "grant",
+ "revoke",
+ "archive"
+ ]
+ },
+ "resource": {
+ "$ref": "resource-name.v1.json",
+ "description": "The resource acted on; required for every action but create."
+ },
+ "resource_kind": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]{0,31}\\.[a-z][a-z0-9-]{0,39}$",
+ "description": "service.type (a common.resource-summary@1 kind), for create, before the resource name exists."
+ },
+ "project_id": {
+ "type": "string",
+ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "params": {
+ "type": "object",
+ "description": "Action-specific input, shaped by the authority's own contract (never redefined here)."
+ },
+ "idempotency_key": {
+ "type": "string",
+ "minLength": 8,
+ "maxLength": 200
+ },
+ "on_behalf_of": {
+ "$ref": "../identity/subject-ref.v1.json",
+ "description": "The subject whose grant the authority checks; the caller's own token says who sent it."
+ },
+ "confirmation_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 80,
+ "description": "The approved confirmation from an earlier confirmation_required answer."
+ },
+ "dry_run": {
+ "type": "boolean",
+ "default": false,
+ "description": "Decide and explain without applying."
+ },
+ "trace_id": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{32}$"
+ }
+ }
+ }
+ },
+ "common.resource-control-result": {
+ "version": "1.0.0",
+ "owner": "contracts",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/common/resource-control-result.v1.json",
+ "title": "ResourceControlResult",
+ "description": "common.resource-control-result@1: the authority's answer to a common.resource-control-request@1 (ADR-048, plan T13). state is the outcome, not the resource's own state: done (applied), pending (accepted and asynchronous; the authority reports completion later on its events), refused (a policy or confirmation gate said no) or failed (an error). State rules: refused carries problem or confirmation_required; failed carries problem; done and pending carry neither; confirmation_required appears only on refused. result carries the authority's own response document and never another service's rows. The state rules are checked by contracts.resources.checkControlResult.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "action",
+ "state",
+ "at"
+ ],
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "resource": {
+ "$ref": "resource-name.v1.json"
+ },
+ "state": {
+ "enum": [
+ "done",
+ "pending",
+ "refused",
+ "failed"
+ ]
+ },
+ "result": {
+ "type": "object"
+ },
+ "confirmation_required": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "confirmation_id",
+ "reason"
+ ],
+ "properties": {
+ "confirmation_id": {
+ "type": "string",
+ "maxLength": 80
+ },
+ "reason": {
+ "type": "string"
+ }
+ },
+ "description": "The action needs its owner's approval; retry the same request with this confirmation_id once approved."
+ },
+ "problem": {
+ "$ref": "../errors/problem.v1.json",
+ "description": "RFC 9457 body when refused or failed."
+ },
+ "at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
}
}
}
}
}
}
@@ -54291,3 +54291,73 @@ export interface RunJobExpiredPayload {
@@ -54291,3 +54291,73 @@ export interface RunJobExpiredPayload {
detail: string | null;
detail: string | null;
};
};
}
}
+
+/** [email protected] (owner: contracts) */
+/**
+ * common.resource-name@1: the one stable, cross-service name of an OpenVibe resource (OVRN; ADR-034 section 2, ADR-048, plan T13): ovrn:<service>:<project_id>:<type>/<id>, for example ovrn:media:prj_01J0000000000000000000000A:object/med_01K0000000000000000000000B. The service segment is the authority that owns the resource; the project segment is its tenancy boundary; type is the second half of a common.resource-summary@1 kind and id is that summary's id, so the name composes from any resource summary. It is used in grant scopes, audit rows, event subjects, usage records, bill lines and console URLs. It never names a person, an app, a request, a session or an event. The pattern is the one common.usage-recorded@1 resource and common.resource-summary@1 ovrn already use.
+ */
+export type ResourceName = string;
+
+/** [email protected] (owner: contracts) */
+/**
+ * common.resource-control-request@1: one control operation OpenVibe.Services (or Actor's Console) sends to the authority that owns a resource (ADR-048, plan T13). Services never mutates another service's database; every create, update, start, stop, delete, resize, rotate, pair, grant or revoke is this request to the owning authority's control API, which decides and answers with a common.resource-control-result@1. A request names exactly one of resource (every action but create) or resource_kind (create, before the name exists). project_id is the tenancy boundary and equals the project segment of resource. idempotency_key makes a retry safe: the authority answers a repeated key with the first result. A sensitive action (money, publishing, deleting, physical control) is refused with confirmation_required until its owner approves; the retry carries that confirmation_id (roadmap WS-Z2). The cross-field rules are checked by contracts.resources.checkControlRequest.
+ */
+export type ResourceControlRequest = {
+ action:
+ | "create"
+ | "update"
+ | "delete"
+ | "start"
+ | "stop"
+ | "suspend"
+ | "resume"
+ | "resize"
+ | "rotate"
+ | "pair"
+ | "grant"
+ | "revoke"
+ | "archive";
+ resource?: ResourceName;
+ /**
+ * service.type (a common.resource-summary@1 kind), for create, before the resource name exists.
+ */
+ resource_kind?: string;
+ project_id: string;
+ /**
+ * Action-specific input, shaped by the authority's own contract (never redefined here).
+ */
+ params?: {};
+ idempotency_key: string;
+ on_behalf_of?: SubjectRef;
+ /**
+ * The approved confirmation from an earlier confirmation_required answer.
+ */
+ confirmation_id?: string | null;
+ /**
+ * Decide and explain without applying.
+ */
+ dry_run?: boolean;
+ trace_id?: string;
+} & {
+ [k: string]: unknown | undefined;
+};
+
+/** [email protected] (owner: contracts) */
+/**
+ * common.resource-control-result@1: the authority's answer to a common.resource-control-request@1 (ADR-048, plan T13). state is the outcome, not the resource's own state: done (applied), pending (accepted and asynchronous; the authority reports completion later on its events), refused (a policy or confirmation gate said no) or failed (an error). State rules: refused carries problem or confirmation_required; failed carries problem; done and pending carry neither; confirmation_required appears only on refused. result carries the authority's own response document and never another service's rows. The state rules are checked by contracts.resources.checkControlResult.
+ */
+export interface ResourceControlResult {
+ action: string;
+ resource?: ResourceName;
+ state: "done" | "pending" | "refused" | "failed";
+ result?: {};
+ /**
+ * The action needs its owner's approval; retry the same request with this confirmation_id once approved.
+ */
+ confirmation_required?: {
+ confirmation_id: string;
+ reason: string;
+ };
+ problem?: Problem;
+ at: string;
+}
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* contracts.http.problem(403, 'capability.denied'); // RFC 9457 body
* contracts.http.problem(403, 'capability.denied'); // RFC 9457 body
* contracts.capabilities.check(tokenClaims, 'media.object.read', { namespace: 'live' });
* contracts.capabilities.check(tokenClaims, 'media.object.read', { namespace: 'live' });
* contracts.staff.can(userTokenClaims, 'staff.moderation.chat'); // staff roles → staff capabilities
* contracts.staff.can(userTokenClaims, 'staff.moderation.chat'); // staff roles → staff capabilities
+ * contracts.resources.parse('ovrn:media:prj_…:object/med_…'); // { service, project_id, type, id }
*/
*/
const registry = require('./lib/registry');
const registry = require('./lib/registry');
@@ -27,6 +28,7 @@ module.exports = {
@@ -27,6 +28,7 @@ module.exports = {
tools: require('./lib/tools'),
tools: require('./lib/tools'),
zones: require('./lib/zones'),
zones: require('./lib/zones'),
usage: require('./lib/usage'),
usage: require('./lib/usage'),
+ resources: require('./lib/resources'),
staff: require('./lib/staff'),
staff: require('./lib/staff'),
openapi: require('./lib/openapi'),
openapi: require('./lib/openapi'),
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * Resource-name and control helpers (ADR-048, plan T13). The common.resource-* schemas hold every rule
+ * JSON Schema can express; these checks add the ones that compare fields, so OpenVibe.Services (before
+ * it sends a control call) and the owning authority (before it acts on one) hold it to the same bar:
+ *
+ * contracts.resources.parse('ovrn:media:prj_…:object/med_…') -> { service, project_id, type, id } | null
+ * contracts.resources.format({ service, project_id, type, id }) -> 'ovrn:…'
+ * contracts.resources.nameOf(resourceSummary) -> the summary's ovrn, or null
+ * contracts.resources.checkControlRequest(q) -> { valid, errors: [{ path, message }] }
+ * create names resource_kind, every other action a resource,
+ * and the resource's project is project_id
+ * contracts.resources.checkControlResult(r) -> { valid, errors } and problem/confirmation_required fit state
+ */
+const registry = require('./registry');
+
+const OVRN = /^ovrn:([a-z][a-z0-9-]{0,31}):(prj_[0-9A-HJKMNP-TV-Z]{26}):([a-z][a-z0-9-]{0,39})\/((?!(?:usr|gst|app|mod|svc|job|evt|req)_)[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26})$/;
+const KIND = /^([a-z][a-z0-9-]{0,31})\.([a-z][a-z0-9-]{0,39})$/;
+
+/** ovrn:<service>:<project_id>:<type>/<id> -> its four segments, or null when it is not a resource name. */
+function parse(name) {
+ const m = typeof name === 'string' ? OVRN.exec(name) : null;
+ return m ? { service: m[1], project_id: m[2], type: m[3], id: m[4] } : null;
+}
+
+/** The four segments -> a resource name; throws when they do not make a valid one. */
+function format({ service, project_id, type, id }) {
+ const name = `ovrn:${service}:${project_id}:${type}/${id}`;
+ if (!OVRN.test(name)) throw new TypeError(`not a resource name: ${name}`);
+ return name;
+}
+
+/** The name of a common.resource-summary@1: its service, project_id, the second half of its kind and its id. */
+function nameOf(summary) {
+ const m = KIND.exec(String((summary && summary.kind) || ''));
+ if (!m || m[1] !== summary.service) return null;
+ const name = `ovrn:${summary.service}:${summary.project_id}:${m[2]}/${summary.id}`;
+ return OVRN.test(name) ? name : null;
+}
+
+function checkName(name) { return registry.validate('common.resource-name@1', name); }
+
+function checkControlRequest(q) {
+ const r = registry.validate('common.resource-control-request@1', q);
+ if (!r.valid) return r;
+ const errors = [];
+ if (q.action === 'create' && q.resource !== undefined) errors.push({ path: '/resource', message: 'create names resource_kind, not resource' });
+ if (q.action !== 'create' && q.resource === undefined) errors.push({ path: '/resource', message: `required for ${q.action}` });
+ // The project is the tenancy boundary: a call for project A never reaches a resource of project B.
+ if (q.resource !== undefined && parse(q.resource).project_id !== q.project_id) errors.push({ path: '/resource', message: `must name a resource of project ${q.project_id}` });
+ return { valid: errors.length === 0, errors };
+}
+
+function checkControlResult(res) {
+ const r = registry.validate('common.resource-control-result@1', res);
+ if (!r.valid) return r;
+ const errors = [];
+ const has = (k) => res[k] !== undefined;
+ if (res.state === 'refused' && !has('problem') && !has('confirmation_required')) errors.push({ path: '/state', message: 'refused carries problem or confirmation_required' });
+ if (res.state === 'failed' && !has('problem')) errors.push({ path: '/problem', message: 'required when failed' });
+ if ((res.state === 'done' || res.state === 'pending') && has('problem')) errors.push({ path: '/problem', message: `not allowed when ${res.state}` });
+ if (res.state !== 'refused' && has('confirmation_required')) errors.push({ path: '/confirmation_required', message: 'only when refused' });
+ return { valid: errors.length === 0, errors };
+}
+
+module.exports = { parse, format, nameOf, checkName, checkControlRequest, checkControlResult };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+// ADR-048 (plan T13): resource names and the control-operation contract every console action uses.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const c = require('../index.js');
+const resources = require('../lib/resources');
+const fx = (id, kind, f) => JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'fixtures', id, kind, f), 'utf8'));
+
+const name = fx('common.resource-name', 'valid', 'media-object.json');
+assert.strictEqual(c.resources, resources, 'index.js exports lib/resources.js');
+const parts = c.resources.parse(name);
+assert.deepStrictEqual(Object.keys(parts), ['service', 'project_id', 'type', 'id'], 'a name parses into its four segments');
+assert.strictEqual(parts.service, 'media');
+assert.strictEqual(c.resources.format(parts), name, 'format is the inverse of parse');
+assert.strictEqual(c.resources.parse(fx('common.resource-name', 'invalid', 'not-an-ovrn.json')), null, 'a non-name parses to null');
+assert.strictEqual(c.resources.parse(42), null);
+assert.strictEqual(c.resources.parse(fx('common.resource-name', 'invalid', 'user-id.json')), null, 'a name whose id is a person parses to null');
+assert.throws(() => c.resources.format({ ...parts, project_id: 'usr_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4' }), TypeError, 'format refuses segments that make no name');
+for (const f of ['media-object.json', 'zone-object-zone.json', 'run-sandbox.json']) {
+ const n = fx('common.resource-name', 'valid', f);
+ assert.ok(c.resources.checkName(n).valid && c.resources.parse(n), `${f}: the schema and parse agree`);
+}
+
+// A resource summary composes its own name.
+const summary = { id: 'wch_01K6R3A1B2C3D4E5F6G7H8J9KA', kind: 'watch.watch', service: 'watch', project_id: 'prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4', state: 'active', created_at: '2026-10-04T12:00:00Z' };
+assert.strictEqual(c.resources.nameOf(summary), 'ovrn:watch:prj_01K6R2Z8C4V7M9Q3T5W1X2Y3Z4:watch/wch_01K6R3A1B2C3D4E5F6G7H8J9KA');
+assert.strictEqual(c.resources.nameOf({ ...summary, service: 'media' }), null, 'a kind of another service names nothing');
+assert.strictEqual(c.resources.nameOf({ ...summary, project_id: undefined }), null, 'a summary without a project names nothing');
+
+// Control requests: the schema passes these, the cross-field rules refuse them.
+for (const f of ['other-project.json', 'create-with-resource.json']) {
+ const q = fx('common.resource-control-request', 'invalid', f);
+ assert.ok(c.validate('common.resource-control-request@1', q).valid, `${f} is schema-valid`);
+ assert.ok(!c.resources.checkControlRequest(q).valid, `${f} is refused by the cross-field check`);
+}
+const other = c.resources.checkControlRequest(fx('common.resource-control-request', 'invalid', 'other-project.json'));
+assert.ok(other.errors.some((e) => e.path === '/resource' && /must name a resource of project/.test(e.message)), 'a resource of another project is refused');
+const del = c.resources.checkControlRequest(fx('common.resource-control-request', 'invalid', 'delete-without-resource.json'));
+assert.ok(del.errors.some((e) => e.path === '/resource' && /required for delete/.test(e.message)), 'delete names its resource');
+assert.ok(!c.resources.checkControlRequest(fx('common.resource-control-request', 'invalid', 'both-names.json')).valid, 'resource and resource_kind together are refused');
+
+// Control results: problem and confirmation_required fit the state.
+for (const f of ['failed-without-problem.json', 'done-with-confirmation.json', 'refused-bare.json']) {
+ const r = fx('common.resource-control-result', 'invalid', f);
+ assert.ok(c.validate('common.resource-control-result@1', r).valid && !c.resources.checkControlResult(r).valid, `${f}: schema-valid, refused by the state rules`);
+}
+const pending = fx('common.resource-control-result', 'valid', 'pending.json');
+assert.ok(!c.resources.checkControlResult({ ...pending, problem: fx('common.resource-control-result', 'valid', 'failed-problem.json').problem }).valid, 'pending carries no problem');
@@ -60,6 +60,8 @@ const FULL_CHECK = {
@@ -60,6 +60,8 @@ const FULL_CHECK = {
'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v),
'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v),
'zone.object-zone': (v) => contracts.zones.checkObjectZone(v), 'zone.object-zone-list': (v) => contracts.zones.checkObjectZoneList(v),
'zone.object-zone': (v) => contracts.zones.checkObjectZone(v), 'zone.object-zone-list': (v) => contracts.zones.checkObjectZoneList(v),
'zone.object-zone-usage': (v) => contracts.zones.checkObjectZoneUsage(v), 'common.usage-recorded': (v) => contracts.usage.checkUsageRecorded(v),
'zone.object-zone-usage': (v) => contracts.zones.checkObjectZoneUsage(v), 'common.usage-recorded': (v) => contracts.usage.checkUsageRecorded(v),
+ 'common.resource-name': (v) => contracts.resources.checkName(v), 'common.resource-control-request': (v) => contracts.resources.checkControlRequest(v),
+ 'common.resource-control-result': (v) => contracts.resources.checkControlResult(v),
};
};
for (const c of contracts.catalog) {
for (const c of contracts.catalog) {
for (const kind of ['valid', 'invalid']) {
for (const kind of ['valid', 'invalid']) {
@@ -908,6 +910,7 @@ sub(path.join(__dirname, 'zones.test.js'));
@@ -908,6 +910,7 @@ sub(path.join(__dirname, 'zones.test.js'));
// *.usage.recorded rollups are counts: only network, codes and zone may consume one, never billing (T5 step 8).
// *.usage.recorded rollups are counts: only network, codes and zone may consume one, never billing (T5 step 8).
sub(path.join(__dirname, 'usage-topics.test.js'));
sub(path.join(__dirname, 'usage-topics.test.js'));
sub(path.join(__dirname, 'billing-grace.test.js'));
sub(path.join(__dirname, 'billing-grace.test.js'));
+sub(path.join(__dirname, 'resources.test.js'));
// ── Generated output and compatibility gate ──────────────────────────────
// ── Generated output and compatibility gate ──────────────────────────────
sub(path.join(__dirname, 'platform-fabric.test.js'));
sub(path.join(__dirname, 'platform-fabric.test.js'));