openvibers/openvibe.sdk Commits

Emit full platform.usage-sample record from govern onUsage with service (#2)

OP OpenVibers committed 2 Oct 2026 · 930cafe742 · 7 files, +2049 −17

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.SDK/pull/2

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a5eb60d..d2e6a3b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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)
ddiff --git a/package-lock.json b/package-lock.json
iindex fd9e59d..fadad71 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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": {
ddiff --git a/package.json b/package.json
iindex 41795b6..7c5c794 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/src/govern.js b/src/govern.js
iindex 6b0505a..659cde4 100644
---- a/src/govern.js
++++ b/src/govern.js
@@@ -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(), re
if (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) };
}
ddiff --git a/test/govern.test.js b/test/govern.test.js
iindex 0188f52..f6fbb58 100644
---- a/test/govern.test.js
++++ b/test/govern.test.js
@@@ -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);
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex 696065e..739da95 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -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;
ddiff --git a/types/govern.d.ts b/types/govern.d.ts
iindex dcdc87b..34db015 100644
---- a/types/govern.d.ts
++++ b/types/govern.d.ts
@@@ -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 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[];