openvibers/openvibe.network Commits

Accept the user-owned trust class in resource offers and node principals

OP OpenVibers committed 4 Oct 2026 · b576490716 · 6 files, +328 −1

Asked: T14 step (dossier /home/workstation/openvibe/agents/ds/prep/T14.md READY NOW lines 257-258) — OpenVibe.Network: let the resource registry accept the user-owned trust class (ADR-046). Contracts 0.90.0 added user-owned to platform.resource-offer@1, but the PostgreSQL CHECK constraints still refuse it…
Harness job network-t14-step-dossier-home-workstatio-160250 (plan T14).

Changes

ddiff --git a/docs/cutover-t14-user-owned-trust.md b/docs/cutover-t14-user-owned-trust.md
nnew file mode 100644
iindex 00000000..807557b6
---- /dev/null
++++ b/docs/cutover-t14-user-owned-trust.md
@@@ -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.
ddiff --git a/migrations/0020_user_owned_trust.sql b/migrations/0020_user_owned_trust.sql
nnew file mode 100644
iindex 00000000..5cf351eb
---- /dev/null
++++ b/migrations/0020_user_owned_trust.sql
@@@ -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')));
ddiff --git a/server/registry/offers.js b/server/registry/offers.js
iindex 0a81323e..ef680cee 100644
---- a/server/registry/offers.js
++++ b/server/registry/offers.js
@@@ -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;
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex c3623e94..d397220f 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/fixtures/rehearsal/user-owned-trust.sql b/test/fixtures/rehearsal/user-owned-trust.sql
nnew file mode 100644
iindex 00000000..ef630d57
---- /dev/null
++++ b/test/fixtures/rehearsal/user-owned-trust.sql
@@@ -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;
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
nnew file mode 100644
iindex 00000000..868482a0
---- /dev/null
++++ b/test/user-owned-trust.test.js
@@@ -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); });