openvibers/openvibe.contracts Commits

v0.41.0: user modules for the rest of D06 — field-level read rules (readers, modules.serviceView), declarative version migrations (migrations, modules.upgrade), new namespaces chat.dm_settings, chat.presence_prefs, live.stats, ai.usage_summary, community.profile, wiki.projects; chat.tts_defaults v2 owned by Chat; tools.usage v2 favorites; ai.preferences readable by ai

OP OpenVibers committed 25 Sept 2026 · a9e00b0c38 · 19 files, +510 −28


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 27ec2017..f51764d3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,24 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
++## 0.41.0 — 2026-09-25
++
++User modules, the rest of D06 (roadmap WS-B task 9):
++
++- **Field-level read rules.** A namespace may list `readers`: `{ service: [fields] }`. A service other than the owner that reads a record (with `network.modules.read`) sees only the public fields and the fields listed for it. `modules.serviceView(namespace, service, data)` applies the rule; Network's service read applies it.
++- **Version migration.** A namespace may list declarative `migrations` (`{ from, to, rename, drop, defaults }`). `modules.upgrade(namespace, data, version)` brings a stored record to the current version. Network upgrades a record when it is read and stores it at the current version on the next write. The contract test requires a path from every earlier version.
++- **New namespaces:**
++ - `chat.dm_settings`: new conversations from everyone or nobody, group invites, previews;
++ - `chat.presence_prefs`: whether the person is named in chat user lists;
++ - `live.stats`: 30-day streaming summary;
++ - `ai.usage_summary`: 30-day AI runs on the person's behalf;
++ - `community.profile`: threads, posts, comments, pastes, first and last activity;
++ - `wiki.projects`: spaces the person owns or edits, where only public ones are named.
++- **`chat.tts_defaults` v2**, now owned by Chat. Its fields are the settings Live's chat panel actually has: `send`, `send_while_live`, `volume`, `sounds`, `sound_volume`, `sources`. v1 (voice, rate, muted) was never written, and a v1 record upgrades to an empty v2 record.
++- **`tools.usage` v2** adds `favorites` (unique tool ids, at most 24). The person may write it; every v1 record is valid v2.
++- **`ai.preferences`** is readable by `ai` (all five fields).
++- The chat and ai service manifests list their new namespaces.
++
## 0.40.0 — 2026-09-24
The chat manifest names its own domain: **openvibe.chat** (`domains`, `publicOrigin`). OpenVibe.Chat serves the site itself (global chat, messages, settings, sign-in with the Network), and Network's first-party CORS list, which comes from these manifests, now includes it. Additive.
ddiff --git a/contracts/modules/namespace.v1.json b/contracts/modules/namespace.v1.json
iindex f2336de8..9bcc5d9d 100644
---- a/contracts/modules/namespace.v1.json
++++ b/contracts/modules/namespace.v1.json
@@@ -17,6 +17,28 @@
"quotaBytes": { "type": "integer", "minimum": 64, "maximum": 65536 },
"onOwnerRemoved": { "enum": ["retain-readonly", "delete-after-retention"], "description": "What happens to records when the owning service or mod is retired." },
"retentionDays": { "type": "integer", "minimum": 0 },
-- "migration": { "type": "string", "description": "How records of the previous version are upgraded." }
++ "migration": { "type": "string", "description": "How records of the previous version are upgraded." },
++ "readers": {
++ "type": "object",
++ "description": "Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.",
++ "propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" },
++ "additionalProperties": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
++ },
++ "migrations": {
++ "type": "array",
++ "description": "Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.",
++ "items": {
++ "type": "object",
++ "required": ["from", "to"],
++ "additionalProperties": false,
++ "properties": {
++ "from": { "type": "integer", "minimum": 1 },
++ "to": { "type": "integer", "minimum": 2 },
++ "rename": { "type": "object", "additionalProperties": { "type": "string" } },
++ "drop": { "type": "array", "items": { "type": "string" } },
++ "defaults": { "type": "object" }
++ }
++ }
++ }
}
}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 0a14c355..c90bcd89 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -1197,6 +1197,57 @@
"migration": {
"type": "string",
"description": "How records of the previous version are upgraded."
++ },
++ "readers": {
++ "type": "object",
++ "description": "Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.",
++ "propertyNames": {
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ },
++ "additionalProperties": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ },
++ "uniqueItems": true
++ }
++ },
++ "migrations": {
++ "type": "array",
++ "description": "Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.",
++ "items": {
++ "type": "object",
++ "required": [
++ "from",
++ "to"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "from": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "to": {
++ "type": "integer",
++ "minimum": 2
++ },
++ "rename": {
++ "type": "object",
++ "additionalProperties": {
++ "type": "string"
++ }
++ },
++ "drop": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "defaults": {
++ "type": "object"
++ }
++ }
++ }
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 63f5b194..ca95ef2e 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -426,6 +426,24 @@ export interface ModuleNamespace {
* How records of the previous version are upgraded.
*/
migration?: string;
++ /**
++ * Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.
++ */
++ readers?: {
++ [k: string]: string[] | undefined;
++ };
++ /**
++ * Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.
++ */
++ migrations?: {
++ from: number;
++ to: number;
++ rename?: {
++ [k: string]: string | undefined;
++ };
++ drop?: string[];
++ defaults?: {};
++ }[];
}
/** [email protected] (owner: network) */
ddiff --git a/lib/modules.js b/lib/modules.js
iindex faf219a0..c995422a 100644
---- a/lib/modules.js
++++ b/lib/modules.js
@@@ -45,4 +45,42 @@ function canWrite(namespace, writer) {
return false;
}
--module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, canWrite, sizeOf };
++/**
++ * What a service may read of a record (field-level read rules): the owner everything; another service
++ * the public fields plus the fields `readers` lists for it.
++ */
++function serviceView(namespace, service, data) {
++ const ns = byNs.get(namespace);
++ if (!ns || !data) return {};
++ if (service === ns.owner) return { ...data };
++ const allowed = new Set([...ns.publicFields, ...((ns.readers && ns.readers[service]) || [])]);
++ const out = {};
++ for (const f of allowed) if (Object.prototype.hasOwnProperty.call(data, f)) out[f] = data[f];
++ return out;
++}
++
++/**
++ * Upgrade a stored record to the namespace's version through its declarative `migrations`
++ * (rename, then drop, then defaults, per step). Returns { data, version, upgraded } or null when no
++ * chain of migrations leads from `version` to the current one.
++ */
++function upgrade(namespace, data, version) {
++ const ns = byNs.get(namespace);
++ if (!ns) return null;
++ let v = Number(version) || 1;
++ let out = { ...(data || {}) };
++ if (v >= ns.version) return { data: out, version: v, upgraded: false };
++ while (v < ns.version) {
++ const step = (ns.migrations || []).find(m => m.from === v);
++ if (!step || step.to <= v) return null;
++ for (const [from, to] of Object.entries(step.rename || {})) {
++ if (Object.prototype.hasOwnProperty.call(out, from)) { out[to] = out[from]; delete out[from]; }
++ }
++ for (const f of step.drop || []) delete out[f];
++ for (const [f, val] of Object.entries(step.defaults || {})) if (!Object.prototype.hasOwnProperty.call(out, f)) out[f] = val;
++ v = step.to;
++ }
++ return { data: out, version: v, upgraded: true };
++}
++
++module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, serviceView, upgrade, canWrite, sizeOf };
ddiff --git a/manifests/namespaces/ai.preferences.json b/manifests/namespaces/ai.preferences.json
iindex 12a5a842..0bb96b28 100644
---- a/manifests/namespaces/ai.preferences.json
++++ b/manifests/namespaces/ai.preferences.json
@@@ -39,5 +39,14 @@
"type": "boolean"
}
}
++ },
++ "readers": {
++ "ai": [
++ "style",
++ "length",
++ "perspective",
++ "personalization",
++ "history"
++ ]
}
}
ddiff --git a/manifests/namespaces/ai.usage_summary.json b/manifests/namespaces/ai.usage_summary.json
nnew file mode 100644
iindex 00000000..ad2273f6
---- /dev/null
++++ b/manifests/namespaces/ai.usage_summary.json
@@@ -0,0 +1,48 @@
++{
++ "namespace": "ai.usage_summary",
++ "owner": "ai",
++ "version": 1,
++ "description": "How much OpenVibe AI did on this person's behalf over the last 30 days (runs, tokens, which services asked). Private; OpenVibe.AI's run log stays the truth.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [],
++ "quotaBytes": 2048,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "runs_30d": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "tokens_30d": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "by_service": {
++ "type": "object",
++ "maxProperties": 30,
++ "propertyNames": {
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ },
++ "additionalProperties": {
++ "type": "integer",
++ "minimum": 0
++ }
++ },
++ "last_run_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 40
++ },
++ "computed_at": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/chat.dm_settings.json b/manifests/namespaces/chat.dm_settings.json
nnew file mode 100644
iindex 00000000..436490ca
---- /dev/null
++++ b/manifests/namespaces/chat.dm_settings.json
@@@ -0,0 +1,31 @@
++{
++ "namespace": "chat.dm_settings",
++ "owner": "chat",
++ "version": 1,
++ "description": "Who may start direct messages with this person, whether they may be added to group conversations, and whether notifications show message text. Chat enforces it; blocks stay in Chat.",
++ "writers": [
++ "owner",
++ "user"
++ ],
++ "publicFields": [],
++ "quotaBytes": 512,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "new_conversations": {
++ "enum": [
++ "everyone",
++ "nobody"
++ ]
++ },
++ "group_invites": {
++ "type": "boolean"
++ },
++ "previews": {
++ "type": "boolean"
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/chat.presence_prefs.json b/manifests/namespaces/chat.presence_prefs.json
nnew file mode 100644
iindex 00000000..35012ba3
---- /dev/null
++++ b/manifests/namespaces/chat.presence_prefs.json
@@@ -0,0 +1,22 @@
++{
++ "namespace": "chat.presence_prefs",
++ "owner": "chat",
++ "version": 1,
++ "description": "Whether this person appears by name in chat user lists. Hidden people are counted, never named. Chat enforces it.",
++ "writers": [
++ "owner",
++ "user"
++ ],
++ "publicFields": [],
++ "quotaBytes": 256,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "show_in_user_list": {
++ "type": "boolean"
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/chat.tts_defaults.json b/manifests/namespaces/chat.tts_defaults.json
iindex 04c3e818..94754273 100644
---- a/manifests/namespaces/chat.tts_defaults.json
++++ b/manifests/namespaces/chat.tts_defaults.json
@@@ -1,30 +1,70 @@
{
"namespace": "chat.tts_defaults",
-- "owner": "live",
-- "version": 1,
-- "description": "Default text-to-speech voice and rate for this person.",
++ "owner": "chat",
++ "version": 2,
++ "description": "A person's chat text-to-speech and sound settings, the same on every site with OpenVibe chat: whether their own messages are read aloud (also while they broadcast), how loud other people's messages and chat sounds play, and which relayed platforms are read. Chat owns it; the person or Chat writes it.",
"writers": [
"owner",
"user"
],
"publicFields": [],
-- "quotaBytes": 2048,
++ "quotaBytes": 1024,
"onOwnerRemoved": "retain-readonly",
++ "migration": "v1 (voice, rate, muted) was owned by Live and never written; v2 moves it to Chat with the settings Live's chat panel actually has. A v1 record upgrades to an empty v2 record (Chat's defaults).",
++ "migrations": [
++ {
++ "from": 1,
++ "to": 2,
++ "drop": [
++ "voice",
++ "rate",
++ "muted"
++ ]
++ }
++ ],
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
-- "voice": {
-- "type": "string",
-- "maxLength": 80
++ "send": {
++ "type": "boolean"
},
-- "rate": {
-- "type": "number",
-- "minimum": 0.5,
-- "maximum": 2
++ "send_while_live": {
++ "type": "boolean"
},
-- "muted": {
++ "volume": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "sounds": {
"type": "boolean"
++ },
++ "sound_volume": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "sources": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "native": {
++ "type": "boolean"
++ },
++ "robotstreamer": {
++ "type": "boolean"
++ },
++ "kick": {
++ "type": "boolean"
++ },
++ "youtube": {
++ "type": "boolean"
++ },
++ "twitch": {
++ "type": "boolean"
++ }
++ }
}
}
}
ddiff --git a/manifests/namespaces/community.profile.json b/manifests/namespaces/community.profile.json
nnew file mode 100644
iindex 00000000..05892a19
---- /dev/null
++++ b/manifests/namespaces/community.profile.json
@@@ -0,0 +1,54 @@
++{
++ "namespace": "community.profile",
++ "owner": "community",
++ "version": 1,
++ "description": "This person's OpenVibe Community activity: threads, posts, comments and pastes they wrote, and when they were first and last active. Community recomputes it on each contribution.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [
++ "threads",
++ "posts",
++ "comments",
++ "pastes",
++ "first_active_at"
++ ],
++ "quotaBytes": 1024,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "threads": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "posts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "comments": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "pastes": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "first_active_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 40
++ },
++ "last_active_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 40
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/live.stats.json b/manifests/namespaces/live.stats.json
nnew file mode 100644
iindex 00000000..4174d5a1
---- /dev/null
++++ b/manifests/namespaces/live.stats.json
@@@ -0,0 +1,46 @@
++{
++ "namespace": "live.stats",
++ "owner": "live",
++ "version": 1,
++ "description": "This person's streaming over the last 30 days, recomputed by Live after each stream and daily. A summary; Live's streams and follows stay the truth.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [
++ "streams_30d",
++ "stream_minutes_30d",
++ "peak_viewers_30d"
++ ],
++ "quotaBytes": 1024,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "streams_30d": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "stream_minutes_30d": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "peak_viewers_30d": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "avg_viewers_30d": {
++ "type": "number",
++ "minimum": 0
++ },
++ "new_followers_30d": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "computed_at": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/tools.usage.json b/manifests/namespaces/tools.usage.json
iindex 80b78a85..2e5872ee 100644
---- a/manifests/namespaces/tools.usage.json
++++ b/manifests/namespaces/tools.usage.json
@@@ -1,10 +1,11 @@
{
"namespace": "tools.usage",
"owner": "tools",
-- "version": 1,
-- "description": "Recently used tools, for the launcher. Written by Tools only.",
++ "version": 2,
++ "description": "Recently used tools and favourite tools, for the Tools launcher and my.openvibe.network. Tools writes recent; the person or Tools writes favorites.",
"writers": [
-- "owner"
++ "owner",
++ "user"
],
"publicFields": [],
"quotaBytes": 8192,
@@@ -33,7 +34,23 @@
}
}
}
++ },
++ "favorites": {
++ "type": "array",
++ "maxItems": 24,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$"
++ }
}
}
-- }
++ },
++ "migration": "v2 adds favorites; every v1 record is a valid v2 record.",
++ "migrations": [
++ {
++ "from": 1,
++ "to": 2
++ }
++ ]
}
ddiff --git a/manifests/namespaces/wiki.projects.json b/manifests/namespaces/wiki.projects.json
nnew file mode 100644
iindex 00000000..8dd2abb0
---- /dev/null
++++ b/manifests/namespaces/wiki.projects.json
@@@ -0,0 +1,53 @@
++{
++ "namespace": "wiki.projects",
++ "owner": "wiki",
++ "version": 1,
++ "description": "The wiki spaces this person owns or edits. Only public spaces are named; the others are counted. Wiki recomputes it when a permission changes.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [
++ "spaces"
++ ],
++ "quotaBytes": 8192,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "spaces": {
++ "type": "array",
++ "maxItems": 50,
++ "items": {
++ "type": "object",
++ "required": [
++ "slug",
++ "name",
++ "role"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "slug": {
++ "type": "string",
++ "maxLength": 80
++ },
++ "name": {
++ "type": "string",
++ "maxLength": 120
++ },
++ "role": {
++ "enum": [
++ "owner",
++ "editor"
++ ]
++ }
++ }
++ }
++ },
++ "private_count": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++}
ddiff --git a/manifests/services/ai.json b/manifests/services/ai.json
iindex 47186c7e..fa91e47c 100644
---- a/manifests/services/ai.json
++++ b/manifests/services/ai.json
@@@ -27,6 +27,6 @@
"ai.run.cached"
],
"eventsConsumed": [],
-- "namespacesOwned": [],
++ "namespacesOwned": ["ai.usage_summary"],
"notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached have payload contracts (v0.30.0, status planned): AI publishes them once its Events client is wired in; it publishes none today. ai.run.create takes ai.run-request@1 and returns ai.run@1."
}
ddiff --git a/manifests/services/chat.json b/manifests/services/chat.json
iindex ab9b86d7..18af23ee 100644
---- a/manifests/services/chat.json
++++ b/manifests/services/chat.json
@@@ -23,9 +23,7 @@
"eventsConsumed": [
"network.user.token_valid_after"
],
-- "namespacesOwned": [
-- "chat.preferences"
-- ],
++ "namespacesOwned": ["chat.preferences", "chat.tts_defaults", "chat.dm_settings", "chat.presence_prefs"],
"notes": "Wave 6: Live's chat (WebSocket, DMs, history, TTS, sounds, moderation) behind a service boundary on 127.0.0.1:4400, serving Live's chat paths since 2026-09-23. Since 2026-09-24 also its own site, openvibe.chat (global chat, messages, settings, sign-in with the Network), which works while Live is down.",
"health": "/health",
"ready": "/ready"
ddiff --git a/package-lock.json b/package-lock.json
iindex 7f53bd9d..a2220c8e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-contracts",
-- "version": "0.40.0",
++ "version": "0.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-contracts",
-- "version": "0.40.0",
++ "version": "0.41.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 8e2e1808..41579d18 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.40.0",
++ "version": "0.41.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
ddiff --git a/test/run.js b/test/run.js
iindex b7c6c376..32d20ca8 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -556,9 +556,26 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);
for (const f of n.publicFields) ok(n.schema.properties && n.schema.properties[f], `${n.namespace} public field ${f} is in its schema`);
ok(n.onOwnerRemoved !== 'delete-after-retention' || Number.isInteger(n.retentionDays), `${n.namespace} deletion has a retention period`);
}
-- ok(modules.validateData('chat.tts_defaults', { voice: 'a', rate: 1 }).valid, 'valid module data passes');
-- ok(!modules.validateData('chat.tts_defaults', { rate: 9 }).valid, 'out-of-range value fails');
-- ok(!modules.validateData('chat.tts_defaults', { voice: 'a', password: 'x' }).valid, 'unknown field fails');
++ for (const n of modules.namespaces) {
++ for (const [svc, fields] of Object.entries(n.readers || {})) {
++ ok(serviceIds.has(svc) && svc !== n.owner, `${n.namespace} reader ${svc} is another service`);
++ for (const f of fields) ok(n.schema.properties[f], `${n.namespace} field ${f} readable by ${svc} is in its schema`);
++ }
++ // Every earlier version has a migration path, and each step's result is valid at the next version.
++ for (let v = 1; v < n.version; v++) ok(modules.upgrade(n.namespace, {}, v) && modules.upgrade(n.namespace, {}, v).version === n.version, `${n.namespace} v${v} upgrades to v${n.version}`);
++ ok(n.version === 1 || typeof n.migration === 'string', `${n.namespace} v${n.version} says how older records upgrade`);
++ }
++ ok(modules.validateData('chat.tts_defaults', { send: true, volume: 40, sources: { kick: false } }).valid, 'valid module data passes');
++ ok(!modules.validateData('chat.tts_defaults', { volume: 101 }).valid, 'out-of-range value fails');
++ ok(!modules.validateData('chat.tts_defaults', { send: true, password: 'x' }).valid, 'unknown field fails');
++ const up = modules.upgrade('chat.tts_defaults', { voice: 'a', rate: 1, muted: true }, 1);
++ ok(up.version === 2 && up.upgraded && JSON.stringify(up.data) === '{}' && modules.validateData('chat.tts_defaults', up.data).valid, 'a v1 tts record upgrades to a valid v2 record');
++ ok(modules.upgrade('tools.usage', { recent: [{ tool: 'img' }] }, 1).data.recent.length === 1 && !modules.upgrade('tools.usage', {}, 2).upgraded, 'tools.usage v1 → v2 keeps recent; current records are left alone');
++ ok(modules.upgrade('live.profile', {}, 1).upgraded === false, 'a current-version record is not upgraded');
++ ok(JSON.stringify(modules.serviceView('ai.preferences', 'ai', { style: 'casual', history: false })) === '{"style":"casual","history":false}', 'a listed reader sees its fields');
++ ok(JSON.stringify(modules.serviceView('live.stats', 'ai', { streams_30d: 2, new_followers_30d: 5 })) === '{"streams_30d":2}', 'an unlisted service sees public fields only');
++ ok(modules.serviceView('live.stats', 'live', { new_followers_30d: 5 }).new_followers_30d === 5, 'the owner sees everything');
++ ok(modules.validateData('tools.usage', { favorites: ['img', 'yt'] }).valid && !modules.validateData('tools.usage', { favorites: ['img', 'img'] }).valid, 'favorites are unique tool ids');
ok(!modules.validateData('tools.usage', { recent: Array.from({ length: 30 }, () => ({ tool: 'x'.repeat(80), at: 'y'.repeat(300) })) }).valid, 'over quota fails');
ok(!modules.validateData('nope.nope', {}).valid && !modules.validateData('chat.preferences', []).valid, 'unknown namespace / non-object fails');
ok(JSON.stringify(modules.publicView('live.profile', { followers: 3, stream_minutes_30d: 99 })) === '{"followers":3}', 'public view keeps only public fields');