openvibers/openvibe.contracts Commits

Plan T14, follow-up 1 of the Node gap audit… (#7)

OP OpenVibers committed 2 Oct 2026 · db0aec94d1 · 40 files, +1715 −5

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Contracts/pull/7

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1c41c336..b977369a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,24 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
++## Unreleased
++
++**Run jobs and their metering** (plan T14 lane G, follow-up 1 of the Node gap audit):
++- New `platform.runtime-class@1`: the classes `function`, `code`, `browser`, `linux`, `desktop` and `gpu`, names
++ only; `function` is the first one implemented.
++- New `platform.job@1`: `id` (`job_<ULID>`, the idempotency and ack key), `class`, `artifact` {`name`, `version`}
++ (required for `function`), `args`, `ttl_ms`, `limits` {`wall_ms`, `cpu_ms`, `mem_bytes`} and `net` (only `deny`,
++ the default).
++- New `platform.job-frame@1`: the job frames on the device control link, in its `v`/`seq`/`ts` envelope. Server to
++ device: `job`, `job_cancel`, `job_exit_ack`. Device to server: `job_started`, `job_stdout` (`chunk_seq`, because
++ `seq` is the envelope's), `job_usage`, `job_exit` (`reason`, `code`, `result`, `usage`).
++- Metering, documented in `job_usage` and `job_exit`: one `platform.usage-sample@1` reading per wall-clock second,
++ `run:<job id>:<second>`, service `run`, operation `function.invoke`, unit `s`, quantity 1, or the fraction for the
++ partial last second, which only `job_exit` reports. Every field comes from the job and the second, so resends and
++ the `job_exit` backfill replay in Billing instead of being billed twice. The link's terminator (Bot now) writes the
++ readings. `usage-sample` gains a description sentence, a `function` example and two fixtures; no field changed.
++- `test/platform-run.test.js` checks the derivation. Additive.
++
## 0.85.0 — 2026-10-02
**Billing records usage readings** (plan T5 lane F): the capability `billing.usage.record` (active, internal,
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex de9dffad..e3f6c0c4 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -5408,6 +5408,36 @@
"status": "active",
"adr": "ADR-034"
},
++ {
++ "id": "platform.runtime-class",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "platform/runtime-class.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-034"
++ },
++ {
++ "id": "platform.job",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "platform/job.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-034"
++ },
++ {
++ "id": "platform.job-frame",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "platform/job-frame.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-034"
++ },
{
"id": "events.delivery-policy",
"version": "1.0.0",
ddiff --git a/contracts/platform/job-frame.v1.json b/contracts/platform/job-frame.v1.json
nnew file mode 100644
iindex 00000000..912aab66
---- /dev/null
++++ b/contracts/platform/job-frame.v1.json
@@@ -0,0 +1,309 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/platform/job-frame.v1.json",
++ "title": "JobFrame",
++ "description": "platform.job-frame@1: one job lifecycle frame on the device control link (plan T14 Run; ADR-034 proposed). The link is OpenVibe.Node's WebSocket to OpenVibe.Bot (Node docs/protocol.md section 2); Fabric may terminate it later. Each frame is one JSON object in one text message, with the link's envelope at the top level: `v` (1), `seq` (per-direction counter from 1 on each connection), `ts` (sender's clock, Unix ms) and `type`. Server to device: `job`, `job_cancel`, `job_exit_ack`. Device to server: `job_started`, `job_stdout`, `job_usage`, `job_exit`. The link's existing `ack`/`nack` frames, keyed by the job id, accept or refuse a `job`; a worker that runs jobs advertises its classes in `status.capabilities`. A running `function` job is metered per wall-clock second into platform.usage-sample@1 readings: see `job_usage` and `job_exit`.",
++ "$comment": "Additive: new frame types and optional fields may extend this v1 contract; a receiver ignores frame types it does not know.",
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Server to device: run this job (platform.job@1). Resent until acked; the job id makes a resend harmless.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "job"
++ ],
++ "properties": {
++ "v": { "$ref": "#/$defs/v" },
++ "seq": { "$ref": "#/$defs/seq" },
++ "ts": { "$ref": "#/$defs/ts" },
++ "type": { "const": "job" },
++ "job": { "$ref": "job.v1.json" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Server to device: stop this job. A running job's process group is killed and `job_exit` says `cancelled`; a job not yet started never starts; an ended or unknown id is ignored (the ended job's `job_exit` is resent if not yet acked).",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id"
++ ],
++ "properties": {
++ "v": { "$ref": "#/$defs/v" },
++ "seq": { "$ref": "#/$defs/seq" },
++ "ts": { "$ref": "#/$defs/ts" },
++ "type": { "const": "job_cancel" },
++ "id": { "$ref": "#/$defs/job_id" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Server to device: every usage reading of this job is recorded (written or replayed by Billing), so the worker may forget it and stops resending its `job_exit`.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id"
++ ],
++ "properties": {
++ "v": { "$ref": "#/$defs/v" },
++ "seq": { "$ref": "#/$defs/seq" },
++ "ts": { "$ref": "#/$defs/ts" },
++ "type": { "const": "job_exit_ack" },
++ "id": { "$ref": "#/$defs/job_id" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: the job's process started. `started_ms` anchors every usage second of the job and never changes.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "started_ms"
++ ],
++ "properties": {
++ "v": { "$ref": "#/$defs/v" },
++ "seq": { "$ref": "#/$defs/seq" },
++ "ts": { "$ref": "#/$defs/ts" },
++ "type": { "const": "job_started" },
++ "id": { "$ref": "#/$defs/job_id" },
++ "started_ms": { "$ref": "#/$defs/started_ms" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: a chunk of the job's stdout, best effort (a chunk lost with the link is not resent; a receiver drops a `chunk_seq` it already has). Never metered.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "chunk_seq",
++ "chunk"
++ ],
++ "properties": {
++ "v": { "$ref": "#/$defs/v" },
++ "seq": { "$ref": "#/$defs/seq" },
++ "ts": { "$ref": "#/$defs/ts" },
++ "type": { "const": "job_stdout" },
++ "id": { "$ref": "#/$defs/job_id" },
++ "chunk_seq": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The job's own chunk counter from 1, across reconnects (the envelope `seq` is the connection's)"
++ },
++ "chunk": {
++ "type": "string",
++ "maxLength": 65536,
++ "description": "UTF-8 text"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: wall-clock second `second` of the job has fully elapsed. Metering (platform.usage-sample@1, written through billing.usage.record by whoever terminates the link: OpenVibe.Bot now, Fabric later): second n is [started_ms + n*1000, started_ms + (n+1)*1000) on the worker's monotonic clock. A worker sends `job_usage` for n only once that second has fully elapsed, so every `job_usage` stands for quantity 1; the partial last second is never sent here, only in `job_exit`. Each second is exactly one reading: `id` and `idempotency_key` `run:<job id>:<n>`, `service` `run`, `operation` `function.invoke`, `unit` `s`, `quantity` 1 (the partial last second: (wall_ms mod 1000)/1000, never 0), `at` new Date(started_ms + n*1000).toISOString(), `resource` the job id, `node` the worker's device id, `source` `openvibe-node.worker`, and `project`, `subject` and `provider` from the dispatcher's own record of the job. No field depends on when or how often a frame was sent, and no rating fields (`cost_estimate`, `free_allowance_used`, `vibes_charged`) are set, because Billing keeps the first reading under a key, replays an identical one and refuses a different one (409): a resend after a reconnect, a `job_usage` and the `job_exit` backfill of the same second, and two writers of one job all land on one record.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "started_ms",
++ "second"
++ ],
++ "properties": {
++ "v": { "$ref": "#/$defs/v" },
++ "seq": { "$ref": "#/$defs/seq" },
++ "ts": { "$ref": "#/$defs/ts" },
++ "type": { "const": "job_usage" },
++ "id": { "$ref": "#/$defs/job_id" },
++ "started_ms": { "$ref": "#/$defs/started_ms" },
++ "second": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "n, counted from 0 at `started_ms`"
++ },
++ "cpu_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "CPU time used in this second; informational, never billed"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: the job ended (only after an `ack`). Its `usage` is authoritative for metering: from it the writer writes (or replays) seconds 0 to floor(wall_ms/1000) - 1 with quantity 1 and, when wall_ms mod 1000 > 0, the partial last second, so a lost `job_usage` is backfilled and none is billed twice. Every reason is billed for the seconds the process held. The worker resends the same `job_exit` after every reconnect until `job_exit_ack`. wall_ms comes from the monotonic clock `job_usage` uses, so it is at least (n+1)*1000 for every second n already sent.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "reason",
++ "code",
++ "result",
++ "usage"
++ ],
++ "properties": {
++ "v": { "$ref": "#/$defs/v" },
++ "seq": { "$ref": "#/$defs/seq" },
++ "ts": { "$ref": "#/$defs/ts" },
++ "type": { "const": "job_exit" },
++ "id": { "$ref": "#/$defs/job_id" },
++ "reason": {
++ "type": "string",
++ "enum": [
++ "exited",
++ "cancelled",
++ "ttl",
++ "limit",
++ "stopped",
++ "failed"
++ ],
++ "description": "exited: the process ended by itself; cancelled: `job_cancel`; ttl: `ttl_ms` ran out; limit: a `limits` cap was hit; stopped: the local kill switch or e-stop; failed: the worker could not run it (spawn error, missing artifact)"
++ },
++ "code": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "description": "The process's exit status; null when it was killed or never ran"
++ },
++ "result": {
++ "description": "The artifact's declared output (any JSON); null unless reason is `exited` with code 0"
++ },
++ "usage": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "wall_ms"
++ ],
++ "properties": {
++ "started_ms": { "$ref": "#/$defs/started_ms" },
++ "wall_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Running time from `started_ms` to the process's end, monotonic clock; 0 when it never started"
++ },
++ "cpu_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Informational, never billed"
++ },
++ "mem_peak_bytes": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Informational, never billed"
++ }
++ },
++ "if": {
++ "properties": {
++ "wall_ms": {
++ "type": "integer",
++ "exclusiveMinimum": 0
++ }
++ }
++ },
++ "then": {
++ "required": [
++ "started_ms"
++ ]
++ }
++ }
++ }
++ }
++ ],
++ "$defs": {
++ "v": {
++ "const": 1
++ },
++ "seq": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "ts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "job_id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "platform.job@1 `id`"
++ },
++ "started_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "When the job's process started, worker's wall clock, Unix ms"
++ }
++ },
++ "examples": [
++ {
++ "v": 1,
++ "seq": 7,
++ "ts": 1790935200000,
++ "type": "job",
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ }
++ }
++ },
++ {
++ "v": 1,
++ "seq": 12,
++ "ts": 1790935201250,
++ "type": "job_usage",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "started_ms": 1790935200250,
++ "second": 0,
++ "cpu_ms": 640
++ },
++ {
++ "v": 1,
++ "seq": 15,
++ "ts": 1790935202600,
++ "type": "job_exit",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "reason": "exited",
++ "code": 0,
++ "result": {
++ "url": "media:object-7"
++ },
++ "usage": {
++ "started_ms": 1790935200250,
++ "wall_ms": 2350,
++ "cpu_ms": 1800,
++ "mem_peak_bytes": 41943040
++ }
++ }
++ ]
++}
ddiff --git a/contracts/platform/job.v1.json b/contracts/platform/job.v1.json
nnew file mode 100644
iindex 00000000..d6c89bc0
---- /dev/null
++++ b/contracts/platform/job.v1.json
@@@ -0,0 +1,129 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/platform/job.v1.json",
++ "title": "Job",
++ "description": "platform.job@1: one unit of work a dispatcher (OpenVibe.Bot now, Fabric later) hands to an execution worker (plan T14 Run; ADR-034 proposed). It rides the device control link inside a `job` frame (platform.job-frame@1). The worker answers it with `ack` (accepted) or `nack` (refused: an unknown or unimplemented class, a missing artifact, limits it cannot honor, the local kill switch), then runs it at most once. Metering: a running `function` job is billed by wall-clock second as platform.usage-sample@1 readings; platform.job-frame@1 `job_usage` gives the mapping.",
++ "$comment": "Additive optional fields may extend this v1 contract.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "class",
++ "args",
++ "ttl_ms",
++ "limits"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Minted by the dispatcher (job_<ULID>): the idempotency and ack key, as a Node command `id` is. A worker that already holds or ran this id never runs it again: a resent `job` is acked again while it runs and answered with its `job_exit` once it ended. Never reused: it is the stem of every usage reading's key (`run:<id>:<second>`)."
++ },
++ "class": {
++ "$ref": "runtime-class.v1.json"
++ },
++ "artifact": {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "The pre-registered artifact to run; required for class `function`.",
++ "required": [
++ "name",
++ "version"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$"
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$",
++ "description": "One exact version, never a range"
++ }
++ }
++ },
++ "args": {
++ "type": "object",
++ "description": "The artifact's declared input as JSON; the whole `job` frame stays within the link's 1 MiB frame limit."
++ },
++ "ttl_ms": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Lifetime from the worker's receipt of the job, covering any wait and the run; the worker caps it with its own local maximum (the stricter wins, as with `max_command_ms`). When it runs out, a job not yet started never starts and a running one is killed; either way `job_exit` says `ttl`."
++ },
++ "limits": {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Caps the worker enforces on the running process (merged stricter with its local caps); exceeding one kills the job and `job_exit` says `limit`.",
++ "required": [
++ "wall_ms",
++ "cpu_ms",
++ "mem_bytes"
++ ],
++ "properties": {
++ "wall_ms": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Running time from `job_started`"
++ },
++ "cpu_ms": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "CPU time, user plus system"
++ },
++ "mem_bytes": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Peak resident memory"
++ }
++ }
++ },
++ "net": {
++ "type": "string",
++ "enum": [
++ "deny"
++ ],
++ "default": "deny",
++ "description": "Network policy; absent means `deny` (no network at all). Further policies are added to v1 additively, and a worker refuses (`nack`) a policy it does not know."
++ }
++ },
++ "allOf": [
++ {
++ "if": {
++ "required": [
++ "class"
++ ],
++ "properties": {
++ "class": {
++ "const": "function"
++ }
++ }
++ },
++ "then": {
++ "required": [
++ "artifact"
++ ]
++ }
++ }
++ ],
++ "examples": [
++ {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "deny"
++ }
++ ]
++}
ddiff --git a/contracts/platform/runtime-class.v1.json b/contracts/platform/runtime-class.v1.json
nnew file mode 100644
iindex 00000000..b52ec5d5
---- /dev/null
++++ b/contracts/platform/runtime-class.v1.json
@@@ -0,0 +1,38 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/platform/runtime-class.v1.json",
++ "title": "RuntimeClass",
++ "description": "platform.runtime-class@1: the kind of execution environment a platform.job@1 asks for (plan T14 Run; ADR-034 proposed). A name only: this contract defines no behavior, and a worker runs only the classes it advertises and refuses every other one (`nack`). `function` is the first class implemented (OpenVibe.Node's function worker); the others are reserved names.",
++ "$comment": "Additive: a class may be added to v1; none is removed, renamed or reordered.",
++ "type": "string",
++ "oneOf": [
++ {
++ "const": "function",
++ "description": "A pre-registered artifact (name and exact version) run once with declared args to one result: single-shot, no persistent filesystem, no dependency resolution, network denied."
++ },
++ {
++ "const": "code",
++ "description": "Arbitrary source with its dependency tree in a longer-lived environment; needs image handling and isolation stronger than a container."
++ },
++ {
++ "const": "browser",
++ "description": "A headless or headed web browser session driven remotely."
++ },
++ {
++ "const": "linux",
++ "description": "A full Linux environment (shell, filesystem, processes) that lives across commands."
++ },
++ {
++ "const": "desktop",
++ "description": "A graphical desktop session with screen and input under control levels."
++ },
++ {
++ "const": "gpu",
++ "description": "A workload that holds one or more GPUs for its lifetime."
++ }
++ ],
++ "examples": [
++ "function",
++ "gpu"
++ ]
++}
ddiff --git a/contracts/platform/usage-sample.v1.json b/contracts/platform/usage-sample.v1.json
iindex d8324d64..b0a0a158 100644
---- a/contracts/platform/usage-sample.v1.json
++++ b/contracts/platform/usage-sample.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/platform/usage-sample.v1.json",
"title": "UsageSample",
-- "description": "One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`.",
++ "description": "One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`. A running platform.job@1 `function` job is one reading per wall-clock second (service `run`, operation `function.invoke`, unit `s`, idempotency_key `run:<job id>:<second>`), derived as platform.job-frame@1 `job_usage` describes.",
"$comment": "Additive optional fields may extend this v1 contract.",
"type": "object",
"additionalProperties": false,
@@@ -129,6 +129,20 @@
"vibes_charged": 4,
"route_epoch": 3,
"trace_id": "tr-42"
++ },
++ {
++ "id": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "idempotency_key": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "service": "run",
++ "project": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "subject": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "resource": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "node": "dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X",
++ "operation": "function.invoke",
++ "quantity": 1,
++ "unit": "s",
++ "at": "2026-10-02T10:00:00.250Z",
++ "source": "openvibe-node.worker"
}
]
}
ddiff --git a/fixtures/platform.job-frame/invalid/envelope-v2.json b/fixtures/platform.job-frame/invalid/envelope-v2.json
nnew file mode 100644
iindex 00000000..fb2b5cbd
---- /dev/null
++++ b/fixtures/platform.job-frame/invalid/envelope-v2.json
@@@ -0,0 +1,7 @@
++{
++ "v": 2,
++ "seq": 8,
++ "ts": 1790935200250,
++ "type": "job_cancel",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/platform.job-frame/invalid/exit-ran-without-started-ms.json b/fixtures/platform.job-frame/invalid/exit-ran-without-started-ms.json
nnew file mode 100644
iindex 00000000..a793c70b
---- /dev/null
++++ b/fixtures/platform.job-frame/invalid/exit-ran-without-started-ms.json
@@@ -0,0 +1,13 @@
++{
++ "v": 1,
++ "seq": 15,
++ "ts": 1790935202600,
++ "type": "job_exit",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "reason": "exited",
++ "code": 0,
++ "result": null,
++ "usage": {
++ "wall_ms": 2350
++ }
++}
ddiff --git a/fixtures/platform.job-frame/invalid/exit-unknown-reason.json b/fixtures/platform.job-frame/invalid/exit-unknown-reason.json
nnew file mode 100644
iindex 00000000..79715126
---- /dev/null
++++ b/fixtures/platform.job-frame/invalid/exit-unknown-reason.json
@@@ -0,0 +1,14 @@
++{
++ "v": 1,
++ "seq": 15,
++ "ts": 1790935202600,
++ "type": "job_exit",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "reason": "oom",
++ "code": null,
++ "result": null,
++ "usage": {
++ "started_ms": 1790935200250,
++ "wall_ms": 2350
++ }
++}
ddiff --git a/fixtures/platform.job-frame/invalid/stdout-without-chunk-seq.json b/fixtures/platform.job-frame/invalid/stdout-without-chunk-seq.json
nnew file mode 100644
iindex 00000000..3067541c
---- /dev/null
++++ b/fixtures/platform.job-frame/invalid/stdout-without-chunk-seq.json
@@@ -0,0 +1,8 @@
++{
++ "v": 1,
++ "seq": 13,
++ "ts": 1790935201350,
++ "type": "job_stdout",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "chunk": "x"
++}
ddiff --git a/fixtures/platform.job-frame/invalid/unknown-type.json b/fixtures/platform.job-frame/invalid/unknown-type.json
nnew file mode 100644
iindex 00000000..633f585f
---- /dev/null
++++ b/fixtures/platform.job-frame/invalid/unknown-type.json
@@@ -0,0 +1,7 @@
++{
++ "v": 1,
++ "seq": 15,
++ "ts": 1790935200250,
++ "type": "job_pause",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/platform.job-frame/invalid/usage-partial-second.json b/fixtures/platform.job-frame/invalid/usage-partial-second.json
nnew file mode 100644
iindex 00000000..8084203d
---- /dev/null
++++ b/fixtures/platform.job-frame/invalid/usage-partial-second.json
@@@ -0,0 +1,10 @@
++{
++ "v": 1,
++ "seq": 12,
++ "ts": 1790935201250,
++ "type": "job_usage",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "started_ms": 1790935200250,
++ "second": 2,
++ "quantity": 0.35
++}
ddiff --git a/fixtures/platform.job-frame/valid/job-cancel.json b/fixtures/platform.job-frame/valid/job-cancel.json
nnew file mode 100644
iindex 00000000..01298255
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job-cancel.json
@@@ -0,0 +1,7 @@
++{
++ "v": 1,
++ "seq": 8,
++ "ts": 1790935201150,
++ "type": "job_cancel",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/platform.job-frame/valid/job-exit-ack.json b/fixtures/platform.job-frame/valid/job-exit-ack.json
nnew file mode 100644
iindex 00000000..d594d84e
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job-exit-ack.json
@@@ -0,0 +1,7 @@
++{
++ "v": 1,
++ "seq": 9,
++ "ts": 1790935202650,
++ "type": "job_exit_ack",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/platform.job-frame/valid/job-exit-never-started.json b/fixtures/platform.job-frame/valid/job-exit-never-started.json
nnew file mode 100644
iindex 00000000..ede30eb3
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job-exit-never-started.json
@@@ -0,0 +1,13 @@
++{
++ "v": 1,
++ "seq": 9,
++ "ts": 1790935260250,
++ "type": "job_exit",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "reason": "ttl",
++ "code": null,
++ "result": null,
++ "usage": {
++ "wall_ms": 0
++ }
++}
ddiff --git a/fixtures/platform.job-frame/valid/job-exit.json b/fixtures/platform.job-frame/valid/job-exit.json
nnew file mode 100644
iindex 00000000..9716307b
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job-exit.json
@@@ -0,0 +1,18 @@
++{
++ "v": 1,
++ "seq": 15,
++ "ts": 1790935202600,
++ "type": "job_exit",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "reason": "exited",
++ "code": 0,
++ "result": {
++ "url": "media:object-7"
++ },
++ "usage": {
++ "started_ms": 1790935200250,
++ "wall_ms": 2350,
++ "cpu_ms": 1800,
++ "mem_peak_bytes": 41943040
++ }
++}
ddiff --git a/fixtures/platform.job-frame/valid/job-started.json b/fixtures/platform.job-frame/valid/job-started.json
nnew file mode 100644
iindex 00000000..12d4622b
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job-started.json
@@@ -0,0 +1,8 @@
++{
++ "v": 1,
++ "seq": 11,
++ "ts": 1790935200255,
++ "type": "job_started",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "started_ms": 1790935200250
++}
ddiff --git a/fixtures/platform.job-frame/valid/job-stdout.json b/fixtures/platform.job-frame/valid/job-stdout.json
nnew file mode 100644
iindex 00000000..fb9828a4
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job-stdout.json
@@@ -0,0 +1,9 @@
++{
++ "v": 1,
++ "seq": 13,
++ "ts": 1790935201350,
++ "type": "job_stdout",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "chunk_seq": 1,
++ "chunk": "resized 1 of 1\n"
++}
ddiff --git a/fixtures/platform.job-frame/valid/job-usage.json b/fixtures/platform.job-frame/valid/job-usage.json
nnew file mode 100644
iindex 00000000..998938da
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job-usage.json
@@@ -0,0 +1,10 @@
++{
++ "v": 1,
++ "seq": 12,
++ "ts": 1790935201250,
++ "type": "job_usage",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "started_ms": 1790935200250,
++ "second": 0,
++ "cpu_ms": 640
++}
ddiff --git a/fixtures/platform.job-frame/valid/job.json b/fixtures/platform.job-frame/valid/job.json
nnew file mode 100644
iindex 00000000..088779a1
---- /dev/null
++++ b/fixtures/platform.job-frame/valid/job.json
@@@ -0,0 +1,23 @@
++{
++ "v": 1,
++ "seq": 7,
++ "ts": 1790935200000,
++ "type": "job",
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ }
++ }
++}
ddiff --git a/fixtures/platform.job/invalid/artifact-version-range.json b/fixtures/platform.job/invalid/artifact-version-range.json
nnew file mode 100644
iindex 00000000..51b36f30
---- /dev/null
++++ b/fixtures/platform.job/invalid/artifact-version-range.json
@@@ -0,0 +1,18 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "^1.2"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "deny"
++}
ddiff --git a/fixtures/platform.job/invalid/function-without-artifact.json b/fixtures/platform.job/invalid/function-without-artifact.json
nnew file mode 100644
iindex 00000000..ebc47e5b
---- /dev/null
++++ b/fixtures/platform.job/invalid/function-without-artifact.json
@@@ -0,0 +1,14 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "deny"
++}
ddiff --git a/fixtures/platform.job/invalid/id-without-prefix.json b/fixtures/platform.job/invalid/id-without-prefix.json
nnew file mode 100644
iindex 00000000..20341f51
---- /dev/null
++++ b/fixtures/platform.job/invalid/id-without-prefix.json
@@@ -0,0 +1,18 @@
++{
++ "id": "01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "deny"
++}
ddiff --git a/fixtures/platform.job/invalid/limits-without-mem.json b/fixtures/platform.job/invalid/limits-without-mem.json
nnew file mode 100644
iindex 00000000..a92d821b
---- /dev/null
++++ b/fixtures/platform.job/invalid/limits-without-mem.json
@@@ -0,0 +1,17 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000
++ },
++ "net": "deny"
++}
ddiff --git a/fixtures/platform.job/invalid/net-allow.json b/fixtures/platform.job/invalid/net-allow.json
nnew file mode 100644
iindex 00000000..a576c684
---- /dev/null
++++ b/fixtures/platform.job/invalid/net-allow.json
@@@ -0,0 +1,18 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "allow"
++}
ddiff --git a/fixtures/platform.job/valid/function.json b/fixtures/platform.job/valid/function.json
nnew file mode 100644
iindex 00000000..74a2a7b1
---- /dev/null
++++ b/fixtures/platform.job/valid/function.json
@@@ -0,0 +1,18 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "deny"
++}
ddiff --git a/fixtures/platform.job/valid/net-absent-means-deny.json b/fixtures/platform.job/valid/net-absent-means-deny.json
nnew file mode 100644
iindex 00000000..341ab6d8
---- /dev/null
++++ b/fixtures/platform.job/valid/net-absent-means-deny.json
@@@ -0,0 +1,17 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ }
++}
ddiff --git a/fixtures/platform.job/valid/reserved-class-without-artifact.json b/fixtures/platform.job/valid/reserved-class-without-artifact.json
nnew file mode 100644
iindex 00000000..5e11ef41
---- /dev/null
++++ b/fixtures/platform.job/valid/reserved-class-without-artifact.json
@@@ -0,0 +1,14 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "gpu",
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "deny"
++}
ddiff --git a/fixtures/platform.runtime-class/invalid/unknown-class.json b/fixtures/platform.runtime-class/invalid/unknown-class.json
nnew file mode 100644
iindex 00000000..b2853e67
---- /dev/null
++++ b/fixtures/platform.runtime-class/invalid/unknown-class.json
@@@ -0,0 +1 @@
++"container"
ddiff --git a/fixtures/platform.runtime-class/invalid/upper-case.json b/fixtures/platform.runtime-class/invalid/upper-case.json
nnew file mode 100644
iindex 00000000..cb07b5d4
---- /dev/null
++++ b/fixtures/platform.runtime-class/invalid/upper-case.json
@@@ -0,0 +1 @@
++"Function"
ddiff --git a/fixtures/platform.runtime-class/valid/function.json b/fixtures/platform.runtime-class/valid/function.json
nnew file mode 100644
iindex 00000000..f45c029c
---- /dev/null
++++ b/fixtures/platform.runtime-class/valid/function.json
@@@ -0,0 +1 @@
++"function"
ddiff --git a/fixtures/platform.runtime-class/valid/gpu.json b/fixtures/platform.runtime-class/valid/gpu.json
nnew file mode 100644
iindex 00000000..310fd2b8
---- /dev/null
++++ b/fixtures/platform.runtime-class/valid/gpu.json
@@@ -0,0 +1 @@
++"gpu"
ddiff --git a/fixtures/platform.usage-sample/valid/run-function-last-partial-second.json b/fixtures/platform.usage-sample/valid/run-function-last-partial-second.json
nnew file mode 100644
iindex 00000000..9c09d04d
---- /dev/null
++++ b/fixtures/platform.usage-sample/valid/run-function-last-partial-second.json
@@@ -0,0 +1,14 @@
++{
++ "id": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:2",
++ "idempotency_key": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:2",
++ "service": "run",
++ "project": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "subject": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "resource": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "node": "dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X",
++ "operation": "function.invoke",
++ "quantity": 0.35,
++ "unit": "s",
++ "at": "2026-10-02T10:00:02.250Z",
++ "source": "openvibe-node.worker"
++}
ddiff --git a/fixtures/platform.usage-sample/valid/run-function-second.json b/fixtures/platform.usage-sample/valid/run-function-second.json
nnew file mode 100644
iindex 00000000..50f802e9
---- /dev/null
++++ b/fixtures/platform.usage-sample/valid/run-function-second.json
@@@ -0,0 +1,14 @@
++{
++ "id": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "idempotency_key": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "service": "run",
++ "project": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "subject": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "resource": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "node": "dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X",
++ "operation": "function.invoke",
++ "quantity": 1,
++ "unit": "s",
++ "at": "2026-10-02T10:00:00.250Z",
++ "source": "openvibe-node.worker"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex dd61391a..cd4740ee 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -58889,7 +58889,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/platform/usage-sample.v1.json",
"title": "UsageSample",
-- "description": "One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`.",
++ "description": "One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`. A running platform.job@1 `function` job is one reading per wall-clock second (service `run`, operation `function.invoke`, unit `s`, idempotency_key `run:<job id>:<second>`), derived as platform.job-frame@1 `job_usage` describes.",
"$comment": "Additive optional fields may extend this v1 contract.",
"type": "object",
"additionalProperties": false,
@@@ -59016,6 +59016,20 @@
"vibes_charged": 4,
"route_epoch": 3,
"trace_id": "tr-42"
++ },
++ {
++ "id": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "idempotency_key": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "service": "run",
++ "project": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "subject": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "resource": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "node": "dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X",
++ "operation": "function.invoke",
++ "quantity": 1,
++ "unit": "s",
++ "at": "2026-10-02T10:00:00.250Z",
++ "source": "openvibe-node.worker"
}
]
}
@@@ -60079,6 +60093,573 @@
]
}
},
++ "platform.runtime-class": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/platform/runtime-class.v1.json",
++ "title": "RuntimeClass",
++ "description": "platform.runtime-class@1: the kind of execution environment a platform.job@1 asks for (plan T14 Run; ADR-034 proposed). A name only: this contract defines no behavior, and a worker runs only the classes it advertises and refuses every other one (`nack`). `function` is the first class implemented (OpenVibe.Node's function worker); the others are reserved names.",
++ "$comment": "Additive: a class may be added to v1; none is removed, renamed or reordered.",
++ "type": "string",
++ "oneOf": [
++ {
++ "const": "function",
++ "description": "A pre-registered artifact (name and exact version) run once with declared args to one result: single-shot, no persistent filesystem, no dependency resolution, network denied."
++ },
++ {
++ "const": "code",
++ "description": "Arbitrary source with its dependency tree in a longer-lived environment; needs image handling and isolation stronger than a container."
++ },
++ {
++ "const": "browser",
++ "description": "A headless or headed web browser session driven remotely."
++ },
++ {
++ "const": "linux",
++ "description": "A full Linux environment (shell, filesystem, processes) that lives across commands."
++ },
++ {
++ "const": "desktop",
++ "description": "A graphical desktop session with screen and input under control levels."
++ },
++ {
++ "const": "gpu",
++ "description": "A workload that holds one or more GPUs for its lifetime."
++ }
++ ],
++ "examples": [
++ "function",
++ "gpu"
++ ]
++ }
++ },
++ "platform.job": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/platform/job.v1.json",
++ "title": "Job",
++ "description": "platform.job@1: one unit of work a dispatcher (OpenVibe.Bot now, Fabric later) hands to an execution worker (plan T14 Run; ADR-034 proposed). It rides the device control link inside a `job` frame (platform.job-frame@1). The worker answers it with `ack` (accepted) or `nack` (refused: an unknown or unimplemented class, a missing artifact, limits it cannot honor, the local kill switch), then runs it at most once. Metering: a running `function` job is billed by wall-clock second as platform.usage-sample@1 readings; platform.job-frame@1 `job_usage` gives the mapping.",
++ "$comment": "Additive optional fields may extend this v1 contract.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "class",
++ "args",
++ "ttl_ms",
++ "limits"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Minted by the dispatcher (job_<ULID>): the idempotency and ack key, as a Node command `id` is. A worker that already holds or ran this id never runs it again: a resent `job` is acked again while it runs and answered with its `job_exit` once it ended. Never reused: it is the stem of every usage reading's key (`run:<id>:<second>`)."
++ },
++ "class": {
++ "$ref": "runtime-class.v1.json"
++ },
++ "artifact": {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "The pre-registered artifact to run; required for class `function`.",
++ "required": [
++ "name",
++ "version"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$"
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$",
++ "description": "One exact version, never a range"
++ }
++ }
++ },
++ "args": {
++ "type": "object",
++ "description": "The artifact's declared input as JSON; the whole `job` frame stays within the link's 1 MiB frame limit."
++ },
++ "ttl_ms": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Lifetime from the worker's receipt of the job, covering any wait and the run; the worker caps it with its own local maximum (the stricter wins, as with `max_command_ms`). When it runs out, a job not yet started never starts and a running one is killed; either way `job_exit` says `ttl`."
++ },
++ "limits": {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Caps the worker enforces on the running process (merged stricter with its local caps); exceeding one kills the job and `job_exit` says `limit`.",
++ "required": [
++ "wall_ms",
++ "cpu_ms",
++ "mem_bytes"
++ ],
++ "properties": {
++ "wall_ms": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Running time from `job_started`"
++ },
++ "cpu_ms": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "CPU time, user plus system"
++ },
++ "mem_bytes": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Peak resident memory"
++ }
++ }
++ },
++ "net": {
++ "type": "string",
++ "enum": [
++ "deny"
++ ],
++ "default": "deny",
++ "description": "Network policy; absent means `deny` (no network at all). Further policies are added to v1 additively, and a worker refuses (`nack`) a policy it does not know."
++ }
++ },
++ "allOf": [
++ {
++ "if": {
++ "required": [
++ "class"
++ ],
++ "properties": {
++ "class": {
++ "const": "function"
++ }
++ }
++ },
++ "then": {
++ "required": [
++ "artifact"
++ ]
++ }
++ }
++ ],
++ "examples": [
++ {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ },
++ "net": "deny"
++ }
++ ]
++ }
++ },
++ "platform.job-frame": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/platform/job-frame.v1.json",
++ "title": "JobFrame",
++ "description": "platform.job-frame@1: one job lifecycle frame on the device control link (plan T14 Run; ADR-034 proposed). The link is OpenVibe.Node's WebSocket to OpenVibe.Bot (Node docs/protocol.md section 2); Fabric may terminate it later. Each frame is one JSON object in one text message, with the link's envelope at the top level: `v` (1), `seq` (per-direction counter from 1 on each connection), `ts` (sender's clock, Unix ms) and `type`. Server to device: `job`, `job_cancel`, `job_exit_ack`. Device to server: `job_started`, `job_stdout`, `job_usage`, `job_exit`. The link's existing `ack`/`nack` frames, keyed by the job id, accept or refuse a `job`; a worker that runs jobs advertises its classes in `status.capabilities`. A running `function` job is metered per wall-clock second into platform.usage-sample@1 readings: see `job_usage` and `job_exit`.",
++ "$comment": "Additive: new frame types and optional fields may extend this v1 contract; a receiver ignores frame types it does not know.",
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Server to device: run this job (platform.job@1). Resent until acked; the job id makes a resend harmless.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "job"
++ ],
++ "properties": {
++ "v": {
++ "$ref": "#/$defs/v"
++ },
++ "seq": {
++ "$ref": "#/$defs/seq"
++ },
++ "ts": {
++ "$ref": "#/$defs/ts"
++ },
++ "type": {
++ "const": "job"
++ },
++ "job": {
++ "$ref": "job.v1.json"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Server to device: stop this job. A running job's process group is killed and `job_exit` says `cancelled`; a job not yet started never starts; an ended or unknown id is ignored (the ended job's `job_exit` is resent if not yet acked).",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id"
++ ],
++ "properties": {
++ "v": {
++ "$ref": "#/$defs/v"
++ },
++ "seq": {
++ "$ref": "#/$defs/seq"
++ },
++ "ts": {
++ "$ref": "#/$defs/ts"
++ },
++ "type": {
++ "const": "job_cancel"
++ },
++ "id": {
++ "$ref": "#/$defs/job_id"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Server to device: every usage reading of this job is recorded (written or replayed by Billing), so the worker may forget it and stops resending its `job_exit`.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id"
++ ],
++ "properties": {
++ "v": {
++ "$ref": "#/$defs/v"
++ },
++ "seq": {
++ "$ref": "#/$defs/seq"
++ },
++ "ts": {
++ "$ref": "#/$defs/ts"
++ },
++ "type": {
++ "const": "job_exit_ack"
++ },
++ "id": {
++ "$ref": "#/$defs/job_id"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: the job's process started. `started_ms` anchors every usage second of the job and never changes.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "started_ms"
++ ],
++ "properties": {
++ "v": {
++ "$ref": "#/$defs/v"
++ },
++ "seq": {
++ "$ref": "#/$defs/seq"
++ },
++ "ts": {
++ "$ref": "#/$defs/ts"
++ },
++ "type": {
++ "const": "job_started"
++ },
++ "id": {
++ "$ref": "#/$defs/job_id"
++ },
++ "started_ms": {
++ "$ref": "#/$defs/started_ms"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: a chunk of the job's stdout, best effort (a chunk lost with the link is not resent; a receiver drops a `chunk_seq` it already has). Never metered.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "chunk_seq",
++ "chunk"
++ ],
++ "properties": {
++ "v": {
++ "$ref": "#/$defs/v"
++ },
++ "seq": {
++ "$ref": "#/$defs/seq"
++ },
++ "ts": {
++ "$ref": "#/$defs/ts"
++ },
++ "type": {
++ "const": "job_stdout"
++ },
++ "id": {
++ "$ref": "#/$defs/job_id"
++ },
++ "chunk_seq": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The job's own chunk counter from 1, across reconnects (the envelope `seq` is the connection's)"
++ },
++ "chunk": {
++ "type": "string",
++ "maxLength": 65536,
++ "description": "UTF-8 text"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: wall-clock second `second` of the job has fully elapsed. Metering (platform.usage-sample@1, written through billing.usage.record by whoever terminates the link: OpenVibe.Bot now, Fabric later): second n is [started_ms + n*1000, started_ms + (n+1)*1000) on the worker's monotonic clock. A worker sends `job_usage` for n only once that second has fully elapsed, so every `job_usage` stands for quantity 1; the partial last second is never sent here, only in `job_exit`. Each second is exactly one reading: `id` and `idempotency_key` `run:<job id>:<n>`, `service` `run`, `operation` `function.invoke`, `unit` `s`, `quantity` 1 (the partial last second: (wall_ms mod 1000)/1000, never 0), `at` new Date(started_ms + n*1000).toISOString(), `resource` the job id, `node` the worker's device id, `source` `openvibe-node.worker`, and `project`, `subject` and `provider` from the dispatcher's own record of the job. No field depends on when or how often a frame was sent, and no rating fields (`cost_estimate`, `free_allowance_used`, `vibes_charged`) are set, because Billing keeps the first reading under a key, replays an identical one and refuses a different one (409): a resend after a reconnect, a `job_usage` and the `job_exit` backfill of the same second, and two writers of one job all land on one record.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "started_ms",
++ "second"
++ ],
++ "properties": {
++ "v": {
++ "$ref": "#/$defs/v"
++ },
++ "seq": {
++ "$ref": "#/$defs/seq"
++ },
++ "ts": {
++ "$ref": "#/$defs/ts"
++ },
++ "type": {
++ "const": "job_usage"
++ },
++ "id": {
++ "$ref": "#/$defs/job_id"
++ },
++ "started_ms": {
++ "$ref": "#/$defs/started_ms"
++ },
++ "second": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "n, counted from 0 at `started_ms`"
++ },
++ "cpu_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "CPU time used in this second; informational, never billed"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "description": "Device to server: the job ended (only after an `ack`). Its `usage` is authoritative for metering: from it the writer writes (or replays) seconds 0 to floor(wall_ms/1000) - 1 with quantity 1 and, when wall_ms mod 1000 > 0, the partial last second, so a lost `job_usage` is backfilled and none is billed twice. Every reason is billed for the seconds the process held. The worker resends the same `job_exit` after every reconnect until `job_exit_ack`. wall_ms comes from the monotonic clock `job_usage` uses, so it is at least (n+1)*1000 for every second n already sent.",
++ "required": [
++ "v",
++ "seq",
++ "ts",
++ "type",
++ "id",
++ "reason",
++ "code",
++ "result",
++ "usage"
++ ],
++ "properties": {
++ "v": {
++ "$ref": "#/$defs/v"
++ },
++ "seq": {
++ "$ref": "#/$defs/seq"
++ },
++ "ts": {
++ "$ref": "#/$defs/ts"
++ },
++ "type": {
++ "const": "job_exit"
++ },
++ "id": {
++ "$ref": "#/$defs/job_id"
++ },
++ "reason": {
++ "type": "string",
++ "enum": [
++ "exited",
++ "cancelled",
++ "ttl",
++ "limit",
++ "stopped",
++ "failed"
++ ],
++ "description": "exited: the process ended by itself; cancelled: `job_cancel`; ttl: `ttl_ms` ran out; limit: a `limits` cap was hit; stopped: the local kill switch or e-stop; failed: the worker could not run it (spawn error, missing artifact)"
++ },
++ "code": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "description": "The process's exit status; null when it was killed or never ran"
++ },
++ "result": {
++ "description": "The artifact's declared output (any JSON); null unless reason is `exited` with code 0"
++ },
++ "usage": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "wall_ms"
++ ],
++ "properties": {
++ "started_ms": {
++ "$ref": "#/$defs/started_ms"
++ },
++ "wall_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Running time from `started_ms` to the process's end, monotonic clock; 0 when it never started"
++ },
++ "cpu_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Informational, never billed"
++ },
++ "mem_peak_bytes": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Informational, never billed"
++ }
++ },
++ "if": {
++ "properties": {
++ "wall_ms": {
++ "type": "integer",
++ "exclusiveMinimum": 0
++ }
++ }
++ },
++ "then": {
++ "required": [
++ "started_ms"
++ ]
++ }
++ }
++ }
++ }
++ ],
++ "$defs": {
++ "v": {
++ "const": 1
++ },
++ "seq": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "ts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "job_id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "platform.job@1 `id`"
++ },
++ "started_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "When the job's process started, worker's wall clock, Unix ms"
++ }
++ },
++ "examples": [
++ {
++ "v": 1,
++ "seq": 7,
++ "ts": 1790935200000,
++ "type": "job",
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "class": "function",
++ "artifact": {
++ "name": "thumbnail",
++ "version": "1.2.0"
++ },
++ "args": {
++ "width": 320
++ },
++ "ttl_ms": 60000,
++ "limits": {
++ "wall_ms": 30000,
++ "cpu_ms": 30000,
++ "mem_bytes": 268435456
++ }
++ }
++ },
++ {
++ "v": 1,
++ "seq": 12,
++ "ts": 1790935201250,
++ "type": "job_usage",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "started_ms": 1790935200250,
++ "second": 0,
++ "cpu_ms": 640
++ },
++ {
++ "v": 1,
++ "seq": 15,
++ "ts": 1790935202600,
++ "type": "job_exit",
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "reason": "exited",
++ "code": 0,
++ "result": {
++ "url": "media:object-7"
++ },
++ "usage": {
++ "started_ms": 1790935200250,
++ "wall_ms": 2350,
++ "cpu_ms": 1800,
++ "mem_peak_bytes": 41943040
++ }
++ }
++ ]
++ }
++ },
"events.delivery-policy": {
"version": "1.0.0",
"owner": "events",
ddiff --git a/generated/openapi/billing.json b/generated/openapi/billing.json
iindex 9f751678..911b600b 100644
---- a/generated/openapi/billing.json
++++ b/generated/openapi/billing.json
@@@ -3618,7 +3618,7 @@
},
"platform.usage-sample.v1": {
"title": "UsageSample",
-- "description": "One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`.",
++ "description": "One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`. A running platform.job@1 `function` job is one reading per wall-clock second (service `run`, operation `function.invoke`, unit `s`, idempotency_key `run:<job id>:<second>`), derived as platform.job-frame@1 `job_usage` describes.",
"$comment": "Additive optional fields may extend this v1 contract.",
"type": "object",
"additionalProperties": false,
@@@ -3745,6 +3745,20 @@
"vibes_charged": 4,
"route_epoch": 3,
"trace_id": "tr-42"
++ },
++ {
++ "id": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "idempotency_key": "run:job_01JAB2C3D4E5F6G7H8J9K0MNPQ:0",
++ "service": "run",
++ "project": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "subject": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "resource": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "node": "dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X",
++ "operation": "function.invoke",
++ "quantity": 1,
++ "unit": "s",
++ "at": "2026-10-02T10:00:00.250Z",
++ "source": "openvibe-node.worker"
}
]
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 2d34608e..287a82ff 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -51082,7 +51082,7 @@ export interface CapacitySnapshot {
/** [email protected] (owner: network) */
/**
-- * One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`.
++ * One metered usage reading with retry-safe attribution (T1 Universal Fabric; ADR-034 proposed). Consumers dedupe on `idempotency_key`. A running platform.job@1 `function` job is one reading per wall-clock second (service `run`, operation `function.invoke`, unit `s`, idempotency_key `run:<job id>:<second>`), derived as platform.job-frame@1 `job_usage` describes.
*/
export interface UsageSample {
id: string;
@@@ -51406,6 +51406,194 @@ export interface HarnessOffer {
};
}
++/** [email protected] (owner: network) */
++/**
++ * platform.runtime-class@1: the kind of execution environment a platform.job@1 asks for (plan T14 Run; ADR-034 proposed). A name only: this contract defines no behavior, and a worker runs only the classes it advertises and refuses every other one (`nack`). `function` is the first class implemented (OpenVibe.Node's function worker); the others are reserved names.
++ */
++export type RuntimeClass = ("function" | "code" | "browser" | "linux" | "desktop" | "gpu") & string;
++
++/** [email protected] (owner: network) */
++/**
++ * platform.job@1: one unit of work a dispatcher (OpenVibe.Bot now, Fabric later) hands to an execution worker (plan T14 Run; ADR-034 proposed). It rides the device control link inside a `job` frame (platform.job-frame@1). The worker answers it with `ack` (accepted) or `nack` (refused: an unknown or unimplemented class, a missing artifact, limits it cannot honor, the local kill switch), then runs it at most once. Metering: a running `function` job is billed by wall-clock second as platform.usage-sample@1 readings; platform.job-frame@1 `job_usage` gives the mapping.
++ */
++export type Job = {
++ [k: string]: unknown | undefined;
++} & {
++ /**
++ * Minted by the dispatcher (job_<ULID>): the idempotency and ack key, as a Node command `id` is. A worker that already holds or ran this id never runs it again: a resent `job` is acked again while it runs and answered with its `job_exit` once it ended. Never reused: it is the stem of every usage reading's key (`run:<id>:<second>`).
++ */
++ id: string;
++ class: RuntimeClass;
++ /**
++ * The pre-registered artifact to run; required for class `function`.
++ */
++ artifact?: {
++ name: string;
++ /**
++ * One exact version, never a range
++ */
++ version: string;
++ };
++ /**
++ * The artifact's declared input as JSON; the whole `job` frame stays within the link's 1 MiB frame limit.
++ */
++ args: {};
++ /**
++ * Lifetime from the worker's receipt of the job, covering any wait and the run; the worker caps it with its own local maximum (the stricter wins, as with `max_command_ms`). When it runs out, a job not yet started never starts and a running one is killed; either way `job_exit` says `ttl`.
++ */
++ ttl_ms: number;
++ /**
++ * Caps the worker enforces on the running process (merged stricter with its local caps); exceeding one kills the job and `job_exit` says `limit`.
++ */
++ limits: {
++ /**
++ * Running time from `job_started`
++ */
++ wall_ms: number;
++ /**
++ * CPU time, user plus system
++ */
++ cpu_ms: number;
++ /**
++ * Peak resident memory
++ */
++ mem_bytes: number;
++ };
++ /**
++ * Network policy; absent means `deny` (no network at all). Further policies are added to v1 additively, and a worker refuses (`nack`) a policy it does not know.
++ */
++ net?: "deny";
++};
++
++/** [email protected] (owner: network) */
++/**
++ * platform.job-frame@1: one job lifecycle frame on the device control link (plan T14 Run; ADR-034 proposed). The link is OpenVibe.Node's WebSocket to OpenVibe.Bot (Node docs/protocol.md section 2); Fabric may terminate it later. Each frame is one JSON object in one text message, with the link's envelope at the top level: `v` (1), `seq` (per-direction counter from 1 on each connection), `ts` (sender's clock, Unix ms) and `type`. Server to device: `job`, `job_cancel`, `job_exit_ack`. Device to server: `job_started`, `job_stdout`, `job_usage`, `job_exit`. The link's existing `ack`/`nack` frames, keyed by the job id, accept or refuse a `job`; a worker that runs jobs advertises its classes in `status.capabilities`. A running `function` job is metered per wall-clock second into platform.usage-sample@1 readings: see `job_usage` and `job_exit`.
++ */
++export type JobFrame =
++ | {
++ v: 1;
++ seq: number;
++ ts: number;
++ type: "job";
++ job: Job;
++ }
++ | {
++ v: 1;
++ seq: number;
++ ts: number;
++ type: "job_cancel";
++ /**
++ * platform.job@1 `id`
++ */
++ id: string;
++ }
++ | {
++ v: 1;
++ seq: number;
++ ts: number;
++ type: "job_exit_ack";
++ /**
++ * platform.job@1 `id`
++ */
++ id: string;
++ }
++ | {
++ v: 1;
++ seq: number;
++ ts: number;
++ type: "job_started";
++ /**
++ * platform.job@1 `id`
++ */
++ id: string;
++ /**
++ * When the job's process started, worker's wall clock, Unix ms
++ */
++ started_ms: number;
++ }
++ | {
++ v: 1;
++ seq: number;
++ ts: number;
++ type: "job_stdout";
++ /**
++ * platform.job@1 `id`
++ */
++ id: string;
++ /**
++ * The job's own chunk counter from 1, across reconnects (the envelope `seq` is the connection's)
++ */
++ chunk_seq: number;
++ /**
++ * UTF-8 text
++ */
++ chunk: string;
++ }
++ | {
++ v: 1;
++ seq: number;
++ ts: number;
++ type: "job_usage";
++ /**
++ * platform.job@1 `id`
++ */
++ id: string;
++ /**
++ * When the job's process started, worker's wall clock, Unix ms
++ */
++ started_ms: number;
++ /**
++ * n, counted from 0 at `started_ms`
++ */
++ second: number;
++ /**
++ * CPU time used in this second; informational, never billed
++ */
++ cpu_ms?: number;
++ }
++ | {
++ v: 1;
++ seq: number;
++ ts: number;
++ type: "job_exit";
++ /**
++ * platform.job@1 `id`
++ */
++ id: string;
++ /**
++ * exited: the process ended by itself; cancelled: `job_cancel`; ttl: `ttl_ms` ran out; limit: a `limits` cap was hit; stopped: the local kill switch or e-stop; failed: the worker could not run it (spawn error, missing artifact)
++ */
++ reason: "exited" | "cancelled" | "ttl" | "limit" | "stopped" | "failed";
++ /**
++ * The process's exit status; null when it was killed or never ran
++ */
++ code: number | null;
++ /**
++ * The artifact's declared output (any JSON); null unless reason is `exited` with code 0
++ */
++ result: {
++ [k: string]: unknown | undefined;
++ };
++ usage: {
++ /**
++ * When the job's process started, worker's wall clock, Unix ms
++ */
++ started_ms?: number;
++ /**
++ * Running time from `started_ms` to the process's end, monotonic clock; 0 when it never started
++ */
++ wall_ms: number;
++ /**
++ * Informational, never billed
++ */
++ cpu_ms?: number;
++ /**
++ * Informational, never billed
++ */
++ mem_peak_bytes?: number;
++ };
++ };
++
/** [email protected] (owner: events) */
/**
* How an event type is carried (roadmap WS-Z3 task 2): its delivery class sets the minimum semantics, which a publisher's intent may raise and never lower; ordering is per key; the route planner picks the carrier.
ddiff --git a/test/platform-fabric.test.js b/test/platform-fabric.test.js
iindex 39fb73ec..b22660a9 100644
---- a/test/platform-fabric.test.js
++++ b/test/platform-fabric.test.js
@@@ -4,7 +4,7 @@ const contracts = require('..');
const names = [
'usage-sample', 'telemetry-sample', 'node-capabilities', 'service-instance',
-- 'runtime-offer', 'storage-offer', 'delivery-offer', 'agent-offer', 'harness-offer',
++ 'runtime-offer', 'storage-offer', 'delivery-offer', 'agent-offer', 'harness-offer', 'job',
];
let checks = 0;
ddiff --git a/test/platform-run.test.js b/test/platform-run.test.js
nnew file mode 100644
iindex 00000000..c1c99afd
---- /dev/null
++++ b/test/platform-run.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++// Plan T14 Run: runtime classes, the job and its link frames, and how a running `function` job maps onto
++// platform.usage-sample@1. The derivation below is the one platform.job-frame@1 `job_usage` describes; the
++// checks hold it to the billing contract: one reading per second, identical however often it is derived.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('..');
++
++let checks = 0;
++const ok = (cond, msg) => { assert.ok(cond, msg); checks++; };
++const fixture = (id, kind, name) => JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'fixtures', id, kind, `${name}.json`), 'utf8'));
++
++// Runtime classes: the six names, `function` first; each has a one-line description.
++const classes = contracts.schema('platform.runtime-class').oneOf;
++assert.deepStrictEqual(classes.map(c => c.const), ['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);
++for (const c of classes) ok(c.description && !c.description.includes('\n'), `runtime class ${c.const} has a one-line description`);
++ok(contracts.schema('platform.job').properties.class.$ref === 'runtime-class.v1.json', 'a job names its class from platform.runtime-class@1');
++
++// A frame's job id is a platform.job@1 id.
++const frame = contracts.schema('platform.job-frame');
++assert.strictEqual(frame.$defs.job_id.pattern, contracts.schema('platform.job').properties.id.pattern);
++checks++;
++
++// The writer's derivation: everything comes from the job, the second and the dispatcher's own record.
++const record = { project: 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', subject: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', node: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X' };
++function reading(id, startedMs, n, quantity) {
++ const key = `run:${id}:${n}`;
++ return {
++ id: key, idempotency_key: key, service: 'run', project: record.project, subject: record.subject, resource: id,
++ node: record.node, operation: 'function.invoke', quantity, unit: 's', at: new Date(startedMs + n * 1000).toISOString(),
++ source: 'openvibe-node.worker',
++ };
++}
++const fromUsage = (f) => reading(f.id, f.started_ms, f.second, 1);
++function fromExit(f) {
++ const { started_ms: startedMs, wall_ms: wall } = f.usage;
++ const out = [];
++ for (let n = 0; n < Math.floor(wall / 1000); n++) out.push(reading(f.id, startedMs, n, 1));
++ if (wall % 1000 > 0) out.push(reading(f.id, startedMs, Math.floor(wall / 1000), (wall % 1000) / 1000));
++ return out;
++}
++
++const usage = fixture('platform.job-frame', 'valid', 'job-usage');
++const exit = fixture('platform.job-frame', 'valid', 'job-exit');
++const fromFrames = fromExit(exit);
++for (const r of fromFrames) ok(contracts.validate('platform.usage-sample', r).valid, `${r.idempotency_key} is a valid usage reading`);
++ok(new Set(fromFrames.map(r => r.idempotency_key)).size === fromFrames.length, 'one reading per second: keys are unique');
++ok(fromFrames.reduce((s, r) => s + Math.round(r.quantity * 1000), 0) === exit.usage.wall_ms, 'the readings add up to wall_ms, partial last second included');
++ok(fromFrames.slice(0, -1).every(r => r.quantity === 1) && fromFrames.at(-1).quantity === 0.35, 'full seconds are 1; only the last is partial');
++// A job_usage and the job_exit backfill of the same second are the same reading (Billing replays, never 409s).
++assert.deepStrictEqual(fromUsage(usage), fromFrames[usage.second]);
++// A resend (new envelope seq and ts after a reconnect) derives the same readings.
++assert.deepStrictEqual(fromUsage({ ...usage, seq: 1, ts: usage.ts + 30000 }), fromUsage(usage));
++assert.deepStrictEqual(fromExit({ ...exit, seq: 1, ts: exit.ts + 30000 }), fromFrames);
++checks += 3;
++// A job that never started bills nothing.
++ok(fromExit(fixture('platform.job-frame', 'valid', 'job-exit-never-started')).length === 0, 'a job that never ran has no readings');
++// The usage-sample example and fixtures for a function job are exactly what the writer derives.
++const example = contracts.schema('platform.usage-sample').examples.find(e => e.service === 'run');
++assert.deepStrictEqual(example, fromFrames[0]);
++assert.deepStrictEqual(fixture('platform.usage-sample', 'valid', 'run-function-second'), fromFrames[0]);
++assert.deepStrictEqual(fixture('platform.usage-sample', 'valid', 'run-function-last-partial-second'), fromFrames.at(-1));
++checks += 3;
++// job_usage only ever stands for a full second: it has no quantity of its own.
++ok(!contracts.validate('platform.job-frame', { ...usage, quantity: 0.5 }).valid, 'a job_usage frame cannot carry a partial quantity');
++
++console.log(`platform run: ${checks} job, frame and metering checks passed`);
ddiff --git a/test/run.js b/test/run.js
iindex e48ed57e..7ca8aba7 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -871,6 +871,7 @@ sub(path.join(__dirname, 'product-catalog.test.js'));
// ── Generated output and compatibility gate ──────────────────────────────
sub(path.join(__dirname, 'platform-fabric.test.js'));
++sub(path.join(__dirname, 'platform-run.test.js'));
sub(path.join(ROOT, 'scripts/generate.js'), '--check');
sub(path.join(ROOT, 'scripts/compat.js'));
{