From 09a04dc328ac709d4a97120e427c8aa39d3a7363 Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 17:50:37 +0100 Subject: [PATCH 1/2] fix(domain): treat unreadable Blizzard profiles as ordinary A 404 from the Profile API turned the whole sweep into a retryable upstream failure, so it published nothing and retried from the root. The live measurement saw 23 of 393 candidates 404, which means in practice no real sweep would ever have published. Skip such a candidate and keep the request it already spent. For the root's own roster or achievements, report an empty sweep instead: the root cannot be fingerprinted at all, so its Raider.IO snapshot should publish rather than the run being stranded on a retry that cannot succeed. Unguilded roots already return an empty roster and are unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qh8Zb2HnaxebWrRLUMoAiv --- .../domain/src/fingerprint-discovery.test.ts | 67 +++++++++++++++++++ packages/domain/src/fingerprint-discovery.ts | 57 +++++++++++++--- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/packages/domain/src/fingerprint-discovery.test.ts b/packages/domain/src/fingerprint-discovery.test.ts index bf22317..23ae781 100644 --- a/packages/domain/src/fingerprint-discovery.test.ts +++ b/packages/domain/src/fingerprint-discovery.test.ts @@ -156,6 +156,73 @@ describe("discoverFingerprintMatches", () => { }); }); + 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({ 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: 4, + characters: [ + { + key: matchingKey, + displayName: "matching", + className: "Mage", + level: 80, + raiderIoUrl: "https://raider.io/characters/eu/silvermoon/matching", + source: "fingerprint" + } + ] + }); + }); + 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. diff --git a/packages/domain/src/fingerprint-discovery.ts b/packages/domain/src/fingerprint-discovery.ts index 58eef1b..b8273c1 100644 --- a/packages/domain/src/fingerprint-discovery.ts +++ b/packages/domain/src/fingerprint-discovery.ts @@ -50,6 +50,15 @@ export type DiscoverFingerprintMatchesOptions = { 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 +220,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 }; } @@ -249,9 +278,19 @@ export async function discoverFingerprintMatches( 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" }; From 95f0c3494b1d071cdb280975362a15002b2b36ba Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 17:57:21 +0100 Subject: [PATCH 2/2] 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)); }