From 47f8f6ab85a746d24d9610bbee1c9ab4be5fb672 Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 17:57:21 +0100 Subject: [PATCH] feat: infer links regardless of Raider.IO ownership visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep gated both its root and every candidate on Raider.IO ownership being public (`ownerId === null`). That condition cannot tell a player who withheld the link from a character never claimed on Raider.IO at all, and it excluded the latter — most characters, and precisely the bank and levelling alts the fingerprint exists to find. Maintainer's decision to remove both gates. This reverses the resolution of "Privacy stance on defeating hidden ownership" (#8) and drops the behavioural mitigation "Data-protection exposure for publishing derived account linkage" (#16) identified as the one that materially moves the UK GDPR balancing and necessity tests. Manual removal requests are now the only exclusion route, and suppression checks are unchanged. Removing the candidate gate also removes one unbudgeted Raider.IO request per roster member — hundreds per sweep, counted against neither the discovery cap nor the Blizzard hourly budget. /privacy no longer promises an exclusion the code does not make; the design spec carries an amendment and CONTEXT.md redefines the term. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qh8Zb2HnaxebWrRLUMoAiv --- CONTEXT.md | 4 +- apps/web/src/app/privacy/page.test.tsx | 14 +++-- apps/web/src/app/privacy/page.tsx | 12 +++- ...chievement-fingerprint-discovery-design.md | 22 +++++++- .../src/discovery-job-handler.test.ts | 56 ++++++++++--------- .../application/src/discovery-job-handler.ts | 9 +-- .../domain/src/fingerprint-discovery.test.ts | 40 ++----------- packages/domain/src/fingerprint-discovery.ts | 9 --- 8 files changed, 76 insertions(+), 90 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 00833ed..3a65d0f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,8 +5,8 @@ SlashWho publishes World of Warcraft character-relationship information derived ## Language **Privacy-hidden ownership**: -The Raider.IO state indicating that a character's ownership link is intentionally not public. It is SlashWho's sole privacy signal for inferred relationships. -_Avoid_: Hidden alt, upstream opt-out +The Raider.IO state in which a character carries no public ownership link. SlashWho records it as a snapshot limitation reason; it does **not** exclude the character from inferred relationships, because the state cannot be told apart from a character never claimed on Raider.IO. A manual removal request is the only exclusion route. +_Avoid_: Hidden alt, upstream opt-out, privacy signal **Fingerprint-derived link**: A relationship between characters inferred from Blizzard achievement-completion data, rather than declared by Raider.IO. diff --git a/apps/web/src/app/privacy/page.test.tsx b/apps/web/src/app/privacy/page.test.tsx index ba4cc2f..581090a 100644 --- a/apps/web/src/app/privacy/page.test.tsx +++ b/apps/web/src/app/privacy/page.test.tsx @@ -6,16 +6,18 @@ import { expect, it } from "vitest"; import PrivacyPage from "./page"; -it("states the fingerprint privacy boundary without publishing a discovery method", () => { - // Break caught: public documentation could promise privacy while leaving it - // unclear that privacy-hidden ownership is excluded from inferred links. +it("states the fingerprint reach without publishing a discovery method", () => { + // Break caught: the page previously promised that privacy-hidden Raider.IO + // ownership was excluded from inferred links. It no longer is, so a page still + // claiming the exclusion would tell players something untrue about their data. render(); - expect( - screen.getByText(/privacy-hidden Raider\.IO ownership is excluded/i) - ).toBeInTheDocument(); + expect(screen.getByText(/ownership is not shown there/i)).toBeInTheDocument(); expect( screen.getByText(/public alt lists do not disclose the discovery method/i) ).toBeInTheDocument(); + expect( + screen.queryByText(/privacy-hidden Raider\.IO ownership is excluded/i) + ).not.toBeInTheDocument(); expect(screen.queryByText(/opt-out/i)).not.toBeInTheDocument(); }); diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 2fb6658..f77e21b 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -28,9 +28,15 @@ export default function PrivacyPage() {

