openvibers/openvibe.ai Commits

account-data: a person's sessions, credentials and runs, with the guard on every person column (#206)

OP OpenVibers committed 14 days ago · 19b80c9bb5 · 9 files, +253 −10

* account-data: a person's sessions, credentials and runs, with the guard on every person column

* account-data: the receipts table comes from a migration, not from the runtime role

The boot-time ensureSchema() ran DDL as the serving role, which may not change
the schema; the table is now migration NNNN, like every other table here.

Changes

ddiff --git a/README.md b/README.md
iindex 4c78de9..c42a762 100644
---- a/README.md
++++ b/README.md
@@@ -49,7 +49,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)
- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp
and the local model server `openvibe-llm.service` on this host
--- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.67.0 (service tokens, events outbox),
++- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.74.0 (service tokens, events outbox),
`openvibe-shared` v2.3.1, pinned by release tarball
## Capabilities
@@@ -722,6 +722,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.241.0
--- openvibe-sdk: v0.67.0
++- openvibe-sdk: v0.74.0
- openvibe-shared: v4.48.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 74c1144..9623f5b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"contracts": "openvibe-contracts v0.241.0",
-- "sdk": "openvibe-sdk v0.67.0",
++ "sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.48.0",
"capabilities": [
"ai.run.create",
ddiff --git a/migrations/0016_account_data.sql b/migrations/0016_account_data.sql
nnew file mode 100644
iindex 0000000..e62ebf7
---- /dev/null
++++ b/migrations/0016_account_data.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- OpenVibe.AI: the receipts of the ADR-033 account export and deletion deliveries this service applied
++-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing.
++-- The row holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
++
++CREATE TABLE IF NOT EXISTS account_data_events (
++ id TEXT PRIMARY KEY,
++ kind TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ outcome JSONB,
++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
++ sent_at TIMESTAMPTZ
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex ae24c64..2a3f534 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
},
@@@ -1556,9 +1556,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.67.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",
++ "version": "0.74.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 0adadf1..9254bc5 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
},
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..2040721
---- /dev/null
++++ b/server/account-data.js
@@@ -0,0 +1,65 @@
++'use strict';
++/**
++ * Account export and deletion → AI (ADR-033; openvibe-sdk/account-data). What AI holds about a person:
++ *
++ * - their console and person sessions (Console sign-in and the AI chat), erased;
++ * - the provider credentials they brought (server/credentials.js): the encrypted key, its hint and the budget, erased
++ * with them (the key itself is never exported, only its hint);
++ * - their runs: the workflows AI ran for them (runs.on_behalf_of is their SubjectRef) and the requests, citations and
++ * private cache entries under them. Those are the person's own content, so they go with the account (ADR-033);
++ * - the free-allowance usage the allowance ledger counts (server/free-allowance.js) and the usage counters: kept,
++ * because the allowance and the spend must still reconcile across their period;
++ * - their quota (their own limit) and the marker of what was last pushed to their modules: settings, they go;
++ * - the route, template and workflow catalogues: the service's own, with created_by as the staff member's audit trail,
++ * and the audit log itself, which says what was done and by whom.
++ *
++ * Telemetry samples that name a person are covered by the SDK itself (telemetry.json, then subject NULL).
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const ledger = 'money records: the ledger must still balance and reconcile, and the books are kept as the billing policy requires';
++
++// The runs AI made for the person: their SubjectRef sits in the JSON column on_behalf_of.
++const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = $1`;
++const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';
++
++const TABLES = [
++ { table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
++ { table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
++ { table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
++ columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },
++ { table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
++ { table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
++ { table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
++ { table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },
++ // The person's own quota (server/quota.js): a limit that only means something while the account lives.
++ { table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },
++ // What the person used, counted against their allowance: the spend ledger, kept.
++ { table: 'usage_counters', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null, erase: { keep: ledger } },
++ // The audit trail of what was done (and by whom): kept, with the subject a tombstone.
++ { table: 'audit_log', subject: 'actor', file: null, erase: { keep: 'the audit trail of what was done and by whom stays; the subject is a tombstone' } },
++ // The marker of what was last pushed to the person's modules (server/user-modules.js): their own state.
++ { table: 'ai_module_pushes', subject: 'subject_id', file: null },
++];
++
++/** The runs AI made for the person, with their input and their answer. */
++async function extraExport(db, subject) {
++ const runs = await db.many(`SELECT ${RUN_COLUMNS} FROM runs WHERE ${PERSON_RUN} ORDER BY created_at DESC LIMIT 5000`, [subject]);
++ return runs.length ? [{ name: 'runs.json', content: runs }] : [];
++}
++
++/** The person's runs go, with the rows that hang under them (the requests, the citations, the cached answers). */
++async function extraErase(t, subjects, counts) {
++ const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);
++ if (!ids.length) return;
++ counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));
++ counts.add(counts.erased, 'citations', await t.exec('DELETE FROM citations WHERE run_id = ANY($1::text[])', [ids]));
++ counts.add(counts.erased, 'cache_entries', await t.exec('DELETE FROM cache_entries WHERE run_id = ANY($1::text[])', [ids]));
++ counts.add(counts.erased, 'runs', await t.exec('DELETE FROM runs WHERE id = ANY($1::text[])', [ids]));
++}
++
++function create({ db, log = console } = {}) {
++ return createAccountData({ db, service: 'ai', tables: TABLES, extraExport, extraErase, log });
++}
++
++module.exports = { create, TABLES, TOPICS, extraExport, extraErase };
ddiff --git a/server/bench/events.js b/server/bench/events.js
iindex 29be8d8..ecc51ff 100644
---- a/server/bench/events.js
++++ b/server/bench/events.js
@@@ -17,10 +17,11 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { parseDelivery } = require('openvibe-sdk/events');
++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
const TOPIC = 'bench.rating.changed';
--function createBenchEvents({ bench, config, log = console, fetchImpl = globalThis.fetch, clientSecret = process.env.OV_OAUTH_CLIENT_SECRET }) {
++function createBenchEvents({ bench, accountData = null, accountSend = null, config, log = console, fetchImpl = globalThis.fetch, clientSecret = process.env.OV_OAUTH_CLIENT_SECRET }) {
const router = express.Router();
const secrets = (config.events && config.events.secrets) || [];
@@@ -46,6 +47,15 @@ function createBenchEvents({ bench, config, log = console, fetchImpl = globalThi
if (!event || typeof event.event_id !== 'string' || !event.event_id) {
return refused(res, 400, 'bench.bad_delivery', 'body must be { event: <envelope> }');
}
++ if (accountData && ACCOUNT_TOPICS.includes(event.event_type)) {
++ try {
++ const outcome = await accountData.apply(event, { send: accountSend });
++ return res.status(200).json({ event_id: event.event_id, outcome });
++ } catch (err) {
++ log.warn(`[account-data] event ${event.event_id} (${event.event_type}) failed: ${err.message}`);
++ return refused(res, 503, 'ai.account_retry', 'not applied; Events will retry');
++ }
++ }
try {
const out = await apply(event);
return res.status(200).json({ event_id: event.event_id, ...out });
ddiff --git a/server/index.js b/server/index.js
iindex 3257a23..6884a3a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -13,6 +13,8 @@ const { createRegistry } = require('./registry');
const { createProviderPool } = require('./providers');
const { createBenchEvidence } = require('./bench/evidence');
const { createBenchEvents } = require('./bench/events');
++const accountDataLib = require('./account-data');
++const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
const { createFetcher } = require('./fetcher');
const { createEngine } = require('./workflows/engine');
const { createCache } = require('./cache');
@@@ -73,7 +75,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const keys = createNetworkKeys({ config });
const auth = createAuth({ config, log });
// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).
-- const benchEvents = createBenchEvents({ bench, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
++ const accountData = accountDataLib.create({ db, log });
++ const accountSend = env.OV_OAUTH_CLIENT_SECRET ? createNetworkSender({
++ networkInternalUrl: config.networkInternalUrl, clientId: config.console.clientId,
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
++ }) : null;
++ const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets });
let jwks = null;
let keyLoaded = Promise.resolve(null);
@@@ -95,6 +102,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
let server = null;
let benchSubscription = null;
++ let accountSubscription = null;
if (listen) userModules.start();
if (listen) {
server = await new Promise((resolve, reject) => {
@@@ -106,6 +114,13 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
server.requestTimeout = config.runs.maxWaitMs + 30000;
log.log(`[ai] listening on http://${config.host}:${server.address().port} (${config.nodeEnv})`);
benchSubscription = benchEvents.startSubscriptions({ port: server.address().port });
++ if (config.events.subscribe && config.events.url && config.events.secrets.length && env.OV_OAUTH_CLIENT_SECRET) {
++ accountSubscription = startSubscriptions({
++ eventsUrl: config.events.url, endpoint: config.events.endpoint || `http://127.0.0.1:${server.address().port}/internal/events`,
++ secret: config.events.secrets[0], networkInternalUrl: config.networkInternalUrl,
++ clientId: config.console.clientId, clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl, log,
++ });
++ }
const shared = await registry.getProvider('shared');
log.log(`[ai] shared provider: ${shared.kind} ${shared.status}, credentials ${registry.publicProvider(shared).credentials}; stub fallback ${config.stubFallback ? 'on' : 'off'}`);
}
@@@ -113,6 +128,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
async function close() {
clearInterval(housekeeping);
if (benchSubscription) benchSubscription.stop();
++ if (accountSubscription) accountSubscription.stop();
userModules.stop();
if (jwks) jwks.stop();
if (server) {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..b3a88ea
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,139 @@
++'use strict';
++/**
++ * ADR-033: AI's part of an account export and deletion, through the signed POST /internal/events route with a
++ * stand-in Network. Ada's console/person sessions and provider credentials go in her export, without the key; her
++ * deletion erases them, keeps the free-allowance ledger and the staff-owned catalogues, and leaves Bo's rows. The
++ * guard fails when a new table with a person column is neither covered nor exempted with a reason.
++ */
++const assert = require('assert');
++const http = require('http');
++const { uncovered, createNetworkSender } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, suite } = require('./helpers');
++const { TABLES } = require('../server/account-data');
++
++const t = suite('account-data');
++const SECRET = `whsec_${'fixture'.repeat(6)}`;
++const A = 'usr_01JZ0000000000000000000AAA';
++const B = 'usr_01JZ0000000000000000000BBB';
++const EXP = 'exp_01JZ0000000000000000000EXP';
++const DEL = 'del_01JZ0000000000000000000DEX';
++
++async function startNetworkStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_ai', token_type: 'Bearer', expires_in: 300 });
++ calls.push({ url: req.url, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
++ return json(201, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
++
++t.test('every table that names a person is covered or exempted with a reason', async () => {
++ const stub = await startNetworkStub();
++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
++ try {
++ const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
++ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
++ } finally { await h.stop(); await stub.close(); }
++});
++
++t.test('an export carries the person\'s sessions and credential hints, and a deletion erases them, keeps the ledger, and confirms once', async () => {
++ const stub = await startNetworkStub();
++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
++ try {
++ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
++ await h.db.exec("INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at) VALUES ($1, $2, $3, '{}', 'c', '2026-10-01', '2027-10-01')", [`h_${name}`, subject, name]);
++ await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);
++ await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);
++ await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);
++ await h.db.exec("INSERT INTO routes (key, version, primary_provider, created_by, created_at) VALUES ($1, 1, 'openai', $2, '2026-10-01')", [`route-${name}`, subject]);
++ }
++ const deliver = async (event) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ };
++
++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }));
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
++ assert.ok(part, 'the part was pushed to Network');
++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
++ assert.strictEqual(files['console-sessions.json'].length, 1);
++ assert.strictEqual(files['person-sessions.json'].length, 1);
++ assert.strictEqual(files['provider-credentials.json'].length, 1);
++ assert.strictEqual(files['provider-credentials.json'][0].key_hint, 'hint-ada');
++ assert.ok(!JSON.stringify(part.body).includes('v1:secret'), 'the encrypted key is never exported');
++ assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
++
++ const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
++ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM routes WHERE created_by = $1', [A])), 1, 'the staff catalogue stays');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [B])), 1, 'Bo stays');
++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
++ assert.ok(confirmation, 'the confirmation was sent');
++ assert.ok(confirmation.body.erased.console_sessions >= 1);
++ } finally { await h.stop(); await stub.close(); }
++});
++
++t.test('their runs, quota and module marker go; the spend ledger and the audit trail stay', async () => {
++ const stub = await startNetworkStub();
++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
++ try {
++ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
++ await h.db.exec(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, on_behalf_of, input, input_hash, output, created_at)
++ VALUES ($1, 'chat', 1, 'succeeded', 'service', 'live', $2, $3, $4, 'answer', '2026-10-01')`,
++ [`run_${name}`, JSON.stringify({ type: 'user', id: subject }), `hello ${name}`, `h-${name}`]);
++ await h.db.exec("INSERT INTO requests (run_id, operation, provider_key, status, created_at) VALUES ($1, 'chat', 'openai', 'ok', '2026-10-01')", [`run_${name}`]);
++ await h.db.exec("INSERT INTO citations (run_id, source_type, title, created_at) VALUES ($1, 'web', 'a source', '2026-10-01')", [`run_${name}`]);
++ await h.db.exec("INSERT INTO cache_entries (cache_key, scope, workflow_key, workflow_version, input_hash, privacy, output, run_id, created_at, expires_at) VALUES ($1, 'private', 'chat', 1, $2, 'private', 'cached', $3, '2026-10-01', 1)", [`ck_${name}`, `h-${name}`, `run_${name}`]);
++ await h.db.exec("INSERT INTO quotas (scope_type, scope_id, \"window\", max_requests, created_at, updated_at) VALUES ('actor', $1, 'minute', 10, '2026-10-01', '2026-10-01')", [`user:${subject}`]);
++ await h.db.exec("INSERT INTO usage_counters (scope_type, scope_id, \"window\", window_start, requests, tokens, cost_usd) VALUES ('actor', $1, 'minute', 1, 2, 30, 0.5)", [`user:${subject}`]);
++ await h.db.exec("INSERT INTO audit_log (at, actor, action, target_type, target_id) VALUES ('2026-10-01', $1, 'credential.put', 'credential', 'x')", [subject]);
++ await h.db.exec("INSERT INTO ai_module_pushes (subject_id, hash, pushed_at) VALUES ($1, 'hash', '2026-10-01')", [subject]);
++ }
++
++ const deliver = async (event) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ };
++
++ const r = await deliver(ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: A }));
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
++ const runs = part.body.files.find((f) => f.name === 'runs.json');
++ assert.ok(runs, 'runs.json is in the part');
++ assert.deepStrictEqual(runs.content.map((x) => x.id), ['run_ada']);
++ assert.strictEqual(runs.content[0].input, 'hello ada');
++
++ const d = await deliver(ev('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
++ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM runs WHERE id = $1', ['run_ada'])), 0, 'her run is gone');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM requests WHERE run_id = $1', ['run_ada'])), 0, 'the request under it is gone');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM citations WHERE run_id = $1', ['run_ada'])), 0, 'the citation under it is gone');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM cache_entries WHERE run_id = $1', ['run_ada'])), 0, 'the cached answer is gone');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM quotas WHERE scope_id = $1', [`user:${A}`])), 0, 'her quota is gone');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM ai_module_pushes WHERE subject_id = $1', [A])), 0, 'her module marker is gone');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM usage_counters WHERE scope_id = $1', [`user:${A}`])), 1, 'the spend ledger stays');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM audit_log WHERE actor = $1', [A])), 1, 'the audit trail stays');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM runs WHERE id = $1', ['run_bo'])), 1, 'Bo\'s run stays');
++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
++ assert.ok(confirmation, 'the confirmation was sent');
++ assert.strictEqual(confirmation.body.erased.runs, 1);
++ assert.ok(confirmation.body.retained.usage_counters >= 1, 'the kept ledger is counted as retained');
++ } finally { await h.stop(); await stub.close(); }
++});