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));
}