openvibers/openvibe.sdk Commits

0.74.0: account-data covers telemetry once, and uncovered() finds a table nobody covers (#102)

OP OpenVibers committed 14 days ago · 2a1322c19d · 12 files, +173 −19

telemetry_samples is exported as telemetry.json and anonymized on deletion by createAccountData itself, unless a service lists the table. uncovered(db, {tables, taskKeyed, exempt}) lists every table with a person column that nothing covers, so a service test can assert it is empty.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e505886..c8e2e00 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,18 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.74.0 (2026-10-30)
++
++- **`openvibe-sdk/account-data`: telemetry leaves with the person, and a guard finds a table nobody covers (ADR-033).**
++ - `createAccountData` handles `telemetry_samples` by itself when the table exists: the person's samples are exported
++ as `telemetry.json` (`at`, `service`, `operation`, `resource`, `status`, `latency_ms`), and a deletion anonymizes
++ them (`subject` set to NULL, the sample kept) instead of removing them. A service that lists the table itself
++ keeps its own entry.
++ - `uncovered(db, {tables, taskKeyed, exempt})` lists every table in the current schema with a person-shaped column
++ (`subject`, `owner`, `created_by`, `user_id`, `person`) that the table map, the telemetry handling, a task-keyed
++ list or a reasoned exemption does not cover. A service's test asserts the list is empty, so a new table fails CI
++ until it is covered or exempted with a reason.
++
## 0.73.0 (2026-10-30)
- **`openvibe-sdk/sso`: a sign-in that recovers, explains itself and can be checked.**
ddiff --git a/README.md b/README.md
iindex 8ffbfcb..403b9d6 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.73.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.74.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.73.0 is tagged:
++After v0.74.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -180,7 +180,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/vision` | both | `createVisionClient(client)`: image questions, OCR, private search, network tools, UI checks, consent and face privacy, and `/mcp`. Audience `openvibe.vision`; service id `vision`. |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
--| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
++| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id; telemetry is covered once here (the person's `telemetry_samples` exported as `telemetry.json`, its `subject` set to NULL and the sample kept) unless the table is listed; `uncovered(db, {tables, taskKeyed?, exempt?})` lists the tables with a person column (`subject`, `owner`, `created_by`, `user_id`, `person`) that nothing covers, for a service test to assert is empty |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
ddiff --git a/STATUS.json b/STATUS.json
iindex 282dd00..7624682 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.73.0",
++ "version": "0.74.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -30,7 +30,8 @@
"usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter — validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
"the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js",
"the telemetry sink (openvibe-sdk/telemetry): telemetrySchema() DDL, createPgTelemetrySink() and pruneTelemetry(), a per-service PostgreSQL table every service stores platform.telemetry-sample@1 into",
++ "account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
"resources (openvibe-sdk/resources): the ADR-048 resource index fan-out and the idempotent control client"
],
-- "updated": "2026-10-28"
++ "updated": "2026-10-30"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 4824863..801b368 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.73.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.74.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, bench, vision, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -45,7 +45,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.73.0';
++const SDK_VERSION = '0.74.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 2ed3242..c7ee270 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -338,7 +338,8 @@
"TOPICS": "array",
"createAccountData": "function",
"createNetworkSender": "function",
-- "startSubscriptions": "function"
++ "startSubscriptions": "function",
++ "uncovered": "function"
},
"openvibe-sdk/actor": {
"createActorClient": "function"
ddiff --git a/esm/account-data.mjs b/esm/account-data.mjs
iindex 88313a3..f447870 100644
---- a/esm/account-data.mjs
++++ b/esm/account-data.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/account-data.js';
export const {
-- createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS,
++ createAccountData, createNetworkSender, startSubscriptions, uncovered, ACCOUNT_DATA_SCHEMA, TOPICS,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 903b5e3..e9e3742 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.73.0",
++ "version": "0.74.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.73.0",
++ "version": "0.74.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex abb4ebb..c714398 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.73.0",
++ "version": "0.74.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/account-data.js b/src/account-data.js
iindex 151b010..3f9f5c6 100644
---- a/src/account-data.js
++++ b/src/account-data.js
@@@ -41,6 +41,16 @@
* apply() answers 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>', and throws when
* Network refuses for a reason worth retrying, so Events redelivers. It is idempotent per export and deletion id
* (account_data_events): a redelivered deletion never erases twice, and a confirmation that failed is sent again.
++ *
++ * Telemetry is handled once here, for every service: when the telemetry table (telemetry_samples) exists and the
++ * service's table map does not list it, that person's samples are exported as telemetry.json (at, service, operation,
++ * resource, status, latency_ms) and erased by anonymizing (subject NULL, the sample kept, counted as a tombstone). A
++ * service that lists telemetry_samples itself keeps its own entry.
++ *
++ * uncovered(db, { tables, taskKeyed, exempt }) lists the tables in the current schema with a person-shaped column
++ * (subject, owner, created_by, user_id, person) that none of `tables`, the telemetry handling above, `taskKeyed` or
++ * `exempt` (table → why) covers. A service's test asserts the list is empty, so a new table with a person column
++ * fails CI until it is covered or exempted with a reason.
*/
const { parseDelivery } = require('./events');
const { createServiceTokenClient } = require('./auth/tokens');
@@@ -53,6 +63,12 @@ const IDENT = /^[a-z_][a-z0-9_]{0,62}$/;
const FILE_RE = /^[a-z0-9][a-z0-9_.-]{0,62}\.json$/;
const MAX_FILES = 64;
++/** The telemetry table openvibe-sdk/telemetry writes, and the sample fields a person may read (not `subject`). */
++const TELEMETRY_TABLE = 'telemetry_samples';
++const TELEMETRY_COLUMNS = Object.freeze(['at', 'service', 'operation', 'resource', 'status', 'latency_ms']);
++/** The columns that name a person; uncovered() looks for a table carrying any of them. */
++const PERSON_COLUMNS = Object.freeze(['subject', 'owner', 'created_by', 'user_id', 'person']);
++
const ACCOUNT_DATA_SCHEMA = `CREATE TABLE IF NOT EXISTS account_data_events (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
@@@ -98,6 +114,15 @@ function createAccountData({ db, service, tables, extraExport = null, extraErase
if (!db || typeof db.many !== 'function' || typeof db.tx !== 'function') throw new TypeError('openvibe-sdk/account-data: db must be an openvibe-sdk/db handle');
if (typeof service !== 'string' || !service) throw new TypeError('openvibe-sdk/account-data: service is required');
const entries = normaliseTables(tables || []);
++ // Telemetry is every service's, so the SDK covers it once; a service that names the table keeps its own entry.
++ const handled = entries.slice();
++ if (!entries.some((e) => e.table === TELEMETRY_TABLE)) {
++ if (entries.some((e) => e.file === 'telemetry.json')) throw new TypeError('openvibe-sdk/account-data: a table already exports to telemetry.json, the telemetry export');
++ handled.push({
++ table: TELEMETRY_TABLE, subject: 'subject', file: 'telemetry.json', columns: [...TELEMETRY_COLUMNS],
++ order: 'at', erase: { anonymize: {} }, value: (s) => s, kind: TELEMETRY_TABLE,
++ });
++ }
const columnCache = new Map();
/** The table's columns, or null when the table does not exist (yet): a table a migration has not made is skipped. */
@@@ -117,7 +142,7 @@ function createAccountData({ db, service, tables, extraExport = null, extraErase
async function exportPart(subject) {
const files = [];
const truncated = [];
-- for (const e of entries) {
++ for (const e of handled) {
if (e.file === null) continue;
const cols = await columnsOf(db, e.table);
if (!cols || !cols.includes(e.subject)) continue;
@@@ -142,7 +167,7 @@ function createAccountData({ db, service, tables, extraExport = null, extraErase
const retained = {};
const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
await db.tx(async (t) => {
-- for (const e of entries) {
++ for (const e of handled) {
const cols = await columnsOf(t, e.table);
if (!cols || !cols.includes(e.subject)) continue;
const forms = formsOf(e, subjects);
@@@ -257,6 +282,41 @@ function createAccountData({ db, service, tables, extraExport = null, extraErase
return { apply, exportPart, erase, ensureSchema, consumer, TOPICS, tables: entries.map((e) => ({ table: e.table, subject: e.subject, file: e.file, erase: e.erase })) };
}
++/**
++ * The tables in the current schema that name a person but that no account-data table map covers: the guard that
++ * makes a forgotten table fail a service's test instead of being found five deletion stages later.
++ *
++ * assert.deepEqual(await uncovered(db, { tables: accountData.tables, taskKeyed: ['scan_tasks'],
++ * exempt: { account_data_events: 'the ledger of requests itself' } }), []);
++ *
++ * `tables` is the service's table map (entries or table names). `taskKeyed` names tables whose person column is a
++ * task's key and whose rows go with the task, not the account. `exempt` is table → why; every exemption needs a
++ * reason. `telemetry_samples` is covered by createAccountData itself, so it need not be listed. A table with any of
++ * the person columns (subject, owner, created_by, user_id, person) and no cover is returned, sorted by name.
++ */
++async function uncovered(db, { tables = [], taskKeyed = [], exempt = {} } = {}) {
++ if (!db || typeof db.many !== 'function') throw new TypeError('openvibe-sdk/account-data: uncovered needs an openvibe-sdk/db handle');
++ const rows = await db.many(`SELECT DISTINCT c.table_name AS table
++ FROM information_schema.columns c
++ JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name
++ WHERE c.table_schema = current_schema() AND t.table_type = 'BASE TABLE' AND c.column_name = ANY($1::text[])
++ ORDER BY c.table_name`, [[...PERSON_COLUMNS]]);
++ const covered = new Set([TELEMETRY_TABLE]);
++ const add = (list, what) => {
++ if (!Array.isArray(list)) throw new TypeError(`openvibe-sdk/account-data: uncovered: ${what} must be an array`);
++ for (const entry of list) covered.add(ident(typeof entry === 'string' ? entry : entry && entry.table, `${what} entry`));
++ };
++ add(tables, 'tables');
++ add(taskKeyed, 'taskKeyed');
++ if (!exempt || typeof exempt !== 'object' || Array.isArray(exempt)) throw new TypeError('openvibe-sdk/account-data: uncovered: exempt must be a table → reason object');
++ for (const [table, reason] of Object.entries(exempt)) {
++ ident(table, 'exempt table');
++ if (typeof reason !== 'string' || !reason.trim()) throw new TypeError(`openvibe-sdk/account-data: uncovered: exempt "${table}" needs a reason`);
++ covered.add(table);
++ }
++ return rows.map((r) => r.table).filter((table) => !covered.has(table));
++}
++
/**
* send(path, body) → Response: POST to Network's internal routes with this service's own client-credentials token
* (audience openvibe.network). A 401 refreshes the token once.
@@@ -337,4 +397,4 @@ function startSubscriptions({
return { topics: [...topics], endpoint, done, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; settle(false); } };
}
--module.exports = { createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS };
++module.exports = { createAccountData, createNetworkSender, startSubscriptions, uncovered, ACCOUNT_DATA_SCHEMA, TOPICS };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 63ffb32..33a4bd8 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.73.0';
++const SDK_VERSION = '0.74.0';
module.exports = {
createClient,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 61ba5c7..a651d4b 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -1,14 +1,16 @@
'use strict';
/**
* openvibe-sdk/account-data against real PostgreSQL (PGlite) and a stand-in Network: the export part, the erase
-- * (delete, anonymize, keep, aliases), idempotency per export/deletion id, retries, and the signed consumer.
++ * (delete, anonymize, keep, aliases), idempotency per export/deletion id, retries, the signed consumer, the telemetry
++ * the SDK covers by itself and the guard that finds a table with a person column nobody covers.
*/
const assert = require('node:assert/strict');
const http = require('node:http');
const { run, stubServer } = require('./helpers');
const { createDb } = require('../src/db');
const { signDeliveryHeaders } = require('../src/events');
--const { createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');
++const { telemetrySchema } = require('../src/telemetry');
++const { createAccountData, createNetworkSender, startSubscriptions, uncovered, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
@@@ -144,6 +146,73 @@ run([
await db.close();
}],
++ ['telemetry is covered once: the six readable fields as telemetry.json, anonymized on deletion', async () => {
++ const db = createDb({ pglite: true });
++ await db.exec(telemetrySchema());
++ await db.exec(`INSERT INTO telemetry_samples (at, service, operation, subject, resource, status, latency_ms) VALUES
++ ('2026-10-01T00:00:00Z', 'codes', 'run.start', '${A}', 'run_1', 'ok', 12),
++ ('2026-10-02T00:00:00Z', 'codes', 'run.finish', '${A}', 'run_1', 'ok', 34),
++ ('2026-10-02T00:00:00Z', 'codes', 'run.finish', '${B}', 'run_2', 'error', 56)`);
++ const accountData = createAccountData({ db, service: 'codes', log: quiet, tables: [] });
++ assert.throws(() => createAccountData({ db, service: 'codes', tables: [{ table: 'plans', subject: 'owner', file: 'telemetry.json' }] }), /already exports to telemetry\.json/);
++ const part = await accountData.exportPart(A);
++ assert.deepEqual(part.files.map((f) => f.name), ['telemetry.json']);
++ const rows = part.files[0].content;
++ assert.equal(rows.length, 2, 'only this person\'s samples');
++ assert.deepEqual(Object.keys(rows[0]).sort(), ['at', 'latency_ms', 'operation', 'resource', 'service', 'status']);
++ assert.equal(rows[0].operation, 'run.finish', 'newest first');
++ assert.ok(!JSON.stringify(part.files).includes(A), 'the subject is not in the file');
++ assert.deepEqual(await accountData.erase([A]), { erased: {}, retained: { tombstones: 2 } });
++ assert.equal(await db.value('SELECT COUNT(*)::int FROM telemetry_samples'), 3, 'the samples are kept');
++ assert.equal(await db.value('SELECT COUNT(*)::int FROM telemetry_samples WHERE subject IS NULL'), 2);
++ assert.equal(await db.value(`SELECT COUNT(*)::int FROM telemetry_samples WHERE subject = '${B}'`), 1);
++ await db.close();
++ }],
++
++ ['a service that lists telemetry_samples itself keeps its own entry', async () => {
++ const db = createDb({ pglite: true });
++ await db.exec(telemetrySchema());
++ await db.exec(`INSERT INTO telemetry_samples (at, service, operation, subject) VALUES
++ ('2026-10-01T00:00:00Z', 'codes', 'run.start', '${A}'), ('2026-10-02T00:00:00Z', 'codes', 'run.finish', '${A}')`);
++ const accountData = createAccountData({
++ db, service: 'codes', log: quiet,
++ tables: [{ table: 'telemetry_samples', subject: 'subject', file: 'mine.json', erase: 'delete' }],
++ });
++ const part = await accountData.exportPart(A);
++ assert.deepEqual(part.files.map((f) => f.name), ['mine.json']);
++ assert.ok('service' in part.files[0].content[0], 'its own columns');
++ assert.deepEqual(await accountData.erase([A]), { erased: { telemetry_samples: 2 }, retained: {} });
++ assert.equal(await db.value('SELECT COUNT(*)::int FROM telemetry_samples'), 0);
++ await db.close();
++ }],
++
++ ['uncovered() names every table with a person column that nothing covers; telemetry is covered by itself', async () => {
++ const db = createDb({ pglite: true });
++ await db.exec(`CREATE TABLE plans (id TEXT, owner TEXT);
++ CREATE TABLE notes (id TEXT, created_by TEXT);
++ CREATE TABLE orders (id TEXT, user_id TEXT);
++ CREATE TABLE members (id TEXT, person TEXT);
++ CREATE TABLE mixed (id TEXT, owner TEXT, subject TEXT);
++ CREATE TABLE scan_tasks (id TEXT, subject TEXT);
++ CREATE TABLE account_data_events (id TEXT, subject TEXT);
++ CREATE TABLE posts (id TEXT, author TEXT);
++ CREATE TABLE plain (id TEXT)`);
++ await db.exec(telemetrySchema());
++ assert.deepEqual(await uncovered(db, {
++ tables: ['plans'],
++ taskKeyed: ['scan_tasks'],
++ exempt: { account_data_events: 'the ledger of requests itself' },
++ }), ['members', 'mixed', 'notes', 'orders'], 'telemetry_samples covered, author not person-shaped, one entry per table');
++ assert.deepEqual(await uncovered(db, {
++ tables: ['plans', { table: 'notes' }, 'orders', 'members', 'mixed'],
++ taskKeyed: ['scan_tasks'],
++ exempt: { account_data_events: 'the ledger of requests itself' },
++ }), [], 'every person table covered');
++ await assert.rejects(uncovered(db, { tables: ['plans'], exempt: { notes: '' } }), /needs a reason/);
++ await assert.rejects(uncovered(db, { tables: 'plans' }), /must be an array/);
++ await db.close();
++ }],
++
['the consumer: v2 signature only, loopback only, 503 without a secret, other topics to onEvent, a failure asks for a retry', async () => {
const { db, accountData } = await setup();
const { stub, send } = await network();
ddiff --git a/types/account-data.d.ts b/types/account-data.d.ts
iindex 06534d6..af43234 100644
---- a/types/account-data.d.ts
++++ b/types/account-data.d.ts
@@@ -36,6 +36,7 @@ export interface AccountData {
export function createAccountData(opts: {
db: any;
service: string;
++ /** The service's tables; telemetry_samples is covered by the SDK unless it is listed here. */
tables: AccountDataTable[];
extraExport?: (db: any, subject: string) => Promise<ExportFile[] | void>;
extraErase?: (tx: any, subjects: string[], counts: EraseCounts & { add(o: Record<string, number>, kind: string, n: number): void }) => Promise<void>;
@@@ -44,6 +45,16 @@ export function createAccountData(opts: {
log?: { log(msg: string): void; warn(msg: string): void };
}): AccountData;
export function createNetworkSender(opts: { networkInternalUrl: string; clientId: string; clientSecret: string; fetch?: typeof fetch; timeoutMs?: number }): NetworkSend;
++export interface UncoveredOptions {
++ /** The service's table map, as entries or bare table names. */
++ tables?: (string | Pick<AccountDataTable, 'table'>)[];
++ /** Tables whose person column is a task's key; their rows go with the task, not the account. */
++ taskKeyed?: string[];
++ /** Tables left out on purpose, each with the reason. */
++ exempt?: Record<string, string>;
++}
++/** The tables with a person column (subject, owner, created_by, user_id, person) that nothing covers, sorted. */
++export function uncovered(db: any, opts?: UncoveredOptions): Promise<string[]>;
export interface Subscriptions { topics: string[]; endpoint: string; done: Promise<boolean>; stop(): void }
/** Create any missing Events subscription for `topics` (default TOPICS) at boot; null when a setting is missing. */
export function startSubscriptions(opts: {