openvibers/openvibe.sdk Commits
Emit full platform.usage-sample record from govern onUsage with service (#2)
Changes
@@@ -7,6 +7,9 @@ release may change an API and says so here.++## Unreleased++++- **`openvibe-sdk/govern` emits a full usage record** (plan T5). `createGovernor({ service, provider?, resource?, region? })`; `reserve()` takes optional `operation`, `provider`, `resource`, `region`, `trace_id` and `route_epoch`, which override the defaults. `onUsage` now gets a `platform.usage-sample@1` reading: `id` (the reservation id), `idempotency_key`, `service`, `project`, `subject`, `resource`, `provider`, `region`, `operation` (default `reserve`), `quantity`, `unit`, `at`, `route_epoch`, `trace_id`, `source` (`openvibe-sdk/govern`); absent values are left out. **Changed:** `amount` is now `quantity` and `state` is gone (the schema allows neither; only new reservations are emitted). No money fields: rating is Billing's. A governor created without `service` still works and logs one warning (`log`, default `console`). Tested against openvibe-contracts 0.84.0.## 0.26.0 (2026-10-01)@@@ -12,7 +12,7 @@"@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.10.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0","pg": "^8.23.0"},"engines": {@@@ -459,9 +459,8 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.84.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0","dev": true,"license": "AGPL-3.0-only","dependencies": {@@@ -228,7 +228,7 @@"@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.10.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0","pg": "^8.23.0"},"peerDependencies": {@@@ -138,10 +138,16 @@ function valkeyStore(valkey) {// ── The governor ─────────────────────────────────────────────────────────────--function createGovernor({ policy = {}, valkey = null, now = () => Date.now(), reservationTtlMs = 6 * 3600e3, onRefused = null, onUsage = null } = {}) {++/**++ * `service` names the service that spends ('openvibe.ai'); every usage record carries it (platform.usage-sample@1 requires it).++ * `provider`, `resource` and `region` are defaults for the records; reserve() can override each.++ */++function createGovernor({ policy = {}, valkey = null, now = () => Date.now(), reservationTtlMs = 6 * 3600e3, onRefused = null, onUsage = null,++ service = null, provider = null, resource = null, region = null, log = console } = {}) {const store = valkey ? valkeyStore(valkey) : memoryStore();++ if (!service) log.warn('[govern] createGovernor() without `service`: usage records lack the service platform.usage-sample@1 requires');-- async function reserve({ subject, tier = 'user', unit, amount, key, project = null }) {++ async function reserve({ subject, tier = 'user', unit, amount, key, project = null, operation, provider: prov, resource: res, region: reg, trace_id, route_epoch }) {if (!UNITS.includes(unit)) throw new TypeError(`govern: unknown unit ${unit}`);if (!(amount >= 0)) throw new TypeError('govern: amount must be ≥ 0');if (!key || String(key).length < 8) throw new TypeError('govern: an idempotency key of 8+ characters is required');@@@ -155,7 +161,13 @@ function createGovernor({ policy = {}, valkey = null, now = () => Date.now(), reif (onRefused) onRefused({ subject, tier, unit, amount, ...out });return out;}-- if (onUsage && !r.replay) onUsage({ subject, project, unit, amount: Number(amount), state: 'reserved', idempotency_key: key, at: new Date(t).toISOString() });++ if (onUsage && !r.replay) {++ // A platform.usage-sample@1 reading: pass-through only, no money fields (rating is Billing's).++ const record = { id, idempotency_key: key, service, project, subject, resource: res ?? resource, provider: prov ?? provider, region: reg ?? region,++ operation: operation || 'reserve', quantity: Number(amount), unit, at: new Date(t).toISOString(), route_epoch, trace_id, source: 'openvibe-sdk/govern' };++ for (const k of Object.keys(record)) if (record[k] == null) delete record[k];++ onUsage(record);++ }return { ok: true, id, replay: Boolean(r.replay) };}@@@ -62,7 +62,50 @@ function suite(label, make) {];}--const tests = suite('memory', async () => createGovernor({ policy, now: () => Date.parse('2026-09-28T12:00:30Z') }));++let contracts = null;++try { contracts = require('openvibe-contracts'); } catch { /* the usage-record case skips */ }++++const usageTests = [++ ['usage record: onUsage gets a platform.usage-sample@1 reading with the service, defaults and per-call overrides, and no money fields', async () => {++ const seen = [];++ const gov = createGovernor({ policy, service: 'openvibe.ai', provider: 'local', region: 'eu-1', resource: 'pool-a', log: quiet,++ now: () => Date.parse('2026-09-28T12:00:30Z'), onUsage: (r) => seen.push(r) });++ const project = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ await gov.reserve({ subject: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', unit: 'browser-second', amount: 12, key: 'usage-1:step-1', project, operation: 'render' });++ await gov.reserve({ subject: 'user:u1', unit: 'job-run', amount: 1, key: 'usage-2:step-1', provider: 'ovh', resource: 'pool-b', region: 'us-1', trace_id: 'tr-42', route_epoch: 3 });++ await gov.reserve({ subject: 'user:u1', unit: 'job-run', amount: 1, key: 'usage-2:step-1', provider: 'ovh' }); // a replay emits nothing++ assert.equal(seen.length, 2);++ const [a, b] = seen;++ assert.equal(a.service, 'openvibe.ai');++ assert.equal(a.project, project);++ assert.equal(a.operation, 'render');++ assert.equal(a.quantity, 12);++ assert.equal(a.provider, 'local');++ assert.equal(a.region, 'eu-1');++ assert.equal(a.resource, 'pool-a');++ assert.equal(a.at, '2026-09-28T12:00:30.000Z');++ assert.equal(a.idempotency_key, 'usage-1:step-1');++ assert.equal(b.provider, 'ovh'); assert.equal(b.resource, 'pool-b'); assert.equal(b.region, 'us-1');++ assert.equal(b.trace_id, 'tr-42'); assert.equal(b.route_epoch, 3);++ assert.ok(!('project' in b), 'an absent project is left out, not null');++ for (const r of seen) for (const k of ['vibes_charged', 'free_allowance_used', 'cost_estimate', 'amount', 'state']) assert.ok(!(k in r), k);++ if (!contracts) { console.log('govern usage record: skipped validation (openvibe-contracts not installed: npm install)'); return; }++ for (const r of seen) assert.deepEqual(contracts.validate('platform.usage-sample@1', r).errors, []);++ }],++ ['usage record: a governor without service still works and warns once at creation', async () => {++ const warned = [];++ const seen = [];++ const gov = createGovernor({ policy, log: { warn: (m) => warned.push(m) }, onUsage: (r) => seen.push(r) });++ assert.equal(warned.length, 1);++ assert.match(warned[0], /service/);++ assert.equal((await gov.reserve({ subject: 'user:u1', unit: 'job-run', amount: 1, key: 'no-service-1' })).ok, true);++ assert.equal(seen.length, 1);++ assert.ok(!('service' in seen[0]));++ assert.equal(warned.length, 1);++ }],++];++++const tests = suite('memory', async () => createGovernor({ policy, service: 'openvibe.test', now: () => Date.parse('2026-09-28T12:00:30Z') }));const URL = process.env.OV_TEST_VALKEY_URL;if (!URL) console.log('govern on valkey: skipped (OV_TEST_VALKEY_URL not set; scripts/test-services.sh up)');else {@@@ -70,8 +113,9 @@ else {tests.push(...suite('valkey', async () => {const v = createValkey({ url: URL, prefix: `ovsdk:gov:${crypto.randomBytes(3).toString('hex')}:`, log: quiet });opened.push(v);-- return createGovernor({ policy, valkey: v, now: () => Date.parse('2026-09-28T12:00:30Z') });++ return createGovernor({ policy, service: 'openvibe.test', valkey: v, now: () => Date.parse('2026-09-28T12:00:30Z') });}));tests.push(['valkey: close', async () => { for (const v of opened) await v.close(); }]);}++tests.push(...usageTests);run(tests);@@@ -1,4 +1,4 @@--/* Copied verbatim from openvibe-contracts v0.49.0 generated/typescript/index.d.ts (the SDK does not++/* Copied verbatim from openvibe-contracts v0.84.0 generated/typescript/index.d.ts (the SDK does not* require the package at runtime; it is a devDependency for the tests). test/types.test.js checks* these against it. *//* eslint-disable */@@@ -27,6 +27,10 @@ export type SubjectRef =| {type: "service" | "system";id: string;++ }++ | {++ type: "agent";++ id: string;};/** [email protected] (owner: network) */@@@ -51,7 +55,7 @@ export type ServiceTokenClaims = {*/cap: string[];/**-- * Namespace constraints, e.g. live.* or mod.example.*++ * Namespace constraints, e.g. live.* or mod.example.*. A developer app token carries its project_id (the whole project, as issued before namespaces were rows) and app.<project_id>.*: on Media, app.<project_id> and app.<project_id>.sandbox are the project's production and sandbox namespaces, with children below them.*/ns?: string[];iat: number;@@@ -111,7 +115,7 @@ export interface Problem {[k: string]: unknown | undefined;}--/** [email protected] (owner: network) */++/** [email protected] (owner: network) *//*** What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.*/@@@ -149,7 +153,7 @@ export interface ServiceManifest {repo?: string;};/**-- * How the network presents the service as a site people visit (the navigation, the network home page, legal pages). A service without it is not a site.++ * How the network presents the service as a site people visit (the navigation, the network home page, legal pages). A service without it is not a site. 1.1.0 (plan T11 lane D) adds the optional showcase fields copied from the OpenVibe.Sites catalog (tld, accent, description, pillars, faq, keywords, vision, highlight, launch, relationships; defined in registry.product@1): a site carrying tld is the catalog home of its domain (its publicOrigin host, or host), so that domain has no manifests/products file.*/site?: {/**@@@ -171,6 +175,1745 @@ export interface ServiceManifest {* Only while the manifest has no publicOrigin.*/host?: string;++ /**++ * The product's short name used everywhere (frame service names, /shared paths), e.g. food for openvibe.food. Not unique: a moved product keeps it on both addresses.++ */++ tld?: string;++ /**++ * Brand colour, lower-case hex.++ */++ accent?: string;++ /**++ * The catalog's description, verbatim.++ */++ description?: string;++ /**++ * Feature pillars, in display order.++ *++ * @maxItems 8++ */++ pillars?:++ | []++ | [++ {++ title: string;++ text: string;++ }++ ]++ | [++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ }++ ]++ | [++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ }++ ]++ | [++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ }++ ]++ | [++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ }++ ]++ | [++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ }++ ]++ | [++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ }++ ]++ | [++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ },++ {++ title: string;++ text: string;++ }++ ];++ /**++ * @maxItems 20++ */++ faq?:++ | []++ | [++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ]++ | [++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ },++ {++ question: string;++ answer: string;++ }++ ];++ /**++ * Comma-separated search keywords, verbatim.++ */++ keywords?: string;++ /**++ * The product page's vision section (badge, call to action, design preview, steps, choices, features, connections), kept verbatim from the catalog: a presentation payload the showcase renders as it is, not a domain contract.++ */++ vision?: {++ [k: string]: unknown | undefined;++ };++ /**++ * A featured block on the product page.++ */++ highlight?: {++ title: string;++ text: string;++ /**++ * @maxItems 10++ */++ points?:++ | []++ | [string]++ | [string, string]++ | [string, string, string]++ | [string, string, string, string]++ | [string, string, string, string, string]++ | [string, string, string, string, string, string]++ | [string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string, string];++ cta?: {++ label: string;++ url: string;++ note?: string;++ };++ more?: {++ label: string;++ url: string;++ note?: string;++ };++ };++ /**++ * What a person still has to do or decide before the product launches. Absent once nothing blocks it.++ */++ launch?: string;++ /**++ * How the product relates to services, repositories and other pages.++ */++ relationships?: {++ /**++ * The service manifest (manifests/services/<id>.json) that serves or will serve this product, when one exists.++ */++ service?: string;++ /**++ * The repository planned or used for the product (under github.com/OpenVibers).++ */++ plannedRepo?: string;++ /**++ * No repository exists for the product yet.++ */++ noRepo?: true;++ /**++ * The sentence introducing links.++ */++ linksLead?: string;++ /**++ * Where to go instead (a closed, moved or status address).++ *++ * @maxItems 10++ */++ links?:++ | []++ | [++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ];++ /**++ * What already works elsewhere on the network while the product is not launched.++ *++ * @maxItems 10++ */++ meanwhile?:++ | []++ | [++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ]++ | [++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ },++ {++ label: string;++ url: string;++ note?: string;++ }++ ];++ /**++ * The status API a status page reads.++ */++ statusApi?: string;++ };};capabilities: string[];eventsProduced: string[];@@@ -179,6 +1922,223 @@ export interface ServiceManifest {contractRanges?: {[k: string]: string | undefined;};++ /**++ * How the service starts, stops, recovers and is rolled back (roadmap WS-P task 1), taken from its code and its systemd units, never from intent. A part that does not apply is { "none": "<why>" }. `ovhost validate` refuses a host service whose lifecycle lacks a field, whose shutdown deadline exceeds its unit's TimeoutStopSec, or whose installed openvibe-contracts is outside contracts.range.++ */++ lifecycle?: {++ /**++ * The endpoint that says the process is alive, and what a 200 from it proves. Readiness (can it serve?) is `ready`.++ */++ liveness:++ | {++ /**++ * A path on internalOrigin; the same as `health`.++ */++ endpoint: string;++ /**++ * What a 200 proves, and what it does not check.++ */++ means: string;++ }++ | {++ /**++ * Why there is none.++ */++ none: string;++ };++ /**++ * What happens between the stop signal and the process exiting.++ */++ shutdown:++ | {++ /**++ * The signal the unit sends (KillSignal=, systemd's default SIGTERM) and the process handles.++ */++ signal: "SIGTERM" | "SIGINT" | "SIGQUIT" | "SIGHUP";++ /**++ * The longest the process takes from the signal to exiting, as its own code bounds it (a forced-exit timer); 0 when it exits at once. Must not exceed the unit's TimeoutStopSec, after which systemd kills it mid-drain.++ */++ deadlineSeconds: number;++ /**++ * What it stops, finishes, flushes or closes before exiting, in order.++ *++ * @minItems 1++ */++ drains: [string, ...string[]];++ /**++ * Worker units the service owns that drain on their own (ovhost never restarts them): how long a stop signal lets them run.++ */++ workers?: {++ /**++ * The longest a worker keeps running after the stop signal, as its code bounds it.++ */++ deadlineSeconds: number;++ /**++ * What a worker keeps serving, and what it ends, before exiting.++ *++ * @minItems 1++ */++ drains: [string, ...string[]];++ };++ /**++ * Anything the fields above do not say (a gap the code has, a handler that is missing).++ */++ note?: string;++ }++ | {++ /**++ * Why there is none.++ */++ none: string;++ };++ /**++ * Work a stop or a crash interrupted that the next start picks up.++ */++ startupRecovery:++ | {++ /**++ * Each thing the process resumes at boot.++ *++ * @minItems 1++ */++ resumes: [++ {++ /**++ * outbox: unsent events; jobs: queued or interrupted background work; sessions: client connections or media sessions; consumer: an event or change feed redelivered or read from a stored cursor; schedule: timed work that fell due; state: in-memory state rebuilt from storage.++ */++ kind: "outbox" | "jobs" | "sessions" | "consumer" | "schedule" | "state";++ /**++ * What is resumed, and how.++ */++ what: string;++ },++ ...{++ /**++ * outbox: unsent events; jobs: queued or interrupted background work; sessions: client connections or media sessions; consumer: an event or change feed redelivered or read from a stored cursor; schedule: timed work that fell due; state: in-memory state rebuilt from storage.++ */++ kind: "outbox" | "jobs" | "sessions" | "consumer" | "schedule" | "state";++ /**++ * What is resumed, and how.++ */++ what: string;++ }[]++ ];++ }++ | {++ /**++ * Why there is none.++ */++ none: string;++ };++ /**++ * When a release is rolled back, for how long that happens by itself, and what makes going back unsafe.++ */++ rollback:++ | {++ /**++ * What triggers a rollback: automatic (readiness not reached after the restart) and manual.++ *++ * @minItems 1++ */++ conditions: [string, ...string[]];++ /**++ * How long after a deploy the rollback is automatic, and what bounds a manual one.++ */++ window: string;++ /**++ * Changes a previous release cannot live with (forward-only migrations, table rebuilds, authority switches), or { none } with the reason.++ */++ blockers:++ | [string, ...string[]]++ | {++ /**++ * Why there is none.++ */++ none: string;++ };++ }++ | {++ /**++ * Why there is none.++ */++ none: string;++ };++ /**++ * The openvibe-contracts versions the service accepts.++ */++ contracts:++ | {++ /**++ * The accepted versions, the same range as contractRanges['openvibe-contracts'].++ */++ range: string;++ /**++ * Where the range comes from, when it is not the obvious one.++ */++ note?: string;++ }++ | {++ /**++ * Why there is none.++ */++ none: string;++ };++ /**++ * What the service claims so that one owner acts at a time, and how a stale owner is fenced.++ */++ leases:++ | {++ /**++ * Each thing claimed, with its holder, expiry and fencing.++ *++ * @minItems 1++ */++ claims: [++ {++ /**++ * What is claimed.++ */++ what: string;++ /**++ * Who holds the claim (a process, a worker id, a row).++ */++ holder: string;++ /**++ * When the claim lapses and how it is renewed.++ */++ expires: string;++ /**++ * How a stale owner is kept from acting after its claim lapsed.++ */++ fencing: string;++ },++ ...{++ /**++ * What is claimed.++ */++ what: string;++ /**++ * Who holds the claim (a process, a worker id, a row).++ */++ holder: string;++ /**++ * When the claim lapses and how it is renewed.++ */++ expires: string;++ /**++ * How a stale owner is kept from acting after its claim lapsed.++ */++ fencing: string;++ }[]++ ];++ }++ | {++ /**++ * Why there is none.++ */++ none: string;++ };++ };notes?: string;}@@@ -203,6 +2163,10 @@ export interface Capability {* Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.*/implementedBy?: string[];++ /**++ * The action has an external side effect (money, sending as the person, publishing, applying, deleting, physical control): an agent needs its owner's confirmation unless a standing rule covers it (roadmap WS-Z2).++ */++ sensitive?: boolean;}/** [email protected] (owner: events) */@@@ -216,6 +2180,7 @@ export interface EventEnvelope {version: number;source: string;actor: SubjectRef;++ on_behalf_of?: SubjectRef;timestamp: string;priority?: "critical" | "important" | "low";visibility?: "public" | "subject" | "internal";@@@ -229,7 +2194,7 @@ export interface EventEnvelope {/** [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). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged, the surviving account keeps its own record in a namespace where both have one (the other is deleted), and a record only the absorbed account had moves to the survivor. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.++ * 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). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged (ADR-029), in a namespace where both have a record the survivor keeps its own and gains only the top-level fields it lacks from the other (its own values always win), a record only the absorbed account had moves to the survivor, and the absorbed account's records are deleted. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.*/export interface ModuleNamespace {namespace: string;@@@ -6,14 +6,23 @@ export type Windowed = { minute?: number; hour?: number; day?: number; month?: nexport type GovernPolicy = Partial<Record<CostUnit, Record<string, Windowed>>>;export type Reserved = { ok: true; id: string; replay: boolean } | { ok: false; unit: CostUnit; window: string; limit: number; used: number; retryAfterS: number };export interface Governor {-- reserve(o: { subject: string; tier?: string; unit: CostUnit; amount: number; key: string; project?: string | null }): Promise<Reserved>;++ reserve(o: { subject: string; tier?: string; unit: CostUnit; amount: number; key: string; project?: string | null;++ operation?: string; provider?: string; resource?: string; region?: string; trace_id?: string; route_epoch?: number }): Promise<Reserved>;commit(id: string, actual: number): Promise<boolean>;release(id: string): Promise<boolean>;usage(o: { subject: string; tier?: string; unit: CostUnit; project?: string | null }): Promise<Record<string, { used: number; limit: number }>>;lease(o: { subject: string; kind: string; max: number; ttlMs?: number }): Promise<{ ok: true; id: string; release(): Promise<void> } | { ok: false; retryAfterS: number }>;policy: GovernPolicy;}++/** What onUsage gets on a new reservation: a platform.usage-sample@1 reading (no money fields; rating is Billing's). */++export interface UsageRecord {++ id: string; idempotency_key: string; service?: string; project?: string; subject: string; resource?: string; provider?: string; region?: string;++ operation: string; quantity: number; unit: CostUnit; at: string; route_epoch?: number; trace_id?: string; source: string;++}export declare function createGovernor(opts?: { policy?: GovernPolicy; valkey?: Valkey | null; now?: () => number; reservationTtlMs?: number;-- onRefused?: (e: object) => void; onUsage?: (e: object) => void }): Governor;++ onRefused?: (e: object) => void; onUsage?: (e: UsageRecord) => void;++ /** The spending service, e.g. 'openvibe.ai'. Without it a warning is logged and records lack `service`. */++ service?: string | null; provider?: string | null; resource?: string | null; region?: string | null;++ log?: { warn(...a: unknown[]): void } }): Governor;export declare const WINDOWS: Record<'minute' | 'hour' | 'day' | 'month', number>;export declare const UNITS: CostUnit[];
@@ -7,6 +7,9 @@ release may change an API and says so here.
@@ -7,6 +7,9 @@ release may change an API and says so here.
+## Unreleased
+
+- **`openvibe-sdk/govern` emits a full usage record** (plan T5). `createGovernor({ service, provider?, resource?, region? })`; `reserve()` takes optional `operation`, `provider`, `resource`, `region`, `trace_id` and `route_epoch`, which override the defaults. `onUsage` now gets a `platform.usage-sample@1` reading: `id` (the reservation id), `idempotency_key`, `service`, `project`, `subject`, `resource`, `provider`, `region`, `operation` (default `reserve`), `quantity`, `unit`, `at`, `route_epoch`, `trace_id`, `source` (`openvibe-sdk/govern`); absent values are left out. **Changed:** `amount` is now `quantity` and `state` is gone (the schema allows neither; only new reservations are emitted). No money fields: rating is Billing's. A governor created without `service` still works and logs one warning (`log`, default `console`). Tested against openvibe-contracts 0.84.0.
## 0.26.0 (2026-10-01)
## 0.26.0 (2026-10-01)
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -459,9 +459,8 @@
@@ -459,9 +459,8 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.84.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
"dev": true,
"dev": true,
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
@@ -228,7 +228,7 @@
@@ -228,7 +228,7 @@
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"peerDependencies": {
"peerDependencies": {
@@ -138,10 +138,16 @@ function valkeyStore(valkey) {
@@ -138,10 +138,16 @@ function valkeyStore(valkey) {
// ── The governor ─────────────────────────────────────────────────────────────
// ── The governor ─────────────────────────────────────────────────────────────
-function createGovernor({ policy = {}, valkey = null, now = () => Date.now(), reservationTtlMs = 6 * 3600e3, onRefused = null, onUsage = null } = {}) {
+/**
+ * `service` names the service that spends ('openvibe.ai'); every usage record carries it (platform.usage-sample@1 requires it).
+ * `provider`, `resource` and `region` are defaults for the records; reserve() can override each.
+ */
+function createGovernor({ policy = {}, valkey = null, now = () => Date.now(), reservationTtlMs = 6 * 3600e3, onRefused = null, onUsage = null,
+ service = null, provider = null, resource = null, region = null, log = console } = {}) {
const store = valkey ? valkeyStore(valkey) : memoryStore();
const store = valkey ? valkeyStore(valkey) : memoryStore();
+ if (!service) log.warn('[govern] createGovernor() without `service`: usage records lack the service platform.usage-sample@1 requires');
- async function reserve({ subject, tier = 'user', unit, amount, key, project = null }) {
+ async function reserve({ subject, tier = 'user', unit, amount, key, project = null, operation, provider: prov, resource: res, region: reg, trace_id, route_epoch }) {
if (!UNITS.includes(unit)) throw new TypeError(`govern: unknown unit ${unit}`);
if (!UNITS.includes(unit)) throw new TypeError(`govern: unknown unit ${unit}`);
if (!(amount >= 0)) throw new TypeError('govern: amount must be ≥ 0');
if (!(amount >= 0)) throw new TypeError('govern: amount must be ≥ 0');
if (!key || String(key).length < 8) throw new TypeError('govern: an idempotency key of 8+ characters is required');
if (!key || String(key).length < 8) throw new TypeError('govern: an idempotency key of 8+ characters is required');
@@ -155,7 +161,13 @@ function createGovernor({ policy = {}, valkey = null, now = () => Date.now(), re
@@ -155,7 +161,13 @@ function createGovernor({ policy = {}, valkey = null, now = () => Date.now(), re
if (onRefused) onRefused({ subject, tier, unit, amount, ...out });
if (onRefused) onRefused({ subject, tier, unit, amount, ...out });
return out;
return out;
}
}
- if (onUsage && !r.replay) onUsage({ subject, project, unit, amount: Number(amount), state: 'reserved', idempotency_key: key, at: new Date(t).toISOString() });
+ if (onUsage && !r.replay) {
+ // A platform.usage-sample@1 reading: pass-through only, no money fields (rating is Billing's).
+ const record = { id, idempotency_key: key, service, project, subject, resource: res ?? resource, provider: prov ?? provider, region: reg ?? region,
+ operation: operation || 'reserve', quantity: Number(amount), unit, at: new Date(t).toISOString(), route_epoch, trace_id, source: 'openvibe-sdk/govern' };
+ for (const k of Object.keys(record)) if (record[k] == null) delete record[k];
+ onUsage(record);
+ }
return { ok: true, id, replay: Boolean(r.replay) };
return { ok: true, id, replay: Boolean(r.replay) };
}
}
@@ -62,7 +62,50 @@ function suite(label, make) {
@@ -62,7 +62,50 @@ function suite(label, make) {
];
];
}
}
-const tests = suite('memory', async () => createGovernor({ policy, now: () => Date.parse('2026-09-28T12:00:30Z') }));
+let contracts = null;
+try { contracts = require('openvibe-contracts'); } catch { /* the usage-record case skips */ }
+
+const usageTests = [
+ ['usage record: onUsage gets a platform.usage-sample@1 reading with the service, defaults and per-call overrides, and no money fields', async () => {
+ const seen = [];
+ const gov = createGovernor({ policy, service: 'openvibe.ai', provider: 'local', region: 'eu-1', resource: 'pool-a', log: quiet,
+ now: () => Date.parse('2026-09-28T12:00:30Z'), onUsage: (r) => seen.push(r) });
+ const project = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ await gov.reserve({ subject: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', unit: 'browser-second', amount: 12, key: 'usage-1:step-1', project, operation: 'render' });
+ await gov.reserve({ subject: 'user:u1', unit: 'job-run', amount: 1, key: 'usage-2:step-1', provider: 'ovh', resource: 'pool-b', region: 'us-1', trace_id: 'tr-42', route_epoch: 3 });
+ await gov.reserve({ subject: 'user:u1', unit: 'job-run', amount: 1, key: 'usage-2:step-1', provider: 'ovh' }); // a replay emits nothing
+ assert.equal(seen.length, 2);
+ const [a, b] = seen;
+ assert.equal(a.service, 'openvibe.ai');
+ assert.equal(a.project, project);
+ assert.equal(a.operation, 'render');
+ assert.equal(a.quantity, 12);
+ assert.equal(a.provider, 'local');
+ assert.equal(a.region, 'eu-1');
+ assert.equal(a.resource, 'pool-a');
+ assert.equal(a.at, '2026-09-28T12:00:30.000Z');
+ assert.equal(a.idempotency_key, 'usage-1:step-1');
+ assert.equal(b.provider, 'ovh'); assert.equal(b.resource, 'pool-b'); assert.equal(b.region, 'us-1');
+ assert.equal(b.trace_id, 'tr-42'); assert.equal(b.route_epoch, 3);
+ assert.ok(!('project' in b), 'an absent project is left out, not null');
+ for (const r of seen) for (const k of ['vibes_charged', 'free_allowance_used', 'cost_estimate', 'amount', 'state']) assert.ok(!(k in r), k);
+ if (!contracts) { console.log('govern usage record: skipped validation (openvibe-contracts not installed: npm install)'); return; }
+ for (const r of seen) assert.deepEqual(contracts.validate('platform.usage-sample@1', r).errors, []);
+ }],
+ ['usage record: a governor without service still works and warns once at creation', async () => {
+ const warned = [];
+ const seen = [];
+ const gov = createGovernor({ policy, log: { warn: (m) => warned.push(m) }, onUsage: (r) => seen.push(r) });
+ assert.equal(warned.length, 1);
+ assert.match(warned[0], /service/);
+ assert.equal((await gov.reserve({ subject: 'user:u1', unit: 'job-run', amount: 1, key: 'no-service-1' })).ok, true);
+ assert.equal(seen.length, 1);
+ assert.ok(!('service' in seen[0]));
+ assert.equal(warned.length, 1);
+ }],
+];
+
+const tests = suite('memory', async () => createGovernor({ policy, service: 'openvibe.test', now: () => Date.parse('2026-09-28T12:00:30Z') }));
const URL = process.env.OV_TEST_VALKEY_URL;
const URL = process.env.OV_TEST_VALKEY_URL;
if (!URL) console.log('govern on valkey: skipped (OV_TEST_VALKEY_URL not set; scripts/test-services.sh up)');
if (!URL) console.log('govern on valkey: skipped (OV_TEST_VALKEY_URL not set; scripts/test-services.sh up)');
else {
else {
@@ -70,8 +113,9 @@ else {
@@ -70,8 +113,9 @@ else {
tests.push(...suite('valkey', async () => {
tests.push(...suite('valkey', async () => {
const v = createValkey({ url: URL, prefix: `ovsdk:gov:${crypto.randomBytes(3).toString('hex')}:`, log: quiet });
const v = createValkey({ url: URL, prefix: `ovsdk:gov:${crypto.randomBytes(3).toString('hex')}:`, log: quiet });
opened.push(v);
opened.push(v);
- return createGovernor({ policy, valkey: v, now: () => Date.parse('2026-09-28T12:00:30Z') });
+ return createGovernor({ policy, service: 'openvibe.test', valkey: v, now: () => Date.parse('2026-09-28T12:00:30Z') });
}));
}));
tests.push(['valkey: close', async () => { for (const v of opened) await v.close(); }]);
tests.push(['valkey: close', async () => { for (const v of opened) await v.close(); }]);
}
}
+tests.push(...usageTests);
run(tests);
run(tests);
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-/* Copied verbatim from openvibe-contracts v0.49.0 generated/typescript/index.d.ts (the SDK does not
+/* Copied verbatim from openvibe-contracts v0.84.0 generated/typescript/index.d.ts (the SDK does not
* require the package at runtime; it is a devDependency for the tests). test/types.test.js checks
* require the package at runtime; it is a devDependency for the tests). test/types.test.js checks
* these against it. */
* these against it. */
/* eslint-disable */
/* eslint-disable */
@@ -27,6 +27,10 @@ export type SubjectRef =
@@ -27,6 +27,10 @@ export type SubjectRef =
| {
| {
type: "service" | "system";
type: "service" | "system";
id: string;
id: string;
+ }
+ | {
+ type: "agent";
+ id: string;
};
};
/** [email protected] (owner: network) */
/** [email protected] (owner: network) */
@@ -51,7 +55,7 @@ export type ServiceTokenClaims = {
@@ -51,7 +55,7 @@ export type ServiceTokenClaims = {
*/
*/
cap: string[];
cap: string[];
/**
/**
- * Namespace constraints, e.g. live.* or mod.example.*
+ * Namespace constraints, e.g. live.* or mod.example.*. A developer app token carries its project_id (the whole project, as issued before namespaces were rows) and app.<project_id>.*: on Media, app.<project_id> and app.<project_id>.sandbox are the project's production and sandbox namespaces, with children below them.
*/
*/
ns?: string[];
ns?: string[];
iat: number;
iat: number;
@@ -111,7 +115,7 @@ export interface Problem {
@@ -111,7 +115,7 @@ export interface Problem {
[k: string]: unknown | undefined;
[k: string]: unknown | undefined;
}
}
-/** [email protected] (owner: network) */
+/** [email protected] (owner: network) */
/**
/**
* What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.
* What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.
*/
*/
@@ -149,7 +153,7 @@ export interface ServiceManifest {
@@ -149,7 +153,7 @@ export interface ServiceManifest {
repo?: string;
repo?: string;
};
};
/**
/**
- * How the network presents the service as a site people visit (the navigation, the network home page, legal pages). A service without it is not a site.
+ * How the network presents the service as a site people visit (the navigation, the network home page, legal pages). A service without it is not a site. 1.1.0 (plan T11 lane D) adds the optional showcase fields copied from the OpenVibe.Sites catalog (tld, accent, description, pillars, faq, keywords, vision, highlight, launch, relationships; defined in registry.product@1): a site carrying tld is the catalog home of its domain (its publicOrigin host, or host), so that domain has no manifests/products file.
*/
*/
site?: {
site?: {
/**
/**
@@ -171,6 +175,1745 @@ export interface ServiceManifest {
@@ -171,6 +175,1745 @@ export interface ServiceManifest {
* Only while the manifest has no publicOrigin.
* Only while the manifest has no publicOrigin.
*/
*/
host?: string;
host?: string;
+ /**
+ * The product's short name used everywhere (frame service names, /shared paths), e.g. food for openvibe.food. Not unique: a moved product keeps it on both addresses.
+ */
+ tld?: string;
+ /**
+ * Brand colour, lower-case hex.
+ */
+ accent?: string;
+ /**
+ * The catalog's description, verbatim.
+ */
+ description?: string;
+ /**
+ * Feature pillars, in display order.
+ *
+ * @maxItems 8
+ */
+ pillars?:
+ | []
+ | [
+ {
+ title: string;
+ text: string;
+ }
+ ]
+ | [
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ }
+ ]
+ | [
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ }
+ ]
+ | [
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ }
+ ]
+ | [
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ }
+ ]
+ | [
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ }
+ ]
+ | [
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ }
+ ]
+ | [
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ },
+ {
+ title: string;
+ text: string;
+ }
+ ];
+ /**
+ * @maxItems 20
+ */
+ faq?:
+ | []
+ | [
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ]
+ | [
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ },
+ {
+ question: string;
+ answer: string;
+ }
+ ];
+ /**
+ * Comma-separated search keywords, verbatim.
+ */
+ keywords?: string;
+ /**
+ * The product page's vision section (badge, call to action, design preview, steps, choices, features, connections), kept verbatim from the catalog: a presentation payload the showcase renders as it is, not a domain contract.
+ */
+ vision?: {
+ [k: string]: unknown | undefined;
+ };
+ /**
+ * A featured block on the product page.
+ */
+ highlight?: {
+ title: string;
+ text: string;
+ /**
+ * @maxItems 10
+ */
+ points?:
+ | []
+ | [string]
+ | [string, string]
+ | [string, string, string]
+ | [string, string, string, string]
+ | [string, string, string, string, string]
+ | [string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string, string];
+ cta?: {
+ label: string;
+ url: string;
+ note?: string;
+ };
+ more?: {
+ label: string;
+ url: string;
+ note?: string;
+ };
+ };
+ /**
+ * What a person still has to do or decide before the product launches. Absent once nothing blocks it.
+ */
+ launch?: string;
+ /**
+ * How the product relates to services, repositories and other pages.
+ */
+ relationships?: {
+ /**
+ * The service manifest (manifests/services/<id>.json) that serves or will serve this product, when one exists.
+ */
+ service?: string;
+ /**
+ * The repository planned or used for the product (under github.com/OpenVibers).
+ */
+ plannedRepo?: string;
+ /**
+ * No repository exists for the product yet.
+ */
+ noRepo?: true;
+ /**
+ * The sentence introducing links.
+ */
+ linksLead?: string;
+ /**
+ * Where to go instead (a closed, moved or status address).
+ *
+ * @maxItems 10
+ */
+ links?:
+ | []
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ];
+ /**
+ * What already works elsewhere on the network while the product is not launched.
+ *
+ * @maxItems 10
+ */
+ meanwhile?:
+ | []
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ]
+ | [
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ },
+ {
+ label: string;
+ url: string;
+ note?: string;
+ }
+ ];
+ /**
+ * The status API a status page reads.
+ */
+ statusApi?: string;
+ };
};
};
capabilities: string[];
capabilities: string[];
eventsProduced: string[];
eventsProduced: string[];
@@ -179,6 +1922,223 @@ export interface ServiceManifest {
@@ -179,6 +1922,223 @@ export interface ServiceManifest {
contractRanges?: {
contractRanges?: {
[k: string]: string | undefined;
[k: string]: string | undefined;
};
};
+ /**
+ * How the service starts, stops, recovers and is rolled back (roadmap WS-P task 1), taken from its code and its systemd units, never from intent. A part that does not apply is { "none": "<why>" }. `ovhost validate` refuses a host service whose lifecycle lacks a field, whose shutdown deadline exceeds its unit's TimeoutStopSec, or whose installed openvibe-contracts is outside contracts.range.
+ */
+ lifecycle?: {
+ /**
+ * The endpoint that says the process is alive, and what a 200 from it proves. Readiness (can it serve?) is `ready`.
+ */
+ liveness:
+ | {
+ /**
+ * A path on internalOrigin; the same as `health`.
+ */
+ endpoint: string;
+ /**
+ * What a 200 proves, and what it does not check.
+ */
+ means: string;
+ }
+ | {
+ /**
+ * Why there is none.
+ */
+ none: string;
+ };
+ /**
+ * What happens between the stop signal and the process exiting.
+ */
+ shutdown:
+ | {
+ /**
+ * The signal the unit sends (KillSignal=, systemd's default SIGTERM) and the process handles.
+ */
+ signal: "SIGTERM" | "SIGINT" | "SIGQUIT" | "SIGHUP";
+ /**
+ * The longest the process takes from the signal to exiting, as its own code bounds it (a forced-exit timer); 0 when it exits at once. Must not exceed the unit's TimeoutStopSec, after which systemd kills it mid-drain.
+ */
+ deadlineSeconds: number;
+ /**
+ * What it stops, finishes, flushes or closes before exiting, in order.
+ *
+ * @minItems 1
+ */
+ drains: [string, ...string[]];
+ /**
+ * Worker units the service owns that drain on their own (ovhost never restarts them): how long a stop signal lets them run.
+ */
+ workers?: {
+ /**
+ * The longest a worker keeps running after the stop signal, as its code bounds it.
+ */
+ deadlineSeconds: number;
+ /**
+ * What a worker keeps serving, and what it ends, before exiting.
+ *
+ * @minItems 1
+ */
+ drains: [string, ...string[]];
+ };
+ /**
+ * Anything the fields above do not say (a gap the code has, a handler that is missing).
+ */
+ note?: string;
+ }
+ | {
+ /**
+ * Why there is none.
+ */
+ none: string;
+ };
+ /**
+ * Work a stop or a crash interrupted that the next start picks up.
+ */
+ startupRecovery:
+ | {
+ /**
+ * Each thing the process resumes at boot.
+ *
+ * @minItems 1
+ */
+ resumes: [
+ {
+ /**
+ * outbox: unsent events; jobs: queued or interrupted background work; sessions: client connections or media sessions; consumer: an event or change feed redelivered or read from a stored cursor; schedule: timed work that fell due; state: in-memory state rebuilt from storage.
+ */
+ kind: "outbox" | "jobs" | "sessions" | "consumer" | "schedule" | "state";
+ /**
+ * What is resumed, and how.
+ */
+ what: string;
+ },
+ ...{
+ /**
+ * outbox: unsent events; jobs: queued or interrupted background work; sessions: client connections or media sessions; consumer: an event or change feed redelivered or read from a stored cursor; schedule: timed work that fell due; state: in-memory state rebuilt from storage.
+ */
+ kind: "outbox" | "jobs" | "sessions" | "consumer" | "schedule" | "state";
+ /**
+ * What is resumed, and how.
+ */
+ what: string;
+ }[]
+ ];
+ }
+ | {
+ /**
+ * Why there is none.
+ */
+ none: string;
+ };
+ /**
+ * When a release is rolled back, for how long that happens by itself, and what makes going back unsafe.
+ */
+ rollback:
+ | {
+ /**
+ * What triggers a rollback: automatic (readiness not reached after the restart) and manual.
+ *
+ * @minItems 1
+ */
+ conditions: [string, ...string[]];
+ /**
+ * How long after a deploy the rollback is automatic, and what bounds a manual one.
+ */
+ window: string;
+ /**
+ * Changes a previous release cannot live with (forward-only migrations, table rebuilds, authority switches), or { none } with the reason.
+ */
+ blockers:
+ | [string, ...string[]]
+ | {
+ /**
+ * Why there is none.
+ */
+ none: string;
+ };
+ }
+ | {
+ /**
+ * Why there is none.
+ */
+ none: string;
+ };
+ /**
+ * The openvibe-contracts versions the service accepts.
+ */
+ contracts:
+ | {
+ /**
+ * The accepted versions, the same range as contractRanges['openvibe-contracts'].
+ */
+ range: string;
+ /**
+ * Where the range comes from, when it is not the obvious one.
+ */
+ note?: string;
+ }
+ | {
+ /**
+ * Why there is none.
+ */
+ none: string;
+ };
+ /**
+ * What the service claims so that one owner acts at a time, and how a stale owner is fenced.
+ */
+ leases:
+ | {
+ /**
+ * Each thing claimed, with its holder, expiry and fencing.
+ *
+ * @minItems 1
+ */
+ claims: [
+ {
+ /**
+ * What is claimed.
+ */
+ what: string;
+ /**
+ * Who holds the claim (a process, a worker id, a row).
+ */
+ holder: string;
+ /**
+ * When the claim lapses and how it is renewed.
+ */
+ expires: string;
+ /**
+ * How a stale owner is kept from acting after its claim lapsed.
+ */
+ fencing: string;
+ },
+ ...{
+ /**
+ * What is claimed.
+ */
+ what: string;
+ /**
+ * Who holds the claim (a process, a worker id, a row).
+ */
+ holder: string;
+ /**
+ * When the claim lapses and how it is renewed.
+ */
+ expires: string;
+ /**
+ * How a stale owner is kept from acting after its claim lapsed.
+ */
+ fencing: string;
+ }[]
+ ];
+ }
+ | {
+ /**
+ * Why there is none.
+ */
+ none: string;
+ };
+ };
notes?: string;
notes?: string;
}
}
@@ -203,6 +2163,10 @@ export interface Capability {
@@ -203,6 +2163,10 @@ export interface Capability {
* Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.
* Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.
*/
*/
implementedBy?: string[];
implementedBy?: string[];
+ /**
+ * The action has an external side effect (money, sending as the person, publishing, applying, deleting, physical control): an agent needs its owner's confirmation unless a standing rule covers it (roadmap WS-Z2).
+ */
+ sensitive?: boolean;
}
}
/** [email protected] (owner: events) */
/** [email protected] (owner: events) */
@@ -216,6 +2180,7 @@ export interface EventEnvelope {
@@ -216,6 +2180,7 @@ export interface EventEnvelope {
version: number;
version: number;
source: string;
source: string;
actor: SubjectRef;
actor: SubjectRef;
+ on_behalf_of?: SubjectRef;
timestamp: string;
timestamp: string;
priority?: "critical" | "important" | "low";
priority?: "critical" | "important" | "low";
visibility?: "public" | "subject" | "internal";
visibility?: "public" | "subject" | "internal";
@@ -229,7 +2194,7 @@ export interface EventEnvelope {
@@ -229,7 +2194,7 @@ export interface EventEnvelope {
/** [email protected] (owner: network) */
/** [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). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged, the surviving account keeps its own record in a namespace where both have one (the other is deleted), and a record only the absorbed account had moves to the survivor. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.
+ * 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). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged (ADR-029), in a namespace where both have a record the survivor keeps its own and gains only the top-level fields it lacks from the other (its own values always win), a record only the absorbed account had moves to the survivor, and the absorbed account's records are deleted. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.
*/
*/
export interface ModuleNamespace {
export interface ModuleNamespace {
namespace: string;
namespace: string;
@@ -6,14 +6,23 @@ export type Windowed = { minute?: number; hour?: number; day?: number; month?: n
@@ -6,14 +6,23 @@ export type Windowed = { minute?: number; hour?: number; day?: number; month?: n
export type GovernPolicy = Partial<Record<CostUnit, Record<string, Windowed>>>;
export type GovernPolicy = Partial<Record<CostUnit, Record<string, Windowed>>>;
export type Reserved = { ok: true; id: string; replay: boolean } | { ok: false; unit: CostUnit; window: string; limit: number; used: number; retryAfterS: number };
export type Reserved = { ok: true; id: string; replay: boolean } | { ok: false; unit: CostUnit; window: string; limit: number; used: number; retryAfterS: number };
export interface Governor {
export interface Governor {
- reserve(o: { subject: string; tier?: string; unit: CostUnit; amount: number; key: string; project?: string | null }): Promise<Reserved>;
+ reserve(o: { subject: string; tier?: string; unit: CostUnit; amount: number; key: string; project?: string | null;
+ operation?: string; provider?: string; resource?: string; region?: string; trace_id?: string; route_epoch?: number }): Promise<Reserved>;
commit(id: string, actual: number): Promise<boolean>;
commit(id: string, actual: number): Promise<boolean>;
release(id: string): Promise<boolean>;
release(id: string): Promise<boolean>;
usage(o: { subject: string; tier?: string; unit: CostUnit; project?: string | null }): Promise<Record<string, { used: number; limit: number }>>;
usage(o: { subject: string; tier?: string; unit: CostUnit; project?: string | null }): Promise<Record<string, { used: number; limit: number }>>;
lease(o: { subject: string; kind: string; max: number; ttlMs?: number }): Promise<{ ok: true; id: string; release(): Promise<void> } | { ok: false; retryAfterS: number }>;
lease(o: { subject: string; kind: string; max: number; ttlMs?: number }): Promise<{ ok: true; id: string; release(): Promise<void> } | { ok: false; retryAfterS: number }>;
policy: GovernPolicy;
policy: GovernPolicy;
}
}
+/** What onUsage gets on a new reservation: a platform.usage-sample@1 reading (no money fields; rating is Billing's). */
+export interface UsageRecord {
+ id: string; idempotency_key: string; service?: string; project?: string; subject: string; resource?: string; provider?: string; region?: string;
+ operation: string; quantity: number; unit: CostUnit; at: string; route_epoch?: number; trace_id?: string; source: string;
+}
export declare function createGovernor(opts?: { policy?: GovernPolicy; valkey?: Valkey | null; now?: () => number; reservationTtlMs?: number;
export declare function createGovernor(opts?: { policy?: GovernPolicy; valkey?: Valkey | null; now?: () => number; reservationTtlMs?: number;
- onRefused?: (e: object) => void; onUsage?: (e: object) => void }): Governor;
+ onRefused?: (e: object) => void; onUsage?: (e: UsageRecord) => void;
+ /** The spending service, e.g. 'openvibe.ai'. Without it a warning is logged and records lack `service`. */
+ service?: string | null; provider?: string | null; resource?: string | null; region?: string | null;
+ log?: { warn(...a: unknown[]): void } }): Governor;
export declare const WINDOWS: Record<'minute' | 'hour' | 'day' | 'month', number>;
export declare const WINDOWS: Record<'minute' | 'hour' | 'day' | 'month', number>;
export declare const UNITS: CostUnit[];
export declare const UNITS: CostUnit[];