openvibers/openvibe.contracts Commits

v0.69.0: account merge (roadmap WS-B task 5, ADR-029). Adds the event network.subject.merged (merge_id, from, into, merged_at, initiated_by person|staff, split_until; visibility internal; each service repoints its own rows from the alias to the survivor, once per merge_id, dropping and counting what it cannot move), network.account-merge-result@1 (what Network moved as counts, replayed for a retry) and staff map 1.2.0 with staff.identity.merge (owner, since 1.2.0: account-recovery merges with a reason and an audit row). Network's manifest lists the event.

OP OpenVibers committed 27 Sept 2026 · 7d0661e2a0 · 40 files, +353 −50


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5b01a9fe..ec7fc0ac 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,13 @@ the notes were in the tag and commit messages (`git tag -n1`).
## Unreleased
++## 0.69.0 — 2026-09-27
++
++**Account merge** (roadmap WS-B task 5, ADR-029).
++- **`network.subject.merged@1`** (event, first-party, visibility internal): `{ merge_id, from, into, merged_at, initiated_by, split_until? }`. `from` is now an alias of `into`. Network has moved what it owns (providers, sessions, OAuth grants, developer projects, OpenCoins, user modules) in the transaction that queues it. Each service repoints its own rows from `from` to `into`; a row it cannot move keeps the survivor's and drops the other, counted. Apply once per `merge_id`. Listed in Network's manifest `eventsProduced`.
++- **`network.account-merge-result@1`**: the answer of a merge, what Network moved as counts, and `replayed` for a retried merge.
++- **Staff map 1.2.0: `staff.identity.merge`** (owner, since 1.2.0): an account-recovery merge with a written reason and an audit row. People merge their own accounts without it, signed in to both. An owner token issued from map 1.1.0 holds it by role.
++
## 0.68.0 — 2026-09-26
**Creator analytics from events** (roadmap WS-E task 6).
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 216d31fb..1d79b5ce 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -2406,6 +2406,16 @@
"compatibility": "backward",
"status": "active"
},
++ {
++ "id": "network.subject.merged",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "events/payloads/network.subject.merged.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-029"
++ },
{
"id": "network.follow.created",
"version": "1.0.0",
@@@ -2545,6 +2555,16 @@
"status": "active",
"adr": "ADR-021"
},
++ {
++ "id": "network.account-merge-result",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "network/account-merge-result.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-029"
++ },
{
"id": "network.follow-list-result",
"version": "1.0.0",
ddiff --git a/contracts/events/payloads/network.subject.merged.v1.json b/contracts/events/payloads/network.subject.merged.v1.json
nnew file mode 100644
iindex 00000000..b29f06f1
---- /dev/null
++++ b/contracts/events/payloads/network.subject.merged.v1.json
@@@ -0,0 +1,17 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/network.subject.merged.v1.json",
++ "title": "NetworkSubjectMergedPayload",
++ "description": "network.subject.merged v1 (OpenVibe.Network; roadmap WS-B task 5, ADR-029). Two accounts became one: `from`, the folded-in subject, is now an alias of `into`, the survivor, which keeps its subject, username, profile and settings. Network has already moved what it owns (linked providers, sessions, OAuth grants, developer projects, OpenCoins and user modules) in the transaction that writes this event to its outbox. Every service that stores subjects repoints its own rows from `from` to `into` in its own transaction and keeps no copy under `from`; a row it cannot move (a unique conflict, such as both accounts following the same channel) keeps the survivor's row and drops the other, as a counted, logged outcome. Until a service has applied it, its rows stay under `from`, which still resolves to `into` through Network's aliases. Apply once per merge_id. Envelope: subject { type: user, id: <into> }, visibility internal, actor the person who merged (or the staff member, for an account-recovery merge).",
++ "type": "object",
++ "required": ["merge_id", "from", "into", "merged_at", "initiated_by"],
++ "additionalProperties": false,
++ "properties": {
++ "merge_id": { "type": "string", "pattern": "^mrg_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The merge; a redelivery carries the same id." },
++ "from": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The folded-in subject, now an alias." },
++ "into": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The survivor." },
++ "merged_at": { "type": "string", "format": "date-time" },
++ "initiated_by": { "enum": ["person", "staff"], "description": "person: the account holder, signed in to both accounts. staff: an account-recovery merge (staff.identity.merge, with a written reason and an audit row)." },
++ "split_until": { "type": "string", "format": "date-time", "description": "Until then Network keeps the folded-in account's pre-merge state, so staff can split a mistaken merge by hand (30 days)." }
++ }
++}
ddiff --git a/contracts/network/account-merge-result.v1.json b/contracts/network/account-merge-result.v1.json
nnew file mode 100644
iindex 00000000..add167f3
---- /dev/null
++++ b/contracts/network/account-merge-result.v1.json
@@@ -0,0 +1,30 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/network/account-merge-result.v1.json",
++ "title": "NetworkAccountMergeResult",
++ "description": "network.account-merge-result@1 (OpenVibe.Network; roadmap WS-B task 5, ADR-029): the answer of a merge, POST /api/v1/account/merge (the person, signed in to the survivor, with a fresh sign-in proof of the other account) or POST /api/admin/account-merges (staff.identity.merge, with a reason). What Network moved in its one transaction, as counts; everything else follows network.subject.merged. A retried merge of the same pair answers the first merge (replayed: true) and moves nothing again.",
++ "type": "object",
++ "required": ["merge_id", "from", "into", "merged_at", "split_until", "moved", "replayed"],
++ "additionalProperties": false,
++ "properties": {
++ "merge_id": { "type": "string", "pattern": "^mrg_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "from": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "into": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "merged_at": { "type": "string", "format": "date-time" },
++ "split_until": { "type": "string", "format": "date-time" },
++ "moved": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["providers", "sessions", "oauth_grants", "projects", "coins", "modules"],
++ "properties": {
++ "providers": { "type": "integer", "minimum": 0, "description": "Linked sign-in providers now on the survivor." },
++ "sessions": { "type": "integer", "minimum": 0, "description": "Sessions and devices moved; both sets stay valid until they expire." },
++ "oauth_grants": { "type": "integer", "minimum": 0 },
++ "projects": { "type": "integer", "minimum": 0, "description": "Developer projects, still owned, now by the survivor." },
++ "coins": { "type": "integer", "minimum": 0, "description": "OpenCoins added to the survivor (one ledger entry per side, reason account_merge)." },
++ "modules": { "type": "integer", "minimum": 0, "description": "User-module records changed on the survivor (its own fields win)." }
++ }
++ },
++ "replayed": { "type": "boolean" }
++ }
++}
ddiff --git a/fixtures/network.account-merge-result/invalid/negative-coins.json b/fixtures/network.account-merge-result/invalid/negative-coins.json
nnew file mode 100644
iindex 00000000..bca2f827
---- /dev/null
++++ b/fixtures/network.account-merge-result/invalid/negative-coins.json
@@@ -0,0 +1 @@
++{"merge_id":"mrg_01JAB2C3D4E5F6G7H8J9K0MNPS","from":"usr_01JAB2C3D4E5F6G7H8J9K0MNPR","into":"usr_01JAB2C3D4E5F6G7H8J9K0MNPQ","merged_at":"2026-09-27T21:00:00.000Z","split_until":"2026-10-27T21:00:00.000Z","moved":{"providers":2,"sessions":3,"oauth_grants":1,"projects":0,"coins":-5,"modules":2},"replayed":false}
ddiff --git a/fixtures/network.account-merge-result/valid/merged.json b/fixtures/network.account-merge-result/valid/merged.json
nnew file mode 100644
iindex 00000000..d1316df6
---- /dev/null
++++ b/fixtures/network.account-merge-result/valid/merged.json
@@@ -0,0 +1 @@
++{"merge_id":"mrg_01JAB2C3D4E5F6G7H8J9K0MNPS","from":"usr_01JAB2C3D4E5F6G7H8J9K0MNPR","into":"usr_01JAB2C3D4E5F6G7H8J9K0MNPQ","merged_at":"2026-09-27T21:00:00.000Z","split_until":"2026-10-27T21:00:00.000Z","moved":{"providers":2,"sessions":3,"oauth_grants":1,"projects":0,"coins":150,"modules":2},"replayed":false}
ddiff --git a/fixtures/network.subject.merged/invalid/leaks-username.json b/fixtures/network.subject.merged/invalid/leaks-username.json
nnew file mode 100644
iindex 00000000..fe711030
---- /dev/null
++++ b/fixtures/network.subject.merged/invalid/leaks-username.json
@@@ -0,0 +1 @@
++{"merge_id":"mrg_01JAB2C3D4E5F6G7H8J9K0MNPS","from":"usr_01JAB2C3D4E5F6G7H8J9K0MNPR","into":"usr_01JAB2C3D4E5F6G7H8J9K0MNPQ","merged_at":"2026-09-27T21:00:00.000Z","initiated_by":"person","username":"bob"}
ddiff --git a/fixtures/network.subject.merged/invalid/legacy-id.json b/fixtures/network.subject.merged/invalid/legacy-id.json
nnew file mode 100644
iindex 00000000..78d3a522
---- /dev/null
++++ b/fixtures/network.subject.merged/invalid/legacy-id.json
@@@ -0,0 +1 @@
++{"merge_id":"mrg_01JAB2C3D4E5F6G7H8J9K0MNPS","from":"42","into":"usr_01JAB2C3D4E5F6G7H8J9K0MNPQ","merged_at":"2026-09-27T21:00:00.000Z","initiated_by":"person"}
ddiff --git a/fixtures/network.subject.merged/valid/person.json b/fixtures/network.subject.merged/valid/person.json
nnew file mode 100644
iindex 00000000..270c26f8
---- /dev/null
++++ b/fixtures/network.subject.merged/valid/person.json
@@@ -0,0 +1 @@
++{"merge_id":"mrg_01JAB2C3D4E5F6G7H8J9K0MNPS","from":"usr_01JAB2C3D4E5F6G7H8J9K0MNPR","into":"usr_01JAB2C3D4E5F6G7H8J9K0MNPQ","merged_at":"2026-09-27T21:00:00.000Z","initiated_by":"person","split_until":"2026-10-27T21:00:00.000Z"}
ddiff --git a/fixtures/network.subject.merged/valid/staff.json b/fixtures/network.subject.merged/valid/staff.json
nnew file mode 100644
iindex 00000000..9afaeecd
---- /dev/null
++++ b/fixtures/network.subject.merged/valid/staff.json
@@@ -0,0 +1 @@
++{"merge_id":"mrg_01JAB2C3D4E5F6G7H8J9K0MNPS","from":"usr_01JAB2C3D4E5F6G7H8J9K0MNPR","into":"usr_01JAB2C3D4E5F6G7H8J9K0MNPQ","merged_at":"2026-09-27T21:00:00.000Z","initiated_by":"staff"}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 7132dcbe..1c47d0ea 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -25379,6 +25379,59 @@
}
}
},
++ "network.subject.merged": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/network.subject.merged.v1.json",
++ "title": "NetworkSubjectMergedPayload",
++ "description": "network.subject.merged v1 (OpenVibe.Network; roadmap WS-B task 5, ADR-029). Two accounts became one: `from`, the folded-in subject, is now an alias of `into`, the survivor, which keeps its subject, username, profile and settings. Network has already moved what it owns (linked providers, sessions, OAuth grants, developer projects, OpenCoins and user modules) in the transaction that writes this event to its outbox. Every service that stores subjects repoints its own rows from `from` to `into` in its own transaction and keeps no copy under `from`; a row it cannot move (a unique conflict, such as both accounts following the same channel) keeps the survivor's row and drops the other, as a counted, logged outcome. Until a service has applied it, its rows stay under `from`, which still resolves to `into` through Network's aliases. Apply once per merge_id. Envelope: subject { type: user, id: <into> }, visibility internal, actor the person who merged (or the staff member, for an account-recovery merge).",
++ "type": "object",
++ "required": [
++ "merge_id",
++ "from",
++ "into",
++ "merged_at",
++ "initiated_by"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merge_id": {
++ "type": "string",
++ "pattern": "^mrg_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The merge; a redelivery carries the same id."
++ },
++ "from": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The folded-in subject, now an alias."
++ },
++ "into": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The survivor."
++ },
++ "merged_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "initiated_by": {
++ "enum": [
++ "person",
++ "staff"
++ ],
++ "description": "person: the account holder, signed in to both accounts. staff: an account-recovery merge (staff.identity.merge, with a written reason and an audit row)."
++ },
++ "split_until": {
++ "type": "string",
++ "format": "date-time",
++ "description": "Until then Network keeps the folded-in account's pre-merge state, so staff can split a mistaken merge by hand (30 days)."
++ }
++ }
++ }
++ },
"network.follow.created": {
"version": "1.0.0",
"owner": "network",
@@@ -26485,6 +26538,96 @@
}
}
},
++ "network.account-merge-result": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/network/account-merge-result.v1.json",
++ "title": "NetworkAccountMergeResult",
++ "description": "network.account-merge-result@1 (OpenVibe.Network; roadmap WS-B task 5, ADR-029): the answer of a merge, POST /api/v1/account/merge (the person, signed in to the survivor, with a fresh sign-in proof of the other account) or POST /api/admin/account-merges (staff.identity.merge, with a reason). What Network moved in its one transaction, as counts; everything else follows network.subject.merged. A retried merge of the same pair answers the first merge (replayed: true) and moves nothing again.",
++ "type": "object",
++ "required": [
++ "merge_id",
++ "from",
++ "into",
++ "merged_at",
++ "split_until",
++ "moved",
++ "replayed"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "merge_id": {
++ "type": "string",
++ "pattern": "^mrg_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "from": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "into": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "merged_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "split_until": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "moved": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "providers",
++ "sessions",
++ "oauth_grants",
++ "projects",
++ "coins",
++ "modules"
++ ],
++ "properties": {
++ "providers": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Linked sign-in providers now on the survivor."
++ },
++ "sessions": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Sessions and devices moved; both sets stay valid until they expire."
++ },
++ "oauth_grants": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "projects": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Developer projects, still owned, now by the survivor."
++ },
++ "coins": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "OpenCoins added to the survivor (one ledger entry per side, reason account_merge)."
++ },
++ "modules": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "User-module records changed on the survivor (its own fields win)."
++ }
++ }
++ },
++ "replayed": {
++ "type": "boolean"
++ }
++ }
++ }
++ },
"network.follow-list-result": {
"version": "1.0.0",
"owner": "network",
ddiff --git a/generated/openapi/ai.json b/generated/openapi/ai.json
iindex a359e041..5aabc077 100644
---- a/generated/openapi/ai.json
++++ b/generated/openapi/ai.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.AI API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/billing.json b/generated/openapi/billing.json
iindex 7af4da79..40c2be52 100644
---- a/generated/openapi/billing.json
++++ b/generated/openapi/billing.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Billing API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/blog.json b/generated/openapi/blog.json
iindex f3635f37..e4593bb8 100644
---- a/generated/openapi/blog.json
++++ b/generated/openapi/blog.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Blog API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/chat.json b/generated/openapi/chat.json
iindex 4c806bb3..f7b86d5e 100644
---- a/generated/openapi/chat.json
++++ b/generated/openapi/chat.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Chat API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/codes.json b/generated/openapi/codes.json
iindex 9a723fb9..9c3a2ebc 100644
---- a/generated/openapi/codes.json
++++ b/generated/openapi/codes.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Codes API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/community.json b/generated/openapi/community.json
iindex 4301d641..e71a1d6b 100644
---- a/generated/openapi/community.json
++++ b/generated/openapi/community.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Community API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/coupons.json b/generated/openapi/coupons.json
iindex 6712594e..0d4fc512 100644
---- a/generated/openapi/coupons.json
++++ b/generated/openapi/coupons.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Coupons API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/deals.json b/generated/openapi/deals.json
iindex 456eb507..b02619ed 100644
---- a/generated/openapi/deals.json
++++ b/generated/openapi/deals.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Deals API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/events.json b/generated/openapi/events.json
iindex b7de9612..81b340e7 100644
---- a/generated/openapi/events.json
++++ b/generated/openapi/events.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Events API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/games.json b/generated/openapi/games.json
iindex d7ffed21..3ad0f2ca 100644
---- a/generated/openapi/games.json
++++ b/generated/openapi/games.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Games API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/host.json b/generated/openapi/host.json
iindex f74adf5e..5cde228c 100644
---- a/generated/openapi/host.json
++++ b/generated/openapi/host.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Host API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/live.json b/generated/openapi/live.json
iindex f77b10bd..7df9d9ba 100644
---- a/generated/openapi/live.json
++++ b/generated/openapi/live.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Live API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/media.json b/generated/openapi/media.json
iindex d6bcf172..a6b50fde 100644
---- a/generated/openapi/media.json
++++ b/generated/openapi/media.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Media API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/network.json b/generated/openapi/network.json
iindex 2d8617cd..f1621874 100644
---- a/generated/openapi/network.json
++++ b/generated/openapi/network.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Network API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/news.json b/generated/openapi/news.json
iindex 2983974d..a34bbf44 100644
---- a/generated/openapi/news.json
++++ b/generated/openapi/news.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.News API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/openre.json b/generated/openapi/openre.json
iindex a7c50ba1..2f5eff07 100644
---- a/generated/openapi/openre.json
++++ b/generated/openapi/openre.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenRe.Stream API",
-- "version": "0.68.0",
-- "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/reviews.json b/generated/openapi/reviews.json
iindex dfed76ec..72842876 100644
---- a/generated/openapi/reviews.json
++++ b/generated/openapi/reviews.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Reviews API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/search.json b/generated/openapi/search.json
iindex cca9a5da..ad970d6c 100644
---- a/generated/openapi/search.json
++++ b/generated/openapi/search.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Search API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/sources.json b/generated/openapi/sources.json
iindex be9162cf..97c2dc7c 100644
---- a/generated/openapi/sources.json
++++ b/generated/openapi/sources.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Sources API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/tips.json b/generated/openapi/tips.json
iindex 7fdccedb..d2d554cb 100644
---- a/generated/openapi/tips.json
++++ b/generated/openapi/tips.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Tips API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/tools.json b/generated/openapi/tools.json
iindex d2f7f996..acb46101 100644
---- a/generated/openapi/tools.json
++++ b/generated/openapi/tools.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Tools API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/trade.json b/generated/openapi/trade.json
iindex f691066b..8ca9bb01 100644
---- a/generated/openapi/trade.json
++++ b/generated/openapi/trade.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Trade API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/vip.json b/generated/openapi/vip.json
iindex 7270f560..2a902e83 100644
---- a/generated/openapi/vip.json
++++ b/generated/openapi/vip.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.VIP API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/wiki.json b/generated/openapi/wiki.json
iindex b8e79427..e9931665 100644
---- a/generated/openapi/wiki.json
++++ b/generated/openapi/wiki.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Wiki API",
-- "version": "0.68.0",
-- "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.68.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.69.0",
++ "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.69.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 00a8b8a9..545a2a10 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -28636,6 +28636,34 @@ export interface NetworkPrincipalGrantChangedPayload {
actor_subject?: string | null;
}
++/** [email protected] (owner: network) */
++/**
++ * network.subject.merged v1 (OpenVibe.Network; roadmap WS-B task 5, ADR-029). Two accounts became one: `from`, the folded-in subject, is now an alias of `into`, the survivor, which keeps its subject, username, profile and settings. Network has already moved what it owns (linked providers, sessions, OAuth grants, developer projects, OpenCoins and user modules) in the transaction that writes this event to its outbox. Every service that stores subjects repoints its own rows from `from` to `into` in its own transaction and keeps no copy under `from`; a row it cannot move (a unique conflict, such as both accounts following the same channel) keeps the survivor's row and drops the other, as a counted, logged outcome. Until a service has applied it, its rows stay under `from`, which still resolves to `into` through Network's aliases. Apply once per merge_id. Envelope: subject { type: user, id: <into> }, visibility internal, actor the person who merged (or the staff member, for an account-recovery merge).
++ */
++export interface NetworkSubjectMergedPayload {
++ /**
++ * The merge; a redelivery carries the same id.
++ */
++ merge_id: string;
++ /**
++ * The folded-in subject, now an alias.
++ */
++ from: string;
++ /**
++ * The survivor.
++ */
++ into: string;
++ merged_at: string;
++ /**
++ * person: the account holder, signed in to both accounts. staff: an account-recovery merge (staff.identity.merge, with a written reason and an audit row).
++ */
++ initiated_by: "person" | "staff";
++ /**
++ * Until then Network keeps the folded-in account's pre-merge state, so staff can split a mistaken merge by hand (30 days).
++ */
++ split_until?: string;
++}
++
/** [email protected] (owner: network) */
/**
* network.follow.created v1 (OpenVibe.Network; roadmap WS-E task 4, ADR-030). A person followed something: today a Live channel, whose target id is the channel owner's subject. Follows are the network's, kept by Network and keyed by subjects; products such as Live keep a projection they can rebuild. Written to Network's outbox in the transaction that stores the follow. A consumer applies the latest revision per (follower, target) and ignores an older one. Following again what is already followed changes nothing and sends nothing. Envelope: subject { type: user, id: <follower> }, visibility subject, actor the follower.
@@@ -29101,6 +29129,42 @@ export interface NetworkCreatorAnalyticsResult {
}[];
}
++/** [email protected] (owner: network) */
++/**
++ * network.account-merge-result@1 (OpenVibe.Network; roadmap WS-B task 5, ADR-029): the answer of a merge, POST /api/v1/account/merge (the person, signed in to the survivor, with a fresh sign-in proof of the other account) or POST /api/admin/account-merges (staff.identity.merge, with a reason). What Network moved in its one transaction, as counts; everything else follows network.subject.merged. A retried merge of the same pair answers the first merge (replayed: true) and moves nothing again.
++ */
++export interface NetworkAccountMergeResult {
++ merge_id: string;
++ from: string;
++ into: string;
++ merged_at: string;
++ split_until: string;
++ moved: {
++ /**
++ * Linked sign-in providers now on the survivor.
++ */
++ providers: number;
++ /**
++ * Sessions and devices moved; both sets stay valid until they expire.
++ */
++ sessions: number;
++ oauth_grants: number;
++ /**
++ * Developer projects, still owned, now by the survivor.
++ */
++ projects: number;
++ /**
++ * OpenCoins added to the survivor (one ledger entry per side, reason account_merge).
++ */
++ coins: number;
++ /**
++ * User-module records changed on the survivor (its own fields win).
++ */
++ modules: number;
++ };
++ replayed: boolean;
++}
++
/** [email protected] (owner: network) */
/**
* network.follow-list-result@1 (ADR-030): a page of follows on OpenVibe.Network, newest first. GET /api/v1/me/follows (what the caller follows), GET /api/v1/follows/:type/:target/followers (who follows a target: the target's owner, or a service with network.follows.read). Lists are never public; counts are.
ddiff --git a/manifests/policy/staff-roles.json b/manifests/policy/staff-roles.json
iindex 5047487f..fa725a3b 100644
---- a/manifests/policy/staff-roles.json
++++ b/manifests/policy/staff-roles.json
@@@ -1,5 +1,5 @@
{
-- "version": "1.1.0",
++ "version": "1.2.0",
"roles": [
{
"id": "user",
@@@ -457,6 +457,17 @@
]
}
},
++ {
++ "id": "staff.identity.merge",
++ "minRole": "owner",
++ "since": "1.2.0",
++ "description": "Merge two accounts for account recovery (ADR-029), with a written reason and an audit row; never silently. People merge their own accounts without it, signed in to both.",
++ "gates": {
++ "network": [
++ "identity/account-merge.js (POST /api/admin/account-merges)"
++ ]
++ }
++ },
{
"id": "staff.roles.grant_admin",
"minRole": "owner",
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex c996c6d3..1ca2b32d 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -59,6 +59,7 @@
"network.credential.rotated",
"network.follow.created",
"network.follow.deleted",
++ "network.subject.merged",
"network.grant.changed",
"network.module.updated",
"network.notification.created",
ddiff --git a/package.json b/package.json
iindex 58ab386d..f4ce8184 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.68.0",
++ "version": "0.69.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
ddiff --git a/test/run.js b/test/run.js
iindex 88894005..6a63fba6 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -398,6 +398,10 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(!staff.can({ ...oldAdmin, staff_map: '1.1.0' }, 'staff.editorial.manage'), 'a token from map 1.1.0 holds only what was issued');
ok(!staff.can({ role: 'global_mod', staff_caps: ['staff.moderation.chat'] }, 'staff.editorial.manage') && staff.can({ role: 'global_mod', staff_caps: ['staff.moderation.chat'] }, 'staff.content.moderate'), 'the role decides, never more than it holds');
ok(!staff.can({ role: 'admin', staff_caps: ['staff.moderation.chat'] }, 'staff.users.manage'), 'capabilities from map 1.0.0 still come only from what was issued');
++ // Map 1.2.0 (v0.69.0, ADR-029): account-recovery merges are the owner's alone, and reach an owner's older token by role.
++ ok(staff.get('staff.identity.merge').since === '1.2.0' && staff.get('staff.identity.merge').minRole === 'owner', 'staff.identity.merge: owner, since 1.2.0');
++ ok(staff.can({ role: 'admin', is_owner: true, staff_caps: [], staff_map: '1.1.0' }, 'staff.identity.merge'), 'an owner token (admin + is_owner) from map 1.1.0 merges by role');
++ ok(!staff.can({ role: 'admin', staff_caps: staff.capabilitiesOf('admin'), staff_map: '1.2.0' }, 'staff.identity.merge'), 'an admin never merges accounts');
ok(map.rules.some(x => /never delegated/.test(x)) && map.rules.some(x => /equal or higher/.test(x)) && map.rules.some(x => /owner's account/.test(x)), 'rules: no delegation, rank protection, owner protection');
const adr = fs.readFileSync(path.join(ROOT, 'docs/adr/ADR-022-moderation-console.md'), 'utf8');
ok(/manifests\/policy\/staff-roles\.json/.test(adr), 'ADR-022 points at the staff map');