Fingerprint-derived links

- Privacy-hidden Raider.IO ownership is excluded from fingerprint-derived - links. Public alt lists do not disclose the discovery method for any - character relationship. + Character relationships are also inferred from public Blizzard + achievement-completion data. Such a link can connect characters that are + not publicly connected on Raider.IO, including characters whose + ownership is not shown there. Public alt lists do not disclose the + discovery method for any character relationship. +

+

+ To have a character excluded, use the removal request below. It is the + only way to remove a character from these results.

Removal requests

diff --git a/docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md b/docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md index 00481fe..9390ea9 100644 --- a/docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md +++ b/docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md @@ -2,7 +2,27 @@ **Date:** 2026-08-10 -**Status:** Approved for implementation planning +**Status:** Approved for implementation planning; privacy boundary amended +2026-08-10 (see below) + +## Amendment: the privacy-hidden exclusion was removed + +Everything below describing privacy-hidden Raider.IO ownership as a reason to +exclude a root or a candidate from fingerprint discovery **no longer describes +the system**. The maintainer removed both exclusions after implementation: the +condition available in code (`ownerId === null`) cannot distinguish a player who +withheld the link from a character never claimed on Raider.IO at all, and it was +skipping the latter — the majority of characters, and the population the sweep +exists to reach. + +The consequences accepted with that decision: a fingerprint-derived link may now +connect characters whose Raider.IO ownership is not public, reversing +[Privacy stance on defeating hidden ownership](https://github.com/Erilla/SlashWho/issues/8) +and the mitigation +[Data-protection exposure for publishing derived account linkage](https://github.com/Erilla/SlashWho/issues/16) +identified as the one that materially moves the UK GDPR balancing and necessity +tests. Manual removal requests are the only remaining exclusion route. The +`/privacy` page was rewritten to state this rather than the old promise. ## Summary diff --git a/packages/application/src/discovery-job-handler.test.ts b/packages/application/src/discovery-job-handler.test.ts index 0318271..74a94af 100644 --- a/packages/application/src/discovery-job-handler.test.ts +++ b/packages/application/src/discovery-job-handler.test.ts @@ -696,14 +696,15 @@ describe("discovery job handler", () => { }); }); - it("never starts a fingerprint sweep from privacy-hidden root ownership", async () => { - // Break caught: a root whose Raider.IO ownership is intentionally hidden - // could seed inferred links despite the project's sole privacy signal. + it("sweeps a root whose Raider.IO ownership is not public", async () => { + // Break caught: gating the sweep on absent Raider.IO ownership excluded + // every character never claimed upstream, which is most of them, leaving + // the sweep unable to reach the alts it exists to find. const repositories = createMemoryRepositories(); const run = await repositories.runs.createOrReuse(rootKey, "anonymous"); repositories.fingerprintSweeps.requestAdmission = vi.fn(async () => ({ kind: "admitted" as const, - reservationId: "privacy-reservation", + reservationId: "unclaimed-reservation", requestCap: 300 })); const gateway = new MutableGateway(); @@ -722,10 +723,8 @@ describe("discovery job handler", () => { delivery() ); - expect( - repositories.fingerprintSweeps.requestAdmission - ).not.toHaveBeenCalled(); - expect(blizzardGateway.getGuildRoster).not.toHaveBeenCalled(); + expect(repositories.fingerprintSweeps.requestAdmission).toHaveBeenCalled(); + expect(blizzardGateway.getGuildRoster).toHaveBeenCalled(); await expect( repositories.snapshots.getCurrent(rootKey) ).resolves.toMatchObject({ @@ -734,35 +733,42 @@ describe("discovery job handler", () => { }); }); - it("never starts a fingerprint sweep when request capping masks hidden root ownership", async () => { - // Break caught: request_cap can take precedence over privacy_hidden while - // preserving the same privacy fact that must bar fingerprint inference. + it("spends no Raider.IO request per swept candidate", async () => { + // Break caught: checking each candidate's upstream ownership cost one + // unbudgeted Raider.IO request per roster member — hundreds per sweep, + // counted against neither the discovery cap nor the Blizzard budget. const repositories = createMemoryRepositories(); const run = await repositories.runs.createOrReuse(rootKey, "anonymous"); repositories.fingerprintSweeps.requestAdmission = vi.fn(async () => ({ kind: "admitted" as const, - reservationId: "capped-privacy-reservation", + reservationId: "roster-reservation", requestCap: 300 })); const gateway = new MutableGateway(); - gateway.getCharacter = async () => ({ - ...character(rootKey), - ownerId: null, - profileGuess: "private-alias" - }); - gateway.resolveProfileGuess = async () => null; + const getCharacter = vi.fn(gateway.getCharacter.bind(gateway)); + gateway.getCharacter = getCharacter; + const blizzardGateway = new MutableBlizzardGateway(); + const roster = Array.from({ length: 5 }, (_, index) => ({ + key: { + region: "eu" as const, + realm: "silvermoon", + name: `member${index}` + }, + displayName: `Member${index}`, + className: "Mage", + level: 80 + })); + blizzardGateway.getGuildRoster = async () => roster; - await handlerFor(repositories, gateway, { requestCap: 1 }).execute( + await handlerFor(repositories, gateway, { blizzardGateway }).execute( run.id, delivery() ); - expect( - repositories.fingerprintSweeps.requestAdmission - ).not.toHaveBeenCalled(); - await expect( - repositories.snapshots.getCurrent(rootKey) - ).resolves.toMatchObject({ limitationCode: "request_cap" }); + const sweptKeys = getCharacter.mock.calls + .map(([key]) => key?.name ?? "") + .filter((name) => name.startsWith("member")); + expect(sweptKeys).toEqual([]); }); it("emits one allowlisted operational record per completed discovery", async () => { diff --git a/packages/application/src/discovery-job-handler.ts b/packages/application/src/discovery-job-handler.ts index 0ef9e55..bfb115d 100644 --- a/packages/application/src/discovery-job-handler.ts +++ b/packages/application/src/discovery-job-handler.ts @@ -239,11 +239,7 @@ export function createDiscoveryJobHandler(options: DiscoveryJobHandlerOptions) { Extract | undefined; const fingerprint = options.fingerprint; const blizzardGateway = options.blizzardGateway; - const privacyHiddenRoot = - outcome.state === "partial" && - (outcome.limitationCode === "privacy_hidden" || - outcome.privacyHiddenObserved === true); - if (fingerprint && blizzardGateway && !privacyHiddenRoot) { + if (fingerprint && blizzardGateway) { const admissionTime = now(); const admission = await options.repositories.fingerprintSweeps.requestAdmission({ @@ -350,9 +346,6 @@ export function createDiscoveryJobHandler(options: DiscoveryJobHandlerOptions) { fingerprint.minimumIdenticalPercent, isSuppressed: (key) => options.repositories.suppressions.isActive(key), - isPrivacyHidden: async (key) => - (await options.gateway.getCharacter(key, context.signal)) - .ownerId === null, signal: context.signal } ); diff --git a/packages/domain/src/fingerprint-discovery.test.ts b/packages/domain/src/fingerprint-discovery.test.ts index 23ae781..61978fb 100644 --- a/packages/domain/src/fingerprint-discovery.test.ts +++ b/packages/domain/src/fingerprint-discovery.test.ts @@ -62,12 +62,11 @@ const options = { requestCap: 3, minimumCommon: 200, minimumIdenticalPercent: 20, - isSuppressed: async (key: CharacterKey) => key.name === "a-suppressed", - isPrivacyHidden: async (key: CharacterKey) => key.name === "b-hidden" + isSuppressed: async (key: CharacterKey) => key.name === "a-suppressed" }; describe("discoverFingerprintMatches", () => { - it("fetches the root once, skips suppressed, privacy-hidden, and cross-region candidates, and stops at its cap", async () => { + it("fetches the root once, skips suppressed and cross-region candidates, and stops at its cap", async () => { // Break caught: roster order or excluded candidates could consume the sweep // budget, preventing an otherwise matching same-region character from being // admitted before the cap. @@ -77,7 +76,6 @@ describe("discoverFingerprintMatches", () => { [ candidate({ region: "eu", realm: "silvermoon", name: "z-last" }), candidate(matchingKey), - candidate({ region: "eu", realm: "silvermoon", name: "b-hidden" }), candidate({ region: "eu", realm: "silvermoon", @@ -125,8 +123,7 @@ describe("discoverFingerprintMatches", () => { requestCap: 3, minimumCommon: 1, minimumIdenticalPercent: 0, - isSuppressed: async () => false, - isPrivacyHidden: async () => false + isSuppressed: async () => false } ); @@ -223,33 +220,6 @@ describe("discoverFingerprintMatches", () => { }); }); - it("rechecks privacy immediately before admitting a matched candidate", async () => { - // Break caught: a privacy-hidden designation that lands while the candidate - // fingerprint is being fetched could still be retained in the result. - let privacyChecks = 0; - const outcome = await discoverFingerprintMatches( - root, - gatewayFor([candidate(matchingKey)], { - [keyId(root)]: fingerprint(200), - [keyId(matchingKey)]: fingerprint(200) - }), - { - ...options, - isSuppressed: async () => false, - isPrivacyHidden: async () => { - privacyChecks += 1; - return privacyChecks > 1; - } - } - ); - - expect(outcome).toEqual({ - kind: "matched", - requestsUsed: 3, - characters: [] - }); - }); - it("rechecks suppression immediately before admitting a matched candidate", async () => { // Break caught: a removal that lands while the candidate fingerprint is // being fetched could still be retained in the result. @@ -265,8 +235,7 @@ describe("discoverFingerprintMatches", () => { isSuppressed: async () => { suppressionChecks += 1; return suppressionChecks > 1; - }, - isPrivacyHidden: async () => false + } } ); @@ -297,7 +266,6 @@ describe("discoverFingerprintMatches", () => { } return false; }, - isPrivacyHidden: async () => false, signal: aborted.signal } ); diff --git a/packages/domain/src/fingerprint-discovery.ts b/packages/domain/src/fingerprint-discovery.ts index b8273c1..9d3f2aa 100644 --- a/packages/domain/src/fingerprint-discovery.ts +++ b/packages/domain/src/fingerprint-discovery.ts @@ -46,7 +46,6 @@ export type DiscoverFingerprintMatchesOptions = { minimumCommon: number; minimumIdenticalPercent: number; isSuppressed(key: CharacterKey): Promise; - isPrivacyHidden(key: CharacterKey): Promise; signal?: AbortSignal; }; @@ -274,9 +273,6 @@ export async function discoverFingerprintMatches( const isSuppressed = await options.isSuppressed(candidate.key); throwIfAborted(); if (isSuppressed) continue; - const isPrivacyHidden = await options.isPrivacyHidden(candidate.key); - throwIfAborted(); - if (isPrivacyHidden) continue; // A roster member with no readable achievement profile is ordinary, not an // upstream fault: the measured live sweep saw 23 of 393 candidates return @@ -302,11 +298,6 @@ export async function discoverFingerprintMatches( ); throwIfAborted(); if (isSuppressedBeforeAdmission) continue; - const isPrivacyHiddenBeforeAdmission = await options.isPrivacyHidden( - candidate.key - ); - throwIfAborted(); - if (isPrivacyHiddenBeforeAdmission) continue; matches.push(discoveredCharacter(candidate)); }