Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions apps/web/src/app/privacy/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PrivacyPage />);

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();
});
12 changes: 9 additions & 3 deletions apps/web/src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ export default function PrivacyPage() {

<h2>Fingerprint-derived links</h2>
<p>
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.
</p>
<p>
To have a character excluded, use the removal request below. It is the
only way to remove a character from these results.
</p>

<h2>Removal requests</h2>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 31 additions & 25 deletions packages/application/src/discovery-job-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down
9 changes: 1 addition & 8 deletions packages/application/src/discovery-job-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,7 @@ export function createDiscoveryJobHandler(options: DiscoveryJobHandlerOptions) {
Extract<DiscoveryOutcome, { kind: "failure" }> | 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({
Expand Down Expand Up @@ -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
}
);
Expand Down
87 changes: 61 additions & 26 deletions packages/domain/src/fingerprint-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand Down Expand Up @@ -125,8 +123,7 @@ describe("discoverFingerprintMatches", () => {
requestCap: 3,
minimumCommon: 1,
minimumIdenticalPercent: 0,
isSuppressed: async () => false,
isPrivacyHidden: async () => false
isSuppressed: async () => false
}
);

Expand Down Expand Up @@ -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"
}
]
});
});

Expand All @@ -198,8 +235,7 @@ describe("discoverFingerprintMatches", () => {
isSuppressed: async () => {
suppressionChecks += 1;
return suppressionChecks > 1;
},
isPrivacyHidden: async () => false
}
}
);

Expand Down Expand Up @@ -230,7 +266,6 @@ describe("discoverFingerprintMatches", () => {
}
return false;
},
isPrivacyHidden: async () => false,
signal: aborted.signal
}
);
Expand Down
Loading
Loading