openvibers/openvibe.network Commits
Accept the user-owned trust class in resource offers and node principals
Changes
nnew file mode 100644@@@ -0,0 +1,200 @@++# Cutover runbook: the user-owned trust class (migration 0020)++++This PR lets the resource registry hold the trust class `user-owned`, a person's own Node (ADR-046, the universal++adaptive fabric). The class has been in the `platform.resource-offer@1` enum since Contracts 0.87.0, and Network's pin++has carried it since 0.90.0. Until now, though, PostgreSQL refused it, so a user's own Node could never be reported.++The data change is `migrations/0020_user_owned_trust.sql`. It drops and re-adds three CHECK constraints, each one++wider than before. No column is added, renamed or dropped, and no row is rewritten. The migration is `phase: expand`++and every drop is `IF EXISTS`, so it is safe to run on a live production database and safe to run twice.++++Like [cutover-t2-confirmations-budgets.md](cutover-t2-confirmations-budgets.md), this is much smaller than the SQLite →++PostgreSQL cutover ([cutover-t2-postgres.md](cutover-t2-postgres.md)): no data move, no freeze window, no snapshot++import. The migration runs as part of the normal boot of the new release.++++---++++## What changes++++| Constraint | Table | Before (0007 / 0008 / 0014) | After (0020) |++| --- | --- | --- | --- |++| `platform_resource_offers_trust_check` | `platform_resource_offers` | `trust IN ('first-party','partner','community','external')` (inline in 0007, so PostgreSQL's default name) | the same plus `user-owned` |++| `platform_node_principals_trust_check` | `platform_node_principals` | the same four (inline in 0008, PostgreSQL's default name) | the same plus `user-owned` |++| `platform_node_principals_owner` | `platform_node_principals` | a `user` machine is `community`; a `project` machine is anything but `first-party` (0014) | a `user` machine is `community` or `user-owned`; a `project` machine is neither `first-party` nor `user-owned` |++++Widening the owner rule is required too. Without it, a `user` principal could still be only `community`. An offer++must carry its node's trust class (`server/registry/cells.js` `checkPlacement`, `registry.trust_mismatch`), so a++user-owned offer from a person's own Node would still be refused. The only thing the owner rule newly refuses is a++`project` machine marked `user-owned`, and no such row can exist, because both trust CHECKs refused that value until++now. Every row in production therefore passes all three new constraints. Each ADD validates the table, which takes++milliseconds on a table this size.++++The code is unchanged apart from a comment in `server/registry/offers.js` `check()`. The contract already accepts++the value, and `columns()` copies `trust` through as it is. Pairing (`server/registry/node-principals.js` `redeem`)++still creates a paired user machine as `community`. Which machines become `user-owned`, and with what consent, are++ADR-046's open questions for the owner. Until a principal holds the class, no `user-owned` offer reaches the table.++++---++++## Prerequisites++++1. **Production is on PostgreSQL and has applied 0019.** Confirm with `ov access run openvibe-ovh health network`++ (must say `ready`) and `validate network` (must say `checkout clean`). The runner refuses a pending file numbered++ below an applied one, so 0020 must be the next number after what production has applied.++2. **`DATABASE_DIRECT_URL` is set and connects.** The migration runner uses the direct (owner-role) connection for++ schema changes. Check by name only:++ ```bash++ sudo grep -cE '^DATABASE_DIRECT_URL=.+' /etc/openvibe/network.env++ ```++ Must print `1`. Never print the value.++3. **A green rehearsal of this PR's head.** See [Step 1](#step-1-rehearse). The deploy gates on its marker.++4. **The target commit is fixed.** It is the merge commit of this PR into `origin/main`. Steps 1 and 2 must use that++ one SHA.++++---++++## Step 1: rehearse++++The repository test builds a database at 0019 and loads the rehearsal seed++(`test/fixtures/rehearsal/user-owned-trust.sql`: one node principal per owner kind, and one offer for each of the four++old trust classes). It then applies 0020 and asserts the following:++++- 0020 applies once and leaves every seeded row unchanged.++- Each table has exactly one trust CHECK, and it names `user-owned`.++- A `user-owned` principal is stored and listed, and its `user-owned` offer passes `check()` and `checkPlacement`,++ is stored, and is listed by `trust=user-owned`.++- A project machine marked `user-owned`, a user machine marked `partner`, and the unknown class `stranger` are all++ still refused.++++```bash++NETWORK_TEST_STORE=pg node test/run.js user-owned-trust++```++++**Go:** exit 0 and `user-owned trust: all tests passed`.++++The harness also rehearses the PR's head by itself (`ov rehearse OpenVibe.Network <PR>`) on a scratch PostgreSQL.++It applies `origin/main`'s migrations (up to 0019), loads the seed named below, applies this PR's migrations (a++second run must apply nothing), and then runs the commands in this block against that database. The first command++checks the migration, the constraints and the seeded rows. The second writes a user-owned principal and offer inside++a transaction that it rolls back, and shows that a `stranger` class and a user-owned project machine are refused.++++```rehearse++seed: test/fixtures/rehearsal/user-owned-trust.sql++migrations: migrations++node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const m=await c.query("SELECT id FROM ov_migrations WHERE id=$1",["0020"]);const k=await c.query("SELECT conname,pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid IN (\x27platform_resource_offers\x27::regclass,\x27platform_node_principals\x27::regclass) AND conname=ANY($1) ORDER BY conname",[["platform_resource_offers_trust_check","platform_node_principals_trust_check","platform_node_principals_owner"]]);const p=await c.query("SELECT node_id||\x27=\x27||trust AS v FROM platform_node_principals WHERE node_id LIKE \x27rh-%\x27 ORDER BY node_id");const o=await c.query("SELECT id||\x27=\x27||trust AS v FROM platform_resource_offers WHERE id LIKE \x27rh-%\x27 ORDER BY id");await c.end();const rows=p.rows.map(r=>r.v).concat(o.rows.map(r=>r.v)).join(",");const want="rh-platform=first-party,rh-project=partner,rh-user=community,rh-platform=first-party,rh-project=partner,rh-provider=external,rh-user=community";if(m.rows.length!==1||k.rows.length!==3||!k.rows.every(r=>r.d.includes("user-owned"))||rows!==want){console.error("FAIL migration="+m.rows.length+" constraints="+JSON.stringify(k.rows)+" rows="+rows);process.exit(1);}console.log("rehearse: 0020 applied, "+k.rows.map(r=>r.conname).join(",")+" name user-owned, seed rows unchanged");})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'++node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const refused=async(sql,args)=>{await c.query("SAVEPOINT s");try{await c.query(sql,args);return false}catch(e){await c.query("ROLLBACK TO SAVEPOINT s");return e.code==="23514"}};const P="INSERT INTO platform_node_principals (id,node_id,home_cell,owner_kind,project_id,owner_subject,trust,created_by,credential_hash) VALUES ($1,$2,\x27wnam-1\x27,$3,$4,$5,$6,\x27rehearsal\x27,$7)";const usr="usr_0000000000000000000000RH01",prj="prj_0000000000000000000000RH01";await c.query("BEGIN");try{await c.query(P,["nod_0000000000000000000000RH04","rh-mine","user",null,usr,"user-owned","d".repeat(64)]);await c.query("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-mine\x27,\x27rh-mine\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27user-owned\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");const a=await refused(P,["nod_0000000000000000000000RH05","rh-stranger","user",null,usr,"stranger","e".repeat(64)]);const b=await refused(P,["nod_0000000000000000000000RH06","rh-prj-mine","project",prj,null,"user-owned","f".repeat(64)]);const d=await refused("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-odd\x27,\x27rh\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27stranger\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");if(!a||!b||!d){console.error("FAIL refused stranger-principal="+a+" project-user-owned="+b+" stranger-offer="+d);process.exitCode=1}else console.log("rehearse: user-owned principal and offer stored; stranger and a user-owned project machine refused (rolled back)")}finally{await c.query("ROLLBACK");await c.end()}})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'++```++++**Go:** the rehearsal job ends with `rehearse: 0020 applied, platform_node_principals_owner,++platform_node_principals_trust_check,platform_resource_offers_trust_check name user-owned, seed rows unchanged` and++`rehearse: user-owned principal and offer stored; stranger and a user-owned project machine refused (rolled back)`++(exit 0).++**No-go:** a held migration, a constraint without `user-owned`, a changed seed row, or a write that is stored when it++should be refused (or refused when it should be stored) means the migration does not apply cleanly on top of++production's schema. Fix it and push; the new head is rehearsed again.++++The harness writes the marker `~/openvibe/agents/ds/deploy/rehearsals/openvibe-network-<PR>.json` from the++rehearsal's exit code. No agent writes it.++++---++++## The backup++++Before the deploy, take the pre-deploy restore point into the broker's backup area:++++```bash++ov access run openvibe-ovh db-backup++```++++It writes outside the release and outside the repository; keep it. The migration only widens constraints, so a code++rollback ([below](#rollback)) is enough and does not need this backup. A restore of this backup overwrites every write++made after it (accounts, sessions, notifications, coin transactions, follows, blocks, preferences, offers and node++principals). Restores are an owner decision; coordinate with the database owner separately.++++---++++## Step 2: deploy++++`server/db/database.js` runs every pending migration at startup, using `DATABASE_DIRECT_URL` for schema changes. No++manual SQL, no script, no freeze.++++```bash++ov deploy OpenVibe.Network "PR #<number>: user-owned trust class (migration 0020)"++```++++After merge, the deploy:++++1. Checks out the merge commit on `/opt/openvibe.network`.++2. Restarts `openvibe-network.service`.++3. The new process boots, runs `0020_user_owned_trust.sql` (the only pending migration), and starts serving.++++**Go:** `health network` says `ready` on the merge SHA; `validate network` says `checkout clean` at that SHA;++`journal openvibe-network.service` shows the boot banner with no migration error.++**No-go:** the service fails to start. Check the journal. A `check constraint … is violated by some row` error here++means production holds a row the rehearsal seed did not foresee. Roll back (below), and find the row with the++constraint's expression before trying again.++++---++++## Verification after deploy++++```bash++ov access run openvibe-ovh journal openvibe-network.service 200 | grep -i '0020\|migration\|constraint'++++# On the host, with the owner connection:++sudo node --env-file=/etc/openvibe/network.env -e '++ const { Client } = require("pg");++ (async () => {++ const c = new Client({ connectionString: process.env.DATABASE_DIRECT_URL });++ await c.connect();++ const k = await c.query("SELECT conname, pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid IN ('platform_resource_offers'::regclass, 'platform_node_principals'::regclass) AND conname = ANY($1) ORDER BY conname",++ [["platform_resource_offers_trust_check", "platform_node_principals_trust_check", "platform_node_principals_owner"]]);++ for (const r of k.rows) console.log(r.conname, r.d.includes("user-owned") ? "names user-owned" : "MISSING user-owned");++ const m = await c.query("SELECT id FROM ov_migrations WHERE id = $1", ["0020"]);++ console.log("migration:", m.rows.length ? "0020" : "MISSING");++ await c.end();++ })().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'++```++++Expected: three lines ending `names user-owned`, and `migration: 0020`. `GET https://openvibe.network/api/v1/resources`++answers as before. It lists no `user-owned` offer until a Node holding that class reports one.++++---++++## Rollback++++- **Code rollback, constraints stay.** Revert to the previous release. Its code never writes `user-owned` (pairing++ writes `community`), so the wider constraints are inert. The migration stays recorded in `ov_migrations`, and a++ later re-deploy sees it as applied.++- **Full rollback, constraints narrowed.** Only when no row holds `user-owned`++ (`SELECT count(*) FROM platform_node_principals WHERE trust = 'user-owned'` and the same on++ `platform_resource_offers` both print 0). Run as the owner role, re-adding the definitions from 0007, 0008 and++ 0014:++ ```sql++ ALTER TABLE platform_resource_offers DROP CONSTRAINT IF EXISTS platform_resource_offers_trust_check;++ ALTER TABLE platform_resource_offers ADD CONSTRAINT platform_resource_offers_trust_check++ CHECK (trust IN ('first-party', 'partner', 'community', 'external'));++ ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_trust_check;++ ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_trust_check++ CHECK (trust IN ('first-party', 'partner', 'community', 'external'));++ ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_owner;++ ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_owner CHECK (++ (owner_kind = 'platform' AND project_id IS NULL AND owner_subject IS NULL AND trust = 'first-party')++ OR (owner_kind = 'project' AND project_id IS NOT NULL AND owner_subject IS NULL AND trust <> 'first-party')++ OR (owner_kind = 'user' AND project_id IS NULL AND owner_subject IS NOT NULL++ AND owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND trust = 'community'));++ DELETE FROM ov_migrations WHERE id = '0020';++ ```++ If any row holds `user-owned`, a person's Node is registered with that class, and narrowing the constraints would++ fail. Revoke or reclassify those principals first; that is an owner decision.++++Restart the service after either rollback and confirm `health network` says `ready`.++++---++++## What is not covered here++++- **Data move.** None. No row is rewritten, and nothing holds `user-owned` until a principal is given the class.++- **Freeze window.** None. Three constraint swaps on small tables take milliseconds.++- **Contracts.** No pin bump. v0.90.0 is already pinned, and it accepts `user-owned`.++- **Who becomes user-owned.** Pairing still creates `community` principals. Switching a paired user machine to++ `user-owned`, and the consent and revocation rule for running work on it, are ADR-046's open questions for the owner.nnew file mode 100644@@@ -0,0 +1,18 @@++-- phase: expand++-- plan T14 (ADR-046, the universal adaptive fabric). A person's own Node is the trust class user-owned: in the++-- platform.resource-offer@1 enum since Contracts 0.87.0, and in Network's pin since 0.90.0. Both trust CHECKs gain it, and a++-- user's own machine may now be user-owned as well as community; a project's machine is never user-owned. Widening++-- only: no column changes, no row is rewritten, and every existing row passes the new constraints.++++ALTER TABLE platform_resource_offers DROP CONSTRAINT IF EXISTS platform_resource_offers_trust_check;++ALTER TABLE platform_resource_offers ADD CONSTRAINT platform_resource_offers_trust_check++ CHECK (trust IN ('first-party', 'user-owned', 'partner', 'community', 'external'));++ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_trust_check;++ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_trust_check++ CHECK (trust IN ('first-party', 'user-owned', 'partner', 'community', 'external'));++ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_owner;++ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_owner CHECK (++ (owner_kind = 'platform' AND project_id IS NULL AND owner_subject IS NULL AND trust = 'first-party')++ OR (owner_kind = 'project' AND project_id IS NOT NULL AND owner_subject IS NULL AND trust NOT IN ('first-party', 'user-owned'))++ OR (owner_kind = 'user' AND project_id IS NULL AND owner_subject IS NOT NULL++ AND owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND trust IN ('community', 'user-owned')));@@@ -46,6 +46,7 @@ function check(body) {}for (let i = 0; i < body.offers.length; i++) {const offer = body.offers[i];++ // trust may be user-owned (a person's own Node, ADR-046) since Contracts 0.90.0; migrations/0020 stores it.const v = validate(CONTRACT, offer);if (!v.valid) return { error: `offer ${i} does not match ${CONTRACT}`, details: (v.errors || []).slice(0, 5) };const detail = offer.detail;@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,16 @@++-- Rehearsal seed for migrations/0020_user_owned_trust.sql (docs/cutover-t14-user-owned-trust.md): rows as production may++-- hold them at 0019, one node principal per owner kind and one offer per trust class 0007 allowed, so the widened++-- CHECKs are validated against existing rows. Fixed ids, repeatable, PostgreSQL only; no secret (a fake 64-hex hash).++INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by)++ VALUES ('prj_0000000000000000000000RH01', 'usr_0000000000000000000000RH01', 'Rehearsal lab', '2026-10-01T00:00:00Z', 'rehearsal') ON CONFLICT DO NOTHING;++INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, owner_subject, trust, created_by, credential_hash) VALUES++ ('nod_0000000000000000000000RH01', 'rh-platform', 'wnam-1', 'platform', NULL, NULL, 'first-party', 'rehearsal', NULL),++ ('nod_0000000000000000000000RH02', 'rh-project', 'wnam-1', 'project', 'prj_0000000000000000000000RH01', NULL, 'partner', 'rehearsal', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'),++ ('nod_0000000000000000000000RH03', 'rh-user', 'wnam-1', 'user', NULL, 'usr_0000000000000000000000RH01', 'community', 'rehearsal', 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc')++ ON CONFLICT DO NOTHING;++INSERT INTO platform_resource_offers (id, source, kind, region, cell, trust, status, price_usd, doc, reported_at) VALUES++ ('rh-platform', 'rehearsal', 'node', 'us-west', 'wnam-1', 'first-party', 'up', 0, '{"offer_id":"rh-platform"}', '2026-10-01T00:00:00Z'),++ ('rh-project', 'rehearsal', 'node', 'us-west', 'wnam-1', 'partner', 'up', 0, '{"offer_id":"rh-project"}', '2026-10-01T00:00:00Z'),++ ('rh-user', 'rehearsal', 'node', 'us-west', 'wnam-1', 'community', 'degraded', 0, '{"offer_id":"rh-user"}', '2026-10-01T00:00:00Z'),++ ('rh-provider', 'rehearsal', 'provider', 'us-west', 'wnam-1', 'external', 'up', 0.01, '{"offer_id":"rh-provider"}', '2026-10-01T00:00:00Z')++ ON CONFLICT DO NOTHING;nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++// The user-owned trust class (ADR-046, migrations/0020_user_owned_trust.sql) on a database that production already++// migrated: a database at 0019 holding offers and node principals gains the class from the normal runner, once, with++// every existing row untouched. A person's own Node then holds a user-owned principal, reports a user-owned offer and++// is listed with it; a project's machine is never user-owned, and a trust value outside the five is still refused.++// node test/user-owned-trust.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { createTestDb } = require('openvibe-sdk/testing');++const { createDb } = require('openvibe-sdk/db');++const offers = require('../server/registry/offers');++const cells = require('../server/registry/cells');++const nodePrincipals = require('../server/registry/node-principals');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const quiet = { log() {}, warn() {}, error() {} };++++(async () => {++ // What production ran before this release: the migrations up to 0019.++ const before = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-user-owned-'));++ for (const f of fs.readdirSync(MIGRATIONS)) if (/^\d{4}_.*\.sql$/.test(f) && f.slice(0, 4) <= '0019') fs.copyFileSync(path.join(MIGRATIONS, f), path.join(before, f));++ const t = await createTestDb({ migrations: before, store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', log: quiet });++ const asOwner = async (fn) => {++ if (!t.directUrl) return fn(t.db);++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, log: quiet });++ try { return await fn(owner); } finally { await owner.close(); }++ };++ const db = t.db;++ // The rows of the cutover rehearsal's seed (docs/cutover-t14-user-owned-trust.md): its project and person.++ const prj = 'prj_0000000000000000000000RH01', usr = 'usr_0000000000000000000000RH01', hash = (c) => c.repeat(64);++ const principal = (p) => db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, owner_subject, trust, created_by, credential_hash)++ VALUES (?, ?, 'wnam-1', ?, ?, ?, ?, 'test', ?)`).run(p.id, p.node_id, p.owner_kind, p.project_id ?? null, p.owner_subject ?? null, p.trust, p.credential_hash ?? null);++ const offer = (id, extra = {}) => ({ offer_id: id, kind: 'node', node_id: id, provider: 'self', region: 'us-west', cell: 'wnam-1', trust: 'user-owned',++ capabilities: ['node:http', 'worker:function'], capacity: { cpu: { utilization: 0.1, available_cores: 8 } }, health: { status: 'up' },++ pricing: { model: 'free-allowance' }, updated_at: '2026-10-04T00:00:00Z', ...extra });++ const trustCheck = async (table) => (await db.prepare(`SELECT conname, pg_get_constraintdef(oid) AS def FROM pg_constraint++ WHERE conrelid = ?::regclass AND conname = ?`).get(table, `${table}_trust_check`))?.def || '';++ try {++ // ── Before: the rows production may hold, and user-owned refused by both tables.++ await db.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'rehearsal', 'user-owned-trust.sql'), 'utf8'));++ await db.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'rehearsal', 'user-owned-trust.sql'), 'utf8')); // repeatable++ await offers.report(db, { source: 'host', offers: [offer('rh-user-2', { node_id: 'rh-user', trust: 'community' })] });++ assert.ok(!(await trustCheck('platform_resource_offers')).includes('user-owned') && !(await trustCheck('platform_node_principals')).includes('user-owned'));++ await assert.rejects(principal({ id: `nod_${'C'.repeat(26)}`, node_id: 'early', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('c') }), 'refused before 0020');++ const rows = async () => ({++ principals: await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all(),++ offers: await db.prepare('SELECT * FROM platform_resource_offers ORDER BY id').all(),++ });++ const seeded = await rows();++++ // ── 0020 applies on top of 0019, once, and rewrites nothing.++ const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand']]);++ assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');++ await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice++ assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');++ for (const table of ['platform_resource_offers', 'platform_node_principals']) {++ assert.match(await trustCheck(table), /'user-owned'/, `${table}_trust_check names user-owned`);++ const n = await db.prepare("SELECT count(*) AS n FROM pg_constraint WHERE conrelid = ?::regclass AND contype = 'c' AND pg_get_constraintdef(oid) LIKE '%trust%first-party%partner%'").get(table);++ assert.strictEqual(Number(n.n), 1, `${table}: the old trust CHECK is gone, not kept beside the new one`);++ }++++ // ── A person's own Node: a user-owned principal, listed with its trust class.++ await principal({ id: `nod_${'D'.repeat(26)}`, node_id: 'my-node', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('d') });++ const mine = (await nodePrincipals.listPrincipals(db, { owner_kind: 'user' })).find((p) => p.node_id === 'my-node');++ assert.deepStrictEqual([mine.owner, mine.trust], [{ kind: 'user', subject: usr }, 'user-owned']);++++ // Its user-owned offer passes the contract and the placement check, is stored with the class, and is listed.++ const report = { source: 'my-node', offers: [offer('my-node')] };++ assert.strictEqual(offers.check(report), null);++ assert.strictEqual(await cells.checkPlacement(db, [{ cell: 'wnam-1', node_id: 'my-node', trust: 'user-owned' }]), null);++ await offers.report(db, report);++ assert.strictEqual((await db.prepare('SELECT trust FROM platform_resource_offers WHERE id = ?').get('my-node')).trust, 'user-owned');++ assert.deepStrictEqual((await offers.list(db, { trust: 'user-owned' })).map((o) => [o.offer_id, o.trust]), [['my-node', 'user-owned']]);++ // The principal's class still binds the offer: a community machine cannot report itself user-owned.++ assert.strictEqual((await cells.checkPlacement(db, [{ cell: 'wnam-1', node_id: 'rh-user', trust: 'user-owned' }])).code, 'registry.trust_mismatch');++++ // ── Still refused: a project's machine as user-owned, a user's machine as partner, and any unknown class.++ await assert.rejects(principal({ id: `nod_${'E'.repeat(26)}`, node_id: 'prj-node', owner_kind: 'project', project_id: prj, trust: 'user-owned', credential_hash: hash('e') }), 'a project machine is never user-owned');++ await assert.rejects(principal({ id: `nod_${'F'.repeat(26)}`, node_id: 'usr-partner', owner_kind: 'user', owner_subject: usr, trust: 'partner', credential_hash: hash('f') }), 'a user machine is community or user-owned');++ await assert.rejects(principal({ id: `nod_${'G'.repeat(26)}`, node_id: 'stranger', owner_kind: 'user', owner_subject: usr, trust: 'stranger', credential_hash: hash('9') }), 'an unknown class is refused by the principals table');++ await assert.rejects(db.prepare(`INSERT INTO platform_resource_offers (id, source, kind, region, cell, trust, status, doc, reported_at)++ VALUES ('odd', 'test', 'node', 'us-west', 'wnam-1', 'stranger', 'up', '{}', '2026-10-04T00:00:00Z')`).run(), 'an unknown class is refused by the offers table');++ assert.ok(offers.check({ source: 'my-node', offers: [offer('my-node', { trust: 'stranger' })] }), 'and by the contract before anything is written');++++ console.log('user-owned trust: all tests passed');++ } finally {++ await t.close();++ }++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -0,0 +1,200 @@
+# Cutover runbook: the user-owned trust class (migration 0020)
+
+This PR lets the resource registry hold the trust class `user-owned`, a person's own Node (ADR-046, the universal
+adaptive fabric). The class has been in the `platform.resource-offer@1` enum since Contracts 0.87.0, and Network's pin
+has carried it since 0.90.0. Until now, though, PostgreSQL refused it, so a user's own Node could never be reported.
+The data change is `migrations/0020_user_owned_trust.sql`. It drops and re-adds three CHECK constraints, each one
+wider than before. No column is added, renamed or dropped, and no row is rewritten. The migration is `phase: expand`
+and every drop is `IF EXISTS`, so it is safe to run on a live production database and safe to run twice.
+
+Like [cutover-t2-confirmations-budgets.md](cutover-t2-confirmations-budgets.md), this is much smaller than the SQLite →
+PostgreSQL cutover ([cutover-t2-postgres.md](cutover-t2-postgres.md)): no data move, no freeze window, no snapshot
+import. The migration runs as part of the normal boot of the new release.
+
+---
+
+## What changes
+
+| Constraint | Table | Before (0007 / 0008 / 0014) | After (0020) |
+| --- | --- | --- | --- |
+| `platform_resource_offers_trust_check` | `platform_resource_offers` | `trust IN ('first-party','partner','community','external')` (inline in 0007, so PostgreSQL's default name) | the same plus `user-owned` |
+| `platform_node_principals_trust_check` | `platform_node_principals` | the same four (inline in 0008, PostgreSQL's default name) | the same plus `user-owned` |
+| `platform_node_principals_owner` | `platform_node_principals` | a `user` machine is `community`; a `project` machine is anything but `first-party` (0014) | a `user` machine is `community` or `user-owned`; a `project` machine is neither `first-party` nor `user-owned` |
+
+Widening the owner rule is required too. Without it, a `user` principal could still be only `community`. An offer
+must carry its node's trust class (`server/registry/cells.js` `checkPlacement`, `registry.trust_mismatch`), so a
+user-owned offer from a person's own Node would still be refused. The only thing the owner rule newly refuses is a
+`project` machine marked `user-owned`, and no such row can exist, because both trust CHECKs refused that value until
+now. Every row in production therefore passes all three new constraints. Each ADD validates the table, which takes
+milliseconds on a table this size.
+
+The code is unchanged apart from a comment in `server/registry/offers.js` `check()`. The contract already accepts
+the value, and `columns()` copies `trust` through as it is. Pairing (`server/registry/node-principals.js` `redeem`)
+still creates a paired user machine as `community`. Which machines become `user-owned`, and with what consent, are
+ADR-046's open questions for the owner. Until a principal holds the class, no `user-owned` offer reaches the table.
+
+---
+
+## Prerequisites
+
+1. **Production is on PostgreSQL and has applied 0019.** Confirm with `ov access run openvibe-ovh health network`
+ (must say `ready`) and `validate network` (must say `checkout clean`). The runner refuses a pending file numbered
+ below an applied one, so 0020 must be the next number after what production has applied.
+2. **`DATABASE_DIRECT_URL` is set and connects.** The migration runner uses the direct (owner-role) connection for
+ schema changes. Check by name only:
+ ```bash
+ sudo grep -cE '^DATABASE_DIRECT_URL=.+' /etc/openvibe/network.env
+ ```
+ Must print `1`. Never print the value.
+3. **A green rehearsal of this PR's head.** See [Step 1](#step-1-rehearse). The deploy gates on its marker.
+4. **The target commit is fixed.** It is the merge commit of this PR into `origin/main`. Steps 1 and 2 must use that
+ one SHA.
+
+---
+
+## Step 1: rehearse
+
+The repository test builds a database at 0019 and loads the rehearsal seed
+(`test/fixtures/rehearsal/user-owned-trust.sql`: one node principal per owner kind, and one offer for each of the four
+old trust classes). It then applies 0020 and asserts the following:
+
+- 0020 applies once and leaves every seeded row unchanged.
+- Each table has exactly one trust CHECK, and it names `user-owned`.
+- A `user-owned` principal is stored and listed, and its `user-owned` offer passes `check()` and `checkPlacement`,
+ is stored, and is listed by `trust=user-owned`.
+- A project machine marked `user-owned`, a user machine marked `partner`, and the unknown class `stranger` are all
+ still refused.
+
+```bash
+NETWORK_TEST_STORE=pg node test/run.js user-owned-trust
+```
+
+**Go:** exit 0 and `user-owned trust: all tests passed`.
+
+The harness also rehearses the PR's head by itself (`ov rehearse OpenVibe.Network <PR>`) on a scratch PostgreSQL.
+It applies `origin/main`'s migrations (up to 0019), loads the seed named below, applies this PR's migrations (a
+second run must apply nothing), and then runs the commands in this block against that database. The first command
+checks the migration, the constraints and the seeded rows. The second writes a user-owned principal and offer inside
+a transaction that it rolls back, and shows that a `stranger` class and a user-owned project machine are refused.
+
+```rehearse
+seed: test/fixtures/rehearsal/user-owned-trust.sql
+migrations: migrations
+node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const m=await c.query("SELECT id FROM ov_migrations WHERE id=$1",["0020"]);const k=await c.query("SELECT conname,pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid IN (\x27platform_resource_offers\x27::regclass,\x27platform_node_principals\x27::regclass) AND conname=ANY($1) ORDER BY conname",[["platform_resource_offers_trust_check","platform_node_principals_trust_check","platform_node_principals_owner"]]);const p=await c.query("SELECT node_id||\x27=\x27||trust AS v FROM platform_node_principals WHERE node_id LIKE \x27rh-%\x27 ORDER BY node_id");const o=await c.query("SELECT id||\x27=\x27||trust AS v FROM platform_resource_offers WHERE id LIKE \x27rh-%\x27 ORDER BY id");await c.end();const rows=p.rows.map(r=>r.v).concat(o.rows.map(r=>r.v)).join(",");const want="rh-platform=first-party,rh-project=partner,rh-user=community,rh-platform=first-party,rh-project=partner,rh-provider=external,rh-user=community";if(m.rows.length!==1||k.rows.length!==3||!k.rows.every(r=>r.d.includes("user-owned"))||rows!==want){console.error("FAIL migration="+m.rows.length+" constraints="+JSON.stringify(k.rows)+" rows="+rows);process.exit(1);}console.log("rehearse: 0020 applied, "+k.rows.map(r=>r.conname).join(",")+" name user-owned, seed rows unchanged");})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'
+node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const refused=async(sql,args)=>{await c.query("SAVEPOINT s");try{await c.query(sql,args);return false}catch(e){await c.query("ROLLBACK TO SAVEPOINT s");return e.code==="23514"}};const P="INSERT INTO platform_node_principals (id,node_id,home_cell,owner_kind,project_id,owner_subject,trust,created_by,credential_hash) VALUES ($1,$2,\x27wnam-1\x27,$3,$4,$5,$6,\x27rehearsal\x27,$7)";const usr="usr_0000000000000000000000RH01",prj="prj_0000000000000000000000RH01";await c.query("BEGIN");try{await c.query(P,["nod_0000000000000000000000RH04","rh-mine","user",null,usr,"user-owned","d".repeat(64)]);await c.query("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-mine\x27,\x27rh-mine\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27user-owned\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");const a=await refused(P,["nod_0000000000000000000000RH05","rh-stranger","user",null,usr,"stranger","e".repeat(64)]);const b=await refused(P,["nod_0000000000000000000000RH06","rh-prj-mine","project",prj,null,"user-owned","f".repeat(64)]);const d=await refused("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-odd\x27,\x27rh\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27stranger\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");if(!a||!b||!d){console.error("FAIL refused stranger-principal="+a+" project-user-owned="+b+" stranger-offer="+d);process.exitCode=1}else console.log("rehearse: user-owned principal and offer stored; stranger and a user-owned project machine refused (rolled back)")}finally{await c.query("ROLLBACK");await c.end()}})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'
+```
+
+**Go:** the rehearsal job ends with `rehearse: 0020 applied, platform_node_principals_owner,
+platform_node_principals_trust_check,platform_resource_offers_trust_check name user-owned, seed rows unchanged` and
+`rehearse: user-owned principal and offer stored; stranger and a user-owned project machine refused (rolled back)`
+(exit 0).
+**No-go:** a held migration, a constraint without `user-owned`, a changed seed row, or a write that is stored when it
+should be refused (or refused when it should be stored) means the migration does not apply cleanly on top of
+production's schema. Fix it and push; the new head is rehearsed again.
+
+The harness writes the marker `~/openvibe/agents/ds/deploy/rehearsals/openvibe-network-<PR>.json` from the
+rehearsal's exit code. No agent writes it.
+
+---
+
+## The backup
+
+Before the deploy, take the pre-deploy restore point into the broker's backup area:
+
+```bash
+ov access run openvibe-ovh db-backup
+```
+
+It writes outside the release and outside the repository; keep it. The migration only widens constraints, so a code
+rollback ([below](#rollback)) is enough and does not need this backup. A restore of this backup overwrites every write
+made after it (accounts, sessions, notifications, coin transactions, follows, blocks, preferences, offers and node
+principals). Restores are an owner decision; coordinate with the database owner separately.
+
+---
+
+## Step 2: deploy
+
+`server/db/database.js` runs every pending migration at startup, using `DATABASE_DIRECT_URL` for schema changes. No
+manual SQL, no script, no freeze.
+
+```bash
+ov deploy OpenVibe.Network "PR #<number>: user-owned trust class (migration 0020)"
+```
+
+After merge, the deploy:
+
+1. Checks out the merge commit on `/opt/openvibe.network`.
+2. Restarts `openvibe-network.service`.
+3. The new process boots, runs `0020_user_owned_trust.sql` (the only pending migration), and starts serving.
+
+**Go:** `health network` says `ready` on the merge SHA; `validate network` says `checkout clean` at that SHA;
+`journal openvibe-network.service` shows the boot banner with no migration error.
+**No-go:** the service fails to start. Check the journal. A `check constraint … is violated by some row` error here
+means production holds a row the rehearsal seed did not foresee. Roll back (below), and find the row with the
+constraint's expression before trying again.
+
+---
+
+## Verification after deploy
+
+```bash
+ov access run openvibe-ovh journal openvibe-network.service 200 | grep -i '0020\|migration\|constraint'
+
+# On the host, with the owner connection:
+sudo node --env-file=/etc/openvibe/network.env -e '
+ const { Client } = require("pg");
+ (async () => {
+ const c = new Client({ connectionString: process.env.DATABASE_DIRECT_URL });
+ await c.connect();
+ const k = await c.query("SELECT conname, pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid IN ('platform_resource_offers'::regclass, 'platform_node_principals'::regclass) AND conname = ANY($1) ORDER BY conname",
+ [["platform_resource_offers_trust_check", "platform_node_principals_trust_check", "platform_node_principals_owner"]]);
+ for (const r of k.rows) console.log(r.conname, r.d.includes("user-owned") ? "names user-owned" : "MISSING user-owned");
+ const m = await c.query("SELECT id FROM ov_migrations WHERE id = $1", ["0020"]);
+ console.log("migration:", m.rows.length ? "0020" : "MISSING");
+ await c.end();
+ })().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'
+```
+
+Expected: three lines ending `names user-owned`, and `migration: 0020`. `GET https://openvibe.network/api/v1/resources`
+answers as before. It lists no `user-owned` offer until a Node holding that class reports one.
+
+---
+
+## Rollback
+
+- **Code rollback, constraints stay.** Revert to the previous release. Its code never writes `user-owned` (pairing
+ writes `community`), so the wider constraints are inert. The migration stays recorded in `ov_migrations`, and a
+ later re-deploy sees it as applied.
+- **Full rollback, constraints narrowed.** Only when no row holds `user-owned`
+ (`SELECT count(*) FROM platform_node_principals WHERE trust = 'user-owned'` and the same on
+ `platform_resource_offers` both print 0). Run as the owner role, re-adding the definitions from 0007, 0008 and
+ 0014:
+ ```sql
+ ALTER TABLE platform_resource_offers DROP CONSTRAINT IF EXISTS platform_resource_offers_trust_check;
+ ALTER TABLE platform_resource_offers ADD CONSTRAINT platform_resource_offers_trust_check
+ CHECK (trust IN ('first-party', 'partner', 'community', 'external'));
+ ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_trust_check;
+ ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_trust_check
+ CHECK (trust IN ('first-party', 'partner', 'community', 'external'));
+ ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_owner;
+ ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_owner CHECK (
+ (owner_kind = 'platform' AND project_id IS NULL AND owner_subject IS NULL AND trust = 'first-party')
+ OR (owner_kind = 'project' AND project_id IS NOT NULL AND owner_subject IS NULL AND trust <> 'first-party')
+ OR (owner_kind = 'user' AND project_id IS NULL AND owner_subject IS NOT NULL
+ AND owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND trust = 'community'));
+ DELETE FROM ov_migrations WHERE id = '0020';
+ ```
+ If any row holds `user-owned`, a person's Node is registered with that class, and narrowing the constraints would
+ fail. Revoke or reclassify those principals first; that is an owner decision.
+
+Restart the service after either rollback and confirm `health network` says `ready`.
+
+---
+
+## What is not covered here
+
+- **Data move.** None. No row is rewritten, and nothing holds `user-owned` until a principal is given the class.
+- **Freeze window.** None. Three constraint swaps on small tables take milliseconds.
+- **Contracts.** No pin bump. v0.90.0 is already pinned, and it accepts `user-owned`.
+- **Who becomes user-owned.** Pairing still creates `community` principals. Switching a paired user machine to
+ `user-owned`, and the consent and revocation rule for running work on it, are ADR-046's open questions for the owner.
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+-- phase: expand
+-- plan T14 (ADR-046, the universal adaptive fabric). A person's own Node is the trust class user-owned: in the
+-- platform.resource-offer@1 enum since Contracts 0.87.0, and in Network's pin since 0.90.0. Both trust CHECKs gain it, and a
+-- user's own machine may now be user-owned as well as community; a project's machine is never user-owned. Widening
+-- only: no column changes, no row is rewritten, and every existing row passes the new constraints.
+
+ALTER TABLE platform_resource_offers DROP CONSTRAINT IF EXISTS platform_resource_offers_trust_check;
+ALTER TABLE platform_resource_offers ADD CONSTRAINT platform_resource_offers_trust_check
+ CHECK (trust IN ('first-party', 'user-owned', 'partner', 'community', 'external'));
+ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_trust_check;
+ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_trust_check
+ CHECK (trust IN ('first-party', 'user-owned', 'partner', 'community', 'external'));
+ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_owner;
+ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_owner CHECK (
+ (owner_kind = 'platform' AND project_id IS NULL AND owner_subject IS NULL AND trust = 'first-party')
+ OR (owner_kind = 'project' AND project_id IS NOT NULL AND owner_subject IS NULL AND trust NOT IN ('first-party', 'user-owned'))
+ OR (owner_kind = 'user' AND project_id IS NULL AND owner_subject IS NOT NULL
+ AND owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND trust IN ('community', 'user-owned')));
@@ -46,6 +46,7 @@ function check(body) {
@@ -46,6 +46,7 @@ function check(body) {
}
}
for (let i = 0; i < body.offers.length; i++) {
for (let i = 0; i < body.offers.length; i++) {
const offer = body.offers[i];
const offer = body.offers[i];
+ // trust may be user-owned (a person's own Node, ADR-046) since Contracts 0.90.0; migrations/0020 stores it.
const v = validate(CONTRACT, offer);
const v = validate(CONTRACT, offer);
if (!v.valid) return { error: `offer ${i} does not match ${CONTRACT}`, details: (v.errors || []).slice(0, 5) };
if (!v.valid) return { error: `offer ${i} does not match ${CONTRACT}`, details: (v.errors || []).slice(0, 5) };
const detail = offer.detail;
const detail = offer.detail;
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- Rehearsal seed for migrations/0020_user_owned_trust.sql (docs/cutover-t14-user-owned-trust.md): rows as production may
+-- hold them at 0019, one node principal per owner kind and one offer per trust class 0007 allowed, so the widened
+-- CHECKs are validated against existing rows. Fixed ids, repeatable, PostgreSQL only; no secret (a fake 64-hex hash).
+INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by)
+ VALUES ('prj_0000000000000000000000RH01', 'usr_0000000000000000000000RH01', 'Rehearsal lab', '2026-10-01T00:00:00Z', 'rehearsal') ON CONFLICT DO NOTHING;
+INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, owner_subject, trust, created_by, credential_hash) VALUES
+ ('nod_0000000000000000000000RH01', 'rh-platform', 'wnam-1', 'platform', NULL, NULL, 'first-party', 'rehearsal', NULL),
+ ('nod_0000000000000000000000RH02', 'rh-project', 'wnam-1', 'project', 'prj_0000000000000000000000RH01', NULL, 'partner', 'rehearsal', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'),
+ ('nod_0000000000000000000000RH03', 'rh-user', 'wnam-1', 'user', NULL, 'usr_0000000000000000000000RH01', 'community', 'rehearsal', 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc')
+ ON CONFLICT DO NOTHING;
+INSERT INTO platform_resource_offers (id, source, kind, region, cell, trust, status, price_usd, doc, reported_at) VALUES
+ ('rh-platform', 'rehearsal', 'node', 'us-west', 'wnam-1', 'first-party', 'up', 0, '{"offer_id":"rh-platform"}', '2026-10-01T00:00:00Z'),
+ ('rh-project', 'rehearsal', 'node', 'us-west', 'wnam-1', 'partner', 'up', 0, '{"offer_id":"rh-project"}', '2026-10-01T00:00:00Z'),
+ ('rh-user', 'rehearsal', 'node', 'us-west', 'wnam-1', 'community', 'degraded', 0, '{"offer_id":"rh-user"}', '2026-10-01T00:00:00Z'),
+ ('rh-provider', 'rehearsal', 'provider', 'us-west', 'wnam-1', 'external', 'up', 0.01, '{"offer_id":"rh-provider"}', '2026-10-01T00:00:00Z')
+ ON CONFLICT DO NOTHING;
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+// The user-owned trust class (ADR-046, migrations/0020_user_owned_trust.sql) on a database that production already
+// migrated: a database at 0019 holding offers and node principals gains the class from the normal runner, once, with
+// every existing row untouched. A person's own Node then holds a user-owned principal, reports a user-owned offer and
+// is listed with it; a project's machine is never user-owned, and a trust value outside the five is still refused.
+// node test/user-owned-trust.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { createTestDb } = require('openvibe-sdk/testing');
+const { createDb } = require('openvibe-sdk/db');
+const offers = require('../server/registry/offers');
+const cells = require('../server/registry/cells');
+const nodePrincipals = require('../server/registry/node-principals');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const quiet = { log() {}, warn() {}, error() {} };
+
+(async () => {
+ // What production ran before this release: the migrations up to 0019.
+ const before = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-user-owned-'));
+ for (const f of fs.readdirSync(MIGRATIONS)) if (/^\d{4}_.*\.sql$/.test(f) && f.slice(0, 4) <= '0019') fs.copyFileSync(path.join(MIGRATIONS, f), path.join(before, f));
+ const t = await createTestDb({ migrations: before, store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', log: quiet });
+ const asOwner = async (fn) => {
+ if (!t.directUrl) return fn(t.db);
+ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, log: quiet });
+ try { return await fn(owner); } finally { await owner.close(); }
+ };
+ const db = t.db;
+ // The rows of the cutover rehearsal's seed (docs/cutover-t14-user-owned-trust.md): its project and person.
+ const prj = 'prj_0000000000000000000000RH01', usr = 'usr_0000000000000000000000RH01', hash = (c) => c.repeat(64);
+ const principal = (p) => db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, owner_subject, trust, created_by, credential_hash)
+ VALUES (?, ?, 'wnam-1', ?, ?, ?, ?, 'test', ?)`).run(p.id, p.node_id, p.owner_kind, p.project_id ?? null, p.owner_subject ?? null, p.trust, p.credential_hash ?? null);
+ const offer = (id, extra = {}) => ({ offer_id: id, kind: 'node', node_id: id, provider: 'self', region: 'us-west', cell: 'wnam-1', trust: 'user-owned',
+ capabilities: ['node:http', 'worker:function'], capacity: { cpu: { utilization: 0.1, available_cores: 8 } }, health: { status: 'up' },
+ pricing: { model: 'free-allowance' }, updated_at: '2026-10-04T00:00:00Z', ...extra });
+ const trustCheck = async (table) => (await db.prepare(`SELECT conname, pg_get_constraintdef(oid) AS def FROM pg_constraint
+ WHERE conrelid = ?::regclass AND conname = ?`).get(table, `${table}_trust_check`))?.def || '';
+ try {
+ // ── Before: the rows production may hold, and user-owned refused by both tables.
+ await db.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'rehearsal', 'user-owned-trust.sql'), 'utf8'));
+ await db.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'rehearsal', 'user-owned-trust.sql'), 'utf8')); // repeatable
+ await offers.report(db, { source: 'host', offers: [offer('rh-user-2', { node_id: 'rh-user', trust: 'community' })] });
+ assert.ok(!(await trustCheck('platform_resource_offers')).includes('user-owned') && !(await trustCheck('platform_node_principals')).includes('user-owned'));
+ await assert.rejects(principal({ id: `nod_${'C'.repeat(26)}`, node_id: 'early', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('c') }), 'refused before 0020');
+ const rows = async () => ({
+ principals: await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all(),
+ offers: await db.prepare('SELECT * FROM platform_resource_offers ORDER BY id').all(),
+ });
+ const seeded = await rows();
+
+ // ── 0020 applies on top of 0019, once, and rewrites nothing.
+ const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand']]);
+ assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
+ await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
+ assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
+ for (const table of ['platform_resource_offers', 'platform_node_principals']) {
+ assert.match(await trustCheck(table), /'user-owned'/, `${table}_trust_check names user-owned`);
+ const n = await db.prepare("SELECT count(*) AS n FROM pg_constraint WHERE conrelid = ?::regclass AND contype = 'c' AND pg_get_constraintdef(oid) LIKE '%trust%first-party%partner%'").get(table);
+ assert.strictEqual(Number(n.n), 1, `${table}: the old trust CHECK is gone, not kept beside the new one`);
+ }
+
+ // ── A person's own Node: a user-owned principal, listed with its trust class.
+ await principal({ id: `nod_${'D'.repeat(26)}`, node_id: 'my-node', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('d') });
+ const mine = (await nodePrincipals.listPrincipals(db, { owner_kind: 'user' })).find((p) => p.node_id === 'my-node');
+ assert.deepStrictEqual([mine.owner, mine.trust], [{ kind: 'user', subject: usr }, 'user-owned']);
+
+ // Its user-owned offer passes the contract and the placement check, is stored with the class, and is listed.
+ const report = { source: 'my-node', offers: [offer('my-node')] };
+ assert.strictEqual(offers.check(report), null);
+ assert.strictEqual(await cells.checkPlacement(db, [{ cell: 'wnam-1', node_id: 'my-node', trust: 'user-owned' }]), null);
+ await offers.report(db, report);
+ assert.strictEqual((await db.prepare('SELECT trust FROM platform_resource_offers WHERE id = ?').get('my-node')).trust, 'user-owned');
+ assert.deepStrictEqual((await offers.list(db, { trust: 'user-owned' })).map((o) => [o.offer_id, o.trust]), [['my-node', 'user-owned']]);
+ // The principal's class still binds the offer: a community machine cannot report itself user-owned.
+ assert.strictEqual((await cells.checkPlacement(db, [{ cell: 'wnam-1', node_id: 'rh-user', trust: 'user-owned' }])).code, 'registry.trust_mismatch');
+
+ // ── Still refused: a project's machine as user-owned, a user's machine as partner, and any unknown class.
+ await assert.rejects(principal({ id: `nod_${'E'.repeat(26)}`, node_id: 'prj-node', owner_kind: 'project', project_id: prj, trust: 'user-owned', credential_hash: hash('e') }), 'a project machine is never user-owned');
+ await assert.rejects(principal({ id: `nod_${'F'.repeat(26)}`, node_id: 'usr-partner', owner_kind: 'user', owner_subject: usr, trust: 'partner', credential_hash: hash('f') }), 'a user machine is community or user-owned');
+ await assert.rejects(principal({ id: `nod_${'G'.repeat(26)}`, node_id: 'stranger', owner_kind: 'user', owner_subject: usr, trust: 'stranger', credential_hash: hash('9') }), 'an unknown class is refused by the principals table');
+ await assert.rejects(db.prepare(`INSERT INTO platform_resource_offers (id, source, kind, region, cell, trust, status, doc, reported_at)
+ VALUES ('odd', 'test', 'node', 'us-west', 'wnam-1', 'stranger', 'up', '{}', '2026-10-04T00:00:00Z')`).run(), 'an unknown class is refused by the offers table');
+ assert.ok(offers.check({ source: 'my-node', offers: [offer('my-node', { trust: 'stranger' })] }), 'and by the contract before anything is written');
+
+ console.log('user-owned trust: all tests passed');
+ } finally {
+ await t.close();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });