openvibers/openvibe.network Commits

Document that usage rollups are counts, never money, and guard it

OP OpenVibers committed 4 Oct 2026 · 5c18e5e6b9 · 5 files, +12 −0

Asked: T5 step 8, N2 (docs and one guard test, no behaviour change). ONE pull request from current origin/main (git fetch first). Design: /home/workstation/openvibe/agents/jobs/network-t5-step-8-design-170122.out.md section 3 rule 2 and section 4 row N2. Do not touch server/identity/principals.js (PR 41 c…
Harness job network-t5-step-8-n2-docs-171308.

Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 07dac3ce..65aafaa1 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -139,6 +139,8 @@ A quota says how much of one capability a project may use: for example, `media.o
The services that own developer capabilities count each project's use per environment and UTC hour, and send one rollup per closed hour through OpenVibe.Events (roadmap WS-N task 4): `tools.usage.recorded` from each Tools satellite (jobs, per `tools.job.create` or `tools.tool.run` and job type or tool) and `events.usage.recorded` from Events (`events.app.publish` in `events`, `events.app.subscribe` in `deliveries`). The payload is `common.usage-recorded@1` (openvibe-contracts 0.63.0): project, environment, capability, dimension, unit, window, quantity, errors, errors by code and up to ten sampled failures (time, code, status, trace id, job or event id). A rollup never names who did the work.
++Rollups are counts for quotas and dashboards, never money. Network never ingests the `platform.usage-sample` readings and stores no subject with a count. Money lives only in Billing.
++
The Events consumer (`POST /internal/events`, [server/developer/usage.js](../server/developer/usage.js)) records each one inside its inbox transaction:
- `dev_usage_windows`: one row per rollup key (service, project, environment, capability, dimension, unit, window start). A later rollup for the same key replaces it (the higher `revision` wins), so a re-sent hour is never counted twice. Kept 35 days.
ddiff --git a/server/developer/usage.js b/server/developer/usage.js
iindex d6ab7578..c1875df6 100644
---- a/server/developer/usage.js
++++ b/server/developer/usage.js
@@@ -14,6 +14,9 @@
* twice. The failures a rollup samples (time, code, status, trace id, job or event id) are kept for
* the dashboard's recent errors. Nothing here names who did the work.
*
++ * Rollups are counts for quotas and dashboards, never money. Network never ingests the
++ * platform.usage-sample readings and stores no subject with a count; money lives only in Billing.
++ *
* dev_usage_windows one row per rollup key (WINDOW_DAYS, 35 days)
* dev_usage_daily per project, env, service, capability, dimension, unit and UTC day (DAILY_DAYS, 400 days)
* dev_usage_errors sampled failures (ERROR_DAYS, 30 days; the newest ERRORS_PER_PROJECT per project)
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 08b8a6fe..57df9a12 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -245,6 +245,8 @@ const DEFAULT_GRANTS = [
...['live', 'chat', 'community'].flatMap(c => ['tools.tool.run', 'tools.job.read'].map(cap => [c, cap, 'openvibe.tools', []])),
// Tools indexes its own tool pages in Search through the owner API (owner tools only).
['tools', 'search.document.write', 'openvibe.search', []],
++ // Plan T5 step 7: Tools posts one usage reading per ended tool job to Billing (POST /api/v1/usage).
++ ['tools', 'billing.usage.record', 'openvibe.billing', []],
];
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 8fc7b1be..93b2c91e 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -162,6 +162,8 @@ const server = http.createServer(app);
await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
const ts = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.search' });
assert.strictEqual(ts.body.scope, 'search.document.write', 'Tools indexes its tools in Search');
++ const tb = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.billing' });
++ assert.strictEqual(tb.body.scope, 'billing.usage.record', 'Tools posts usage readings to Billing and holds no money capability');
// Coupons subscribes to its Events sources with its own token (OpenVibe.Coupons scripts/subscribe.js).
await db.prepare("UPDATE oauth_clients SET client_secret = 'coupons-secret' WHERE client_id = 'coupons'").run();
const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });
ddiff --git a/test/project-usage.test.js b/test/project-usage.test.js
iindex 82714232..f8b8b077 100644
---- a/test/project-usage.test.js
++++ b/test/project-usage.test.js
@@@ -83,6 +83,9 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
// ── The consumer subscribes to both rollups, and records them ──
assert.ok(TOPICS.includes('tools.usage.recorded') && TOPICS.includes('events.usage.recorded'));
++ // Rollups are counts, never money: the consumer takes no billing topic and no raw usage sample.
++ // billing.staff.action is the one exception: a staff audit record (server/admin/moderation-audit.js), no amounts.
++ assert.deepStrictEqual(TOPICS.filter((t) => (t.startsWith('billing.') && t !== 'billing.staff.action') || t.includes('usage-sample')), []);
assert.deepStrictEqual(topicsFrom(['--topic', 'tools.usage.recorded', '--topic', 'events.usage.recorded']), ['tools.usage.recorded', 'events.usage.recorded']);
const rollup = (svc, payload, over = {}) => ({
event_id: ids.newId('event'), event_type: `${svc}.usage.recorded`, version: 1, source: svc, actor: { type: 'service', id: svc },