openvibers/openvibe.billing Commits

Record usage readings under billing.usage.record (#6)

OP OpenVibers committed 4 Oct 2026 · 75f86b6256 · 8 files, +540 −12

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Billing/pull/6

Changes

ddiff --git a/README.md b/README.md
iindex 7fae8f4..c823c90 100644
---- a/README.md
++++ b/README.md
@@@ -48,7 +48,7 @@ webhooks land here and nowhere else.
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Events (the outbox relay, only when `EVENTS_URL` is set)
- the payment providers whose secrets are configured (none in production today)
--- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v2.5.0, pinned by release tarball
++- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v2.5.0, pinned by release tarball
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
@@@ -57,7 +57,8 @@ webhooks land here and nowhere else.
Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under
[API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`,
`billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`,
--`billing.entitlement.check` and `billing.ledger.admin`. Callers today are Live (after the cutover),
++`billing.entitlement.check`, `billing.ledger.admin` and `billing.usage.record` (usage readings; released in
++Contracts v0.85.0). Callers today are Live (after the cutover),
Tips (`billing.intent.create`, `billing.transfer.create`) and VIP (`billing.intent.create`,
`billing.subscription.manage`, `billing.entitlement.check`).
@@@ -150,6 +151,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing |
| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, payable_value_cents}` |
| `GET /api/v1/transactions?subject=&cursor=&limit=`, `GET …/:id` | `billing.balance.read` | history (cursor paging), one transaction with `reversed_by` |
++| `POST /api/v1/usage` | `billing.usage.record` | store a `platform.usage-sample@1` reading (charges nothing; accepted while frozen); idempotent by the reading's `idempotency_key`: same reading → 200 with the stored row (`Idempotent-Replayed: true`), a different one → 409 `billing.usage_key_reused`; no `Idempotency-Key` header |
++| `GET /api/v1/usage?project=&subject=&service=&from=&to=&limit=&cursor=` | `billing.ledger.admin` | readings newest first, `from` ≤ `at` < `to`, cursor paging (`limit` ≤ 500) |
| `GET\|POST /api/v1/admin/freeze` | `billing.ledger.admin` | read / set `{on, reason?}`; unfreeze drains queued webhooks |
| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation |
| `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report |
@@@ -160,11 +163,15 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` |
| `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host |
--The capabilities and the service manifest are released in openvibe-contracts (v0.8.0;
--`billing.staff.action` since v0.17.0); the drafts they came from stay in
--[docs/capabilities-proposal/](docs/capabilities-proposal/) and
--[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched with
--contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
++The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0;
++`billing.staff.action` since v0.17.0). `billing.usage.record` is released in Contracts v0.85.0: input is
++`platform.usage-sample@1`; output is `billing.usage-record-result@1` =
++`{ record: { id, idempotency_key, project|null, subject|null, service, at, received_at, principal, reading } }`,
++201 when stored and 200 with `Idempotent-Replayed: true` on a replay; `GET /api/v1/usage` needs
++`billing.ledger.admin` and returns `{ records, next_cursor }`. Network grants `billing.usage.record`
++(audience `openvibe.billing`) to each producer service in `server/identity/principals.js`, and no service
++holds it yet: the first producer gets the grant in the same change that wires its calls. Grants are matched
++with contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
### Per-person limits
ddiff --git a/docs/cutover-pr-openvibe-billing-6.md b/docs/cutover-pr-openvibe-billing-6.md
nnew file mode 100644
iindex 0000000..cf1dc19
---- /dev/null
++++ b/docs/cutover-pr-openvibe-billing-6.md
@@@ -0,0 +1,217 @@
++# Cutover runbook — PR #6 (OpenVibe.Billing usage readings)
++
++PR #6 adds one empty table, `usage_records`, with four indexes, and two HTTP routes:
++`POST /api/v1/usage` (idempotent by the reading's own `idempotency_key`) and
++`GET /api/v1/usage` (filters by project, subject, service and `[from, to)`,
++cursor paging). Nothing existing is renamed, dropped or altered; no ledger column is
++the result of this PR; no provider webhook changes; no auth changes. Reads of
++`usage_records` are not a money movement.
++
++## What the migration does
++
++`migrations/0002_usage.sql` (`phase: expand`) creates the new table only:
++
++```sql
++CREATE TABLE usage_records (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ idempotency_key text COLLATE "C" NOT NULL UNIQUE,
++ project text COLLATE "C",
++ subject text COLLATE "C",
++ service text COLLATE "C" NOT NULL,
++ at timestamptz NOT NULL,
++ reading jsonb NOT NULL,
++ reading_hash text COLLATE "C" NOT NULL,
++ principal text COLLATE "C" NOT NULL,
++ received_at timestamptz NOT NULL
++);
++CREATE INDEX usage_records_at ON usage_records (at, id);
++CREATE INDEX usage_records_project ON usage_records (project, at, id) WHERE project IS NOT NULL;
++CREATE INDEX usage_records_subject ON usage_records (subject, at, id) WHERE subject IS NOT NULL;
++CREATE INDEX usage_records_service ON usage_records (service, at, id);
++```
++
++The runner is `openvibe-sdk/db`'s `migrate()`: it locks `ov_migrations`, runs
++the file inside one transaction (no `no-transaction` header is set, so it is
++transactional), records the sha256 of the file's content, and refuses any
++later edit of an applied migration. No `ov_migrations` row exists yet for
++`0002_usage.sql` on the current production ledger; the runner will add one.
++
++## What is not changed
++
++- No existing table is renamed, dropped or altered. The migration is additive.
++- No index on an existing table is created, dropped or rebuilt.
++- No money-bearing column, account kind, transaction type or ledger enum moves.
++- No provider webhook URL moves (the PowerChat webhook, the cutover's step 6,
++ is a separate change governed by `docs/live-cutover.md`; this PR is independent).
++- No `BILLING_AUTHORITY` change. The cutover's switch flips in another runbook.
++- No service-token, capability or grant change. `billing.usage.record` is
++ declared in `docs/capabilities-proposal/billing.usage.record.json` but is not
++ yet in OpenVibe.Contracts' released capability manifests (v0.84.0); until
++ Contracts releases it, the route is reachable only to service tokens the
++ operator grants by hand (none today). No call site anywhere in the network
++ POSTs to `/api/v1/usage` on the day of this PR's merge.
++
++## Prerequisites
++
++1. **Snapshot the ledger.** The new migration only adds an empty table, but
++ the pre-merge rule for any data change is a snapshot the operator can roll
++ back to. Force the weekly logical dump for the Billing database
++ (`pg_dump -Fc` lives at `/var/backups/openvibe/billing/<stamp>/billing.dump`,
++ owned `root:root`, 0600; `OpenVibe.Host/docs/backups.md:108-113`):
++ ```bash
++ sudo ovhost backup --all --logical
++ ```
++ The recipe verifies `pg_stat_archiver.failed_count` has not increased,
++ reads the per-service row counts and writes the dump. Note the stamp
++ printed at the end of the run; the rollback section uses it.
++2. **Confirm the unit is healthy.** `sudo systemctl status openvibe-billing`
++ answers active (running) and `/api/ready` returns 200 with `db: "ok"`.
++3. **Confirm no freeze in flight.** `node scripts/freeze.js state` (run from
++ `/opt/openvibe.billing`) returns `{"on": false}`. If it is on, find and
++ resolve the freeze first; this PR does not interact with the freeze but the
++ snapshot step must be taken when writes are healthy.
++
++## Rehearsal on a snapshot
++
++Run this on a copy of the production data, **never on the production ledger**;
++the rehearsal is green when `ds/deploy/rehearsals/billing-usage-records-001.json`
++on the harness says `{"ok": true}`. This section is the rehearsal.
++
++1. Restore the snapshot taken in prerequisites into a scratch database role
++ the operator stands up for the rehearsal (a separate Postgres role on the
++ same cluster, isolated from `ov_billing`). `openvibe-sdk/db`'s `migrate()`
++ is idempotent across roles; the rehearsal role has its own `ov_migrations`.
++2. Run the migration runner against the rehearsal role, with the same
++ `migrations/` directory the deployed release ships:
++ ```bash
++ DATABASE_URL=<rehearsal-url> DATABASE_DIRECT_URL=<rehearsal-direct-url> \
++ node -e 'const { openDb } = require("./server/db"); openDb({ db: { url: process.env.DATABASE_URL, directUrl: process.env.DATABASE_DIRECT_URL }, isProduction: false, log: { info: console.log, warn: console.warn, error: console.error } }).then((db) => db.close())'
++ ```
++ The runner is silent on a clean ledger; a successful run adds one row to
++ `ov_migrations` with `id = '0002'`, `name = 'usage'`, `phase = 'expand'`.
++3. Verify the schema is exactly what the PR added:
++ ```sql
++ \d usage_records
++ SELECT indexname FROM pg_indexes WHERE tablename = 'usage_records' ORDER BY indexname;
++ SELECT id, name, phase FROM ov_migrations WHERE id = '0002';
++ ```
++ The table has the nine columns listed above; `usage_records.id` is a
++ `bigint GENERATED ALWAYS AS IDENTITY`; the four indexes are present;
++ `ov_migrations` has one row for `0002_usage`.
++4. Boot the rehearsal instance against the rehearsal database:
++ `NODE_ENV=production PORT=4601 DATABASE_URL=<rehearsal-url>
++ DATABASE_DIRECT_URL=<rehearsal-direct-url> node server/index.js`.
++ `/api/ready` returns 200; the boot log prints `migrations: applied 0002_usage
++ (expand, NNms)` once and not again on a second boot (the checksum is
++ recorded).
++5. Run the focused suite on the rehearsal instance:
++ `ov test test/usage.test.js test/dependency-pins.test.js`. Both pass.
++ No existing test regresses (`test/usage.test.js` is the only test the PR
++ adds; `test/dependency-pins.test.js` is the README-pin guard the PR also
++ adds).
++6. Exercise the routes by hand:
++ ```bash
++ curl -sS -X POST http://127.0.0.1:4601/api/v1/usage \
++ -H "Authorization: Bearer $BILLING_USAGE_TOKEN" \
++ -H "Content-Type: application/json" \
++ -d '{"id":"use-rhb-1","idempotency_key":"rehearsal:0002:1","service":"media","operation":"deliver","quantity":1,"unit":"GiB","at":"2026-10-03T12:00:00Z","source":"rehearsal"}'
++ ```
++ A valid `platform.usage-sample@1` body returns 201 with `record` populated;
++ the same body again returns 200 with `Idempotent-Replayed: true` and the
++ stored row; a body with a different reading under the same key returns 409
++ `billing.usage_key_reused`. `GET /api/v1/usage?project=...` lists the
++ row newest-first. Reject 422 for an invalid reading; reject 401 without a
++ token; reject 403 with a token that lacks `billing.usage.record`.
++7. Write the green marker:
++ ```bash
++ mkdir -p ~/openvibe/agents/ds/deploy/rehearsals
++ printf '{"ok": true}\n' > ~/openvibe/agents/ds/deploy/rehearsals/billing-usage-records-001.json
++ ```
++ The harness reads this file; the deploy is gated on `{"ok": true}`.
++
++## Cutover
++
++The merge is automated once the rehearsal is green. The deploy is the standard
++`openvibe-billing` deploy:
++
++1. **Merge the PR.** The PR description carries the cutover manifest below;
++ the harness's `data` risk class waits for `ds/deploy/rehearsals/billing-usage-records-001.json`
++ to be `{"ok": true}` before letting the deploy start.
++2. **Deploy.** `sudo ovhost deploy billing "usage readings (PR #6) — additive
++ migration 0002_usage"`. The `git-checkout` recipe fast-forwards
++ `/opt/openvibe.billing`, installs on a lockfile change (this PR bumps
++ `openvibe-contracts` v0.76.0 → v0.84.0 and `openvibe-shared` v2.3.1 →
++ v2.5.0), restarts `openvibe-billing.service`, waits for `/api/ready`.
++3. **The boot applies the migration.** The systemd unit runs `node
++ server/index.js`; `server/db.js`'s `openDb` runs `owner.migrate({ dir:
++ MIGRATIONS, log })` against `DATABASE_DIRECT_URL`. The runner takes the
++ `ov_migrations` advisory lock, applies `0002_usage.sql` in one transaction,
++ records the checksum, releases the lock. The journal line is
++ `migrations: applied 0002_usage (expand, NNms)`. The boot then opens the
++ PgBouncer-backed serving handle and the unit is healthy.
++4. **Verify on production.**
++ - `sudo ovhost access run openvibe-ovh show billing` reads clean: the unit
++ is active (running), `/api/ready` is 200 with `db: "ok"`, the boot log
++ mentions `0002_usage` exactly once.
++ - `psql -d ov_billing -c '\d usage_records'` lists the nine columns and the
++ four indexes; `ov_migrations` has the `0002_usage` row.
++ - `curl -sS http://127.0.0.1:4600/api/v1/usage` with a token that has
++ `billing.usage.record` returns `{ records: [], next_cursor: null }`.
++ - `node scripts/reconcile.js` (run from `/opt/openvibe.billing`) prints
++ `accounts: N, transactions: N, ledger_entries: N` with the same totals
++ as before the deploy. Nothing money-bearing moved.
++
++The cutover is done when step 4's four lines are clean.
++
++## Rollback
++
++Because the migration is additive and the table is empty on the day of the
++merge, the rollback is one DDL away. Pick the path that matches the elapsed
++time.
++
++- **No usage row has been written yet (the common case the day of the merge).**
++ Roll back the deploy and drop the empty table:
++ 1. `sudo ovhost rollback billing` — the recipe takes the unit to the
++ previous release (`openvibe-contracts` v0.76.0, `openvibe-shared` v2.3.1,
++ no `0002_usage.sql`). The previous release's boot refuses to apply a
++ migration it does not know about (the file is absent) and the runner is
++ silent on a ledger that already has the `ov_migrations` row from the bad
++ release. The unit is healthy on the old code; `/api/ready` is 200.
++ 2. `psql -d ov_billing -c 'DROP INDEX IF EXISTS usage_records_at; DROP INDEX IF EXISTS usage_records_project; DROP INDEX IF EXISTS usage_records_subject; DROP INDEX IF EXISTS usage_records_service; DROP TABLE IF EXISTS usage_records; DELETE FROM ov_migrations WHERE id = '\''0002'\'';`
++ The rollback unit can also run a `drover` step that wipes the table and
++ the `ov_migrations` row in one transaction; either form is fine.
++ 3. Verify: `\d usage_records` says "Did not find any relation named
++ 'usage_records'"; `ov_migrations` has no `0002` row; `ov test
++ test/usage.test.js` is skipped (the previous release has no usage
++ code); the focused suite that *did* exist on the previous release
++ passes.
++- **Usage rows have been written since the merge.** A `DELETE FROM
++ usage_records` is destructive (the readings are first-party service
++ telemetry; nothing else references them in the journal). If a row was
++ written, decide with the owner whether to keep the table (preferred: the
++ rows are additive and the table does not block the rollback) or to wipe
++ them. If the call is to wipe, run `DELETE FROM usage_records; DELETE FROM
++ ov_migrations WHERE id = '0002';` in one transaction. The deploy rollback
++ then completes as above. The snapshot taken before the merge
++ (`/var/backups/openvibe/billing/<stamp>/billing.dump`) is the last resort
++ if a partial state cannot be reasoned about; restore it into a scratch role,
++ compare `usage_records` row counts, then act.
++- **The deploy itself is broken on the new release.** `sudo ovhost rollback
++ billing` takes the unit back to the previous release in one step; the
++ migration runner refuses to run `0002_usage.sql` again (the file is not in
++ the previous release) and the unit boots on the old code.
++
++## PR description
++
++The PR carries this block verbatim:
++
++````
++```cutover
++{"runbook": "docs/cutover-pr-openvibe-billing-6.md", "rehearsal": "billing-usage-records-001"}
++```
++````
++
++The harness reads the `runbook` and `rehearsal` from this block, refuses the
++deploy until `ds/deploy/rehearsals/billing-usage-records-001.json` is
++`{"ok": true}`, and otherwise proceeds with the standard `git-checkout`
++recipe.
\\ No newline at end of file
ddiff --git a/migrations/0002_usage.sql b/migrations/0002_usage.sql
nnew file mode 100644
iindex 0000000..b0cc953
---- /dev/null
++++ b/migrations/0002_usage.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++-- Usage readings (platform.usage-sample@1, openvibe-contracts v0.84.0; plan T5 lane F): stored and reported, never
++-- charged. A reading's own idempotency_key dedupes retries across every caller; the indexed fields are columns and
++-- the whole reading, exactly as accepted, is `reading`. `at` is the reading's time, normalised to UTC milliseconds
++-- (the cursor's precision); the original string stays in `reading`. reading_hash is the sha256 of the reading's
++-- canonical JSON (sorted keys), compared on a replay. Additive only: no existing table changes.
++
++CREATE TABLE usage_records (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ idempotency_key text COLLATE "C" NOT NULL UNIQUE,
++ project text COLLATE "C",
++ subject text COLLATE "C",
++ service text COLLATE "C" NOT NULL,
++ at timestamptz NOT NULL,
++ reading jsonb NOT NULL,
++ reading_hash text COLLATE "C" NOT NULL,
++ principal text COLLATE "C" NOT NULL,
++ received_at timestamptz NOT NULL
++);
++
++CREATE INDEX usage_records_at ON usage_records (at, id);
++CREATE INDEX usage_records_project ON usage_records (project, at, id) WHERE project IS NOT NULL;
++CREATE INDEX usage_records_subject ON usage_records (subject, at, id) WHERE subject IS NOT NULL;
++CREATE INDEX usage_records_service ON usage_records (service, at, id);
ddiff --git a/package-lock.json b/package-lock.json
iindex 977ddcd..656a8d3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0"
@@@ -1059,9 +1059,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.76.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
-- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
++ "version": "0.85.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
++ "integrity": "sha512-Xo54hG4kiL1kOTcxwbnH8cmt2zZhvp/gqCs+egPBLLSBg+SjVuhXIPaX8EJPQux5x1YxwO7wjDDywcHvIaGoqA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 9f36868..79af20b 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0"
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex c3fcde0..82ba20f 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -25,6 +25,7 @@ const admin = require('../ops/admin');
const providers = require('../providers');
const { reconcile } = require('../reconcile');
const external = require('../ops/external');
++const usage = require('../ops/usage');
const CAP = {
intent: 'billing.intent.create',
@@@ -35,6 +36,7 @@ const CAP = {
subscription: 'billing.subscription.manage',
entitlement: 'billing.entitlement.check',
admin: 'billing.ledger.admin',
++ usage: 'billing.usage.record',
};
function v1Router({ ctx, auth, adapters, limits }) {
@@@ -245,6 +247,16 @@ function v1Router({ ctx, auth, adapters, limits }) {
res.json({ transaction: presentTxn(t), reversed_by: reversals });
}));
++ // ── Usage readings (platform.usage-sample@1; ops/usage.js) ──
++ // Stored and reported, never charged. Not a money movement, so accepted while frozen; idempotent by the
++ // reading's own idempotency_key (no Idempotency-Key header): a replay is 200 with the stored row, a new one 201.
++ r.post('/usage', auth.needs(CAP.usage), wrap(async (req, res) => {
++ const out = await usage.record(ctx, req.body, { principal: req.principal.sub });
++ if (out.replayed) res.setHeader('Idempotent-Replayed', 'true');
++ res.status(out.replayed ? 200 : 201).json({ record: out.record });
++ }));
++ r.get('/usage', ...read(CAP.admin, async (req, res) => res.json(await usage.list(db, req.query))));
++
// ── Admin (billing.ledger.admin) ─────────────────────────
r.get('/admin/freeze', ...read(CAP.admin, async (req, res) => res.json(await admin.freezeState(db))));
r.post('/admin/freeze', auth.needs(CAP.admin), wrap(async (req, res) => {
ddiff --git a/server/ops/usage.js b/server/ops/usage.js
nnew file mode 100644
iindex 0000000..45556e8
---- /dev/null
++++ b/server/ops/usage.js
@@@ -0,0 +1,90 @@
++'use strict';
++
++/**
++ * Usage readings (platform.usage-sample@1, plan T5 lane F): a service reports what was used — by which project and
++ * subject, on which provider, how much, and, once rated, the free allowance it consumed and the Vibes charged — and
++ * Billing keeps it. Storing a reading charges nothing and moves no balance: `vibes_charged` records a charge already
++ * made in the ledger, it is never a request to make one.
++ *
++ * Idempotent by the reading's own idempotency_key (the contract's dedupe key, stable across retries, unique across
++ * every caller): the first reading under a key is stored; the same reading again returns the stored row (replay);
++ * a different reading under the same key is refused (409 billing.usage_key_reused) and the stored one is kept.
++ * "The same" is the canonical JSON (sorted keys), so key order and whitespace don't matter.
++ */
++const crypto = require('crypto');
++const { validate } = require('openvibe-contracts');
++const { stable } = require('../api/idempotency');
++const { fail, positiveInt } = require('./common');
++
++const CONTRACT = 'platform.usage-sample@1';
++const MAX_PAGE = 500;
++
++const hashOf = (reading) => crypto.createHash('sha256').update(stable(reading)).digest('hex');
++
++function present(row) {
++ return {
++ id: String(row.id), idempotency_key: row.idempotency_key, project: row.project, subject: row.subject, service: row.service,
++ at: row.at, received_at: row.received_at, principal: row.principal,
++ reading: typeof row.reading === 'string' ? JSON.parse(row.reading) : row.reading,
++ };
++}
++
++/** Store one reading. Returns { record, replayed }. */
++async function record(ctx, reading, { principal }) {
++ const v = validate('platform.usage-sample@1', reading);
++ if (!v.valid) fail(422, 'billing.invalid_input', `not a ${CONTRACT}: ${v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`, v.errors);
++ const atMs = Date.parse(reading.at);
++ if (!Number.isFinite(atMs)) fail(422, 'billing.invalid_input', `at ${reading.at} is not a time Billing can store`);
++ if (reading.free_allowance_used != null && reading.free_allowance_used > reading.quantity) {
++ fail(422, 'billing.invalid_input', 'free_allowance_used is more than quantity');
++ }
++ const { db } = ctx;
++ const hash = hashOf(reading);
++ const row = await db.prepare(`INSERT INTO usage_records (idempotency_key, project, subject, service, at, reading, reading_hash, principal, received_at)
++ VALUES (?, ?, ?, ?, ?::timestamptz, ?::jsonb, ?, ?, ?::timestamptz)
++ ON CONFLICT (idempotency_key) DO NOTHING RETURNING *`)
++ .get(reading.idempotency_key, reading.project || null, reading.subject || null, reading.service, new Date(atMs).toISOString(),
++ JSON.stringify(reading), hash, principal, new Date(ctx.now()).toISOString());
++ if (row) return { record: present(row), replayed: false };
++ const prev = await db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(reading.idempotency_key);
++ if (prev.reading_hash !== hash) {
++ fail(409, 'billing.usage_key_reused', `a different reading is already stored under idempotency_key ${reading.idempotency_key}`);
++ }
++ return { record: present(prev), replayed: true };
++}
++
++const time = (v, field) => {
++ const ms = Date.parse(String(v));
++ if (!Number.isFinite(ms)) fail(422, 'billing.invalid_input', `${field} must be an ISO-8601 date-time`);
++ return new Date(ms).toISOString();
++};
++
++/**
++ * Readings, newest first (by `at`, then id), filtered by any of project, subject, service and the time range
++ * from ≤ at < to. Cursor paging: next_cursor continues after the last row of this page, null on the last page.
++ */
++async function list(db, q = {}) {
++ const where = [];
++ const args = {};
++ for (const f of ['project', 'subject', 'service']) {
++ if (q[f] != null && q[f] !== '') { where.push(`${f} = @${f}`); args[f] = String(q[f]); }
++ }
++ if (q.from) { where.push('at >= @from::timestamptz'); args.from = time(q.from, 'from'); }
++ if (q.to) { where.push('at < @to::timestamptz'); args.to = time(q.to, 'to'); }
++ if (q.cursor) {
++ let cur;
++ try { cur = JSON.parse(Buffer.from(String(q.cursor), 'base64url').toString('utf8')); } catch { /* below */ }
++ if (!Array.isArray(cur) || cur.length !== 2 || !Number.isFinite(Date.parse(cur[0])) || !/^\d{1,18}$/.test(String(cur[1]))) fail(422, 'billing.invalid_input', 'bad cursor');
++ where.push('(at < @c_at::timestamptz OR (at = @c_at::timestamptz AND id < @c_id::bigint))');
++ args.c_at = cur[0]; args.c_id = String(cur[1]);
++ }
++ const limit = Math.min(MAX_PAGE, positiveInt(q.limit || 100, 'limit'));
++ args.n = limit + 1;
++ const rows = await db.prepare(`SELECT * FROM usage_records ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY at DESC, id DESC LIMIT @n`).all(args);
++ const page = rows.slice(0, limit);
++ const last = page[page.length - 1];
++ const next = rows.length > limit ? Buffer.from(JSON.stringify([last.at, String(last.id)])).toString('base64url') : null;
++ return { records: page.map(present), next_cursor: next };
++}
++
++module.exports = { record, list, present, CONTRACT };
ddiff --git a/test/usage.test.js b/test/usage.test.js
nnew file mode 100644
iindex 0000000..214ea38
---- /dev/null
++++ b/test/usage.test.js
@@@ -0,0 +1,178 @@
++'use strict';
++/**
++ * Usage readings (platform.usage-sample@1; ops/usage.js, migrations/0002_usage.sql): POST /api/v1/usage validates
++ * and stores a reading — the indexed fields as columns, the whole reading as jsonb — idempotently by its own
++ * idempotency_key (a replay returns the stored row, a different reading under the key is 409); GET /api/v1/usage
++ * filters by project, subject, service and time and pages with a cursor. Service tokens only. Nothing is charged:
++ * the journal is untouched.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/app');
++
++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const PRJ2 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';
++let n = 0;
++const reading = (over = {}) => {
++ n++;
++ return {
++ id: `use-${n}`, idempotency_key: `media:delivery:${n}`, service: 'media', project: PRJ, subject: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ',
++ resource: 'object-1', provider: 'local', operation: 'deliver', quantity: 1.5, unit: 'GiB', at: '2026-09-30T10:00:00Z', source: 'media.egress',
++ ...over,
++ };
++};
++const REC = ['billing.usage.record'];
++const counts = async (db) => ({
++ usage: (await db.prepare('SELECT COUNT(*) AS n FROM usage_records').get()).n,
++ txns: (await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n,
++ entries: (await db.prepare('SELECT COUNT(*) AS n FROM ledger_entries').get()).n,
++});
++
++/** A real user access token from the Network stub (authorization code + PKCE). */
++async function userAccessToken(t) {
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const code = t.network.authorize({ subject_id: t.user(900).id, challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), redirect_uri: 'http://x/cb' });
++ const res = await fetch(`${t.network.url}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: 'http://x/cb', client_id: 'billing', client_secret: 'shh' }).toString(),
++ });
++ return (await res.json()).access_token;
++}
++
++(async () => {
++ console.log('usage readings');
++ const t = await boot();
++ const post = (body, opts = {}) => t.call('POST', '/api/v1/usage', { body, cap: REC, key: null, sub: 'svc:media', ...opts });
++ const get = (qs, opts = {}) => t.call('GET', `/api/v1/usage${qs ? `?${qs}` : ''}`, { cap: REC, ...opts });
++
++ await check('a full reading (all 15 plan fields) is stored: columns plus the reading as jsonb; nothing is charged', async () => {
++ const before = await counts(t.db);
++ const body = reading({ node: 'node-1', cell: 'cell-west', region: 'us-west', cost_estimate: 0.03, free_allowance_used: 1, vibes_charged: 250, route_epoch: 7, trace_id: '0af7651916cd43dd8448eb211c80319c' });
++ const r = await post(body);
++ assert.strictEqual(r.status, 201, r.text);
++ const rec = r.json.record;
++ assert.deepStrictEqual(rec.reading, body);
++ assert.strictEqual(rec.idempotency_key, body.idempotency_key);
++ assert.strictEqual(rec.project, PRJ);
++ assert.strictEqual(rec.subject, body.subject);
++ assert.strictEqual(rec.service, 'media');
++ assert.strictEqual(rec.at, '2026-09-30T10:00:00.000Z');
++ assert.strictEqual(rec.principal, 'svc:media');
++ const row = await t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(body.idempotency_key);
++ assert.deepStrictEqual(row.reading, body);
++ assert.strictEqual(row.reading.vibes_charged, 250);
++ const after = await counts(t.db);
++ assert.deepStrictEqual(after, { ...before, usage: before.usage + 1 });
++ });
++
++ await check('a reading with only the required fields is stored (project and subject null)', async () => {
++ const { project, subject, resource, provider, ...minimal } = reading();
++ const r = await post(minimal);
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.record.project, null);
++ assert.strictEqual(r.json.record.subject, null);
++ });
++
++ await check('an invalid reading is a 422 problem and nothing is stored', async () => {
++ const before = (await counts(t.db)).usage;
++ const { quantity, ...noQuantity } = reading();
++ for (const bad of [noQuantity, reading({ extra: 1 }), reading({ project: 'prj_nope' }), reading({ quantity: -1 }), reading({ vibes_charged: 1.5 }),
++ reading({ vibes_charged: -1 }), reading({ at: 'yesterday' }), reading({ quantity: 1, free_allowance_used: 2 }), []]) {
++ const r = await post(bad);
++ assert.strictEqual(r.status, 422, `${JSON.stringify(bad)} → ${r.status} ${r.text}`);
++ assert.strictEqual(r.json.code, 'billing.invalid_input');
++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
++ }
++ assert.strictEqual((await counts(t.db)).usage, before);
++ });
++
++ await check('a replay of the same reading returns the stored row (key order does not matter); a different one under the key is 409', async () => {
++ const body = reading();
++ const first = await post(body);
++ assert.strictEqual(first.status, 201, first.text);
++ const reordered = Object.fromEntries(Object.entries(body).reverse());
++ const again = await post(reordered, { sub: 'svc:other' });
++ assert.strictEqual(again.status, 200, again.text);
++ assert.strictEqual(again.headers.get('idempotent-replayed'), 'true');
++ assert.deepStrictEqual(again.json.record, first.json.record);
++ const changed = await post({ ...body, quantity: 2 });
++ assert.strictEqual(changed.status, 409, changed.text);
++ assert.strictEqual(changed.json.code, 'billing.usage_key_reused');
++ const rows = await t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').all(body.idempotency_key);
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].reading.quantity, 1.5);
++ });
++
++ await check('service tokens with billing.usage.record only: no token 401, a user token 401, another capability 403', async () => {
++ assert.strictEqual((await post(reading(), { token: null })).status, 401);
++ const user = await userAccessToken(t);
++ assert.ok(user);
++ assert.strictEqual((await post(reading(), { token: user })).status, 401);
++ assert.strictEqual((await get('', { token: user })).status, 401);
++ const denied = await post(reading(), { cap: ['billing.transfer.create'] });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(denied.json.code, 'capability.denied');
++ assert.strictEqual((await post(reading(), { cap: ['billing.ledger.admin'] })).status, 403, 'admin reads readings, does not record them');
++ assert.strictEqual((await get('')).status, 403, 'recording access cannot list other projects or subjects');
++ assert.strictEqual((await get(`project=${PRJ}`)).status, 403, 'a query filter does not authorize a reading');
++ assert.strictEqual((await get('', { cap: ['billing.ledger.admin'] })).status, 200);
++ assert.strictEqual((await get('', { cap: ['billing.balance.read'] })).status, 403);
++ });
++
++ await check('readings are accepted while the economy is frozen (they move no money)', async () => {
++ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { body: { on: true, reason: 'test' } })).status, 200);
++ try {
++ const r = await post(reading());
++ assert.strictEqual(r.status, 201, r.text);
++ } finally {
++ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { body: { on: false } })).status, 200);
++ }
++ });
++ await t.close();
++
++ // ── Listing: filters and paging, on a fresh database ──
++ const u = await boot();
++ const uPost = (body) => u.call('POST', '/api/v1/usage', { body, cap: REC, key: null });
++ const uGet = async (qs) => { const r = await u.call('GET', `/api/v1/usage?${qs}`, { cap: ['billing.ledger.admin'] }); assert.strictEqual(r.status, 200, r.text); return r.json; };
++ const seed = [
++ reading({ project: PRJ, subject: 'user:a', service: 'media', at: '2026-09-01T00:00:00Z' }),
++ reading({ project: PRJ, subject: 'user:b', service: 'media', at: '2026-09-02T00:00:00Z' }),
++ reading({ project: PRJ2, subject: 'user:a', service: 'compute', at: '2026-09-03T00:00:00Z' }),
++ reading({ project: PRJ2, subject: 'user:b', service: 'media', at: '2026-09-04T00:00:00+02:00' }),
++ reading({ project: PRJ, subject: 'user:a', service: 'compute', at: '2026-09-05T00:00:00Z' }),
++ ];
++ for (const s of seed) assert.strictEqual((await uPost(s)).status, 201);
++ const keys = (out) => out.records.map((r) => r.idempotency_key);
++ const k = (i) => seed[i].idempotency_key;
++
++ await check('GET filters by project, subject, service and the time range [from, to), newest first', async () => {
++ assert.deepStrictEqual(keys(await uGet(`project=${PRJ}`)), [k(4), k(1), k(0)]);
++ assert.deepStrictEqual(keys(await uGet('subject=user:a')), [k(4), k(2), k(0)]);
++ assert.deepStrictEqual(keys(await uGet('service=compute')), [k(4), k(2)]);
++ assert.deepStrictEqual(keys(await uGet(`project=${PRJ2}&service=media`)), [k(3)]);
++ assert.deepStrictEqual(keys(await uGet('from=2026-09-02T00:00:00Z&to=2026-09-05T00:00:00Z')), [k(3), k(2), k(1)]);
++ // 2026-09-04T00:00:00+02:00 is 2026-09-03T22:00Z: compared as a time, not as text.
++ assert.deepStrictEqual(keys(await uGet('from=2026-09-03T21:00:00Z&to=2026-09-03T23:00:00Z')), [k(3)]);
++ assert.deepStrictEqual(keys(await uGet('subject=user:nobody')), []);
++ });
++
++ await check('GET pages with a cursor until next_cursor is null; a bad cursor, limit or time is 422', async () => {
++ const seen = [];
++ let cursor = null;
++ let pages = 0;
++ do {
++ const out = await uGet(`limit=2${cursor ? `&cursor=${cursor}` : ''}`);
++ seen.push(...keys(out));
++ cursor = out.next_cursor;
++ pages++;
++ } while (cursor && pages < 10);
++ assert.strictEqual(pages, 3);
++ assert.deepStrictEqual(seen, [k(4), k(3), k(2), k(1), k(0)]);
++ for (const qs of ['cursor=nope', 'limit=0', 'from=yesterday', 'to=2026-13-40']) {
++ const r = await u.call('GET', `/api/v1/usage?${qs}`, { cap: ['billing.ledger.admin'] });
++ assert.strictEqual(r.status, 422, `${qs} → ${r.status} ${r.text}`);
++ }
++ });
++ await u.close();
++ done();
++})();