diff --git a/dist/schemas/multiaccount-changed-event.schema.json b/dist/schemas/multiaccount-changed-event.schema.json index 21dd85a..e13f05c 100644 --- a/dist/schemas/multiaccount-changed-event.schema.json +++ b/dist/schemas/multiaccount-changed-event.schema.json @@ -51,6 +51,8 @@ "revision", "previous_revision", "user_hids", + "added_user_hids", + "removed_user_hids", "users_count", "members_complete", "evaluated_at", @@ -95,7 +97,7 @@ "cluster_id": { "type": "string", "format": "uuid", - "description": "ID of the exact sorted user set within this site. A membership change resolves the old group and detects a new group." + "description": "Persistent site-qualified group identity. Unambiguous expansion or contraction preserves this ID; ambiguous split/merge starts new identities." }, "revision": { "type": "string", @@ -128,6 +130,24 @@ } } }, + "added_user_hids": { + "type": "array", + "uniqueItems": true, + "description": "Sorted members added since the previous transition; all current members for detected.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "removed_user_hids": { + "type": "array", + "uniqueItems": true, + "description": "Sorted members removed since the previous transition; all previous members for resolved.", + "items": { + "type": "string", + "minLength": 1 + } + }, "users_count": { "type": "integer", "minimum": 3, @@ -198,6 +218,34 @@ } }, "allOf": [ + { + "if": { + "properties": { + "action": { + "const": "detected" + } + }, + "required": [ + "action" + ] + }, + "then": { + "additionalProperties": true, + "properties": { + "previous_revision": { + "type": "null" + } + } + }, + "else": { + "additionalProperties": true, + "properties": { + "previous_revision": { + "type": "string" + } + } + } + }, { "if": { "properties": { diff --git a/dist/schemas/webhook-event.schema.json b/dist/schemas/webhook-event.schema.json index d9b7de1..d085438 100644 --- a/dist/schemas/webhook-event.schema.json +++ b/dist/schemas/webhook-event.schema.json @@ -880,6 +880,8 @@ "revision", "previous_revision", "user_hids", + "added_user_hids", + "removed_user_hids", "users_count", "members_complete", "evaluated_at", @@ -924,7 +926,7 @@ "cluster_id": { "type": "string", "format": "uuid", - "description": "ID of the exact sorted user set within this site. A membership change resolves the old group and detects a new group." + "description": "Persistent site-qualified group identity. Unambiguous expansion or contraction preserves this ID; ambiguous split/merge starts new identities." }, "revision": { "type": "string", @@ -957,6 +959,24 @@ } } }, + "added_user_hids": { + "type": "array", + "uniqueItems": true, + "description": "Sorted members added since the previous transition; all current members for detected.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "removed_user_hids": { + "type": "array", + "uniqueItems": true, + "description": "Sorted members removed since the previous transition; all previous members for resolved.", + "items": { + "type": "string", + "minLength": 1 + } + }, "users_count": { "type": "integer", "minimum": 3, @@ -1027,6 +1047,34 @@ } }, "allOf": [ + { + "if": { + "properties": { + "action": { + "const": "detected" + } + }, + "required": [ + "action" + ] + }, + "then": { + "additionalProperties": true, + "properties": { + "previous_revision": { + "type": "null" + } + } + }, + "else": { + "additionalProperties": true, + "properties": { + "previous_revision": { + "type": "string" + } + } + } + }, { "if": { "properties": { diff --git a/dist/shieldlabs-api.json b/dist/shieldlabs-api.json index 177a510..1a95ca7 100644 --- a/dist/shieldlabs-api.json +++ b/dist/shieldlabs-api.json @@ -439,7 +439,7 @@ "Webhooks" ], "summary": "A multi-account group changed", - "description": "Opt-in notification when a complete background detector publication detects, updates, or\nresolves an exact group of accounts. Enable `multiaccount: true` on the webhook endpoint.\nThe default is false; existing identification.scored subscriptions are preserved.\n\nA repeated build with unchanged membership, level, and rules does not produce a new event.\nA membership change produces resolved for the previous exact group and detected for the new\nexact group. Resolved means the group is no longer supported by the current complete source\ncut, including evidence expiry; it does not certify that its accounts are safe.\n\nDelivery is at least once, with no global ordering guarantee. Save event_id atomically with\nyour business operation. Within a site, publication epoch, and cluster_id, compare revision\nas an integer and ignore older transitions. Verify HMAC-SHA256 over the raw received bytes;\nX-Shield-Event-Id is a convenience header, and the signed body is authoritative.\n\nThis event has no request_id or risk_score. Its source identifies the published group result;\nit does not mutate an earlier identification.scored body or an already pinned Console view.", + "description": "Opt-in notification when a complete background detector publication detects, updates, or\nresolves an exact group of accounts. Enable `multiaccount: true` on the webhook endpoint.\nThe default is false; existing identification.scored subscriptions are preserved.\n\nA repeated build with unchanged membership, level, and rules does not produce a new event.\nAn unambiguous membership expansion or contraction preserves cluster_id and produces updated.\nAmbiguous splits or merges resolve replaced groups and detect new identities. Resolved means the group is no longer supported by the current complete source\ncut, including evidence expiry; it does not certify that its accounts are safe.\n\nDelivery is at least once, with no global ordering guarantee. Save event_id atomically with\nyour business operation. Within a site, publication epoch, and cluster_id, compare revision\nas an integer and ignore older transitions. Verify HMAC-SHA256 over the raw received bytes;\nX-Shield-Event-Id is a convenience header, and the signed body is authoritative.\n\nThis event has no request_id or risk_score. Its source identifies the published group result;\nit does not mutate an earlier identification.scored body or an already pinned Console view.", "security": [], "parameters": [ { @@ -2039,6 +2039,8 @@ "revision", "previous_revision", "user_hids", + "added_user_hids", + "removed_user_hids", "users_count", "members_complete", "evaluated_at", @@ -2083,7 +2085,7 @@ "cluster_id": { "type": "string", "format": "uuid", - "description": "ID of the exact sorted user set within this site. A membership change resolves the old group and detects a new group." + "description": "Persistent site-qualified group identity. Unambiguous expansion or contraction preserves this ID; ambiguous split/merge starts new identities." }, "revision": { "type": "string", @@ -2116,6 +2118,24 @@ } } }, + "added_user_hids": { + "type": "array", + "uniqueItems": true, + "description": "Sorted members added since the previous transition; all current members for detected.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "removed_user_hids": { + "type": "array", + "uniqueItems": true, + "description": "Sorted members removed since the previous transition; all previous members for resolved.", + "items": { + "type": "string", + "minLength": 1 + } + }, "users_count": { "type": "integer", "minimum": 3, @@ -2186,6 +2206,34 @@ } }, "allOf": [ + { + "if": { + "properties": { + "action": { + "const": "detected" + } + }, + "required": [ + "action" + ] + }, + "then": { + "additionalProperties": true, + "properties": { + "previous_revision": { + "type": "null" + } + } + }, + "else": { + "additionalProperties": true, + "properties": { + "previous_revision": { + "type": "string" + } + } + } + }, { "if": { "properties": { @@ -3436,7 +3484,7 @@ "summary": "Multi-account group detected", "description": "Generated by the Core event marshaler for a synthetic three-account group.", "value": { - "event_id": "ad5efdbb113f50d1207c3a3b1d03e54ee60dfb9a", + "event_id": "7795f86574643ece49997885d0a0a3e5768dcb74", "site_id": 319, "event_type": "hre.multi_account.changed", "schema_version": "2026-10-07", @@ -3448,9 +3496,15 @@ "status": "evaluated", "detected": true, "level": "medium", - "cluster_id": "4293f02e-7cf0-5cda-9150-b5b8c4ee685d", + "cluster_id": "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f", "revision": "7412", "previous_revision": null, + "added_user_hids": [ + "account_a", + "account_b", + "account_c" + ], + "removed_user_hids": [], "user_hids": [ "account_a", "account_b", @@ -3481,7 +3535,7 @@ "summary": "Multi-account group updated", "description": "Generated by the Core event marshaler for a synthetic three-account group.", "value": { - "event_id": "27225e88895f45c6f96562578308d3afea9d4ea2", + "event_id": "2feaa43f05ad19dadd78c85c465c8b4d101f9e2f", "site_id": 319, "event_type": "hre.multi_account.changed", "schema_version": "2026-10-07", @@ -3493,9 +3547,11 @@ "status": "evaluated", "detected": true, "level": "high", - "cluster_id": "4293f02e-7cf0-5cda-9150-b5b8c4ee685d", + "cluster_id": "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f", "revision": "7414", "previous_revision": "7412", + "added_user_hids": [], + "removed_user_hids": [], "user_hids": [ "account_a", "account_b", @@ -3526,7 +3582,7 @@ "summary": "Multi-account group resolved", "description": "Generated by the Core event marshaler for a synthetic three-account group.", "value": { - "event_id": "0e703af70ff450065df0457d71789d32df4de079", + "event_id": "1f12f427edb3e4b74df2c0c958db49186ef0a7b9", "site_id": 319, "event_type": "hre.multi_account.changed", "schema_version": "2026-10-07", @@ -3538,9 +3594,15 @@ "status": "evaluated", "detected": false, "level": null, - "cluster_id": "4293f02e-7cf0-5cda-9150-b5b8c4ee685d", + "cluster_id": "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f", "revision": "7415", "previous_revision": "7412", + "added_user_hids": [], + "removed_user_hids": [ + "account_a", + "account_b", + "account_c" + ], "user_hids": [ "account_a", "account_b", diff --git a/dist/shieldlabs-api.yaml b/dist/shieldlabs-api.yaml index 9cbe011..09dcf54 100644 --- a/dist/shieldlabs-api.yaml +++ b/dist/shieldlabs-api.yaml @@ -478,8 +478,8 @@ webhooks: The default is false; existing identification.scored subscriptions are preserved. A repeated build with unchanged membership, level, and rules does not produce a new event. - A membership change produces resolved for the previous exact group and detected for the new - exact group. Resolved means the group is no longer supported by the current complete source + An unambiguous membership expansion or contraction preserves cluster_id and produces updated. + Ambiguous splits or merges resolve replaced groups and detect new identities. Resolved means the group is no longer supported by the current complete source cut, including evidence expiry; it does not certify that its accounts are safe. Delivery is at least once, with no global ordering guarantee. Save event_id atomically with @@ -1796,6 +1796,8 @@ components: - revision - previous_revision - user_hids + - added_user_hids + - removed_user_hids - users_count - members_complete - evaluated_at @@ -1830,7 +1832,7 @@ components: cluster_id: type: string format: uuid - description: ID of the exact sorted user set within this site. A membership change resolves the old group and detects a new group. + description: Persistent site-qualified group identity. Unambiguous expansion or contraction preserves this ID; ambiguous split/merge starts new identities. revision: type: string pattern: ^[1-9][0-9]{0,19}$ @@ -1855,6 +1857,20 @@ components: - '-1' - fail - 00000000-0000-0000-0000-000000000000 + added_user_hids: + type: array + uniqueItems: true + description: Sorted members added since the previous transition; all current members for detected. + items: + type: string + minLength: 1 + removed_user_hids: + type: array + uniqueItems: true + description: Sorted members removed since the previous transition; all previous members for resolved. + items: + type: string + minLength: 1 users_count: type: integer minimum: 3 @@ -1906,6 +1922,22 @@ components: type: boolean description: Whether the detector's underlying detailed evidence was truncated. Group membership remains complete. allOf: + - if: + properties: + action: + const: detected + required: + - action + then: + additionalProperties: true + properties: + previous_revision: + type: 'null' + else: + additionalProperties: true + properties: + previous_revision: + type: string - if: properties: action: @@ -2928,7 +2960,7 @@ components: summary: Multi-account group detected description: Generated by the Core event marshaler for a synthetic three-account group. value: - event_id: ad5efdbb113f50d1207c3a3b1d03e54ee60dfb9a + event_id: 7795f86574643ece49997885d0a0a3e5768dcb74 site_id: 319 event_type: hre.multi_account.changed schema_version: '2026-10-07' @@ -2940,9 +2972,14 @@ components: status: evaluated detected: true level: medium - cluster_id: 4293f02e-7cf0-5cda-9150-b5b8c4ee685d + cluster_id: abc35eb1-2cd7-5c28-a805-f3b7c1682a2f revision: '7412' previous_revision: null + added_user_hids: + - account_a + - account_b + - account_c + removed_user_hids: [] user_hids: - account_a - account_b @@ -2965,7 +3002,7 @@ components: summary: Multi-account group updated description: Generated by the Core event marshaler for a synthetic three-account group. value: - event_id: 27225e88895f45c6f96562578308d3afea9d4ea2 + event_id: 2feaa43f05ad19dadd78c85c465c8b4d101f9e2f site_id: 319 event_type: hre.multi_account.changed schema_version: '2026-10-07' @@ -2977,9 +3014,11 @@ components: status: evaluated detected: true level: high - cluster_id: 4293f02e-7cf0-5cda-9150-b5b8c4ee685d + cluster_id: abc35eb1-2cd7-5c28-a805-f3b7c1682a2f revision: '7414' previous_revision: '7412' + added_user_hids: [] + removed_user_hids: [] user_hids: - account_a - account_b @@ -3002,7 +3041,7 @@ components: summary: Multi-account group resolved description: Generated by the Core event marshaler for a synthetic three-account group. value: - event_id: 0e703af70ff450065df0457d71789d32df4de079 + event_id: 1f12f427edb3e4b74df2c0c958db49186ef0a7b9 site_id: 319 event_type: hre.multi_account.changed schema_version: '2026-10-07' @@ -3014,9 +3053,14 @@ components: status: evaluated detected: false level: null - cluster_id: 4293f02e-7cf0-5cda-9150-b5b8c4ee685d + cluster_id: abc35eb1-2cd7-5c28-a805-f3b7c1682a2f revision: '7415' previous_revision: '7412' + added_user_hids: [] + removed_user_hids: + - account_a + - account_b + - account_c user_hids: - account_a - account_b diff --git a/spec/components/examples/MultiaccountDetected.yaml b/spec/components/examples/MultiaccountDetected.yaml index 7647c25..635319e 100644 --- a/spec/components/examples/MultiaccountDetected.yaml +++ b/spec/components/examples/MultiaccountDetected.yaml @@ -2,7 +2,7 @@ summary: "Multi-account group detected" description: "Generated by the Core event marshaler for a synthetic three-account group." value: - event_id: "ad5efdbb113f50d1207c3a3b1d03e54ee60dfb9a" + event_id: "7795f86574643ece49997885d0a0a3e5768dcb74" site_id: 319 event_type: "hre.multi_account.changed" schema_version: "2026-10-07" @@ -14,9 +14,14 @@ value: status: "evaluated" detected: true level: "medium" - cluster_id: "4293f02e-7cf0-5cda-9150-b5b8c4ee685d" + cluster_id: "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f" revision: "7412" previous_revision: null + added_user_hids: + - "account_a" + - "account_b" + - "account_c" + removed_user_hids: [] user_hids: - "account_a" - "account_b" diff --git a/spec/components/examples/MultiaccountResolved.yaml b/spec/components/examples/MultiaccountResolved.yaml index b1b8fce..5524f64 100644 --- a/spec/components/examples/MultiaccountResolved.yaml +++ b/spec/components/examples/MultiaccountResolved.yaml @@ -2,7 +2,7 @@ summary: "Multi-account group resolved" description: "Generated by the Core event marshaler for a synthetic three-account group." value: - event_id: "0e703af70ff450065df0457d71789d32df4de079" + event_id: "1f12f427edb3e4b74df2c0c958db49186ef0a7b9" site_id: 319 event_type: "hre.multi_account.changed" schema_version: "2026-10-07" @@ -14,9 +14,14 @@ value: status: "evaluated" detected: false level: null - cluster_id: "4293f02e-7cf0-5cda-9150-b5b8c4ee685d" + cluster_id: "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f" revision: "7415" previous_revision: "7412" + added_user_hids: [] + removed_user_hids: + - "account_a" + - "account_b" + - "account_c" user_hids: - "account_a" - "account_b" diff --git a/spec/components/examples/MultiaccountUpdated.yaml b/spec/components/examples/MultiaccountUpdated.yaml index f813223..9d0642f 100644 --- a/spec/components/examples/MultiaccountUpdated.yaml +++ b/spec/components/examples/MultiaccountUpdated.yaml @@ -2,7 +2,7 @@ summary: "Multi-account group updated" description: "Generated by the Core event marshaler for a synthetic three-account group." value: - event_id: "27225e88895f45c6f96562578308d3afea9d4ea2" + event_id: "2feaa43f05ad19dadd78c85c465c8b4d101f9e2f" site_id: 319 event_type: "hre.multi_account.changed" schema_version: "2026-10-07" @@ -14,9 +14,11 @@ value: status: "evaluated" detected: true level: "high" - cluster_id: "4293f02e-7cf0-5cda-9150-b5b8c4ee685d" + cluster_id: "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f" revision: "7414" previous_revision: "7412" + added_user_hids: [] + removed_user_hids: [] user_hids: - "account_a" - "account_b" diff --git a/spec/components/schemas/MultiaccountChangedData.yaml b/spec/components/schemas/MultiaccountChangedData.yaml index 3cb4bdf..9282fe1 100644 --- a/spec/components/schemas/MultiaccountChangedData.yaml +++ b/spec/components/schemas/MultiaccountChangedData.yaml @@ -1,6 +1,6 @@ type: object additionalProperties: false -required: [kind, domain, action, status, detected, level, cluster_id, revision, previous_revision, user_hids, users_count, members_complete, evaluated_at, rules_version, source, evidence_summary] +required: [kind, domain, action, status, detected, level, cluster_id, revision, previous_revision, user_hids, added_user_hids, removed_user_hids, users_count, members_complete, evaluated_at, rules_version, source, evidence_summary] properties: kind: const: multi_account @@ -21,7 +21,7 @@ properties: cluster_id: type: string format: uuid - description: ID of the exact sorted user set within this site. A membership change resolves the old group and detects a new group. + description: Persistent site-qualified group identity. Unambiguous expansion or contraction preserves this ID; ambiguous split/merge starts new identities. revision: type: string pattern: '^[1-9][0-9]{0,19}$' @@ -40,6 +40,16 @@ properties: minLength: 1 not: enum: [anonymous, '-1', fail, '00000000-0000-0000-0000-000000000000'] + added_user_hids: + type: array + uniqueItems: true + description: Sorted members added since the previous transition; all current members for detected. + items: {type: string, minLength: 1} + removed_user_hids: + type: array + uniqueItems: true + description: Sorted members removed since the previous transition; all previous members for resolved. + items: {type: string, minLength: 1} users_count: type: integer minimum: 3 @@ -83,6 +93,18 @@ properties: type: boolean description: Whether the detector's underlying detailed evidence was truncated. Group membership remains complete. allOf: + - if: + properties: + action: {const: detected} + required: [action] + then: + additionalProperties: true + properties: + previous_revision: {type: 'null'} + else: + additionalProperties: true + properties: + previous_revision: {type: string} - if: properties: action: {const: resolved} diff --git a/spec/webhooks/multiaccount-changed.yaml b/spec/webhooks/multiaccount-changed.yaml index 2dfc54d..588424f 100644 --- a/spec/webhooks/multiaccount-changed.yaml +++ b/spec/webhooks/multiaccount-changed.yaml @@ -8,8 +8,8 @@ post: The default is false; existing identification.scored subscriptions are preserved. A repeated build with unchanged membership, level, and rules does not produce a new event. - A membership change produces resolved for the previous exact group and detected for the new - exact group. Resolved means the group is no longer supported by the current complete source + An unambiguous membership expansion or contraction preserves cluster_id and produces updated. + Ambiguous splits or merges resolve replaced groups and detect new identities. Resolved means the group is no longer supported by the current complete source cut, including evidence expiry; it does not certify that its accounts are safe. Delivery is at least once, with no global ordering guarantee. Save event_id atomically with diff --git a/test/fixtures/webhook-multiaccount-detected.json b/test/fixtures/webhook-multiaccount-detected.json index 3f4c68d..c217f3d 100644 --- a/test/fixtures/webhook-multiaccount-detected.json +++ b/test/fixtures/webhook-multiaccount-detected.json @@ -1,5 +1,5 @@ { - "event_id": "ad5efdbb113f50d1207c3a3b1d03e54ee60dfb9a", + "event_id": "7795f86574643ece49997885d0a0a3e5768dcb74", "site_id": 319, "event_type": "hre.multi_account.changed", "schema_version": "2026-10-07", @@ -11,9 +11,15 @@ "status": "evaluated", "detected": true, "level": "medium", - "cluster_id": "4293f02e-7cf0-5cda-9150-b5b8c4ee685d", + "cluster_id": "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f", "revision": "7412", "previous_revision": null, + "added_user_hids": [ + "account_a", + "account_b", + "account_c" + ], + "removed_user_hids": [], "user_hids": [ "account_a", "account_b", diff --git a/test/fixtures/webhook-multiaccount-resolved.json b/test/fixtures/webhook-multiaccount-resolved.json index 9627904..159b126 100644 --- a/test/fixtures/webhook-multiaccount-resolved.json +++ b/test/fixtures/webhook-multiaccount-resolved.json @@ -1,5 +1,5 @@ { - "event_id": "0e703af70ff450065df0457d71789d32df4de079", + "event_id": "1f12f427edb3e4b74df2c0c958db49186ef0a7b9", "site_id": 319, "event_type": "hre.multi_account.changed", "schema_version": "2026-10-07", @@ -11,9 +11,15 @@ "status": "evaluated", "detected": false, "level": null, - "cluster_id": "4293f02e-7cf0-5cda-9150-b5b8c4ee685d", + "cluster_id": "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f", "revision": "7415", "previous_revision": "7412", + "added_user_hids": [], + "removed_user_hids": [ + "account_a", + "account_b", + "account_c" + ], "user_hids": [ "account_a", "account_b", diff --git a/test/fixtures/webhook-multiaccount-updated.json b/test/fixtures/webhook-multiaccount-updated.json index df380db..a822219 100644 --- a/test/fixtures/webhook-multiaccount-updated.json +++ b/test/fixtures/webhook-multiaccount-updated.json @@ -1,5 +1,5 @@ { - "event_id": "27225e88895f45c6f96562578308d3afea9d4ea2", + "event_id": "2feaa43f05ad19dadd78c85c465c8b4d101f9e2f", "site_id": 319, "event_type": "hre.multi_account.changed", "schema_version": "2026-10-07", @@ -11,9 +11,11 @@ "status": "evaluated", "detected": true, "level": "high", - "cluster_id": "4293f02e-7cf0-5cda-9150-b5b8c4ee685d", + "cluster_id": "abc35eb1-2cd7-5c28-a805-f3b7c1682a2f", "revision": "7414", "previous_revision": "7412", + "added_user_hids": [], + "removed_user_hids": [], "user_hids": [ "account_a", "account_b", diff --git a/test/multiaccount-webhook.test.mjs b/test/multiaccount-webhook.test.mjs index 5f83084..6977a40 100644 --- a/test/multiaccount-webhook.test.mjs +++ b/test/multiaccount-webhook.test.mjs @@ -27,3 +27,14 @@ test('known multi-account events cannot bypass result validation',()=>{ const bad=structuredClone(e);mutate(bad);assert.equal(union(bad),false); } }); +test('transition ancestry distinguishes a new episode from an existing group',()=>{ + for (const action of ['detected','updated','resolved']) { + const event=JSON.parse(readFileSync(new URL('./fixtures/webhook-multiaccount-'+action+'.json',import.meta.url))); + event.data.previous_revision=action==='detected'?'1':null; + assert.equal(validate(event),false,action+' accepted invalid transition ancestry'); + assert.equal(union(event),false,action+' bypassed union validation'); + } + const baseline=JSON.parse(readFileSync(new URL('./fixtures/webhook-multiaccount-resolved.json',import.meta.url))); + baseline.data.previous_revision='0'; + assert.ok(validate(baseline),JSON.stringify(validate.errors)); +});