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 bf22317..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 } ); @@ -156,30 +153,70 @@ 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; + it("finds nothing rather than failing when the root has no readable roster", async () => { + // Break caught: Blizzard 404s a character it holds no current profile for, + // which would discard an otherwise good Raider.IO snapshot and retry a root + // that can never be swept. + await expect( + discoverFingerprintMatches( + root, + { + async getGuildRoster() { + throw Object.assign(new Error("missing"), { kind: "not_found" }); + }, + async getAchievementFingerprint() { + throw new Error("unreachable"); + } + }, + options + ) + ).resolves.toEqual({ kind: "matched", requestsUsed: 1, characters: [] }); + }); + + it("finds nothing rather than failing when the root has no readable profile", async () => { + // Break caught: the same 404 on the root's own achievements would strand the + // run instead of publishing its Raider.IO result. + await expect( + discoverFingerprintMatches( + root, + gatewayFor([candidate(matchingKey)], {}), + options + ) + ).resolves.toEqual({ kind: "matched", requestsUsed: 2, characters: [] }); + }); + + it("skips a candidate with no readable profile and keeps sweeping", async () => { + // Break caught: a roster member whose achievements are unreadable is + // ordinary — the measured live sweep saw 23 of 393 — so treating one as an + // upstream failure would abandon every real sweep and publish nothing. 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; + gatewayFor( + [ + candidate({ region: "eu", realm: "silvermoon", name: "a-missing" }), + candidate(matchingKey) + ], + { + [keyId(root)]: fingerprint(200), + [keyId(matchingKey)]: fingerprint(200) } - } + ), + { ...options, requestCap: 4, isSuppressed: async () => false } ); expect(outcome).toEqual({ kind: "matched", - requestsUsed: 3, - characters: [] + requestsUsed: 4, + characters: [ + { + key: matchingKey, + displayName: "matching", + className: "Mage", + level: 80, + raiderIoUrl: "https://raider.io/characters/eu/silvermoon/matching", + source: "fingerprint" + } + ] }); }); @@ -198,8 +235,7 @@ describe("discoverFingerprintMatches", () => { isSuppressed: async () => { suppressionChecks += 1; return suppressionChecks > 1; - }, - isPrivacyHidden: async () => false + } } ); @@ -230,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 58eef1b..9d3f2aa 100644 --- a/packages/domain/src/fingerprint-discovery.ts +++ b/packages/domain/src/fingerprint-discovery.ts @@ -46,10 +46,18 @@ export type DiscoverFingerprintMatchesOptions = { minimumCommon: number; minimumIdenticalPercent: number; isSuppressed(key: CharacterKey): Promise; - isPrivacyHidden(key: CharacterKey): Promise; signal?: AbortSignal; }; +function isNotFound(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "kind" in error && + error.kind === "not_found" + ); +} + function isCharacterKey(value: unknown): value is CharacterKey { if (typeof value !== "object" || value === null) return false; @@ -211,17 +219,37 @@ export async function discoverFingerprintMatches( } try { - const roster = await request(() => - gateway.getGuildRoster(root, options.signal) - ); + // Blizzard holds no current profile for plenty of characters Raider.IO + // knows, and answers 404. That makes this root unsweepable rather than the + // upstream broken, so report an empty sweep and let its Raider.IO snapshot + // publish instead of stranding the run on a retry that cannot succeed. + let roster: readonly FingerprintCandidate[] | typeof budgetExhausted; + try { + roster = await request(() => + gateway.getGuildRoster(root, options.signal) + ); + } catch (error) { + if (isNotFound(error)) { + return { kind: "matched", characters: [], requestsUsed }; + } + throw error; + } if (roster === budgetExhausted) { return { kind: "capped", characters: [], requestsUsed }; } if (!isCandidateList(roster)) throw { kind: "schema_drift" }; - const rootFingerprint = await request(() => - gateway.getAchievementFingerprint(root, options.signal) - ); + let rootFingerprint: ReadonlyMap | typeof budgetExhausted; + try { + rootFingerprint = await request(() => + gateway.getAchievementFingerprint(root, options.signal) + ); + } catch (error) { + if (isNotFound(error)) { + return { kind: "matched", characters: [], requestsUsed }; + } + throw error; + } if (rootFingerprint === budgetExhausted) { return { kind: "capped", characters: [], requestsUsed }; } @@ -245,13 +273,20 @@ 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; - const candidateFingerprint = await request(() => - gateway.getAchievementFingerprint(candidate.key, options.signal) - ); + // A roster member with no readable achievement profile is ordinary, not an + // upstream fault: the measured live sweep saw 23 of 393 candidates return + // one. Skip the candidate and keep the request it already consumed. + let candidateFingerprint: + ReadonlyMap | typeof budgetExhausted; + try { + candidateFingerprint = await request(() => + gateway.getAchievementFingerprint(candidate.key, options.signal) + ); + } catch (error) { + if (isNotFound(error)) continue; + throw error; + } if (candidateFingerprint === budgetExhausted) break; if (!isFingerprint(candidateFingerprint)) throw { kind: "schema_drift" }; @@ -263,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)); }