openvibers/openvibe.contracts Commits

v0.34.0: ADR amendments dated 2026-09-24 (roadmap §28.3, W22): ADR-003 delegated authorization (who may name the acting person, intersection of app grants, approved scopes and the person's own rights, staff power and money never delegated, 5-minute unrefreshable tokens, audit names app and person) and the approval UX in force (PKCE, no prompt=none for apps, registered name and redirect host, narrow-only exchange) plus what Network still owes (capability list on the chooser, stored authorizations, connected apps with revoke); ADR-006 four placement classes (canonical, hot cache, asset origin, local scratch) mapped to today's providers, local as the first-class dev provider, infrequent-access tier deferred with the measured 2,839 objects / 481 GB; ADR-007 PgBouncer transaction pooling, read replicas only for lag-tolerant reads, SERIALIZABLE plus ordered row locks and 40001 retries on money paths, Redis never authoritative; ADR-012 roadmap ledger names mapped to Billing types and legs (platform fee, creator earning, payout hold/release, reversals), OpenCoins stays in Network (supersedes W8 "OpenCoins wallet becomes a Billing client"), payouts/refunds/plans substituted by cashouts, reversal transactions and VIP plans; ADR-013 package signing and review named open owner decisions, mod monetization only through Billing and VIP primitives, manifest 1.1.0 fields; ADR-021 extraction decided from measured volume (Live 1.78M raw events / 30 days, 58k a day, 502 MB): no analytics service, analytics stays per service through openvibe-shared/analytics, the schema stays in Shared, revisit triggers; ADR-022 Wave 12 revisit: the decision stands, next trigger named

OP OpenVibers committed 24 Sept 2026 · 1534e51d7c · 8 files, +340 −14


Changes

ddiff --git a/docs/adr/ADR-003-service-principals.md b/docs/adr/ADR-003-service-principals.md
iindex 8c93c032..53ac0052 100644
---- a/docs/adr/ADR-003-service-principals.md
++++ b/docs/adr/ADR-003-service-principals.md
@@@ -1,6 +1,6 @@
# ADR-003: Service/app principal authentication and capability grants
--**Status:** Accepted, implemented 2026-09-22
++**Status:** Accepted, implemented 2026-09-22. Amended 2026-09-24: delegated authorization semantics and approval UX (roadmap §28.3).
## Context and current evidence
@@@ -29,3 +29,78 @@ Remove a grant (new tokens stop carrying it) or stop sending tokens (callers fal
## Acceptance tests
`OpenVibe.Network/test/principals.test.js`, `modules.test.js`, Contracts service-auth tests (forged, expired, wrong audience, alg none, tampered, ungranted, namespace, owner), Media `service-token.test.js`. Production smoke: tokens issued, cross-app and cross-namespace calls refused.
++
++## Amendment 2026-09-24: delegated authorization
++
++Roadmap §28.3 carries "delegated authorization semantics and approval UX" into this ADR. Delegation
++already runs in production: Network issues app tokens with `on_behalf_of` (ADR-014,
++`server/developer/tokens.js`), and Wiki, Blog, News, Reviews and Trade honour it
++(`server/auth/viewer.js` in each, deployed 2026-09-23). This section records the rules they follow,
++so every other receiver implements the same ones.
++
++### Who can act for whom
++
++| Principal | May name the acting person | How |
++|---|---|---|
++| First-party service (`svc:<id>`, `actor_type: service`) | any person | `X-OV-Subject: usr_…` on the request. The service vouches for its own signed-in user. |
++| Developer app (`app:app_…`) | only the person who authorized it | `on_behalf_of` in the token. An `X-OV-Subject` naming anyone else gets 403 `subject.not_delegated`. |
++| Mod (`mod:mod_…`, ADR-013) | only the person who installed or authorized it | the same rule as an app |
++| Any principal, `client_credentials` token | nobody | no `on_behalf_of`. The call is the principal's own, never a person's. |
++
++### What a delegated call may do
++
++- **Intersection, never union.** The effective permission of a delegated call is the capabilities
++ in the token (the app's approved grants ∩ its project allowance ∩ what is still grantable, for that
++ audience), narrowed to the scopes the person approved, and then limited again by what that person
++ may do on the receiver. A delegated call can never do more than the person could do, nor more than
++ the app was granted.
++- **Staff power is never delegated.** A receiver treats a delegated caller as a plain user: staff
++ roles and staff capabilities (`manifests/policy/staff-roles.json`, v0.34.0) are never granted
++ through an app or mod token. Moderation needs a first-party staff surface.
++- **Money is not delegable by default.** A developer app can be granted only `active`
++ capabilities whose manifest says `visibility: public` or `partner` (partner only when staff put it
++ in one project's allowance by hand; Network `server/developer/policy.js`). The only public
++ money-adjacent ones are the read-only
++ `vip.plan.list` and `vip.perk.list`; no capability that moves money, credit or payouts is public,
++ and making one public needs an ADR-012 amendment.
++- **Short and unrefreshable.** App tokens last 5 minutes and have no refresh token. Revoking the
++ app, its credential or a grant stops new tokens at once, and issued tokens end within 5 minutes.
++- **Sandbox stays sandbox.** A receiver refuses `env: sandbox` tokens (401 `token.sandbox_refused`)
++ unless it opted in. Only members of the app's project may authorize a sandbox app.
++- **Audit names both.** A receiver's audit rows and events record the principal (`app:…`) as the
++ actor and the person as `on_behalf_of`. They never record it as the person acting alone.
++
++### Approval UX (Network)
++
++In force today (Network `server/auth/oauth-routes.js`, `public/login.html`):
++
++1. `/oauth/authorize` for an app requires an exact registered `redirect_uri` and PKCE S256.
++ `prompt=none` is refused with `interaction_required`, so a third-party app never gets a code
++ without the person choosing to continue.
++2. The account chooser shows the app's registered name, marked "(third-party app)", and the host
++ the code will be sent to (`/oauth/client-info`). The name is looked up by client id, never taken
++ from the URL.
++3. The code is single use, lasts 5 minutes, and records the approved scope. The token exchange may
++ narrow that scope but never widen it (400 `invalid_scope`).
++
++Required next, owned by Network (not built yet):
++
++4. **The chooser lists the requested capabilities** in plain words (each capability manifest's
++ `description`), before the person continues. A capability the app was not granted is not shown
++ and not issued.
++5. **A stored authorization** per (person, app), holding the approved scopes and a date. A
++ repeated request within those scopes may skip the chooser. A request for more always shows it.
++6. **"Connected apps" on my.openvibe.network**, listing each authorization with its scopes and last
++ use, and a revoke action. Revoking stops new tokens for that person and app at once.
++7. Revoking a person's authorization, and every Network ban, also ends `on_behalf_of` issuance for
++ them (the ban check at exchange exists today).
++
++### Tests
++
++- Receivers: an app token naming another person in `X-OV-Subject` gets 403
++ `subject.not_delegated`. A sandbox token gets 401. A first-party service may name the person.
++ (Wiki, Blog, News, Reviews and Trade have these tests.)
++- Network: `prompt=none` for an app returns `interaction_required`. A widened scope at exchange is
++ `invalid_scope`. A reused or expired code is `invalid_grant`.
++- Still to add, with items 4 to 6: the chooser lists exactly the issued capabilities, and a revoked
++ authorization gets no new token.
ddiff --git a/docs/adr/ADR-006-media-objects.md b/docs/adr/ADR-006-media-objects.md
iindex 28533443..14f9ef83 100644
---- a/docs/adr/ADR-006-media-objects.md
++++ b/docs/adr/ADR-006-media-objects.md
@@@ -1,6 +1,6 @@
# ADR-006: Media object, location and namespace model
--**Status:** Accepted 2026-09-22; implementation in progress (OpenVibe.Media Wave 4)
++**Status:** Accepted 2026-09-22; implementation in progress (OpenVibe.Media Wave 4). Amended 2026-09-24: four placement classes, the local/dev provider, the infrequent-access tier deferred (roadmap §28.3 m1).
## Context and current evidence
@@@ -30,3 +30,32 @@ The object layer is additive; old routes keep working throughout.
## Acceptance tests
Non-Live app uploads/reads with only a capability grant; deliberate replica loss is detected; private objects cannot be fetched without a grant; invariant violations are reported.
++
++## Amendment 2026-09-24: placement classes, local provider, infrequent access
++
++Roadmap §28.3 m1 and §28.4 item 6. A copy's **placement class** says why it exists and what may
++remove it. The provider says where it is. There are four classes:
++
++| Class | Purpose | Where today | Rules |
++|---|---|---|---|
++| **canonical** | The durable copy of record | B2 (`provider b2`, `storage_class cold`); the local disk for an object not yet offloaded | Never removed while the object exists or is held. Demotion never touches it. Deletion only after holds clear. |
++| **hot cache** | A promoted copy for heavy playback | R2 (`provider r2`, `storage_class cache`) | Only analytics-gated promotion creates it (the Decision above; roadmap §28.2 M2). Demotion removes only this copy. It is never the only copy. |
++| **asset origin** | Small public assets served through the CDN: avatars, thumbnails, previews, sprites, paste screenshots and similar | Media's own host disk behind Cloudflare (`provider local`, `storage_class hot`) | May be the canonical copy for small assets. It is served with long-lived, content-hashed or revisioned URLs. A separate cheap origin VPS is used only when disk or egress measurements call for one. |
++| **local scratch** | Development and active processing: recordings in progress, remux and cut work, job temporaries | the host's local disk | Never a durable copy and never a location of record. A job cleans up after itself, and a restart may discard scratch. |
++
++`media_locations.storage_class` keeps its values (`hot`, `cold`, `cache`). The class is derived from
++the provider and the object kind until Media adds a `placement` column. That column is additive,
++and Media records it with its lifecycle work.
++
++**Local/dev provider.** `local` is a first-class provider. With no B2 or R2 credentials, every
++Media path degrades to it: uploads, playback, thumbnails, jobs and the objects API
++(`providerConfigured('b2')` and `('r2')` gates in `server/vod/vod-storage.js`). Development and tests
++therefore need no cloud account. Still to do in Media: one provider interface (`put`, `get`,
++`head`, `delete`, `presign`, `list`) with `local`, `b2` and `r2` implementations behind it, instead of
++branches inside `vod-storage.js`. The S3 facade (Alternatives above) would sit on that interface.
++
++**Infrequent-access tier: deferred.** No infrequent-access or archive storage class (B2 has none; S3
++IA and Glacier-style tiers are not used) is adopted at current scale. On 2026-09-24, production held 2,839
++ready objects totalling 481 GB, which fits B2 at its single price. Revisit when retrieval-priced storage would save more than it costs
++in retrieval fees and restore latency, measured from `media_locations` sizes and access. That
++change is a new amendment here.
ddiff --git a/docs/adr/ADR-007-data-ownership.md b/docs/adr/ADR-007-data-ownership.md
iindex 7ac79e09..1b20b357 100644
---- a/docs/adr/ADR-007-data-ownership.md
++++ b/docs/adr/ADR-007-data-ownership.md
@@@ -1,6 +1,6 @@
# ADR-007: Logical data ownership and database technology
--**Status:** Accepted 2026-09-22
++**Status:** Accepted 2026-09-22. Amended 2026-09-24: PostgreSQL runtime posture (pooling, replicas, money-path isolation, Redis non-authoritative; roadmap §28.3 m10).
## Context and current evidence
@@@ -28,3 +28,46 @@ n/a
## Acceptance tests
Wave 0 baseline shows no service reading another's database; `/ready` truthfulness checked per service in Wave 22.
++
++## Amendment 2026-09-24: PostgreSQL runtime posture
++
++Roadmap §28.3 m10 and §28.4 item 15. No service runs on PostgreSQL today. Every service is SQLite on
++one host (the Decision above still holds). These rules bind the first service that moves, and every
++one after it.
++
++1. **Canonical truth, pooled.** A service's PostgreSQL database is its canonical relational truth.
++ Application processes connect through **PgBouncer in transaction-pooling mode**, never with one
++ long-lived connection per process. Code that moves must therefore work without session state:
++ no session-level `SET`, advisory locks held across transactions, `LISTEN`/`NOTIFY` or
++ session-scoped prepared statements through the pooler. Migrations and those few session
++ features use a direct connection with its own role.
++2. **Read replicas serve reads.** Replicas may serve reads that tolerate lag: public pages, lists,
++ search feeds and read models. A read that decides a write, checks a balance, an entitlement, a
++ grant or a ban, or answers read-your-own-write goes to the primary. A service that routes reads
++ to a replica says in `/ready` which reads may lag and by how much.
++3. **Money paths are SERIALIZABLE with row locks.** Every transaction that moves money, credit or a
++ payout (the ADR-012 MONEY and CREDIT classes: Billing's ledger, and anything Tips or VIP writes
++ that changes a balance) runs at `SERIALIZABLE` isolation. It locks the account rows it debits
++ (`SELECT … FOR UPDATE`, in a fixed order by account id so two transfers cannot deadlock), and it
++ retries a serialization failure (SQLSTATE 40001) a bounded number of times with the same
++ idempotency key. A balance check and the entry it guards are always in one transaction.
++ Loyalty ledgers (OpenCoins, channel points) follow the same rule when they move.
++4. **Redis is never authoritative** for money, credit, entitlements, account or identity state,
++ grants or bans. It may hold caches, rate-limit counters, presence and fan-out that can be lost and
++ rebuilt from the owner's database. Losing Redis must never lose a write or change an answer about
++ money or accounts. No service uses Redis today; Chat's and Live's history stores keep a note that
++ a ring buffer or Redis may replace the in-process cache later, under this rule.
++5. **One write role per service**, as the Decision says. A shared cluster isolates ownership by
++ database and role (roadmap §17.1). Cross-domain joins happen in read models or analytics.
++
++While on SQLite, the equivalent money-path rule is already in force: Billing posts every journal
++transaction inside one `better-sqlite3` transaction together with its funds check
++(`server/ledger.js`). SQLite's single writer serializes those transactions.
++
++### Tests (when the first service moves)
++
++- The service's suite passes against PostgreSQL behind PgBouncer in transaction mode.
++- A concurrent-debit test: two transfers race for the same balance, exactly one succeeds, and the
++ journal still sums to zero.
++- A lag test: a replica read is never used for a balance, entitlement or grant decision.
++- Redis flushed mid-run changes no money or account answer.
ddiff --git a/docs/adr/ADR-012-economic-classification.md b/docs/adr/ADR-012-economic-classification.md
iindex 4400f001..027e80d5 100644
---- a/docs/adr/ADR-012-economic-classification.md
++++ b/docs/adr/ADR-012-economic-classification.md
@@@ -1,6 +1,6 @@
# ADR-012: Billing vs loyalty, credits and currency classification
--**Status:** Accepted 2026-09-22. Gates Wave 8 (OpenVibe.Billing), Wave 9 (Tips) and Wave 10 (VIP).
++**Status:** Accepted 2026-09-22. Gates Wave 8 (OpenVibe.Billing), Wave 9 (Tips) and Wave 10 (VIP). Amended 2026-09-24: ledger transaction types mapped to the roadmap's names; OpenCoins stays in Network; the payouts/refunds/plans table substitution.
## Context and current evidence
@@@ -70,3 +70,52 @@ Until cutover, Live remains authoritative and Billing only shadows (imports and
- Creator payables reconcile exactly to the ledger; imported opening balances equal the Live columns at cutover, with every adjustment listed.
- The freeze switch refuses writes and serves reads.
- Entitlements are queryable with Live offline.
++
++## Amendment 2026-09-24: transaction types, OpenCoins, table substitution
++
++### Ledger transaction types (roadmap §28.3, §28.4 item 23)
++
++The roadmap asks for named types: platform fee, creator earning, payout hold and release, and the
++compensating reversals. Billing does not add them as new `transactions.type` values. Each of them
++is a **leg** (an entry on an account kind) of an existing, named type. The mapping is binding, and
++reports and events use these names:
++
++| Roadmap name | Billing type(s) | Leg (account kind) | Where |
++|---|---|---|---|
++| **Platform fee** (purchase spread, retained subscription share, site-route fee) | `purchase`, `subscription`, site-routed tips from receipts | credit to `platform_revenue` (cents or bits). The provider's own fee is kept as `fee_cents` in metadata, never as revenue. | `server/ops/common.js` receiptEntries, `ops/subscriptions.js` |
++| **Creator earning** | `donation` (tip, donation, paid interaction), `subscription` (the creator's share), `import` / `subscription_share` for imported Live history | credit to `creator_payable:<subject>` | `ops/transfers.js`, `ops/subscriptions.js`, `importer/live.js` |
++| **Payout hold** | `cashout_request` | `creator_payable` → `payouts_pending`, with `escrow_until`. Billing refuses approval during escrow (rule 10). | `ops/cashouts.js` request |
++| **Payout release** (paid out) | `cashout_paid` | `payouts_pending` → `provider_clearing:<provider>_payouts` (bits to cents through `fx_conversion`), with the provider's payout reference required | `ops/cashouts.js` approve |
++| **Payout release** (returned) | `cashout_denied` | `payouts_pending` → `creator_payable`, and `reverses_txn` → the request | `ops/cashouts.js` deny |
++| **Compensating reversals** | `refund`, `chargeback` (with `reverses_txn` → the original); `refund` of a transfer (a media request that never played); `adjustment` (staff, with a reason) | back to `user_credit` or out through `refunds` / `chargeback_loss`. A creator's payable is never clawed back for credit already given away: the loss is booked and flagged for review. | `ops/reversals.js`, `ops/transfers.js` refund, `ops/admin.js` |
++| (MONEY → CREDIT) | `recycle` | `creator_payable` → `user_credit` (rule 4) | `ops/cashouts.js` recycle |
++
++A new money movement gets a new `type` value only when no existing type and leg describes it.
++Adding one is additive in Billing's CHECK and in `billing.transaction.settled@1`.
++
++### OpenCoins stays in Network (supersession)
++
++Roadmap Wave 8 (line 692) says "Network's OpenCoins wallet becomes a Billing client". **This ADR
++supersedes that line.** OpenCoins are LOYALTY. Network owns them, with their own ledger, and
++Billing does not hold, mirror or proxy them (see Decision, and Migration consequences: "OpenCoins
++and channel points are **not** migrated into Billing in Wave 8"). A Billing loyalty ledger, if it
++ever comes, is a separate step with its own ADR amendment, and it never shares accounts with MONEY
++or CREDIT. The Network requirement ledger (D28) and the roadmap crosswalk should cite this
++paragraph instead of listing the line as remaining.
++
++### Table substitution (roadmap §11.1.1)
++
++The roadmap's minimal entity list names twelve tables. Billing has nine of them under those names:
++`accounts`, `ledger_entries`, `transactions`, `provider_events`, `payment_intents`, `subscriptions`,
++`entitlements`, `reconciliation_runs` and `idempotency_keys`. It deliberately substitutes the other
++three:
++
++| Roadmap entity | Billing's equivalent | Why |
++|---|---|---|
++| `payouts` | `cashouts` (`server/db.js`), with the `cashout_request` / `cashout_paid` / `cashout_denied` journal transactions | The product calls them cashouts. The row holds the payout method, escrow and provider payout reference. |
++| `refunds` | reversal transactions (`type refund` or `chargeback`, `reverses_txn` → the original) plus the `refunds` and `chargeback_loss` accounts (`server/ops/reversals.js`) | A refund is a journal fact, not a separate mutable record. Partial and cumulative reversals are checked against the original. |
++| `plans` | VIP's `vip_plans` (OpenVibe.VIP) | Plans and perks are VIP's (see the ENTITLEMENT row in the Decision). Billing holds only the entitlement truth. |
++
++Billing also has `external_receipts` (EXTERNAL-class PowerChat tips, recorded without liability)
++and `account_balances` (a projection of the journal, checked by reconciliation). Thin `payouts` or
++`refunds` views may be added for reporting. They are not required.
ddiff --git a/docs/adr/ADR-013-mod-manifest.md b/docs/adr/ADR-013-mod-manifest.md
iindex 9e3fb55c..8d33a02d 100644
---- a/docs/adr/ADR-013-mod-manifest.md
++++ b/docs/adr/ADR-013-mod-manifest.md
@@@ -1,6 +1,6 @@
# ADR-013: Mod manifest, trust and sandbox/resource model
--**Status:** Accepted 2026-09-23. Gates Wave 12 (mods) and Wave 21 Stage C (sandboxed user code).
++**Status:** Accepted 2026-09-23. Gates Wave 12 (mods) and Wave 21 Stage C (sandboxed user code). Amended 2026-09-24: signing and review are open owner decisions; mod monetization only through Billing; manifest 1.1.0 (read/write grants, billing hooks, dependencies).
## Context and current evidence
@@@ -32,3 +32,59 @@ Revoke the grants; the manifest schema stays.
- A capability the mod was denied cannot be reached from its scripted runtime.
- A revoked mod stops affecting the world.
- Install, grant, use and revoke all appear in the audit log.
++
++## Amendment 2026-09-24: open decisions, monetization and manifest 1.1.0
++
++Roadmap §28.3 (m6 and "Mod signing and review") asks for three things this ADR left implicit.
++
++### Open owner decisions: package signing and review
++
++Neither is decided. Both stay **open decisions for the owner**, and nothing may fill them with a
++silent default.
++
++1. **Package signing.** Who signs a mod release and with what key: the publisher, the platform at
++ review time, or both. Which artifact the signature covers: the manifest, the pack or bundle, and
++ the Media asset hashes. Where a runtime checks the signature. What an unsigned release may do.
++2. **Review process.** Who reviews, against which checklist, and what a review changes. It may raise
++ the trust tier. It never grants anything, because tiers stay metadata. Also open: how a published
++ review is withdrawn, and whether the ADR's tier names (`unreviewed`, `reviewed`, `first-party`) or
++ the roadmap §15 vocabulary are final (requirement ledger D32).
++
++Until the owner decides, installs stay as they are. Only staff install mods: Games
++`POST /api/v1/mods` is staff-only, with the approved capability subset given at install. No
++self-service publishing or install exists. Executable mods wait for Host Stage C in any case. The
++decision will be recorded as a new amendment here, before any non-staff install path ships.
++
++### Monetization only through Billing
++
++A mod earns money only through Billing and marketplace primitives (ADR-012, ADR-025), never around
++them:
++
++- A mod never takes payment, never sees payment details and never holds a balance. Prices, plans and
++ entitlements live in Billing and VIP, not in the mod or its manifest.
++- A mod may **check an entitlement** that the person holds (`billing.entitlement.check`,
++ `vip.entitlement.check`) and may **send the person to a platform checkout**
++ (`vip.membership.checkout`, Billing intents). Both need the capability in the install's approved
++ grants, like any other call.
++- Every paid unlock a mod gates is an ENTITLEMENT (ADR-012). Game state bought with money needs its
++ own ADR (ADR-012 rule 7), and so does any creator share for a mod publisher.
++- A marketplace for mods is out of scope until ADR-025 is revisited.
++
++### Manifest 1.1.0 (openvibe-contracts v0.34.0)
++
++`mods.mod-manifest@1` gains three optional fields, so every 1.0.0 manifest stays valid:
++
++- `permissions.readGrants` and `permissions.writeGrants` split module and Media namespace access into
++ read-only and read-write, each as `{ modules?, mediaNamespaces? }`. A namespace that is only in
++ `readGrants` is read-only. The older `permissions.modules` and `permissions.mediaNamespaces` still
++ mean read and write.
++- `billingHooks`: the entitlement checks and checkouts the mod uses, as `{ kind, key, description? }`.
++ `kind` is `entitlement` or `checkout`, and `key` is an entitlement or plan key (`vip.plan:pln_…`).
++ These declare intent for review and for the install screen. The capability still has to be
++ granted.
++- `dependencies`: other mods this release needs, as `{ id: mod_…, version: <semver range>,
++ optional? }`. A runtime refuses to enable a mod whose required dependency is not installed, not
++ enabled, or outside the range.
++
++Games validates against a local copy of the 1.0.0 schema (`apps/server/src/mods/manifestSchema.ts`).
++It adopts 1.1.0 when it bumps its contracts pin.
ddiff --git a/docs/adr/ADR-021-analytics.md b/docs/adr/ADR-021-analytics.md
iindex 438d766a..49f8f771 100644
---- a/docs/adr/ADR-021-analytics.md
++++ b/docs/adr/ADR-021-analytics.md
@@@ -1,6 +1,6 @@
# ADR-021: Analytics ownership and privacy bounds
--**Status:** Accepted 2026-09-23. Contract now; extraction decision at Wave 22.
++**Status:** Accepted 2026-09-23. Contract now; extraction decision at Wave 22. Amended 2026-09-24: extraction decided (no analytics service; analytics stays per service through `openvibe-shared/analytics`).
## Context and current evidence
@@@ -31,3 +31,42 @@ Pruning is the only destructive step. Export the rollups before enabling it.
- No raw analytics row older than 30 days.
- No IP address or subject id in any analytics table.
++
++## Amendment 2026-09-24: the extraction decision
++
++Wave 22 asked whether to create an analytics service, "from measured volume". **Decision: no
++analytics service.** Analytics stays per service, through the shared module
++`openvibe-shared/analytics` (tracker, privacy, retention, schema, prune CLI; Shared ≥ 1.4.0).
++The ADR-021 privacy rules above apply unchanged: no IP address, precise location or subject id in
++raw analytics, and at most 30 days of raw events.
++
++**Measured volume** (production, read-only, 2026-09-24):
++
++- Live `analytics.db`, the largest: 1,776,181 raw events in its 30-day window (2026-08-25 to
++ 2026-09-24), 58,247 in the last 24 hours, a 502 MB SQLite file. It was 1.7 GB before the
++ ADR-021 prune and scrub.
++- Network: raw analytics in its own database, pruned to 30 days (network.db 731 → 276 MB after the
++ ADR-021 scrub and VACUUM).
++- Tools: one analytics database per satellite, each smaller than Live's.
++
++One process per service writes this volume into SQLite without strain. No product needs raw events
++joined across services: the cross-site rollup already lives in Tools and Network. An extra
++service would add a deploy unit, a network hop on every page view and a second copy of the privacy
++rules, and would buy nothing measured.
++
++**Where the contract lives.** The event schema is `openvibe-shared/analytics/event.v1.json`
++(`docs/schemas/analytics-event.v1.json` in OpenVibe.Shared), versioned with the module that writes
++it. It is not copied into this repository's catalog, because a second copy would drift. The Decision
++above said "in OpenVibe.Contracts"; this amendment corrects that.
++
++**Revisit** (a new amendment here) when any of these is measured:
++
++- one service's raw events pass 1,000,000 a day, or its 30-day raw table passes 5 GB;
++- a product needs raw events joined across services (funnels or attribution across sites), which
++ rollups cannot answer;
++- analytics writes show up in a service's latency or `/ready`;
++- the platform moves to more than one host for a service that records analytics.
++
++**Consumers:** Live (`server/index.js`, `server/paths.js`), Network (`server/analytics/network.js`)
++and the Tools satellites (`apps/*/server/index.js`) use the module. A new product that wants
++analytics uses it too, with its own database and the nightly prune.
ddiff --git a/docs/adr/ADR-022-moderation-console.md b/docs/adr/ADR-022-moderation-console.md
iindex 6db4885d..d830cade 100644
---- a/docs/adr/ADR-022-moderation-console.md
++++ b/docs/adr/ADR-022-moderation-console.md
@@@ -1,6 +1,6 @@
# ADR-022: Moderation and policy console
--**Status:** Accepted 2026-09-23. Revisit at Wave 12.
++**Status:** Accepted 2026-09-23. Revisited at Wave 12 (2026-09-24): the decision stands, with no moderation service; next revisit at Wave 22 or when the trigger below is met.
## Context and current evidence
@@@ -33,3 +33,38 @@ The events are additive.
- Every staff action in Live, Community and Chat appears in the audit log with actor and reason.
- A service without the moderate capability cannot vouch for staff.
++
++## Revisit at Wave 12 (2026-09-24): outcome
++
++Wave 12 (Games and mods) was the named revisit point, because mods could have made moderation a
++multi-product workflow. **Outcome: the decision stands.** Network stays the staff directory and
++the moderation audit log, enforcement stays with each owner, and no moderation service is created.
++
++Evidence:
++
++- **No cross-product workflow exists.** Each moderated thing has one owner:
++ - Chat moderates chat (channel-scoped bans, deletions; it emits `chat.moderation.action`).
++ - Community moderates pastes, threads and comments (`community.*.moderate`).
++ - Tips moderates interactions (`tips.interaction.moderated`).
++ - Billing logs staff money actions (`billing.staff.action`).
++ - Games installs, grants and revokes mods itself: installing is staff-only, audited in its own
++ `mod_audit`.
++
++ No queue, appeal or case spans two of them.
++- **Mods did not change that.** Games' mods are declarative packs installed by staff. There is no
++ self-service publishing, so there is no review queue. Mod signing and review are open owner
++ decisions (ADR-013 amendment of 2026-09-24).
++- **What is still missing is inside this decision, not a reason to change it.**
++ - Network does not consume `*.moderation.action` yet (requirement D05-R2), so the audit log has
++ no entries.
++ - Services still check raw roles. v0.34.0 adds the staff capability map
++ (`manifests/policy/staff-roles.json`): `user < streamer < global_mod < admin < owner` and the
++ `staff.*` capabilities each role holds. Network issues it as claims, and Live, Chat and
++ Community replace their raw checks with it.
++
++**Trigger for the next revisit** (at Wave 22 at the latest): the first workflow that no single
++owner can host, for example:
++
++- an appeal that crosses products;
++- one report queue for Chat, Community and Media;
++- a public mod marketplace with review.
ddiff --git a/docs/adr/README.md b/docs/adr/README.md
iindex 66c7ae1f..3a511f51 100644
---- a/docs/adr/README.md
++++ b/docs/adr/README.md
@@@ -6,17 +6,17 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
|---|---|---|
| [ADR-001](ADR-001-canonical-subjects.md) | Canonical subject IDs and legacy identity mapping | Accepted, implemented 2026-09-22 |
| [ADR-002](ADR-002-contract-repository.md) | Contract repository and compatibility policy | Accepted, implemented 2026-09-22 (openvibe-contracts v0.1.0+) |
--| [ADR-003](ADR-003-service-principals.md) | Service/app principal authentication and capability grants | Accepted, implemented 2026-09-22 |
++| [ADR-003](ADR-003-service-principals.md) | Service/app principal authentication and capability grants | Accepted, implemented 2026-09-22; amended 2026-09-24 (delegated authorization and approval UX) |
| [ADR-004](ADR-004-durable-events.md) | Durable event store, outbox/inbox and delivery semantics | Accepted 2026-09-22; implementation in progress (OpenVibe.Events) |
| [ADR-005](ADR-005-realtime.md) | Realtime topics, cursor/resume and whether OpenVibe.Realtime exists | Accepted 2026-09-22: realtime runs inside OpenVibe.Events |
--| [ADR-006](ADR-006-media-objects.md) | Media object, location and namespace model | Accepted 2026-09-22; implementation in progress (OpenVibe.Media Wave 4) |
--| [ADR-007](ADR-007-data-ownership.md) | Logical data ownership and database technology | Accepted 2026-09-22 |
++| [ADR-006](ADR-006-media-objects.md) | Media object, location and namespace model | Accepted 2026-09-22; implementation in progress (OpenVibe.Media Wave 4); amended 2026-09-24 (placement classes, local provider, infrequent access deferred) |
++| [ADR-007](ADR-007-data-ownership.md) | Logical data ownership and database technology | Accepted 2026-09-22; amended 2026-09-24 (PgBouncer, replicas, SERIALIZABLE money paths, Redis non-authoritative) |
| [ADR-008](ADR-008-shared-packages.md) | Shared package publication and release manifests | Accepted 2026-09-22; implementation in progress (OpenVibe.Shared) |
| [ADR-009](ADR-009-live-openre-boundary.md) | Live versus OpenRe boundary | Accepted 2026-09-23; OpenRe deployed, no slot moved yet |
| [ADR-010](ADR-010-chat-boundary.md) | Chat versus Live/Community boundary | Accepted and executed 2026-09-23 (Chat serves Live's chat) |
| [ADR-011](ADR-011-community-pastes.md) | Community paste and comment ownership migration | Accepted and executed 2026-09-22 |
--| [ADR-012](ADR-012-economic-classification.md) | Billing vs loyalty, credits and currency classification | Accepted 2026-09-22; gates Waves 8-10 |
--| [ADR-013](ADR-013-mod-manifest.md) | Mod manifest, trust and sandbox/resource model | Accepted 2026-09-23; gates Waves 12 and 21C |
++| [ADR-012](ADR-012-economic-classification.md) | Billing vs loyalty, credits and currency classification | Accepted 2026-09-22; gates Waves 8-10; amended 2026-09-24 (transaction-type mapping, OpenCoins stays in Network, table substitution) |
++| [ADR-013](ADR-013-mod-manifest.md) | Mod manifest, trust and sandbox/resource model | Accepted 2026-09-23; gates Waves 12 and 21C; amended 2026-09-24 (signing and review open, monetization through Billing, manifest 1.1.0) |
| [ADR-014](ADR-014-developer-projects.md) | Developer project, tenant and quota model | Accepted 2026-09-23; gates Waves 20-21 |
| [ADR-015](ADR-015-ai-workflows.md) | AI workflow and publication ownership boundary | Accepted 2026-09-23; OpenVibe.AI deployed |
| [ADR-016](ADR-016-active-client-updates.md) | Active client update safety and mixed-version window | Accepted 2026-09-23; Track R |
@@@ -24,8 +24,8 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
| [ADR-018](ADR-018-search-home.md) | Home of the search index and query contract | Accepted 2026-09-23: OpenVibe.Search, FTS first |
| [ADR-019](ADR-019-publishing-packages.md) | Home of the shared publishing packages | Accepted 2026-09-23: OpenVibe.Publishing, packages only |
| [ADR-020](ADR-020-notification-delivery.md) | Notification delivery ownership | Accepted 2026-09-23: Network authority, Events transport |
--| [ADR-021](ADR-021-analytics.md) | Analytics ownership and privacy bounds | Accepted 2026-09-23: contract now, 30-day raw retention |
--| [ADR-022](ADR-022-moderation-console.md) | Moderation and policy console | Accepted 2026-09-23: Network module + audit events |
++| [ADR-021](ADR-021-analytics.md) | Analytics ownership and privacy bounds | Accepted 2026-09-23: contract now, 30-day raw retention; extraction decided 2026-09-24: no analytics service |
++| [ADR-022](ADR-022-moderation-console.md) | Moderation and policy console | Accepted 2026-09-23: Network module + audit events; Wave 12 revisit 2026-09-24: stands |
| [ADR-023](ADR-023-client-surfaces.md) | First-party client surfaces | Accepted 2026-09-23: OpenVibe.Extensions at Wave 18 |
| [ADR-024](ADR-024-theme-authority.md) | Theme authority between Network and Shared | Accepted 2026-09-23 |
| [ADR-025](ADR-025-marketplace-scope.md) | Marketplace and commerce scope | Accepted 2026-09-23: Trade stays informational |