From cd1515617ea72ee0684579625dd8d59317041c49 Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 10:26:34 +0100 Subject: [PATCH 1/3] docs: design fingerprint sweep measurement prototype --- ...10-fingerprint-sweep-measurement-design.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md diff --git a/docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md b/docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md new file mode 100644 index 0000000..3bb5fe5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md @@ -0,0 +1,32 @@ +# Fingerprint sweep measurement prototype + +## Purpose + +Measure the live candidate-generation and achievement-fingerprint path that SlashWho would use for `Ictinus` on EU Argent Dawn. The prototype answers whether a bounded guild-roster breadth-first sweep has acceptable request volume, latency, payload weight, and discrimination for five known same-account characters. + +## Scope + +The throwaway CLI starts from `Ictinus`, enumerates reachable guild rosters breadth-first, and fetches achievement data for candidates until it reaches a hard ceiling of 3,000 Blizzard achievement requests. `Ictinus`, `Driptinus`, `Boptinus`, `Cryptinus`, and `Mistakinus` are ground truth for the same account. Raider.IO exposes no ownership or Warband links for `Ictinus`. + +The prototype uses the Railway `test` worker environment at runtime. Credentials remain environment variables and are neither printed nor written to disk. + +## Design + +`scripts/prototypes/fingerprint-sweep-measurement.ts` is a clearly marked disposable CLI, run through a single package script. It: + +1. Authenticates to Blizzard with the worker's runtime credentials. +2. Traverses guild rosters breadth-first from `Ictinus`. +3. Fetches candidate achievements transiently and measures each response's reported `Content-Length` when available, received-body size, and elapsed time. +4. Compares each candidate with `Ictinus` using shared achievement IDs and equal completion timestamps. +5. Immediately discards each raw response and derived fingerprint. +6. Prints one redacted JSON summary. + +The summary contains candidate and request counts, elapsed time, payload-size distribution, score distribution, matches among the five known characters, cap status, and aggregate failure counts. It contains no credentials, raw responses, achievement identifiers, timestamps, or persisted fingerprints. + +## Boundaries + +The prototype performs no database writes and creates no snapshots. It does not change production worker behavior. API, transport, 429, and 5xx failures are counted in the summary only. The source remains on the `codex/prototype-fingerprint-sweep-measurements` scratch branch as the primary record of the measurement. + +## Verification + +The prototype is validated by one successful bounded run against the Railway `test` environment and by inspecting its redacted summary. It intentionally has no automated tests because it is a throwaway measurement tool. From 929fd0a727980580cb30998bb8ccbe9fd03ac9e1 Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 10:39:17 +0100 Subject: [PATCH 2/3] chore: add fingerprint sweep measurement prototype --- package.json | 1 + .../fingerprint-sweep-measurement.ts | 463 ++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 scripts/prototypes/fingerprint-sweep-measurement.ts diff --git a/package.json b/package.json index 70d0cc9..1b88c57 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "format:check": "prettier --check .", "lint": "eslint .", "ops:removals": "tsx scripts/removals.mts", + "prototype:fingerprint-sweep": "tsx scripts/prototypes/fingerprint-sweep-measurement.ts", "typecheck": "tsc -p tsconfig.tools.json && pnpm -r typecheck", "test": "vitest run", "test:unit": "vitest run --project unit", diff --git a/scripts/prototypes/fingerprint-sweep-measurement.ts b/scripts/prototypes/fingerprint-sweep-measurement.ts new file mode 100644 index 0000000..2bdcf48 --- /dev/null +++ b/scripts/prototypes/fingerprint-sweep-measurement.ts @@ -0,0 +1,463 @@ +/** + * PROTOTYPE — measures the live guild-roster fingerprint path for Ictinus. + * It never writes to the database or disk and discards every upstream body and + * fingerprint after use. Its sole output is a redacted aggregate JSON report. + */ + +type CharacterKey = Readonly<{ + region: "eu"; + realm: string; + name: string; +}>; +type Fingerprint = ReadonlyMap; +type Match = Readonly<{ + common: number; + identical: number; + percent: number; + isMatch: boolean; +}>; +type Guild = Readonly<{ realm: string; name: string; depth: number }>; +type FailureKind = + | "not_found" + | "rate_limited" + | "server_error" + | "other_http" + | "transport" + | "schema"; +type Distribution = Readonly<{ + count: number; + min: number | null; + median: number | null; + max: number | null; +}>; +type KnownComparison = Readonly<{ + encountered: boolean; + common: number | null; + identical: number | null; + percent: number | null; + isMatch: boolean | null; +}>; +type MeasurementSummary = Readonly<{ + root: CharacterKey; + requestCap: number; + capReached: boolean; + guildsVisited: number; + candidatesConsidered: number; + achievementRequests: number; + wallTimeMs: number; + payloadBytes: { + contentLength: Distribution; + receivedBody: Distribution; + }; + scores: Distribution; + knownCharacters: Record; + matchedCharacters: number; + failures: Record; +}>; + +class ResponseSchemaError extends Error {} + +const root: CharacterKey = { + region: "eu", + realm: "argent-dawn", + name: "ictinus" +}; +const knownCharacterNames = [ + "ictinus", + "driptinus", + "boptinus", + "cryptinus", + "mistakinus" +] as const; +const knownCharacters = new Set(knownCharacterNames); +const requestCap = 3_000; +const minCommonAchievements = 200; +const matchPercentThreshold = 20; + +function requiredEnvironment( + name: "BLIZZARD_CLIENT_ID" | "BLIZZARD_CLIENT_SECRET" +): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name.toLowerCase()}_required`); + return value; +} + +function compareFingerprints(left: Fingerprint, right: Fingerprint): Match { + let common = 0; + let identical = 0; + for (const [id, timestamp] of left) { + const otherTimestamp = right.get(id); + if (otherTimestamp === undefined) continue; + common += 1; + if (otherTimestamp === timestamp) identical += 1; + } + const percent = common === 0 ? 0 : (identical / common) * 100; + return { + common, + identical, + percent, + isMatch: common >= minCommonAchievements && percent >= matchPercentThreshold + }; +} + +function normalizedKey(value: Pick): string { + return `${value.realm.toLocaleLowerCase("en-US")}/${value.name.toLocaleLowerCase("en-US")}`; +} + +function normalizedGuildKey(value: Pick): string { + return `${value.realm.toLocaleLowerCase("en-US")}/${value.name.toLocaleLowerCase("en-US")}`; +} + +function distribution(values: readonly number[]): Distribution { + if (values.length === 0) { + return { count: 0, min: null, median: null, max: null }; + } + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; + return { + count: sorted.length, + min: sorted[0], + median, + max: sorted.at(-1) ?? null + }; +} + +function emptyFailures(): Record { + return { + not_found: 0, + rate_limited: 0, + server_error: 0, + other_http: 0, + transport: 0, + schema: 0 + }; +} + +function emptyKnownComparisons(): Record { + return Object.fromEntries( + knownCharacterNames.map((name) => [ + name, + { + encountered: name === root.name, + common: null, + identical: null, + percent: null, + isMatch: null + } + ]) + ); +} + +function failureKind(status: number): FailureKind { + if (status === 404) return "not_found"; + if (status === 429) return "rate_limited"; + if (status >= 500) return "server_error"; + return "other_http"; +} + +async function getAccessToken( + clientId: string, + clientSecret: string +): Promise { + const response = await fetch("https://oauth.battle.net/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}` + }, + body: "grant_type=client_credentials" + }); + if (!response.ok) throw new Error(`oauth_http_${response.status}`); + const payload: unknown = await response.json(); + if ( + !payload || + typeof payload !== "object" || + typeof (payload as { access_token?: unknown }).access_token !== "string" + ) { + throw new Error("oauth_schema"); + } + return (payload as { access_token: string }).access_token; +} + +async function measuredJson( + url: URL, + token: string +): Promise<{ + status: number; + elapsedMs: number; + contentLength: number | null; + receivedBytes: number; + body: unknown; +}> { + const startedAt = performance.now(); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } + }); + const elapsedMs = performance.now() - startedAt; + const contentLengthHeader = response.headers.get("Content-Length"); + const contentLength = + contentLengthHeader && /^\d+$/.test(contentLengthHeader) + ? Number(contentLengthHeader) + : null; + const bytes = await response.arrayBuffer(); + let body: unknown = null; + if (bytes.byteLength > 0) { + try { + body = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new ResponseSchemaError("invalid_json_response"); + } + } + return { + status: response.status, + elapsedMs, + contentLength, + receivedBytes: bytes.byteLength, + body + }; +} + +function profileUrl(character: CharacterKey): URL { + return new URL( + `https://eu.api.blizzard.com/profile/wow/character/${encodeURIComponent(character.realm)}/${encodeURIComponent(character.name)}?namespace=profile-eu&locale=en_GB` + ); +} + +function achievementsUrl(character: CharacterKey): URL { + return new URL( + `https://eu.api.blizzard.com/profile/wow/character/${encodeURIComponent(character.realm)}/${encodeURIComponent(character.name)}/achievements?namespace=profile-eu&locale=en_GB` + ); +} + +function rosterUrl(guild: Guild): URL { + return new URL( + `https://eu.api.blizzard.com/data/wow/guild/${encodeURIComponent(guild.realm)}/${encodeURIComponent(guild.name)}/roster?namespace=profile-eu&locale=en_GB` + ); +} + +function guildFromProfile(value: unknown, depth: number): Guild | null { + if (!value || typeof value !== "object") return null; + const guild = (value as { guild?: unknown }).guild; + if (!guild || typeof guild !== "object") return null; + const name = (guild as { name?: unknown }).name; + const realm = (guild as { realm?: { slug?: unknown } }).realm?.slug; + return typeof name === "string" && typeof realm === "string" + ? { name, realm, depth } + : null; +} + +function rosterMembers(value: unknown): CharacterKey[] | null { + if (!value || typeof value !== "object") return null; + const members = (value as { members?: unknown }).members; + if (!Array.isArray(members)) return null; + const characters: CharacterKey[] = []; + for (const member of members) { + const character = + member && typeof member === "object" + ? (member as { character?: unknown }).character + : null; + if (!character || typeof character !== "object") continue; + const name = (character as { name?: unknown }).name; + const realm = (character as { realm?: { slug?: unknown } }).realm?.slug; + if (typeof name === "string" && typeof realm === "string") { + characters.push({ + region: "eu", + realm, + name: name.toLocaleLowerCase("en-US") + }); + } + } + return characters; +} + +function fingerprintFromAchievements(value: unknown): Fingerprint | null { + if (!value || typeof value !== "object") return null; + const achievements = (value as { achievements?: unknown }).achievements; + if (!Array.isArray(achievements)) return null; + const entries = new Map(); + for (const achievement of achievements) { + if (!achievement || typeof achievement !== "object") continue; + const id = (achievement as { id?: unknown }).id; + const timestamp = (achievement as { completed_timestamp?: unknown }) + .completed_timestamp; + if (typeof id === "number" && typeof timestamp === "number") { + entries.set(id, timestamp); + } + } + return entries; +} + +async function main(): Promise { + const startedAt = performance.now(); + const failures = emptyFailures(); + const contentLengths: number[] = []; + const receivedBytes: number[] = []; + const scores: number[] = []; + const knownComparisons = emptyKnownComparisons(); + const clientId = requiredEnvironment("BLIZZARD_CLIENT_ID"); + const clientSecret = requiredEnvironment("BLIZZARD_CLIENT_SECRET"); + let capReached = false; + let guildsVisited = 0; + let candidatesConsidered = 0; + let achievementRequests = 0; + let matchedCharacters = 0; + + const summary = (): MeasurementSummary => ({ + root, + requestCap, + capReached, + guildsVisited, + candidatesConsidered, + achievementRequests, + wallTimeMs: Math.round(performance.now() - startedAt), + payloadBytes: { + contentLength: distribution(contentLengths), + receivedBody: distribution(receivedBytes) + }, + scores: distribution(scores), + knownCharacters: knownComparisons, + matchedCharacters, + failures + }); + + let token: string; + try { + token = await getAccessToken(clientId, clientSecret); + } catch { + failures.transport += 1; + return summary(); + } + + async function fetchProfile(character: CharacterKey): Promise { + try { + const response = await measuredJson(profileUrl(character), token); + if (!response.status.toString().startsWith("2")) { + failures[failureKind(response.status)] += 1; + return null; + } + const guild = guildFromProfile(response.body, 0); + if (!guild) failures.schema += 1; + return guild; + } catch (error) { + failures[error instanceof ResponseSchemaError ? "schema" : "transport"] += + 1; + return null; + } + } + + async function fetchRoster(guild: Guild): Promise { + try { + const response = await measuredJson(rosterUrl(guild), token); + if (!response.status.toString().startsWith("2")) { + failures[failureKind(response.status)] += 1; + return null; + } + const members = rosterMembers(response.body); + if (!members) failures.schema += 1; + return members; + } catch (error) { + failures[error instanceof ResponseSchemaError ? "schema" : "transport"] += + 1; + return null; + } + } + + async function fetchFingerprint( + character: CharacterKey + ): Promise { + achievementRequests += 1; + try { + const response = await measuredJson(achievementsUrl(character), token); + if (response.contentLength !== null) { + contentLengths.push(response.contentLength); + } + receivedBytes.push(response.receivedBytes); + if (!response.status.toString().startsWith("2")) { + failures[failureKind(response.status)] += 1; + return null; + } + const fingerprint = fingerprintFromAchievements(response.body); + if (!fingerprint) failures.schema += 1; + return fingerprint; + } catch (error) { + failures[error instanceof ResponseSchemaError ? "schema" : "transport"] += + 1; + return null; + } + } + + const rootFingerprint = await fetchFingerprint(root); + const rootGuild = await fetchProfile(root); + if (!rootFingerprint || !rootGuild) return summary(); + + const guildQueue: Guild[] = [rootGuild]; + const visitedGuilds = new Set(); + const seenCharacters = new Set([normalizedKey(root)]); + + while (guildQueue.length > 0 && !capReached) { + const guild = guildQueue.shift(); + if (!guild) break; + const guildKey = normalizedGuildKey(guild); + if (visitedGuilds.has(guildKey)) continue; + visitedGuilds.add(guildKey); + guildsVisited += 1; + + const members = await fetchRoster(guild); + if (!members) continue; + for (const candidate of members) { + if (achievementRequests >= requestCap) { + capReached = true; + break; + } + const characterKey = normalizedKey(candidate); + if (seenCharacters.has(characterKey)) continue; + seenCharacters.add(characterKey); + candidatesConsidered += 1; + + const fingerprint = await fetchFingerprint(candidate); + if (!fingerprint) continue; + const match = compareFingerprints(rootFingerprint, fingerprint); + scores.push(match.percent); + + const knownName = candidate.name.toLocaleLowerCase("en-US"); + if (knownCharacters.has(knownName)) { + knownComparisons[knownName] = { + encountered: true, + common: match.common, + identical: match.identical, + percent: match.percent, + isMatch: match.isMatch + }; + } + + if (!match.isMatch) continue; + matchedCharacters += 1; + const nextGuild = await fetchProfile(candidate); + if (nextGuild) { + guildQueue.push({ ...nextGuild, depth: guild.depth + 1 }); + } + } + } + + return summary(); +} + +void main() + .then((summary) => { + process.stdout.write(`${JSON.stringify(summary)}\n`); + }) + .catch(() => { + process.stdout.write( + `${JSON.stringify({ + root, + requestCap, + failures: { transport: 1 }, + message: "prototype_failed_without_response_output" + })}\n` + ); + process.exitCode = 1; + }); From ac76ffd2fa1aa419ce236183c5bebdf943ce6b4e Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 10:47:44 +0100 Subject: [PATCH 3/3] docs: record fingerprint sweep runner --- ...026-08-10-fingerprint-sweep-measurement.md | 257 ++++++++++++++++++ ...10-fingerprint-sweep-measurement-design.md | 8 +- .../fingerprint-sweep-measurement.ts | 6 +- 3 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-fingerprint-sweep-measurement.md diff --git a/docs/superpowers/plans/2026-08-10-fingerprint-sweep-measurement.md b/docs/superpowers/plans/2026-08-10-fingerprint-sweep-measurement.md new file mode 100644 index 0000000..4be7321 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-fingerprint-sweep-measurement.md @@ -0,0 +1,257 @@ +# Fingerprint Sweep Measurement Prototype Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a disposable CLI that measures a live, 3,000-request-capped Blizzard achievement-fingerprint breadth-first guild sweep seeded from `Ictinus`. + +**Architecture:** A standalone TypeScript script owns all live HTTP requests and summary aggregation; it has no dependency on the application database or worker runtime. It obtains Blizzard credentials only from the process environment supplied by `railway run`, holds achievement data only long enough to compare it, and emits one redacted JSON report to stdout. + +**Tech Stack:** Node 22, TypeScript, `tsx`, native `fetch`, Raider.IO public API, Blizzard OAuth client-credentials and Profile APIs. + +## Global Constraints + +- Run only against the Railway `test` worker environment. +- Begin at `eu / argent-dawn / ictinus`; ground-truth same-account characters are `ictinus`, `driptinus`, `boptinus`, `cryptinus`, and `mistakinus`. +- Traverse guild rosters breadth-first and never make more than 3,000 achievement requests. +- Do not print or persist client credentials, raw responses, achievement IDs, completion timestamps, or derived fingerprints. +- Do not write to PostgreSQL, create snapshots, or modify production worker behavior. +- Count transport, 429, and 5xx outcomes in the final summary; do not treat them as non-matches. +- The tool is a prototype: it has no automated tests and remains on the scratch branch after the decision is captured. + +--- + +## File structure + +- Create: `scripts/prototypes/fingerprint-sweep-measurement.ts` — disposable live sweep, bounded traversal, transient comparison, and redacted report. +- Modify: `package.json` — adds the one-command prototype runner only. +- Modify: `docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md` — adds the final run command and report contract once they are implemented. + +### Task 1: Implement the disposable live measurement CLI + +**Files:** + +- Create: `scripts/prototypes/fingerprint-sweep-measurement.ts` +- Modify: `package.json` + +**Interfaces:** + +- Consumes: `BLIZZARD_CLIENT_ID` and `BLIZZARD_CLIENT_SECRET` from the process environment, plus public Raider.IO and Blizzard Profile endpoints. +- Produces: `Promise` from `main()` and exactly one JSON `MeasurementSummary` on stdout. + +- [x] **Step 1: Add the executable package script** + +Add this exact script entry to `package.json`: + +```json +"prototype:fingerprint-sweep": "tsx scripts/prototypes/fingerprint-sweep-measurement.ts" +``` + +The single live-run command is: + +```powershell +railway run --service worker --environment test -- pnpm prototype:fingerprint-sweep +``` + +- [x] **Step 2: Define the prototype-only data shapes and fixed sample** + +At the top of `scripts/prototypes/fingerprint-sweep-measurement.ts`, add a `/** PROTOTYPE — ... */` comment that states the question and no-persistence boundary. Define the fixed root, known-character set, hard limit, and pure comparison result: + +```ts +type CharacterKey = Readonly<{ region: "eu"; realm: string; name: string }>; +type Fingerprint = ReadonlyMap; +type Match = Readonly<{ + common: number; + identical: number; + percent: number; + isMatch: boolean; +}>; + +const root: CharacterKey = { + region: "eu", + realm: "argent-dawn", + name: "ictinus" +}; +const knownCharacters = new Set([ + "ictinus", + "driptinus", + "boptinus", + "cryptinus", + "mistakinus" +]); +const requestCap = 3_000; +const minCommonAchievements = 200; +const matchPercentThreshold = 20; +``` + +Implement `compareFingerprints(left, right): Match` by counting common achievement IDs, equal timestamps, percentage, and the two existing threshold checks. Keep this function pure and do not serialise a fingerprint. + +- [x] **Step 3: Implement credential, OAuth, and measured Blizzard requests** + +Implement: + +```ts +function requiredEnvironment( + name: "BLIZZARD_CLIENT_ID" | "BLIZZARD_CLIENT_SECRET" +): string; +async function getAccessToken( + clientId: string, + clientSecret: string +): Promise; +async function measuredJson( + url: URL, + token: string +): Promise<{ + status: number; + elapsedMs: number; + contentLength: number | null; + receivedBytes: number; + body: unknown; +}>; +``` + +`getAccessToken` posts `grant_type=client_credentials` to `https://oauth.battle.net/token` with HTTP Basic authentication. `measuredJson` uses `fetch`, records elapsed time with `performance.now()`, reads the body once as an `ArrayBuffer`, records `Content-Length` when present and the received-body byte length, then parses JSON from the bytes. It must never log its URL authorization header, token, or body. + +For a successful achievements response, extract only entries with both numeric `id` and numeric `completed_timestamp` into a local `Map`. Do not retain that map after the candidate has been compared. Classify a non-2xx status into `not_found`, `rate_limited`, `server_error`, or `other_http`; increment its aggregate count and return no fingerprint. + +- [x] **Step 4: Implement root guild lookup and bounded breadth-first traversal** + +Use Blizzard's Character Profile endpoint for the root to obtain its current guild name and guild realm. Use the Blizzard Guild Roster endpoint for each queued guild. Identify a guild by normalized `realm/name`, track `visitedGuilds`, and queue each new roster exactly once. + +Use this traversal state: + +```ts +type Guild = Readonly<{ realm: string; name: string; depth: number }>; +const guildQueue: Guild[] = [rootGuild]; +const visitedGuilds = new Set(); +const seenCharacters = new Set(); +let achievementRequests = 0; +let capReached = false; +``` + +For each roster member, skip the root and previously seen characters. Before the achievements request, stop and set `capReached = true` when `achievementRequests === requestCap`. Fetch one candidate at a time so the hard cap and response metrics are exact. For a matching candidate only, look up its current Character Profile and enqueue its guild at `depth + 1` if unseen; do not expand non-matches. This preserves the production-shaped BFS without requiring storage or cached fingerprints. + +- [x] **Step 5: Aggregate and print only the redacted report** + +Maintain aggregate counters for roster requests, candidate count, achievement requests, guild count, total elapsed time, response `Content-Length` values, received-body byte values, score values, failure classes, known-character comparisons, and matched-character count. Print exactly one object shaped as: + +```ts +type MeasurementSummary = Readonly<{ + root: CharacterKey; + requestCap: number; + capReached: boolean; + guildsVisited: number; + candidatesConsidered: number; + achievementRequests: number; + wallTimeMs: number; + payloadBytes: { + contentLength: { + count: number; + min: number | null; + median: number | null; + max: number | null; + }; + receivedBody: { + count: number; + min: number | null; + median: number | null; + max: number | null; + }; + }; + scores: { + count: number; + min: number | null; + median: number | null; + max: number | null; + }; + knownCharacters: Record< + string, + { + encountered: boolean; + common: number | null; + identical: number | null; + percent: number | null; + isMatch: boolean | null; + } + >; + matchedCharacters: number; + failures: Record< + | "not_found" + | "rate_limited" + | "server_error" + | "other_http" + | "transport" + | "schema", + number + >; +}>; +``` + +Sort numeric arrays before computing median. Keep all character names except the five fixed ground-truth names out of the report. In a top-level `main().catch`, emit a non-zero exit code only after writing a summary containing the aggregate failure count; never dump an exception response body. + +- [x] **Step 6: Run static validation without invoking live Blizzard calls** + +Run: + +```powershell +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test:unit +``` + +Expected: formatting, linting, type checking, and unit tests pass. Do not add automated tests for this throwaway prototype. The full integration suite remains blocked locally because Docker Desktop's Linux container engine is unavailable. + +- [x] **Step 7: Commit the prototype implementation** + +```powershell +git add package.json scripts/prototypes/fingerprint-sweep-measurement.ts +git commit -m "chore: add fingerprint sweep measurement prototype" +``` + +### Task 2: Run and capture the live measurement + +**Files:** + +- Modify: `docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md` + +**Interfaces:** + +- Consumes: the Task 1 runner and Railway `test` worker environment. +- Produces: a redacted summary pasted into the prototype ticket as the measurement result; the branch is the primary source for the code. + +- [x] **Step 1: Execute one bounded live sweep** + +Run exactly once: + +```powershell +railway run --service worker --environment test -- pnpm prototype:fingerprint-sweep +``` + +Expected: one JSON summary, no credentials or raw upstream data in stdout, and no more than 3,000 achievement requests. + +- [x] **Step 2: Check the report against ground truth and safety boundaries** + +Confirm from the JSON summary that all five named known characters have an `encountered` value, compare their `isMatch` and score fields, record whether the request cap was reached, and record the payload-byte and wall-time distributions. Confirm the report contains no unknown roster-member name, achievement ID, timestamp, credential, or response body. + +- [x] **Step 3: Amend the design with the exact runner contract** + +Append this command to the design's Verification section: + +```powershell +railway run --service worker --environment test -- pnpm prototype:fingerprint-sweep +``` + +Add one sentence that stdout is a redacted `MeasurementSummary` and is the artifact to attach to the issue; no output file is created. + +- [x] **Step 4: Commit the finalized prototype record** + +```powershell +git add docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md +git commit -m "docs: record fingerprint sweep runner" +``` + +## Self-review + +- Spec coverage: Task 1 implements live BFS, a 3,000-request ceiling, transient achievement processing, payload/timing/score metrics, redaction, and failure aggregation. Task 2 executes the sole permitted live run and captures the artifact. +- Placeholder scan: the plan contains no unfilled work markers or delegated implementation choices. +- Type consistency: `CharacterKey`, `Fingerprint`, `Match`, and `MeasurementSummary` are defined in Task 1 and are the only script-owned interfaces referenced later. diff --git a/docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md b/docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md index 3bb5fe5..a0c5b5a 100644 --- a/docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md +++ b/docs/superpowers/specs/2026-08-10-fingerprint-sweep-measurement-design.md @@ -29,4 +29,10 @@ The prototype performs no database writes and creates no snapshots. It does not ## Verification -The prototype is validated by one successful bounded run against the Railway `test` environment and by inspecting its redacted summary. It intentionally has no automated tests because it is a throwaway measurement tool. +Run the bounded measurement once through the Railway `test` worker environment: + +```powershell +railway run --service worker --environment test -- pnpm prototype:fingerprint-sweep +``` + +Stdout is one redacted `MeasurementSummary`, which is the artifact to attach to the issue; no output file is created. The prototype is also checked with formatting, linting, type checking, and the unit suite. It intentionally has no automated tests of its own because it is a throwaway measurement tool. diff --git a/scripts/prototypes/fingerprint-sweep-measurement.ts b/scripts/prototypes/fingerprint-sweep-measurement.ts index 2bdcf48..c48c630 100644 --- a/scripts/prototypes/fingerprint-sweep-measurement.ts +++ b/scripts/prototypes/fingerprint-sweep-measurement.ts @@ -108,6 +108,10 @@ function normalizedGuildKey(value: Pick): string { return `${value.realm.toLocaleLowerCase("en-US")}/${value.name.toLocaleLowerCase("en-US")}`; } +function blizzardSlug(value: string): string { + return value.trim().toLocaleLowerCase("en-US").replace(/\s+/g, "-"); +} + function distribution(values: readonly number[]): Distribution { if (values.length === 0) { return { count: 0, min: null, median: null, max: null }; @@ -235,7 +239,7 @@ function achievementsUrl(character: CharacterKey): URL { function rosterUrl(guild: Guild): URL { return new URL( - `https://eu.api.blizzard.com/data/wow/guild/${encodeURIComponent(guild.realm)}/${encodeURIComponent(guild.name)}/roster?namespace=profile-eu&locale=en_GB` + `https://eu.api.blizzard.com/data/wow/guild/${encodeURIComponent(blizzardSlug(guild.realm))}/${encodeURIComponent(blizzardSlug(guild.name))}/roster?namespace=profile-eu&locale=en_GB` ); }