openvibers/openvibe.bot Commits
Add the README sections and STATUS.json the docs check needs (#15)
Changes
@@@ -2,6 +2,56 @@The open control panel for robots (openvibe.bot). Design: OpenVibe.Contracts docs/adr/ADR-043-bot-devices-and-control.md; plan track T15.++## Purpose++++OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operator command. A device opens one outbound WebSocket to `/device`; a person drives it from `/control` or the REST API, through the same gate — roles, per-robot limits, cooldowns and the turn budget — over a panel built from the robot's profile rather than per-robot code.++++## Owns++++- Robots, their profiles and each robot's limits, `allow` lists, roles and operators.++- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.++- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.++- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.++- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.++++## Does not own++++- Identity, subjects and tokens (OpenVibe.Network) and the robot's record on Network.++- Video: a device publishes over WHIP to OpenRe.Stream; Bot only hands out the URL at pairing.++- The agent that runs on the robot; it ships with the T15 device-agent job.++- The public openvibe.bot front page and legal pages, built by OpenVibe.Sites.++++## Depends on++++- PostgreSQL 18: robots, devices, profile rows, pairing codes, the command audit and the event outbox; `migrations/` applies on boot.++- Valkey (`VALKEY_URL`, optional): the shared per-actor limit counters; unset, limits are counted per process.++- OpenVibe.Network: user and service tokens, node tokens, and pairing when the authority is `network`.++- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.++- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).++++## Capabilities++++| capability | guards |++|---|---|++| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices and the audit, for a service acting for an owner |++| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators |++| `bot.robot.control` | the e-stop set and clear (clear is owner-only) and the control gate |++| `bot.device.connect` | rotate and revoke a device credential |++++A person acts on their own robots with a Network user token; a service acts with a service token plus the capability, for the subject it names. The REST routes are in `docs/protocol.md` §3 and the two WebSockets in §§1–2.++++## Tests++++`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the owner fence, the e-stop and the outbox.++++## Security++++- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.++- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.++- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.++- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.++- `/api/health` and `/api/ready` are loopback-only; nginx terminates TLS and upgrades `/device` and `/control`.++## DeployProduction runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device` and `/control` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.nnew file mode 100644@@@ -0,0 +1,41 @@++{++ "repository": "OpenVibers/OpenVibe.Bot",++ "stage": "alpha",++ "code": true,++ "runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",++ "port": 4630,++ "domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",++ "contracts": "openvibe-contracts v0.85.0",++ "packages": [++ "openvibe-contracts v0.85.0",++ "openvibe-sdk v0.26.0",++ "openvibe-shared v2.5.0"++ ],++ "capabilities": [++ "bot.robot.read",++ "bot.robot.manage",++ "bot.robot.control",++ "bot.device.connect"++ ],++ "events": [++ "bot.robot.online",++ "bot.robot.offline",++ "bot.estop.set",++ "bot.estop.cleared",++ "bot.command.refused"++ ],++ "deployed": true,++ "deployment": "Production at https://openvibe.bot on openvibe-ovh since 2026-10-02: release layout /opt/openvibe.bot, unit openvibe-bot.service (WorkingDirectory /opt/openvibe.bot, EnvironmentFile /etc/openvibe/bot.env, PORT 4630, TimeoutStopSec 10), behind the nginx vhost deploy/nginx/openvibe.bot.conf, deployed with `ov deploy bot`. Liveness /api/health and readiness /api/ready are loopback-only.",++ "features": [++ "device WebSocket /device: one outbound socket per machine, credential or Network node token, heartbeat and telemetry, commands never replayed after a reconnect",++ "operator WebSocket /control and the REST API (/api/v1) over one command gate: roles, per-robot limits and allowlists, cooldowns and the turn budget",++ "pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",++ "device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",++ "profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",++ "a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",++ "command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",++ "public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",++ "per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)"++ ],++ "updated": "2026-10-03"++}nnew file mode 100644@@@ -0,0 +1,77 @@++# OpenVibe.Bot — cutover runbook for PR #15++++The harness marks OpenVibe.Bot **risk data** (the repository owns `migrations/`), so a PR that merges into it++must carry a cutover manifest and a green rehearsal even when the change itself touches no schema. This is the++runbook for PR #15: how it is ordered, what is backed up, how the result is verified, and the way back.++++## Scope — what this PR actually changes++++PR #15 adds documentation and a test only:++++- `README.md` — the sections `OpenVibe.Contracts/scripts/docs-currency.js` requires (Purpose, Owns,++ Does not own, Depends on, Capabilities, Tests, Security), plus the existing Deploy.++- `STATUS.json` (new) — the estate record (`repository`, `stage`, `deployed`, `contracts`, `features`,++ `updated`) that the same check reads.++- `test/docs.test.js` (new) — asserts those two files against the check's rules.++++No migration, no `server/` code, no route, no configuration, no environment variable and no data change.++Merging and deploying it writes nothing to the database. The cutover is therefore a **no-op on data**; the steps++below are the standard deploy guards, kept so the rehearsal exercises them on a copy of the data.++++## What runs, in which order++++1. **Merge gate (harness).** Checks green, review SHIP, this runbook in the PR description++ (`{"runbook": "docs/cutover-pr-openvibe-bot-15.md", "rehearsal": "bot-pr15"}`) and++ `ds/deploy/rehearsals/bot-pr15.json` saying `{"ok": true}`. Only then does the squash-merge run.++2. **Backup.** Take the standard pre-deploy backup of Bot's PostgreSQL database (pgBackRest, the nightly++ repository in B2) and confirm the latest restore point is current. This PR cannot need it, but the guard is++ unconditional: no deploy starts on an unbacked database.++3. **Deploy.** Bot deploys through its own pipeline (`ov deploy bot` queues the guarded deploy on++ `openvibe-ovh`; the unit is `openvibe-bot.service`, layout `/opt/openvibe.bot`, port 4630). On boot the++ service applies `migrations/` with the owner role on `DATABASE_DIRECT_URL` and then serves on the pooled++ `DATABASE_URL`.++4. **Verify.** Run the checks in the next section before the deploy is called good. A failed readiness check++ rolls the release back automatically.++++## The backup++++- Command: the standard pgBackRest backup of the Bot database (the same one every Bot deploy takes), then++ `pgbackrest info` to confirm a restore point newer than the deploy start.++- Because this PR changes no schema, there is nothing to restore for it; the backup is the guard that lets the++ deploy proceed at all, and it is the way back for any other commit that ships in the same release.++- No `scripts/migrate-to-postgres.js` import step applies: Bot is already on PostgreSQL (schema final in++ `migrations/0001_bot.sql`, with `0002_node_principal.sql` additive).++++## How the result is verified++++1. `openvibe-bot.service` is active after the restart (no crash loop).++2. `GET http://127.0.0.1:4630/api/health` answers 2xx (the process, online devices and outbox status).++3. `GET http://127.0.0.1:4630/api/ready` answers 2xx: database, Valkey, Network readiness and the outbox relay.++4. `node <OpenVibe.Contracts>/scripts/docs-currency.js ~/OpenVibers --only bot` prints `✓ bot current`++ (the fix this PR carries).++5. The migrations table shows the same set of applied migrations as before the deploy — **0 pending** for this++ PR — and row counts of `robots`, `devices`, `command_audit` and `bot_event_outbox` are unchanged.++6. `https://openvibe.bot/` serves, and the `/device` and `/control` WebSocket upgrades still answer.++++If any of 1–4 fails, the deploy is not good: roll back (below) and investigate.++++## Way back (rollback)++++- **Automatic:** while the deploy waits for readiness, a non-2xx `/api/ready` restores the previous release's++ sha. Nothing in this PR needs a data rollback, so the previous release serves immediately.++- **Manual:** `ovhost rollback bot --to <previous sha>` on `openvibe-ovh`, then re-run the verification above.++- **Data:** there is no data to roll back for this PR (no migration ran and no row changed). If a later commit++ in the same release did move data, restore from the pgBackRest backup taken in step 2 and roll the release++ back to the sha that matches it.++++## Rehearsal on a copy of the data++++Whoever runs this runbook rehearses it **on a copy** — never on production:++++1. Restore a copy of a recent Bot database backup into a scratch database and start Bot against it (a scratch++ instance, not `openvibe-bot.service`), with the branch's `migrations/` in place.++2. Confirm the migration step reports **0 pending** for this PR and leaves the tables and row counts as they++ were, then run the verification list above against the scratch instance.++3. When all of it is green, record the marker++ `ds/deploy/rehearsals/bot-pr15.json` = `{"ok": true}` on the harness. **This runbook must not write that++ marker itself** — it is written by whoever performed the rehearsal.nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++// The documentation contract (OpenVibe.Contracts scripts/docs-currency.js, roadmap WS-U task 3): the README++// states what the ecosystem needs to know about Bot, and STATUS.json matches the code in the same release.++// The Contracts check runs against this checkout; this test keeps the same rules green in Bot's own suite.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { execFileSync } = require('child_process');++const { check, done } = require('./helpers/app');++++const ROOT = path.join(__dirname, '..');++const MANIFEST_REPOSITORY = 'OpenVibers/OpenVibe.Bot';++const STATUS_FIELDS = ['repository', 'stage', 'deployed', 'contracts', 'features', 'updated'];++// The sections docs-currency.js requires of every service README.++const REQUIRED = [++ ['Purpose', /^purpose\b|^what (it|this) is\b/i],++ ['Owns', /^owns\b/i],++ ['Does not own', /^does not own\b|^doesn't own\b/i],++ ['Depends on', /^depends on\b|^dependencies\b/i],++ ['Capabilities', /^capabilities\b|^grants\b|^principal\b/i],++ ['Acceptance', /^acceptance\b|^tests?\b|^testing\b/i],++ ['Security', /^security\b/i],++ ['Deploy', /^deploy|^deployment\b|^running it in production\b|^production\b/i],++];++++(async () => {++ const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8');++ const status = JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8'));++ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));++++ await check('the README has every section docs-currency.js requires', () => {++ const headings = readme.split('\n').filter((l) => /^##\s+/.test(l)).map((l) => l.replace(/^##\s+/, '').replace(/[`*_]/g, '').trim());++ for (const [label, re] of REQUIRED) assert.ok(headings.some((h) => re.test(h)), `README: no "## ${label}" section`);++ });++++ await check('STATUS.json has the required fields and the manifest repository', () => {++ for (const f of STATUS_FIELDS) assert.ok(status[f] !== undefined, `STATUS.json: no "${f}"`);++ assert.strictEqual(status.repository, MANIFEST_REPOSITORY);++ assert.ok(status.features.length > 0, 'STATUS.json: features is empty');++ });++++ await check('STATUS.json names the openvibe-contracts tag package.json pins', () => {++ const spec = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }['openvibe-contracts'];++ const m = /refs\/tags\/(v\d+\.\d+\.\d+)/.exec(spec) || /^\^?~?(\d+\.\d+\.\d+)$/.exec(spec);++ assert.ok(m, `package.json does not pin a tag for openvibe-contracts: ${spec}`);++ assert.ok(String(status.contracts).includes(m[1]), `STATUS.json: contracts "${status.contracts}", package.json pins ${m[1]}`);++ });++++ await check('STATUS.json was refreshed with the newest package.json change', () => {++ let changed;++ try {++ changed = execFileSync('git', ['-C', ROOT, 'log', '-1', '--format=%cI', '--', 'package.json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().slice(0, 10);++ } catch { changed = ''; } // no git (a tarball): the Contracts check has the same limit++ if (changed) assert.ok(String(status.updated).slice(0, 10) >= changed, `STATUS.json: updated ${status.updated}, package.json changed ${changed}`);++ });++++ done();++})();
@@ -2,6 +2,56 @@
@@ -2,6 +2,56 @@
The open control panel for robots (openvibe.bot). Design: OpenVibe.Contracts docs/adr/ADR-043-bot-devices-and-control.md; plan track T15.
The open control panel for robots (openvibe.bot). Design: OpenVibe.Contracts docs/adr/ADR-043-bot-devices-and-control.md; plan track T15.
+## Purpose
+
+OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operator command. A device opens one outbound WebSocket to `/device`; a person drives it from `/control` or the REST API, through the same gate — roles, per-robot limits, cooldowns and the turn budget — over a panel built from the robot's profile rather than per-robot code.
+
+## Owns
+
+- Robots, their profiles and each robot's limits, `allow` lists, roles and operators.
+- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
+- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
+- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
+- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
+
+## Does not own
+
+- Identity, subjects and tokens (OpenVibe.Network) and the robot's record on Network.
+- Video: a device publishes over WHIP to OpenRe.Stream; Bot only hands out the URL at pairing.
+- The agent that runs on the robot; it ships with the T15 device-agent job.
+- The public openvibe.bot front page and legal pages, built by OpenVibe.Sites.
+
+## Depends on
+
+- PostgreSQL 18: robots, devices, profile rows, pairing codes, the command audit and the event outbox; `migrations/` applies on boot.
+- Valkey (`VALKEY_URL`, optional): the shared per-actor limit counters; unset, limits are counted per process.
+- OpenVibe.Network: user and service tokens, node tokens, and pairing when the authority is `network`.
+- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
+- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).
+
+## Capabilities
+
+| capability | guards |
+|---|---|
+| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices and the audit, for a service acting for an owner |
+| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators |
+| `bot.robot.control` | the e-stop set and clear (clear is owner-only) and the control gate |
+| `bot.device.connect` | rotate and revoke a device credential |
+
+A person acts on their own robots with a Network user token; a service acts with a service token plus the capability, for the subject it names. The REST routes are in `docs/protocol.md` §3 and the two WebSockets in §§1–2.
+
+## Tests
+
+`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the owner fence, the e-stop and the outbox.
+
+## Security
+
+- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.
+- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
+- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.
+- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
+- `/api/health` and `/api/ready` are loopback-only; nginx terminates TLS and upgrades `/device` and `/control`.
+
## Deploy
## Deploy
Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device` and `/control` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.
Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device` and `/control` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+{
+ "repository": "OpenVibers/OpenVibe.Bot",
+ "stage": "alpha",
+ "code": true,
+ "runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
+ "port": 4630,
+ "domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
+ "contracts": "openvibe-contracts v0.85.0",
+ "packages": [
+ "openvibe-contracts v0.85.0",
+ "openvibe-sdk v0.26.0",
+ "openvibe-shared v2.5.0"
+ ],
+ "capabilities": [
+ "bot.robot.read",
+ "bot.robot.manage",
+ "bot.robot.control",
+ "bot.device.connect"
+ ],
+ "events": [
+ "bot.robot.online",
+ "bot.robot.offline",
+ "bot.estop.set",
+ "bot.estop.cleared",
+ "bot.command.refused"
+ ],
+ "deployed": true,
+ "deployment": "Production at https://openvibe.bot on openvibe-ovh since 2026-10-02: release layout /opt/openvibe.bot, unit openvibe-bot.service (WorkingDirectory /opt/openvibe.bot, EnvironmentFile /etc/openvibe/bot.env, PORT 4630, TimeoutStopSec 10), behind the nginx vhost deploy/nginx/openvibe.bot.conf, deployed with `ov deploy bot`. Liveness /api/health and readiness /api/ready are loopback-only.",
+ "features": [
+ "device WebSocket /device: one outbound socket per machine, credential or Network node token, heartbeat and telemetry, commands never replayed after a reconnect",
+ "operator WebSocket /control and the REST API (/api/v1) over one command gate: roles, per-robot limits and allowlists, cooldowns and the turn budget",
+ "pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
+ "device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
+ "profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",
+ "a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
+ "command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
+ "public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
+ "per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)"
+ ],
+ "updated": "2026-10-03"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+# OpenVibe.Bot — cutover runbook for PR #15
+
+The harness marks OpenVibe.Bot **risk data** (the repository owns `migrations/`), so a PR that merges into it
+must carry a cutover manifest and a green rehearsal even when the change itself touches no schema. This is the
+runbook for PR #15: how it is ordered, what is backed up, how the result is verified, and the way back.
+
+## Scope — what this PR actually changes
+
+PR #15 adds documentation and a test only:
+
+- `README.md` — the sections `OpenVibe.Contracts/scripts/docs-currency.js` requires (Purpose, Owns,
+ Does not own, Depends on, Capabilities, Tests, Security), plus the existing Deploy.
+- `STATUS.json` (new) — the estate record (`repository`, `stage`, `deployed`, `contracts`, `features`,
+ `updated`) that the same check reads.
+- `test/docs.test.js` (new) — asserts those two files against the check's rules.
+
+No migration, no `server/` code, no route, no configuration, no environment variable and no data change.
+Merging and deploying it writes nothing to the database. The cutover is therefore a **no-op on data**; the steps
+below are the standard deploy guards, kept so the rehearsal exercises them on a copy of the data.
+
+## What runs, in which order
+
+1. **Merge gate (harness).** Checks green, review SHIP, this runbook in the PR description
+ (`{"runbook": "docs/cutover-pr-openvibe-bot-15.md", "rehearsal": "bot-pr15"}`) and
+ `ds/deploy/rehearsals/bot-pr15.json` saying `{"ok": true}`. Only then does the squash-merge run.
+2. **Backup.** Take the standard pre-deploy backup of Bot's PostgreSQL database (pgBackRest, the nightly
+ repository in B2) and confirm the latest restore point is current. This PR cannot need it, but the guard is
+ unconditional: no deploy starts on an unbacked database.
+3. **Deploy.** Bot deploys through its own pipeline (`ov deploy bot` queues the guarded deploy on
+ `openvibe-ovh`; the unit is `openvibe-bot.service`, layout `/opt/openvibe.bot`, port 4630). On boot the
+ service applies `migrations/` with the owner role on `DATABASE_DIRECT_URL` and then serves on the pooled
+ `DATABASE_URL`.
+4. **Verify.** Run the checks in the next section before the deploy is called good. A failed readiness check
+ rolls the release back automatically.
+
+## The backup
+
+- Command: the standard pgBackRest backup of the Bot database (the same one every Bot deploy takes), then
+ `pgbackrest info` to confirm a restore point newer than the deploy start.
+- Because this PR changes no schema, there is nothing to restore for it; the backup is the guard that lets the
+ deploy proceed at all, and it is the way back for any other commit that ships in the same release.
+- No `scripts/migrate-to-postgres.js` import step applies: Bot is already on PostgreSQL (schema final in
+ `migrations/0001_bot.sql`, with `0002_node_principal.sql` additive).
+
+## How the result is verified
+
+1. `openvibe-bot.service` is active after the restart (no crash loop).
+2. `GET http://127.0.0.1:4630/api/health` answers 2xx (the process, online devices and outbox status).
+3. `GET http://127.0.0.1:4630/api/ready` answers 2xx: database, Valkey, Network readiness and the outbox relay.
+4. `node <OpenVibe.Contracts>/scripts/docs-currency.js ~/OpenVibers --only bot` prints `✓ bot current`
+ (the fix this PR carries).
+5. The migrations table shows the same set of applied migrations as before the deploy — **0 pending** for this
+ PR — and row counts of `robots`, `devices`, `command_audit` and `bot_event_outbox` are unchanged.
+6. `https://openvibe.bot/` serves, and the `/device` and `/control` WebSocket upgrades still answer.
+
+If any of 1–4 fails, the deploy is not good: roll back (below) and investigate.
+
+## Way back (rollback)
+
+- **Automatic:** while the deploy waits for readiness, a non-2xx `/api/ready` restores the previous release's
+ sha. Nothing in this PR needs a data rollback, so the previous release serves immediately.
+- **Manual:** `ovhost rollback bot --to <previous sha>` on `openvibe-ovh`, then re-run the verification above.
+- **Data:** there is no data to roll back for this PR (no migration ran and no row changed). If a later commit
+ in the same release did move data, restore from the pgBackRest backup taken in step 2 and roll the release
+ back to the sha that matches it.
+
+## Rehearsal on a copy of the data
+
+Whoever runs this runbook rehearses it **on a copy** — never on production:
+
+1. Restore a copy of a recent Bot database backup into a scratch database and start Bot against it (a scratch
+ instance, not `openvibe-bot.service`), with the branch's `migrations/` in place.
+2. Confirm the migration step reports **0 pending** for this PR and leaves the tables and row counts as they
+ were, then run the verification list above against the scratch instance.
+3. When all of it is green, record the marker
+ `ds/deploy/rehearsals/bot-pr15.json` = `{"ok": true}` on the harness. **This runbook must not write that
+ marker itself** — it is written by whoever performed the rehearsal.
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+// The documentation contract (OpenVibe.Contracts scripts/docs-currency.js, roadmap WS-U task 3): the README
+// states what the ecosystem needs to know about Bot, and STATUS.json matches the code in the same release.
+// The Contracts check runs against this checkout; this test keeps the same rules green in Bot's own suite.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const { check, done } = require('./helpers/app');
+
+const ROOT = path.join(__dirname, '..');
+const MANIFEST_REPOSITORY = 'OpenVibers/OpenVibe.Bot';
+const STATUS_FIELDS = ['repository', 'stage', 'deployed', 'contracts', 'features', 'updated'];
+// The sections docs-currency.js requires of every service README.
+const REQUIRED = [
+ ['Purpose', /^purpose\b|^what (it|this) is\b/i],
+ ['Owns', /^owns\b/i],
+ ['Does not own', /^does not own\b|^doesn't own\b/i],
+ ['Depends on', /^depends on\b|^dependencies\b/i],
+ ['Capabilities', /^capabilities\b|^grants\b|^principal\b/i],
+ ['Acceptance', /^acceptance\b|^tests?\b|^testing\b/i],
+ ['Security', /^security\b/i],
+ ['Deploy', /^deploy|^deployment\b|^running it in production\b|^production\b/i],
+];
+
+(async () => {
+ const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8');
+ const status = JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8'));
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
+
+ await check('the README has every section docs-currency.js requires', () => {
+ const headings = readme.split('\n').filter((l) => /^##\s+/.test(l)).map((l) => l.replace(/^##\s+/, '').replace(/[`*_]/g, '').trim());
+ for (const [label, re] of REQUIRED) assert.ok(headings.some((h) => re.test(h)), `README: no "## ${label}" section`);
+ });
+
+ await check('STATUS.json has the required fields and the manifest repository', () => {
+ for (const f of STATUS_FIELDS) assert.ok(status[f] !== undefined, `STATUS.json: no "${f}"`);
+ assert.strictEqual(status.repository, MANIFEST_REPOSITORY);
+ assert.ok(status.features.length > 0, 'STATUS.json: features is empty');
+ });
+
+ await check('STATUS.json names the openvibe-contracts tag package.json pins', () => {
+ const spec = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }['openvibe-contracts'];
+ const m = /refs\/tags\/(v\d+\.\d+\.\d+)/.exec(spec) || /^\^?~?(\d+\.\d+\.\d+)$/.exec(spec);
+ assert.ok(m, `package.json does not pin a tag for openvibe-contracts: ${spec}`);
+ assert.ok(String(status.contracts).includes(m[1]), `STATUS.json: contracts "${status.contracts}", package.json pins ${m[1]}`);
+ });
+
+ await check('STATUS.json was refreshed with the newest package.json change', () => {
+ let changed;
+ try {
+ changed = execFileSync('git', ['-C', ROOT, 'log', '-1', '--format=%cI', '--', 'package.json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().slice(0, 10);
+ } catch { changed = ''; } // no git (a tarball): the Contracts check has the same limit
+ if (changed) assert.ok(String(status.updated).slice(0, 10) >= changed, `STATUS.json: updated ${status.updated}, package.json changed ${changed}`);
+ });
+
+ done();
+})();