openvibers/openvibe.contracts Commits

v0.4.0: user-module namespaces (modules.namespace@1, modules.module-record@1, 6 seed namespaces, validateData/publicView/canWrite), network.modules.read/write capabilities

OP OpenVibers committed 22 Sept 2026 · dbb2b83608 · 24 files, +692 −3


Changes

ddiff --git a/README.md b/README.md
iindex 52eed7b1..6c040b39 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Machine-readable contracts for the whole OpenVibe network.
--**Status:** alpha, v0.3 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
++**Status:** alpha, v0.4 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -64,6 +64,10 @@ Manifests: `manifests/services/` (the 7 running services plus the 21 charter rep
**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
++## User modules (v0.4)
++
++`manifests/namespaces/*.json` define per-subject module namespaces (`modules.namespace@1`): owner, data schema, writers (`owner` service and/or the `user`), public fields, quota and what happens when the owner is retired. OpenVibe.Network stores the records (`modules.module-record@1`, revision-checked writes). `contracts.modules.validateData / publicView / canWrite` apply the same rules everywhere. Modules hold portable preferences and summaries, never domain truth, money or authoritative game inventory.
++
## Versioning and compatibility
- A contract id is permanent. Minor versions only add optional fields.
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 0e01c120..05ae5fb3 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -88,6 +88,25 @@
"compatibility": "backward",
"status": "active",
"adr": "ADR-006"
++ },
++ {
++ "id": "modules.namespace",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "modules/namespace.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-001"
++ },
++ {
++ "id": "modules.module-record",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "modules/module-record.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
}
]
}
ddiff --git a/contracts/modules/module-record.v1.json b/contracts/modules/module-record.v1.json
nnew file mode 100644
iindex 00000000..23df9b29
---- /dev/null
++++ b/contracts/modules/module-record.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/modules/module-record.v1.json",
++ "title": "ModuleRecord",
++ "description": "One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.",
++ "type": "object",
++ "required": ["subject", "namespace", "version", "revision", "data", "updated_at"],
++ "additionalProperties": false,
++ "properties": {
++ "subject": { "$ref": "../identity/subject-ref.v1.json" },
++ "namespace": { "type": "string" },
++ "version": { "type": "integer", "minimum": 1 },
++ "revision": { "type": "integer", "minimum": 0 },
++ "data": { "type": "object" },
++ "updated_at": { "type": "string" },
++ "updated_by": { "type": "string", "description": "user:<subject> or svc:<service>" }
++ }
++}
ddiff --git a/contracts/modules/namespace.v1.json b/contracts/modules/namespace.v1.json
nnew file mode 100644
iindex 00000000..84f3aa45
---- /dev/null
++++ b/contracts/modules/namespace.v1.json
@@@ -0,0 +1,22 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/modules/namespace.v1.json",
++ "title": "ModuleNamespace",
++ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",
++ "type": "object",
++ "required": ["namespace", "owner", "version", "schema", "writers", "publicFields", "quotaBytes", "onOwnerRemoved"],
++ "additionalProperties": false,
++ "properties": {
++ "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$" },
++ "owner": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "Service that owns the namespace and may write it with network.modules.write." },
++ "version": { "type": "integer", "minimum": 1, "description": "Schema version stored with every record; a change needs a migration note." },
++ "description": { "type": "string" },
++ "schema": { "type": "object", "description": "JSON Schema (2020-12) every stored value must satisfy." },
++ "writers": { "type": "array", "minItems": 1, "items": { "enum": ["owner", "user"] }, "description": "owner = the owning service with a token; user = the subject themselves." },
++ "publicFields": { "type": "array", "items": { "type": "string" }, "description": "Top-level fields anyone may read. Everything else is readable only by the subject and granted services." },
++ "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." }
++ }
++}
ddiff --git a/fixtures/modules.module-record/invalid/negative-revision.json b/fixtures/modules.module-record/invalid/negative-revision.json
nnew file mode 100644
iindex 00000000..c50c299b
---- /dev/null
++++ b/fixtures/modules.module-record/invalid/negative-revision.json
@@@ -0,0 +1,11 @@
++{
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "chat.tts_defaults",
++ "version": 1,
++ "revision": -1,
++ "data": {},
++ "updated_at": "x"
++}
ddiff --git a/fixtures/modules.module-record/valid/tts.json b/fixtures/modules.module-record/valid/tts.json
nnew file mode 100644
iindex 00000000..fcda79c1
---- /dev/null
++++ b/fixtures/modules.module-record/valid/tts.json
@@@ -0,0 +1,15 @@
++{
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "chat.tts_defaults",
++ "version": 1,
++ "revision": 3,
++ "data": {
++ "voice": "en-US-1",
++ "rate": 1.2
++ },
++ "updated_at": "2026-09-22 21:40:00",
++ "updated_by": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/modules.namespace/invalid/huge-quota.json b/fixtures/modules.namespace/invalid/huge-quota.json
nnew file mode 100644
iindex 00000000..6a1be240
---- /dev/null
++++ b/fixtures/modules.namespace/invalid/huge-quota.json
@@@ -0,0 +1,12 @@
++{
++ "namespace": "x.y",
++ "owner": "live",
++ "version": 1,
++ "schema": {},
++ "writers": [
++ "user"
++ ],
++ "publicFields": [],
++ "quotaBytes": 10000000,
++ "onOwnerRemoved": "retain-readonly"
++}
ddiff --git a/fixtures/modules.namespace/invalid/no-writers.json b/fixtures/modules.namespace/invalid/no-writers.json
nnew file mode 100644
iindex 00000000..90d424fd
---- /dev/null
++++ b/fixtures/modules.namespace/invalid/no-writers.json
@@@ -0,0 +1,10 @@
++{
++ "namespace": "x.y",
++ "owner": "live",
++ "version": 1,
++ "schema": {},
++ "writers": [],
++ "publicFields": [],
++ "quotaBytes": 100,
++ "onOwnerRemoved": "retain-readonly"
++}
ddiff --git a/fixtures/modules.namespace/valid/live-profile.json b/fixtures/modules.namespace/valid/live-profile.json
nnew file mode 100644
iindex 00000000..3b27ecb4
---- /dev/null
++++ b/fixtures/modules.namespace/valid/live-profile.json
@@@ -0,0 +1,44 @@
++{
++ "namespace": "live.profile",
++ "owner": "live",
++ "version": 1,
++ "description": "Live channel summary shown on other sites. Written by Live only.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [
++ "channel_url",
++ "followers",
++ "is_streamer",
++ "last_live_at"
++ ],
++ "quotaBytes": 4096,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "channel_url": {
++ "type": "string",
++ "format": "uri"
++ },
++ "followers": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "is_streamer": {
++ "type": "boolean"
++ },
++ "last_live_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "stream_minutes_30d": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 93e80dcb..fa6a1e0d 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -669,6 +669,137 @@
}
}
}
++ },
++ "modules.namespace": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/modules/namespace.v1.json",
++ "title": "ModuleNamespace",
++ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",
++ "type": "object",
++ "required": [
++ "namespace",
++ "owner",
++ "version",
++ "schema",
++ "writers",
++ "publicFields",
++ "quotaBytes",
++ "onOwnerRemoved"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "namespace": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$"
++ },
++ "owner": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "Service that owns the namespace and may write it with network.modules.write."
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Schema version stored with every record; a change needs a migration note."
++ },
++ "description": {
++ "type": "string"
++ },
++ "schema": {
++ "type": "object",
++ "description": "JSON Schema (2020-12) every stored value must satisfy."
++ },
++ "writers": {
++ "type": "array",
++ "minItems": 1,
++ "items": {
++ "enum": [
++ "owner",
++ "user"
++ ]
++ },
++ "description": "owner = the owning service with a token; user = the subject themselves."
++ },
++ "publicFields": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ },
++ "description": "Top-level fields anyone may read. Everything else is readable only by the subject and granted services."
++ },
++ "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."
++ }
++ }
++ }
++ },
++ "modules.module-record": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/modules/module-record.v1.json",
++ "title": "ModuleRecord",
++ "description": "One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.",
++ "type": "object",
++ "required": [
++ "subject",
++ "namespace",
++ "version",
++ "revision",
++ "data",
++ "updated_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "subject": {
++ "$ref": "../identity/subject-ref.v1.json"
++ },
++ "namespace": {
++ "type": "string"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "data": {
++ "type": "object"
++ },
++ "updated_at": {
++ "type": "string"
++ },
++ "updated_by": {
++ "type": "string",
++ "description": "user:<subject> or svc:<service>"
++ }
++ }
++ }
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex e4bc30da..360d6995 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -193,3 +193,61 @@ export interface MediaRef {
role?: string;
variant?: string;
}
++
++/** [email protected] (owner: network) */
++/**
++ * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).
++ */
++export interface ModuleNamespace {
++ namespace: string;
++ /**
++ * Service that owns the namespace and may write it with network.modules.write.
++ */
++ owner: string;
++ /**
++ * Schema version stored with every record; a change needs a migration note.
++ */
++ version: number;
++ description?: string;
++ /**
++ * JSON Schema (2020-12) every stored value must satisfy.
++ */
++ schema: {};
++ /**
++ * owner = the owning service with a token; user = the subject themselves.
++ *
++ * @minItems 1
++ */
++ writers: ["owner" | "user", ...("owner" | "user")[]];
++ /**
++ * Top-level fields anyone may read. Everything else is readable only by the subject and granted services.
++ */
++ publicFields: string[];
++ quotaBytes: number;
++ /**
++ * What happens to records when the owning service or mod is retired.
++ */
++ onOwnerRemoved: "retain-readonly" | "delete-after-retention";
++ retentionDays?: number;
++ /**
++ * How records of the previous version are upgraded.
++ */
++ migration?: string;
++}
++
++/** [email protected] (owner: network) */
++/**
++ * One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.
++ */
++export interface ModuleRecord {
++ subject: SubjectRef;
++ namespace: string;
++ version: number;
++ revision: number;
++ data: {};
++ updated_at: string;
++ /**
++ * user:<subject> or svc:<service>
++ */
++ updated_by?: string;
++}
ddiff --git a/index.js b/index.js
iindex 8e351030..c292bdde 100644
---- a/index.js
++++ b/index.js
@@@ -21,4 +21,5 @@ module.exports = {
capabilities: require('./lib/capabilities'),
services: require('./lib/services'),
serviceAuth: require('./lib/service-auth'),
++ modules: require('./lib/modules'),
};
ddiff --git a/lib/modules.js b/lib/modules.js
nnew file mode 100644
iindex 00000000..faf219a0
---- /dev/null
++++ b/lib/modules.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * User-module namespaces (manifests/namespaces/*.json, contract modules.namespace@1).
++ * Network stores the records; every service uses the same rules through these helpers.
++ */
++const fs = require('fs');
++const path = require('path');
++const { ajv } = require('./registry');
++
++const DIR = path.join(__dirname, '..', 'manifests', 'namespaces');
++const namespaces = fs.readdirSync(DIR).filter(f => f.endsWith('.json')).sort()
++ .map(f => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));
++const byNs = new Map(namespaces.map(n => [n.namespace, n]));
++const validators = new Map(namespaces.map(n => [n.namespace, ajv.compile(n.schema)]));
++
++const sizeOf = (data) => Buffer.byteLength(JSON.stringify(data));
++
++/** { valid, errors } for a value in a namespace: its schema plus its quota. */
++function validateData(namespace, data) {
++ const ns = byNs.get(namespace);
++ if (!ns) return { valid: false, errors: [{ path: '/', message: `unknown namespace ${namespace}` }] };
++ if (!data || typeof data !== 'object' || Array.isArray(data)) return { valid: false, errors: [{ path: '/', message: 'data must be an object' }] };
++ const fn = validators.get(namespace);
++ const errors = fn(data) ? [] : fn.errors.map(e => ({ path: e.instancePath || '/', message: e.message }));
++ const size = sizeOf(data);
++ if (size > ns.quotaBytes) errors.push({ path: '/', message: `${size} bytes exceeds the ${ns.quotaBytes}-byte quota` });
++ return { valid: errors.length === 0, errors };
++}
++
++/** Only the fields the namespace declares public. */
++function publicView(namespace, data) {
++ const ns = byNs.get(namespace);
++ const out = {};
++ if (!ns || !data) return out;
++ for (const f of ns.publicFields) if (Object.prototype.hasOwnProperty.call(data, f)) out[f] = data[f];
++ return out;
++}
++
++/** May this writer write? writer = { type: 'user' } | { type: 'service', id } */
++function canWrite(namespace, writer) {
++ const ns = byNs.get(namespace);
++ if (!ns || !writer) return false;
++ if (writer.type === 'user') return ns.writers.includes('user');
++ if (writer.type === 'service') return ns.writers.includes('owner') && writer.id === ns.owner;
++ return false;
++}
++
++module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, canWrite, sizeOf };
ddiff --git a/manifests/capabilities/network.modules.read.json b/manifests/capabilities/network.modules.read.json
nnew file mode 100644
iindex 00000000..d87b72ba
---- /dev/null
++++ b/manifests/capabilities/network.modules.read.json
@@@ -0,0 +1,20 @@
++{
++ "id": "network.modules.read",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read a subject's user-module record in a granted namespace.",
++ "outputSchema": "modules.module-record@1",
++ "permissions": [
++ "modules:read"
++ ],
++ "resourceConstraints": [
++ "namespace"
++ ],
++ "quotaClass": "modules-read",
++ "events": [],
++ "implementedBy": [
++ "GET /internal/modules/:namespace/:subject"
++ ]
++}
ddiff --git a/manifests/capabilities/network.modules.write.json b/manifests/capabilities/network.modules.write.json
nnew file mode 100644
iindex 00000000..c944b235
---- /dev/null
++++ b/manifests/capabilities/network.modules.write.json
@@@ -0,0 +1,23 @@
++{
++ "id": "network.modules.write",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Write a subject's record in a namespace the service owns (If-Match revision).",
++ "inputSchema": "modules.module-record@1",
++ "outputSchema": "modules.module-record@1",
++ "permissions": [
++ "modules:write"
++ ],
++ "resourceConstraints": [
++ "namespace"
++ ],
++ "quotaClass": "modules-write",
++ "events": [
++ "network.module.updated"
++ ],
++ "implementedBy": [
++ "PUT /internal/modules/:namespace/:subject"
++ ]
++}
ddiff --git a/manifests/namespaces/ai.preferences.json b/manifests/namespaces/ai.preferences.json
nnew file mode 100644
iindex 00000000..12a5a842
---- /dev/null
++++ b/manifests/namespaces/ai.preferences.json
@@@ -0,0 +1,43 @@
++{
++ "namespace": "ai.preferences",
++ "owner": "network",
++ "version": 1,
++ "description": "How this person wants AI features to behave (roadmap 28.3 m12: style, length, perspective, personalization opt-out).",
++ "writers": [
++ "owner",
++ "user"
++ ],
++ "publicFields": [],
++ "quotaBytes": 2048,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "style": {
++ "enum": [
++ "neutral",
++ "casual",
++ "formal"
++ ]
++ },
++ "length": {
++ "enum": [
++ "short",
++ "medium",
++ "long"
++ ]
++ },
++ "perspective": {
++ "type": "string",
++ "maxLength": 80
++ },
++ "personalization": {
++ "type": "boolean"
++ },
++ "history": {
++ "type": "boolean"
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/chat.preferences.json b/manifests/namespaces/chat.preferences.json
nnew file mode 100644
iindex 00000000..a39262e9
---- /dev/null
++++ b/manifests/namespaces/chat.preferences.json
@@@ -0,0 +1,36 @@
++{
++ "namespace": "chat.preferences",
++ "owner": "live",
++ "version": 1,
++ "description": "Portable chat display preferences (moves to OpenVibe.Chat in Wave 6).",
++ "writers": [
++ "owner",
++ "user"
++ ],
++ "publicFields": [],
++ "quotaBytes": 4096,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "timestamps": {
++ "type": "boolean"
++ },
++ "compact": {
++ "type": "boolean"
++ },
++ "font_scale": {
++ "type": "number",
++ "minimum": 0.75,
++ "maximum": 2
++ },
++ "show_badges": {
++ "type": "boolean"
++ },
++ "hide_emotes": {
++ "type": "boolean"
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/chat.tts_defaults.json b/manifests/namespaces/chat.tts_defaults.json
nnew file mode 100644
iindex 00000000..04c3e818
---- /dev/null
++++ b/manifests/namespaces/chat.tts_defaults.json
@@@ -0,0 +1,31 @@
++{
++ "namespace": "chat.tts_defaults",
++ "owner": "live",
++ "version": 1,
++ "description": "Default text-to-speech voice and rate for this person.",
++ "writers": [
++ "owner",
++ "user"
++ ],
++ "publicFields": [],
++ "quotaBytes": 2048,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "voice": {
++ "type": "string",
++ "maxLength": 80
++ },
++ "rate": {
++ "type": "number",
++ "minimum": 0.5,
++ "maximum": 2
++ },
++ "muted": {
++ "type": "boolean"
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/games.progress.summary.json b/manifests/namespaces/games.progress.summary.json
nnew file mode 100644
iindex 00000000..44cadaef
---- /dev/null
++++ b/manifests/namespaces/games.progress.summary.json
@@@ -0,0 +1,38 @@
++{
++ "namespace": "games.progress.summary",
++ "owner": "games",
++ "version": 1,
++ "description": "Public summary of game progress. The authoritative inventory stays in OpenVibe.Games.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [
++ "level",
++ "achievements",
++ "playtime_hours"
++ ],
++ "quotaBytes": 4096,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "level": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "achievements": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "playtime_hours": {
++ "type": "number",
++ "minimum": 0
++ },
++ "last_world": {
++ "type": "string",
++ "maxLength": 120
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/live.profile.json b/manifests/namespaces/live.profile.json
nnew file mode 100644
iindex 00000000..3b27ecb4
---- /dev/null
++++ b/manifests/namespaces/live.profile.json
@@@ -0,0 +1,44 @@
++{
++ "namespace": "live.profile",
++ "owner": "live",
++ "version": 1,
++ "description": "Live channel summary shown on other sites. Written by Live only.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [
++ "channel_url",
++ "followers",
++ "is_streamer",
++ "last_live_at"
++ ],
++ "quotaBytes": 4096,
++ "onOwnerRemoved": "retain-readonly",
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "channel_url": {
++ "type": "string",
++ "format": "uri"
++ },
++ "followers": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "is_streamer": {
++ "type": "boolean"
++ },
++ "last_live_at": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "stream_minutes_30d": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++}
ddiff --git a/manifests/namespaces/tools.usage.json b/manifests/namespaces/tools.usage.json
nnew file mode 100644
iindex 00000000..80b78a85
---- /dev/null
++++ b/manifests/namespaces/tools.usage.json
@@@ -0,0 +1,39 @@
++{
++ "namespace": "tools.usage",
++ "owner": "tools",
++ "version": 1,
++ "description": "Recently used tools, for the launcher. Written by Tools only.",
++ "writers": [
++ "owner"
++ ],
++ "publicFields": [],
++ "quotaBytes": 8192,
++ "onOwnerRemoved": "delete-after-retention",
++ "retentionDays": 90,
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "recent": {
++ "type": "array",
++ "maxItems": 30,
++ "items": {
++ "type": "object",
++ "required": [
++ "tool"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "tool": {
++ "type": "string",
++ "maxLength": 80
++ },
++ "at": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++}
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex 1331c575..ebe5ffb7 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -22,7 +22,9 @@
"network.coins.credit",
"network.coins.debit",
"network.coins.transfer",
-- "network.notifications.push"
++ "network.notifications.push",
++ "network.modules.read",
++ "network.modules.write"
],
"eventsProduced": [],
"eventsConsumed": [],
ddiff --git a/package.json b/package.json
iindex cdb415bb..1809c9a1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.3.0",
++ "version": "0.4.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 609344cd..75775c2c 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -156,6 +156,26 @@ client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forces
const failing = serviceAuth.createTokenClient({ tokenUrl: 'x', clientId: 'live', clientSecret: 'bad', audience: 'a', fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ error: 'invalid_client' }) }) });
await assert.rejects(failing.getToken(), /401: invalid_client/);
++// ── User-module namespaces ────────────────────────────────────────────────
++{
++ const { modules } = contracts;
++ ok(modules.namespaces.length >= 6, 'seed namespaces present');
++ for (const n of modules.namespaces) {
++ ok(contracts.validate('modules.namespace@1', n).valid, `namespace ${n.namespace} matches the contract`);
++ ok(serviceIds.has(n.owner), `${n.namespace} owner ${n.owner} is a service`);
++ 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');
++ 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');
++ ok(modules.canWrite('chat.preferences', { type: 'user' }) && !modules.canWrite('live.profile', { type: 'user' }), 'user write follows writers');
++ ok(modules.canWrite('live.profile', { type: 'service', id: 'live' }) && !modules.canWrite('live.profile', { type: 'service', id: 'tools' }), 'only the owning service writes');
++}
++
// ── openvibe-contracts-check (the CLI services run in CI) ─────────────────
{
const os = require('os');