From 6fc197af4a4539a34d2d442e023660579a39d0f2 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Sun, 30 Aug 2026 18:05:17 -0600 Subject: [PATCH 1/3] =?UTF-8?q?docs(spec):=20Telltale=20=E2=80=94=20user?= =?UTF-8?q?=20bug/crash=20to=20issue=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project-generalizable feedback pipeline for the apps and games, landing as a new `feedback` source on the Project Dashboard. Three rounds of adversarial critique cut it roughly in half: - the entire crash-side pipeline is deleted in favour of Sentry's native GitHub integration (it was reimplementing grouping and user counts that Sentry supplies, on a gate that could never fire); - fleet dispatch is out of scope — it would have widened a credentialed agent's push target from one sandbox to every registry repo, for a pipeline whose input is internet-authored text. What remains: one Cloudflare Worker, a checked-in registry, four ~60-line senders, and one dashboard adapter. --- ...08-30-telltale-feedback-pipeline-design.md | 829 ++++++++++++++++++ 1 file changed, 829 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md diff --git a/docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md b/docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md new file mode 100644 index 0000000..c8c6827 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md @@ -0,0 +1,829 @@ +# Telltale — User Bug/Crash → Issue Pipeline — Design Spec + +> A **project-generalizable** pipeline that turns user-reported bugs and runtime crashes, from any +> app or game in the portfolio, into issues in that project's own tracker — and surfaces them as a +> **seventh source** in the Command Center Project Dashboard. +> +> Status: **design**. Author: brainstorming session 2026-08-30. Single operator, single machine. +> Standalone by construction: every phase before P3 works with no Command Center running. + +A *telltale* is the ribbon on a sail that shows which way the wind is actually blowing; in +engineering it is a warning indicator. It sits beside **Halyard** (release coordination) and does +not overlap with it (§7). + +**This spec was cut roughly in half by three rounds of adversarial critique.** Read §11 before +proposing anything already removed, and §0 for the shape of the reduction. + +--- + +## 0. The shape of the system, after cutting + +The first draft built a distributed counter store, a threshold gate, a dedup decision table, and a +fleet-dispatch extension. Critique established that **Sentry already does the crash half** and that +**fleet dispatch should not be part of this system at all**. What remains: + +| Half | Mechanism | Telltale code | +|---|---|---| +| **Crashes** | Sentry SDK → alert rule → **Sentry's native GitHub integration** opens and links the issue | **None.** Per-repo configuration (§3 — four steps, not trivial). | +| **Bug reports** | In-app form → one Cloudflare Worker → GitHub issue | ~350 lines + 4 senders | +| **Visibility** | Worker read endpoint; one dashboard adapter | ~1 adapter (§6.7 records the case against even this) | +| **Dispatch** | **Out of scope.** The operator works the issue by hand. | **None.** | + +--- + +## 1. Goal & scope + +**Goal:** an automated `user bug/crash → issue tracking` pipeline that works for every shipped app +and game and terminates in something the operator can act on. + +**In scope:** **T1** the `FeedbackEvent` contract; **T2** per-platform senders; **T3** the ingest +worker; **T4** the GitHub Issues sink; **T5** a `feedback` source adapter for the Project Dashboard. + +**Covered projects (operator decision 2026-08-30): apps + games.** + +| Project | Platform | Sender | Notes | +|---|---|---|---| +| `tenzy`, `giftkeeper`, `purposefull` | Expo / React Native | RN | | +| `ironsoul` | Flutter | Dart | | +| `audience`, `lineage`, `robo.learn` | Web | Web | | +| `prima-tactica` | Godot 4.6 (verified in `project.godot`) | GDScript | outside `CURRENT/` | +| `hexy` | Vanilla JS | Web | outside `CURRENT/` | + +`perennial-blade` and `heart-of-the-gods` are **cut**: jam-scale games, plausibly zero users, no +`docs/` directory. + +**Out of scope (YAGNI, deliberate):** an in-cockpit triage UI (GitHub Issues *is* the triage +surface); screenshots/attachments; a user-facing reply thread; analytics; multi-tenant; CLI and +library projects; **`kind: "idea"`**; and **fleet dispatch** (§6.5). + +### Locked decisions (do not relitigate) + +1. **The deliverable is a contract, not an SDK** (§2). Senders are copied reference files. +2. **Telltale does not handle crashes.** Sentry's native GitHub integration owns that path end to + end (§3). Telltale is a **bug-report** pipeline that *reads* the issues Sentry creates. +3. **Ingest runs on a Cloudflare Worker**, stateless apart from small KV counters. +4. **The ingest is authenticated, and the header is the sole project authority** (§4.1). +5. **Issues land in the project's own repo by default** (operator decision 2026-08-30; accepted + trade-offs in §4.3 and §10). The registry can redirect **bug reports** with no code change; + **redirecting crashes means hand-editing Sentry alert rules** (§4.3). +6. **The cockpit holds no *GitHub* credential.** It reads through the Worker with a Worker-scoped + read token that is never shipped in an app binary (§5.4). +7. **Dispatch is out of scope** (§6.5). +8. **Standalone before integrated.** P1–P2 ship and are useful with no Command Center running. + +--- + +## 2. The contract (T1) + +The covered set spans Expo/React Native, Flutter, web, Godot 4, and vanilla JS. **No single client +SDK spans those**; building one means five runtimes of maintenance for a payload ten fields wide. +Every one of them can POST JSON. + +Senders are ~60-line reference files, **copied** rather than depended upon: a project that copies one +takes on zero upgrade obligation, and an archived project keeps working forever. + +### 2.1 `FeedbackEvent` + +```jsonc +{ + "schema_version": 1, + "title": "Save button does nothing on the guide screen", + "body": "…", // user-typed free text + "release": { "version": "1.4.2", "surface": "android" }, // optional + "context": { "platform": "android", "os_version": "14", "locale": "en-US" }, + "reporter": { "anon_id": "…" }, // opaque, install-scoped, NOT trusted (§4.2) + "occurred_at": "2026-08-30T18:04:11Z" +} +``` + +Three fields an earlier draft carried are gone: + +- **`kind`** — every event on this endpoint is a bug report; crashes never traverse it (§3). +- **`fingerprint`** — the Worker computes it (§4.4); requiring senders to produce a value the server + discards was ceremony. +- **`project`** — **it now lives only in the `X-Telltale-Project` header** (§4.1). Carrying it in + both places, with nothing requiring them to match, meant an extracted secret from the weakest + client could publish into any repo in the registry. One authority, one field fewer in five senders. + +| Field | Rule | +|---|---| +| `schema_version` | MUST be `1`. Unknown → `400`, never best-effort parsed. | +| `title` | 1–120 chars after trim. **User-typed, therefore scrubbed** (§4.3). | +| `body` | 0–8000 chars. Truncated with a visible marker, never rejected — a user's report is not worth losing to a length rule. | +| `release.surface` | `ios\|android\|web\|desktop`. | +| `context` | Bounded key set (`platform`, `os_version`, `locale`). Unknown keys dropped, not stored — this prevents `context` becoming an unaudited PII channel. | +| `reporter.anon_id` | Opaque ≤64 chars, install-scoped. | +| `occurred_at` | RFC 3339, informational. **Not** security-load-bearing — the replay window uses a signed header (§4.1). | + +**Deliberately absent: any contact field.** No `email`, `name`, or `account_id` — a schema-level +guarantee that a sender *cannot* transmit contact details in a structured field. (Users can still +type them into `title`/`body`; §4.3.) + +### 2.2 The release key + +`release` serializes to `{project}-{surface}@{version}`, the shape Halyard's `sentry-client.ts` +builds. **A SHOULD, not a MUST** — `INFRASTRUCTURE/halyard/apps/` contains one app (`aurora`) and no +covered project is Halyard-onboarded. Named so the key exists if correlation is ever wanted (§7). + +--- + +## 3. Crashes: Sentry's native GitHub integration, and no Telltale code + +**Telltale does not touch the crash path.** Sentry already provides, as configuration: grouping +(better than anything computed here), per-issue event and distinct-user counts, an alert-rule +condition of exactly the form *"an issue is seen by more than 3 users in 24h"*, and a **native GitHub +integration** that opens one issue per Sentry issue, links them bidirectionally, and does not reopen +on every event. + +An earlier draft routed Sentry through the Worker to recompute all of this. That was wrong twice: +it duplicated the vendor, and it could not work — the only server-observed identity on a webhook is +*Sentry's own egress IP*, so a `distinct_server_identities ≥ 3` gate had cardinality 1 and could +never fire (§11.6). + +**Configuration required per project — four steps, and this is more work than "configuration only" +suggests:** + +1. **Sentry project + SDK.** DSN wired; releases tagged `{project}-{surface}@{version}`; + symbolication / source-map upload. +2. **Data scrubbing — do not skip.** Set `sendDefaultPii: false`, enable Sentry's server-side data + scrubbers, and add a `beforeSend` that strips request URLs and local variables. **Crash issues are + authored by Sentry directly into the same public repos and never pass through §4.3's scrub.** A + Sentry payload is a *richer* PII channel than a bug form: exception messages, breadcrumb values, + URLs with tokens, `user.email` when default PII is on, and locals in stack frames. This step is + the crash half's entire PII control. +3. **Pre-create the labels in the target repo** — `telltale`, `telltale:crash`. Sentry's alert-action + label field is a picker over labels that already exist in that repo, so a rule cannot reference a + label that has never been created there. Per-repo bootstrap, easy to forget, silent when missed. +4. **The issue-alert rule:** *seen by > 3 users in 24h* → create a GitHub issue in **that project's + own repo** (per locked decision #5), with the labels from step 3. Pin the rule frequency + deliberately; the default throttles to one notification per issue per 30 min, which is fine here + because Sentry counts, not Telltale. + +Telltale's only relationship to crashes is that the **dashboard adapter reads those issues** by +label, exactly as it reads bug issues (§6). + +### 3.1 Instrumentation is the real blocker, and P0 is scoped down because of it + +**Verified 2026-08-30.** Nine project trees grepped for `sentry|bugsnag|crashlytics|rollbar`; seven +are in the covered set: + +| Checked, in scope | Crash reporting | +|---|---| +| `tenzy` | **Yes** — `client/lib/sentry.ts`, `@sentry/*` in `client/package.json` | +| `giftkeeper`, `audience`, `purposefull`, `lineage`, `robo.learn` | None found | +| `ironsoul` | None in source (only a Dart build artifact matched) | + +`appforge` and `reqdrive` were checked but are **out of scope**. **No game was checked.** So: *of +seven in-scope projects checked, one has crash reporting; the games are unmeasured.* An earlier +draft's "eleven of twelve" extrapolated beyond its sample and is withdrawn. + +Rolling this out portfolio-wide means all four steps above across Expo/RN, Flutter, **Godot (no +first-party SDK — a community GDExtension)**, and vanilla JS. For a single operator that is plausibly +larger than everything else here combined. **P0 is therefore a two-project feasibility probe** — +`tenzy` (already instrumented) plus one game — not a rollout (§9). The bug half has no such +dependency and ships first. + +--- + +## 4. The ingest worker (T3) + +One Cloudflare Worker, three routes: + +| Route | Auth | Purpose | +|---|---|---| +| `POST /v1/events` | Per-project HMAC (§4.1) | Bug-report intake | +| `GET /v1/issues` | **Operator read token** (§5.4) | The cockpit's read path | +| `GET /v1/stats` | **Operator read token** | Operability (§4.6) | + +It holds the only GitHub credential in the system. + +### 4.1 Authentication + +The threat is concrete: this endpoint turns an HTTP request into a **public GitHub issue in the +operator's repo, authored by the operator's token** — a remote "publish arbitrary text under Alex's +identity" primitive. Slugs are guessable by design. + +- Headers: `X-Telltale-Project`, `X-Telltale-Timestamp`, + `X-Telltale-Signature: HMAC-SHA256(secret, timestamp + "." + rawBody)`. +- **`X-Telltale-Project` is the sole authority** for both secret selection and repo resolution. The + body carries no project field (§2.1), so the two cannot disagree. +- **Signed over raw request bytes**, never a canonicalized JSON re-serialization. Five independent + canonicalizers (GDScript, Dart, RN, browser JS, Worker) agreeing byte-for-byte on key order and + number formatting is a silent-`401` generator. +- The timestamp is a **header**, not `occurred_at`, so senders never reason about canonicalization. +- Replay window ±10 min against server time. **No nonce: a captured request is replayable inside + that window.** Accepted — the payoff is a duplicate report, which dedup collapses anyway. +- **Clock skew** (common on Android) is handled by the response, not by client state: a + skew rejection returns `401` with the server time in a `X-Telltale-Server-Time` header, and the + sender's *already-mandated* single retry (§8.3) re-signs with it. An earlier draft had each of five + runtimes persist a clock offset across sessions — heavier than the problem, and it failed on a + device's first report, which for many users is the only one they ever send. + +**A secret shipped inside a distributed client binary is extractable.** HMAC raises the bar from a +one-line `curl` to reverse-engineering a specific app and — the real benefit — makes abuse +**attributable to one project**, so the response is rotating one secret. Because the header is now +the sole authority, that rotation actually protects the victim. It is a managed risk (§10.1), not a +solved one. + +### 4.2 Rate limiting, and the honest role of `anon_id` + +`anon_id` is client-controlled and regenerable, so it is **never a security boundary**. But per-IP +limiting alone is wrong here: carrier-grade NAT puts thousands of mobile users behind one IPv4 +address, and four of five sender rows are mobile. A tight per-IP cap would silently destroy the 21st +genuine reporter on a carrier. + +| Key | Limit | +|---|---| +| IP + `anon_id` pair | 10 events/hour | +| IP (distinct `anon_id`s) | 200 events/hour | +| Project | 1000 events/hour | + +Stored in Worker **KV** with 1h TTLs. KV is correct *here* — approximate abuse counters where a lost +increment is harmless. (An earlier draft used KV for a correctness-critical dedup gate, where it was +not; that gate no longer exists.) Exceeded → `429` + `Retry-After`; dropped, not queued. + +### 4.3 PII scrub — accepted-risk mitigation + +**Recorded operator decision (2026-08-30): issues land in the project's own repo, always, including +public repos. The exposure below was raised and knowingly accepted.** + +Residual hazard: `title` and `body` are user-typed. A user who types their email into a bug report on +a public repo has it published permanently — editing does not scrub issue history. + +1. **Ingest-side scrub of BOTH `title` and `body`**, before the sink. An earlier draft scrubbed only + `body`, leaving the issue *title* — the most visible, most indexed, most notification-carrying + field — unredacted. Email and E.164/NANP phone patterns → a visible `[redacted:email]` marker, so + triage knows something was removed rather than silently mangled. The **long-digit-run** rule is + `body`-only (on a title it is all false positives). Redaction is lossy and one-way: the original + is never stored, because storing it recreates the hazard. +2. **Submit-time notice** adjacent to submit, stating the report will be posted publicly. + +**This covers the bug half only.** The crash half never passes through the Worker, so its PII +control is Sentry-side scrubbing (§3 step 2) — a different mechanism in a different console, and the +one most likely to be skipped. Similarly, the escape hatch differs: redirecting **bug reports** to a +private intake repo is a `registry.yml` edit, but redirecting **crashes** means hand-editing each +Sentry alert rule. Locked decision #5's "no code change" claim is scoped accordingly. + +Neither scrub is a guarantee — patterns miss obfuscated forms ("alex at example dot com"), and a +notice is not consent. **Accepted as tolerable at this portfolio's scale.** + +**Request-level data:** Cloudflare and the Worker observe the client IP. Used transiently for rate +limiting, stored only as a salted hash with a 1h TTL, **never written to an issue**. + +### 4.4 Fingerprinting and dedup + +`fingerprint = sha256(normalize(scrubbed_title))[0:16]`, where `normalize` lowercases, collapses +whitespace, and strips punctuation. **Computed over the scrubbed title** so identity is stable +regardless of what redaction removed. + +This is *deliberately weak* grouping over the title alone. It catches verbatim repeats and misses +paraphrases. Semantic grouping is a model call, and a model call in the dedup path makes issue +identity non-deterministic and untestable. **Not doing it is the decision.** + +**Dedup is a label lookup.** The fingerprint is carried as the label `tt:{fingerprint}`. Before +creating, the Worker calls `GET /repos/{o}/{r}/issues?labels=tt:{fingerprint}&state=all`: + +| Result | Action | +|---|---| +| No match | **Create** with the `tt:` label. | +| Open match | **Comment** (throttled: one per fingerprint per hour, KV-tracked). | +| Closed, `state_reason: completed` | **Comment only.** Never reopen. | +| Closed, `state_reason: not_planned`, or labeled `telltale:muted` | **Ignore.** Silent by operator intent. | +| Closed, `state_reason: null` (legacy closures) | Treat as `completed` — comment only. | +| Multiple matches | Use the lowest-numbered open one; log `duplicate_fingerprint` to `/v1/stats`. | +| Any match that is a **pull request** | Skip it. `GET /issues` returns PRs as issues; a fix PR carrying the `telltale` label would otherwise read as an open bug report. Filter on the `pull_request` key. | + +**Reopen is never automatic.** An earlier draft's "reopen always" was a trap: mobile users run old +builds for months, so a crash fixed in 1.4.3 keeps arriving from 1.4.1 clients and would perpetually +reopen the issue its PR closed. + +**The label is the idempotency key — for retries, not for concurrency.** `POST /issues` has no +idempotency key, so a create that succeeds with a lost response would double-open; the pre-create +lookup finds that orphan on retry. But the lookup is a **check-then-act with no mutual exclusion**: +two simultaneous reports of the same title can both query before either create returns, and both +create. That is why the "multiple matches" row exists. A narrowing mitigation — a `tt:` KV marker +with a 30s TTL, reusing the throttle infrastructure — collapses the window to sub-second without +reintroducing a Durable Object. **The residual duplicate is accepted and listed in §10.5.** + +**Label-creation failure is silent and must be detected.** GitHub **silently drops** `labels` on +`POST /issues` when the token lacks push/triage access on that repo. Since `tt:` is simultaneously +the idempotency key, the dedup key, and the read-path key, a dropped label means every subsequent +report opens a fresh duplicate forever — while the Worker reports success. **The Worker MUST verify +`labels` in the create response and emit a `labels_dropped` rejection to `/v1/stats`.** + +**Over-merge has no mechanism, by decision.** Title-only grouping will occasionally merge two +unrelated defects. An earlier draft's `telltale:split` re-salted with the retired issue number but +still keyed on `normalize(title)`, so both defects produced the same new fingerprint and re-merged +immediately — a "start over" button, not a split. The remedy is the operator closing the issue and +opening two by hand. **Zero mechanism beats a mechanism that does not work** (§11.3). + +**Label growth is unbounded and deliberately unmanaged.** Every distinct report title mints a +permanent `tt:` label in a shipped product's repo; after a year the label picker holds hundreds. This +is still the right trade — labels are the only queryable, indexed key, and the REST list endpoint's +`labels=` filter is exact, AND-semantic, and not subject to the search API's 30 req/min limit or its +eventual consistency. The janitor job it implies (delete `tt:` labels whose issue closed >90d ago) is +**named and deliberately not built**. + +### 4.5 Ordering and failure + +Scrub → fingerprint → label lookup → create-or-comment → verify labels. A GitHub failure returns +`503`; the sender retries once (§8.3) and the label lookup makes that retry safe. **No queue** — a +durable hosted queue is a service to operate, and the failure it guards costs one report. + +### 4.6 A read path, so silence is diagnosable + +`GET /v1/stats` returns accepted/rejected counts by reason over 24h — no bodies, no PII. Reasons +include `bad_signature`, `clock_skew`, `rate_limited`, `unregistered_project`, `labels_dropped`, +`duplicate_fingerprint`. Roughly 30 lines. + +Without it, the operator's observable universe is "issues appear, or nothing happens," and a `401` +storm from signature drift, a rate-limit drop, a dropped-label cascade, and *"nobody filed a bug this +week"* are **the same observation**. §9.1's grader is a synthetic probe; it cannot explain why +production is quiet. This is the difference between an operable system and one abandoned the first +time it goes silent. + +--- + +## 5. The GitHub Issues sink (T4) + +### 5.1 The registry + +A file **checked into this repo** at `telltale/registry.yml`: + +```yaml +version: 1 +projects: + tenzy: + repo: /tenzy + labels: [telltale] + prima-tactica: + repo: /prima-tactica + labels: [telltale, game] + pawsport: + repo: /telltale-intake # crash-only; no sender ships for this project + labels: [telltale] + __probe__: + repo: /telltale-probe # §9.1's grader target — never a real product repo + labels: [telltale] +``` + +- The **Worker** bundles it at build time and is its only authority. The cockpit does not read it — + it calls `GET /v1/issues` and the Worker resolves repos. An earlier draft gave the cockpit its own + copy; drift there means writing a user's bug report into the wrong repository. +- An entry **without a shipping sender** (e.g. `pawsport`) is crash-only: it receives Sentry-created + issues and appears on the board, but no app POSTs to it. +- `base_branch`/`test_cmd` are gone with dispatch (§6.5). + +### 5.2 Issue shape + +- **Title:** the scrubbed `title`, prefixed `[bug]`. (Sentry sets its own for crashes.) +- **Labels:** `telltale`, `telltale:bug`, `tt:{fingerprint}`, plus the registry's per-project labels. +- **Body:** the scrubbed body, plus context and release, and a human-facing footer. + +An earlier draft also wrote `origin:{project}`. **Cut:** under locked decision #5 the issue is in the +project's own repo, so *the repo is the project* — the Worker knows which repo it queried and maps it +back through the registry. `origin:` carried zero information and cost a hand-configured field in +every Sentry alert rule. + +Also cut: `telltale:count-*` labels (§11.4) — they needed a lifetime counter the store cannot +provide, and churned a label on every event. + +**Note the body is free.** `GET /repos/{o}/{r}/issues` returns each issue's full `body` in the list +response. An earlier draft justified the label scheme by "avoiding body parsing"; that was a strawman +(the real cost was the *comments* fetch it had chosen). Labels are still right for dedup and +filtering — indexed and queryable, which a body is not — but prose is available for free. + +### 5.3 Repos that cannot receive issues + +`pawsport` and `elevation-broker` are believed archived on GitHub and to reject writes; some repos +are private with a known Actions billing failure. **Asserted, not verified this session** — the +GitHub MCP server failed to connect (`400: Authorization header is badly formatted`). **Verify before +P1.** The registry redirect exists either way, configured explicitly, never inferred. + +### 5.4 Credentials + +| Holder | Credential | Scope | +|---|---|---| +| Worker | GitHub App installation token, minted per-request from the App private key in Worker secrets | `issues: write`, `metadata: read` | +| Worker | Per-project HMAC secrets | one per sender | +| Cockpit | **A Worker-scoped read token** — `TELLTALE_TOKEN`, read in `dashboard.rs` alongside the existing `HALYARD_BIN` / `AUDIENCE_API_URL` env vars | Calls `/v1/issues` and `/v1/stats` only | + +**The cockpit holds no *GitHub* credential** — that is the claim, and it is narrower than an earlier +draft's "no credential." `/v1/issues` cannot be anonymous: the Worker can read **private** registry +repos, so an open read endpoint would serve every bug-report body in every private repo to anyone who +guesses the hostname. The read token is distinct from the per-project sender secrets and is **never +shipped in an app binary**, which is what makes it meaningfully different from the extractable HMAC +secrets of §4.1. + +Two earlier drafts got the GitHub side wrong: `public_repo` is a *write* scope and cannot read +private repos; a second App installation in the cockpit was worse, since installation tokens expire +hourly and are minted by signing a JWT with the **App private key** — the desktop would hold a +credential minting tokens for every installed repo. + +The registry spans two accounts (two separate GitHub accounts), so the App needs an installation on each +— two installations, one private key, all Worker-side. + +--- + +## 6. Command Center integration (T5) + +### 6.1 What actually changes + +The dashboard spec's locked decision #6 says a new source is "one new adapter, no board change." +**That is not true here**, and the local-tracker spec already retracted the same overclaim for itself. +Verified change surface: + +| File | Change | +|---|---| +| `cockpit/ui/src/lib/dashboard/model.ts` | `Source` union `+ 'feedback'`; **amend the file-header comment**, which still asserts "adding a fifth source later is one new adapter, zero board change" — the exact overclaim this table retracts | +| `cockpit/ui/src/lib/dashboard/adapters/feedback.ts` | New adapter + `FeedbackReader` seam | +| `cockpit/ui/src/lib/dashboard/api.ts` | `tauriFeedbackReader`, beside `tauriHalyardReader` / `tauriAudienceReader` | +| `cockpit/ui/src/lib/dashboard/store.ts` | New `pollFeedback` | +| `cockpit/ui/src/views/Dashboard.svelte` | `SOURCE_LABEL` entry (without it the badge renders raw lowercase `feedback` via the `?? c.source` fallback); a `pollFeedback` call | +| `cockpit/ui/src-tauri/src/dashboard.rs` | `feedback_issues` command; `TELLTALE_BASE_URL` + `TELLTALE_TOKEN` env vars beside the existing `HALYARD_BIN` / `AUDIENCE_API_URL` | +| `cockpit/ui/src-tauri/src/lib.rs` | Register `dashboard::feedback_issues` in `tauri::generate_handler![…]` — without it the command does not exist at runtime | + +`model.ts`'s `dispatch` field and its "local source only" comment are **untouched** — a consequence +of cutting dispatch (§6.5). + +### 6.2 The adapter and its read seam + +```ts +export interface TelltaleIssue { + repo: string; + number: number; + title: string; + body: string; + kind: 'bug' | 'crash' | 'unknown'; // explicit whitelist over telltale:* — see below + project: string; // resolved by the Worker from the registry, not from a label + isOpen: boolean; + hasAssignee: boolean; // the triage signal (§6.4) + createdIso: string; // `n new this week` is a created_at question + updatedIso: string; + labels: string[]; + url: string; +} + +/** The swappable read seam — mirrors `HalyardReader` / `AudienceReader`. */ +export interface FeedbackReader { + issues(): Promise; +} +``` + +`kind` is derived from an **explicit whitelist** (`telltale:bug` → `bug`, `telltale:crash` → `crash`, +anything else → `unknown`), not a `telltale:*` prefix parse — `telltale:muted` also matches that +prefix, and an issue hand-labeled plain `telltale` matches neither. + +Written against `FeedbackReader` rather than `fetch`, for the same reason `halyard.ts` is written +against `HalyardReader`: environment-agnostic and **unit-testable with fakes**. + +### 6.3 Transport, cadence, and partial failure + +`dashboard.rs` gains `feedback_issues`, which calls `GET /v1/issues` and — following +`halyard_status`'s existing pattern of returning an untyped `Value` and letting TypeScript map it — +does no parsing in Rust. + +**Cadence.** The cockpit polls the *Worker*, not GitHub, so the GitHub-side arithmetic an earlier +draft worried about no longer applies. The Worker caches the issues list for 60s and uses conditional +requests (`ETag` / `If-None-Match`) upstream, where 304s do not count against the REST limit. GitHub +therefore sees at most one request per repo per minute regardless of poll rate. The earlier draft's +second mitigation — a per-source 300s interval requiring a structural change to `Dashboard.svelte`'s +single-timer `refresh()` — is **redundant and dropped**; the existing 15s shared timer is fine. +`staleAfterSec: 600`. + +**Partial repo failure is required behaviour, not an edge case.** §5.3 states outright that some +registry repos are archived and some are private with broken billing. If `/v1/issues` fans out over +~10 repos and one 403s, a naive implementation fails the whole call — and because a failed poll +yields a single synthetic `__source__` card while `replaceSource` drops that source's prior cards, +**one bad repo would blank the entire feedback lane.** So `/v1/issues` returns the repos that +answered plus a per-repo error list, and the adapter degrades only the affected projects. + +### 6.4 Cards + +**One card per registry project *with at least one open issue*.** An earlier draft emitted a card per +registry project including empty ones; that is wrong for a verified reason: `sortedCards` ranks +`Idle` at **99**, below `Archived`, so ~10 permanently-grey "no open reports" cards would pile at the +bottom of the grid, inflate the header's project `total`, and duplicate projects already on the board +via the `local` source — carrying no information to justify the duplication. + +| Condition | Stage | Detail | +|---|---|---| +| Any open `telltale:crash` issue with **no assignee** | `Blocked` — `BlockedInfo { gate: 'manual', action: 'triage crash report', deepLink: }` | `n open · untriaged crash` | +| Any other open issues | `Idle` | `n open · m new this week` | + +Precedence is explicit, top row wins. + +**Why `Idle` and not `Build`.** `model.ts` documents `PIPELINE` as the *"cross-tool happy-path +pipeline"* — where a project sits in its lifecycle. Mapping "has ≥1 open bug report" to `Build` would +make every shipped project permanently display **BUILD**, and `sortedCards` ranks `Build` above +`Review`, `Ship`, and `Live` — so "one old bug exists" would sort above "this project is live in +production." `Idle` is the honest reading: *known to a source, not currently advancing.* + +**Why `no assignee` and not a `telltale:triaged` label.** `blockedCount` is the board's headline +**"NEEDS YOU"** number, so a `Blocked` condition that never clears is a permanent false positive on +the board's most valuable signal. An earlier draft gated it on a `telltale:triaged` label — but +nothing created that label, nothing applied it, and no test covered it; it would have required the +operator to type an exact magic string on every crash issue forever, with no affordance and no +default. **Assignee is native, free in the list response, self-clearing, and one click on +github.com.** Self-assigning is already what "I am looking at this" means. + +**`family` is set to the project slug but is inert.** Verified: `family` is written by `halyard.ts`, +`audience.ts`, and `appPlugin.ts` and **read by nothing** — `sortedCards` sorts by stage rank and +`Dashboard.svelte` renders a flat grid; the tracker spec lists *"real `family` clustering"* under +"named, not built." A feedback card renders as an unrelated card elsewhere on the board today. + +**`health` on a failed poll:** the copied pattern returns a *single synthetic `__source__` card*, and +`replaceSource` drops that source's prior cards — so the per-project feedback cards **do disappear**, +replaced by one placeholder. Stated plainly rather than described as "degrading gracefully." +Last-known-good retention is a `store.ts` change and is **not** in scope. + +### 6.5 Dispatch is out of scope + +An earlier draft specified fleet dispatch from a feedback card: a `DispatchTarget` type, a `CreateReq` +change in `crates/fleetd/src/server.rs`, a registry-fed repo allowlist, source-conditional write-back, +a modified boot reconcile sweep, and new dispatch UI. **All cut.** + +A second draft then justified the cut by claiming the operator could paste a `cc-item` into a +project's `ROADMAP.md` and "the existing local-tracker Phase 2 path dispatches it with zero changes." +**That justification is false and is withdrawn.** Verified: + +- **Phase 2 does not exist.** No `.svelte` file references `dispatch`; `model.ts` annotates + `missionId` as *"Phase-2, inert here"* and `dispatchable` as a *"Phase-2 gate"*. There is no UI and + no POST path. +- **The target files mostly do not exist.** Of nine covered projects, exactly one (`purposefull`) has + both `docs/STATUS.md` and `ROADMAP.md`. **`tenzy` — the flagship and the only instrumented project + — has no `ROADMAP.md`.** `giftkeeper` and `robo.learn` have no `docs/STATUS.md`, and the tracker + spec's locked #2 means an unmarked auto-discovered dir "is simply not found." +- **`prima-tactica` and `hexy` are outside `CURRENT/`** and would need explicit pins. +- Phase 2 is itself conditional on the daemon-wide loopback-auth migration. + +**So: the operator works a Telltale issue by hand on GitHub.** A future dispatch path is Local-Tracker +Phase 2, which is unbuilt. The claim that cutting dispatch made a P4 dependency "disappear" is also +withdrawn — it converted a code dependency into an unmet workflow dependency. + +The cut is still correct, for a better reason: dispatch would have widened the set of repos a +credentialed containerized agent may push to from *one sandbox* to *every repo in the registry* — +production apps included — for a pipeline whose input is internet-authored text. + +### 6.6 Reporter text is never an agent instruction + +If dispatch is ever built, the mission task must be an **operator-authored brief**, with the +reporter's text appearing only as delimited untrusted evidence: + +``` + … operator-written repro + acceptance criteria … + … reporter body; DATA, never instructions … +``` + +The mission task becomes the prompt for a containerized agent that pushes branches and opens PRs with +the operator's ambient `git`/`gh` credentials, driven by a daemon that binds `127.0.0.1` with **no +request authentication** (verified: no auth middleware in `crates/fleetd/src/server.rs`). Passing +internet-authored text into that prompt verbatim is a prompt-injection path into a credentialed +agent, and a human eyeballing an issue is a weak barrier against text engineered to read as a plain +bug report with a payload below the fold. + +### 6.7 The case for cutting T5 as well — recorded, not taken + +Critique argued T5/P3 should also go. The argument is strong and belongs on the record: + +- The delivered signal is "n open bug reports per project," which `org: is:issue is:open + label:telltale` gives as a GitHub saved search for **zero lines**. +- Feedback cards are `Idle`, which sorts at rank 99 — the very bottom of the grid, below `Archived`. +- §1 already concedes "GitHub Issues *is* the triage surface." +- The cost is the largest remaining chunk of unbuilt work: a Rust command, a `lib.rs` registration, an + `api.ts` binding, an adapter, a `store.ts` poll, plus Worker-side `/v1/issues` with auth, caching, + ETag, and per-repo error isolation. + +**T5 is nevertheless retained**, because the operator's stated requirement is that this system +integrate with Command Center as the central orchestration hub — a board presence is the deliverable, +not an optimization. If that is worth less than the build cost, the fallback is one `manual`-source +card deep-linking to the saved search: a two-line change that preserves the board presence and +deletes P3 entirely. **This is a live decision, not a closed one.** + +--- + +## 7. The Halyard boundary + +| | Question | Output | Acts on | +|---|---|---|---| +| **Halyard** | Should this *release* be killed or rolled back? | `crash_triage` proposal | Release state — flag kill, hotfix, rollback | +| **Telltale** | What *work* should be done about it? | An issue on the board | Engineering backlog | + +**Invariant: Telltale never touches release state; Halyard never opens issues.** Correlation via the +§2.2 release key is not built this cycle; no covered project is Halyard-onboarded. + +--- + +## 8. Senders (T2) + +### 8.1 Required behaviour + +1. Build a `FeedbackEvent`; POST to `/v1/events` with the project, timestamp, and HMAC headers (§4.1). +2. Persist an `anon_id` (generate once, store locally). +3. **Never block the UI.** Fire-and-forget. +4. **Never transmit contact details** in a structured field — the schema has none. +5. Display the §4.3 public-posting notice adjacent to submit. +6. On a `401` carrying `X-Telltale-Server-Time`, re-sign once with that time (§4.1). + +**Size:** ~60 lines for RN/web/Dart. **Godot 4 is the outlier** — `HMACContext` exists in Godot 4 but +not Godot 3; `prima-tactica` is verified Godot 4.6. Note §4.1's HMAC means every sender implements +SHA-256 regardless; an earlier draft justified removing the client fingerprint by "sparing senders +from SHA-256," which the auth requirement had already made false. + +### 8.2 Reference form + +`tenzy`'s shipped feedback screen (`client/app/feedback.tsx`) is the UX model: title + description, +disabled submit until both non-empty, double-submit guard, deep-link-safe back navigation. **Its UX +is the reference; its Convex transport is replaced by the ingest POST.** + +### 8.3 Retry + +One retry after 2s, then drop. No persistent outbox — that means durable on-device storage of +user-typed text, and one recovered report does not justify it. + +--- + +## 9. Build phasing & testing + +| Phase | Content | Depends on | +|---|---|---| +| **P0** | Crash **feasibility probe**: all four §3 steps on `tenzy` + one game. Configuration, no code. | — | +| **P1** | Contract (T1) + Worker: auth, scrub, label dedup, label-drop detection, `/v1/issues`, `/v1/stats` (T3, T4). Standalone. | — | +| **P2** | Senders (T2): RN, web, Dart, GDScript. | P1 | +| **P3** | Command Center `feedback` adapter (T5) — see §6.7. | P1 | + +**There is no P4.** P0 and P1 are independent and run concurrently. + +### 9.1 Testing + +- **Pure logic** — scrub, `normalize`, fingerprint, and the §4.4 decision table are pure functions, + unit-tested directly. Named cases: scrub applies to **title as well as body**; the long-digit rule + does **not** apply to titles; the fingerprint is computed over the **scrubbed** title; + `not_planned` and `telltale:muted` are silent; `state_reason: null` behaves as `completed`; a + completed-closed match comments and does **not** reopen; **a matching pull request is skipped**. +- **Auth** — unsigned, wrong-secret, and stale-timestamp requests each return `401` and reach no + sink; a stale-timestamp `401` carries `X-Telltale-Server-Time`. +- **Label-drop detection** — a create whose response omits `labels` emits `labels_dropped` and does + not report success. +- **Idempotency** — a create whose response is lost, followed by a sender retry, yields **one** + issue. (The *concurrent* case is a known residual, §10.5 — asserted as a risk, not as a passing test.) +- **Adapter** — mapping tests against a `FeedbackReader` fake, mirroring `adapters.test.ts`: + the §6.4 precedence order, the `kind` whitelist fallback to `unknown`, per-repo partial failure, + and the source-down path. +- **Independent grader** — POST N identical synthetic reports **to the `__probe__` registry entry** + (§5.1), then assert **by reading GitHub back** that exactly one issue exists, then close what it + created. It must never target a product repo: an earlier draft's grader would have published + synthetic issues into a shipped product's public tracker, and would have tested the dedup path + rather than the create path on every run after the first. + +--- + +## 10. Known risks, stated plainly + +1. **Abuse of the ingest.** It converts a request into a public issue under the operator's identity. + HMAC (§4.1) makes abuse attributable and rotatable; rate limiting (§4.2) bounds it; the registry + redirect (§5.1) is the escalation for the bug half. **A secret in a distributed binary is + extractable — managed, not solved.** *This risk was never put to the operator as a decision (the + PII question was); it should be.* +2. **PII in public issues.** Bug reports are scrubbed (§4.3); **crash issues are not** — they are + authored by Sentry directly and depend entirely on §3 step 2's Sentry-side scrubbing, a different + control in a different console. The private-repo escape hatch is a config edit for bugs but N + hand-edited alert rules for crashes. +3. **Prompt injection into a credentialed agent** (§6.6) — currently moot, since dispatch is out of + scope and the agent's push target stays at the sandbox. +4. **The crash half is inert wherever instrumentation is absent** (§3.1) — six of seven checked + in-scope projects; the games are unmeasured. §3's four configuration steps are the real cost. +5. **Concurrent duplicate issues** (§4.4) — simultaneous reports of the same title can both pass the + pre-create lookup. Narrowed by a 30s KV marker, not eliminated. The "multiple matches" row is the + containment. +6. **Weak grouping** (§4.4) — paraphrased duplicates open separate issues; over-merges have no + automated remedy by decision; `tt:` labels accumulate without a janitor. +7. **Unverified external facts**, to check before P1: the archive state of `pawsport` / + `elevation-broker` (§5.3 — GitHub MCP was down this session); Cloudflare free-tier limits for + Workers + KV; and whether Sentry's GitHub alert action can set the §3 step-3 labels as assumed. + +--- + +## 11. Cut from earlier drafts (recorded so they are not re-proposed) + +1. **The whole crash-side pipeline** — Durable Object, threshold gate, dedup decision table, count + labels. Sentry's alert rules and native GitHub integration do all of it as configuration (§3). +2. **Fleet dispatch** — six verified blockers and a widened agent blast radius (§6.5). +3. **`telltale:split`** — re-salted with the retired issue number but still keyed on + `normalize(title)`, so both defects re-merged immediately. +4. **`telltale:count-*` labels** — needed a lifetime counter the store could not provide. +5. **A cockpit GitHub credential** — `public_repo` is a write scope; a second App installation means + the desktop holds the App private key. The cockpit reads through the Worker (§5.4). +6. **`distinct_server_identities ≥ 3` for crashes** — the only server identity on a Sentry webhook is + Sentry, so the branch had cardinality 1 and could never fire. +7. **`telltale:triaged`** — a magic string nothing created or applied; replaced by assignee (§6.4). +8. **`origin:{project}` labels** — zero information when the issue is in the project's own repo. +9. **`project` in the request body** — a second, unvalidated copy of the header's authority (§2.1). +10. **The per-source 300s poll interval** — redundant once the cockpit polls the Worker (§6.3). +11. **`kind: "idea"`**, the HyperLogLog sketch, `telltale:tier2`, the 30-day regression rule, the + unsatisfiable Halyard-slug MUST, and `perennial-blade` / `heart-of-the-gods`. +12. **"Zero new dispatch code" / "no board change" / "`family` clusters the cards" / "Phase 2 + dispatches it with zero changes"** — all four verifiably false; replaced by §6.1, §6.4, §6.5. + +--- + +## 12. References + +- `docs/superpowers/specs/2026-06-09-project-dashboard-design.md` — `ProjectCard`, `SourceAdapter`, + the stage model, locked decision #6 (which §6.1 declines to rely on). +- `docs/superpowers/specs/2026-07-06-local-project-tracker-design.md` — the `cc-item` convention and + the unbuilt Phase 2 path §6.5 declines to depend on. +- `cockpit/ui/src/lib/dashboard/model.ts` · `adapters/halyard.ts` · `adapters/local.ts` · `api.ts` · + `store.ts` · `stage.ts` · `views/Dashboard.svelte` · `src-tauri/src/dashboard.rs` · + `src-tauri/src/lib.rs` — the change surface in §6.1. +- `crates/fleetd/src/server.rs` — the absent auth middleware behind §6.6. +- `INFRASTRUCTURE/halyard/docs/OBSERVABILITY.md` — the boundary §7 draws. +- `CURRENT/tenzy/client/app/feedback.tsx` — the in-app form UX reference (§8.2). + +--- + +## Design Critique Log + +Three independent adversarial rounds, each on the revision the prior round produced. The design lost +roughly half its mass across them; the crash pipeline and fleet dispatch were both deleted outright. + +### Critique Round 1 + +Verified every integration claim against `model.ts`, `halyard.ts`, `local.ts`, `api.ts`, `store.ts`, +`Dashboard.svelte`, `App.svelte`, `roadmap.ts`, `crates/fleetd/src/server.rs`, and Halyard. Fourteen +findings; the severe ones all sustained. + +| # | Finding | Resolution | +|---|---|---| +| 1 | **Unauthenticated public endpoint** writing to public repos under the operator's token; one `curl` published arbitrary text. Abuse absent from the risk list. | §4.1 HMAC; §4.2 rate limiting; abuse promoted to risk #1. | +| 2 | **Prompt injection into a credentialed agent** — the issue body became the mission prompt. | §6.6; later moot once dispatch was cut. | +| 3 | **"Zero new dispatch code" false in six ways** — hardcoded sandbox repo/`base_branch`/`test_cmd`; allowlist and `test_cmd` derived from a `STATUS.md` a feedback project lacks; write-back needs `projectDir`+`roadmapHash`; boot reconcile orphans; no dispatch UI. | Retracted; specified honestly in R2, **cut entirely** in R3, and its replacement justification withdrawn in R4 (§6.5). | +| 4 | **"No board change" false** — `SOURCE_LABEL`, `App.svelte`, `store.ts`, and a `model.ts` comment all change. | Retracted; §6.1 is a change-surface table (completed in R4). | +| 5 | **KV cannot implement the gate** — last-write-wins loses increments during the storm it exists for. | Durable Object in R2; gate **deleted** in R3. | +| 6 | **`public_repo` read-only is not a real scope.** | Two App installations in R2; **cockpit GitHub credential deleted** in R3 (§5.4). | +| 7 | **Limits and gate keyed on client-chosen `anon_id`.** | §4.2, refined in R3 for CGNAT. | +| 8 | **`count`/`project` had no workable read path.** | §5.2 labels; `origin:` later cut as redundant. | +| 9 | **Fingerprint contract inconsistent** — required-then-discarded; a hex rule would `400` Sentry's decimal ids. | §2.1/§4.4; simplified again once crashes left the Worker. | +| 10 | **P0 numbers didn't hold** — out-of-scope projects counted, games unchecked, extrapolation beyond the sample. | §3.1 rewritten to what was verified; P0 scoped to a two-project probe. | +| 11 | Scope to cut: `kind:"idea"`, HLL sketch, 30-day regression, `telltale:tier2`, unsatisfiable Halyard MUST. | All cut (§11.11). | +| 12 | **`health: 'unknown'` unachievable** — `replaceSource` drops the source's cards. | §6.4 states the real behaviour. | +| 13 | **"Does not increment counters" discarded the storm the counters existed for.** | Ordering fixed; moot (no counters). | +| 14 | Client IP observability unaddressed; digit-run redaction would destroy crash stack addresses. | §4.3. | + +### Critique Round 2 + +A fresh critic on revision 2, tasked with what round 1 missed and what its fixes broke. Eighteen +findings; two were structural and forced a rewrite. + +| # | Finding | Resolution | +|---|---|---| +| **2** | **The system reimplemented Sentry.** The DO, gate, dedup table, and count labels recomputed grouping and distinct-user counts Sentry supplies — and Sentry's *native GitHub integration* already opens and links one issue per group with zero code. | **The entire crash-side pipeline deleted** (§3, §11.1). Roughly halved the spec. | +| **18** | **Dispatch should not exist here.** It would widen the credentialed agent's push target from one sandbox to every registry repo, for a pipeline whose input is internet-authored text — a blast-radius risk never listed. | **Cut** (§6.5). | +| 1 | **The crash gate was unimplementable** — a Sentry webhook's only server identity is Sentry, so the `N` branch had cardinality 1. | Moot: gate deleted (§11.6). | +| 3 | **The DO serialized on a GitHub fetch** inside the critical section, on the one fingerprint a storm shares. | Moot: DO deleted. (Its removal reintroduced a concurrency window — see R3 #6.) | +| 4 | **Reopen-always was a trap** — `not_planned` closures reopen forever; mobile users on old builds perpetually reopen fixed issues. No rows for deletion or transfer. | §4.4: never auto-reopen; explicit rows. | +| 5 | **`telltale:split` didn't split** — re-salting still keyed on `normalize(title)`. | **Cut**; remedy is manual. | +| 6 | **HMAC destroyed §4.5's own rationale**, and `canonical_body` was undefined across five runtimes. | §4.1 signs **raw bytes** with a timestamp **header**. | +| 7 | **CGNAT breaks per-IP limiting** for the four mobile rows. | §4.2 IP+`anon_id` pair keying. | +| 8 | **No read path** — a `401` storm and "nobody filed a bug" were the same observation. | §4.6 `/v1/stats`. | +| 9 | **The dispatch brief had no transport**; §5.2's "no body parsing" justification was a strawman. | `body` added (§6.2); §5.2 corrected. | +| 10 | **`family` clustering claim false** — written by three adapters, read by nothing. | §6.4 states it is inert. | +| 11 | **Stage mapping abused the vocabulary** — `Build` outranked `Live`; `Blocked` never cleared; precedence unstated. | §6.4 rewritten. | +| 12 | **`staleAfterSec` is not a poll interval** — the real cadence is a shared 15s timer. | §6.3 (arithmetic corrected again in R4). | +| 13 | **The registry had three consumers and one deployment location.** | §5.1: checked in, Worker-bundled, cockpit reads none of it. | +| 14 | **The cockpit's installation token had no minting story** — it would need the App private key. | §5.4 (completed in R4 — see R3 #1). | +| 16 | **`POST /issues` has no idempotency key** — a create with a lost response double-opens, violating the *at most one open issue per fingerprint* invariant. (An earlier log entry mis-cited this as "locked decision #6"; that numbering was stale.) | §4.4: the `tt:` label is the idempotency key. | +| 17 | **`title` was never scrubbed** — the most visible field in the system. | §4.3 scrubs both; §4.4 fingerprints the scrubbed title. | +| 15 | DO state table missing lifetime count, throttle timestamp, retirement, repo, GC, cost model. | Moot: DO deleted. | +| — | `perennial-blade`/`heart-of-the-gods` are jam games, invisible to discovery, plausibly zero users. | Cut from §1. | + +### Critique Round 3 + +A fresh critic on revision 3, tasked specifically with what the aggressive simplification **broke** — +deletion often severs load-bearing things. Fifteen findings; four were severe, and three of those were +defects the *simplification itself* introduced. + +| # | Finding | Resolution | +|---|---|---| +| **1** | **`GET /v1/issues` had no authentication**, and could not have one without falsifying "the cockpit holds no credential." The Worker can read private registry repos, so an open read endpoint served every private bug-report body to anyone guessing the hostname. R2's deletion of the cockpit's GitHub credential had *relocated* the problem, and the log recorded it as a clean win. | §5.4: the cockpit holds a **Worker-scoped read token** (`TELLTALE_TOKEN`, beside the existing `HALYARD_BIN`/`AUDIENCE_API_URL` env vars), never shipped in a binary. The claim is narrowed to "no *GitHub* credential" in the header, locked #6, and §5.4. | +| **2** | **Project identity was asserted twice and never reconciled.** The HMAC header selected the secret; the body's `project` resolved the repo. Extract the weakest client's secret (`hexy`), sign a body naming `tenzy`, publish into the flagship's public tracker — and the stated response, rotating the attributable project's secret, protects the wrong project. A defect *introduced* by R2's header fix. | **`project` deleted from the body schema** (§2.1); the header is the sole authority (§4.1, locked #4). One fewer field in five senders. | +| **4** | **The simplification routed half the pipeline around every PII control.** §4.3's scrub applies only to the Worker; crash issues are authored by Sentry straight into the same public repos with zero scrubbing — and a Sentry payload is a *richer* PII channel (exception messages, breadcrumbs, URLs with tokens, `user.email`, stack locals). The private-repo escape hatch also doesn't reach them: that's N hand-edited alert rules, not a `registry.yml` edit. | §3 gains **step 2 (Sentry data scrubbing)** as a required configuration step; locked #5 and §4.3 scope the redirect claim to the bug half; §10.2 restated. | +| **3** | **"Phase 2 dispatches it with zero changes" was false in three ways** — Phase 2 has no UI and no POST path; of nine covered projects exactly one has both `docs/STATUS.md` and `ROADMAP.md`, and **`tenzy` has no `ROADMAP.md` at all**; `prima-tactica` and `hexy` are outside the scan root. The cut was right, the reason was not. | §6.5 rewritten: dispatch is **out of scope**, worked by hand. The "P4 dependency disappears" claim is withdrawn — it became an unmet *workflow* dependency. Added to §11.12. | +| **5** | **`telltale:triaged` was a magic string nothing created, applied, or tested** — yet the board's headline `blockedCount` depended on it clearing. Its own stated purpose was defeated by its mechanism. | §6.4: `Blocked` iff an open crash issue has **no assignee** — native, free in the list response, self-clearing, one click. Label cut (§11.7). | +| **6** | **"Solves create idempotency" overreached.** Deleting the DO removed the only serialization; the pre-create lookup is check-then-act, so simultaneous reports of the same title both create. The "multiple matches" row was framed as an anomaly rather than the design's own motivating scenario. The named test covers only the sequential retry. | §4.4 restated as **retry** idempotency, with a 30s KV marker as a narrowing and the residual listed as **risk §10.5**. §9.1 says plainly that the concurrent case is a risk, not a passing test. | +| **7** | **T5 should arguably be cut too** — `Idle` sorts at rank 99 (below `Archived`), so cards pile at the grid's bottom; ~10 cards inflate the header total and duplicate `local` cards; the whole delivered signal is a GitHub saved search. | **T5 retained** — a board presence is the operator's stated requirement, not an optimization. But the argument is recorded verbatim in **§6.7** with a two-line fallback, and §6.4 now emits a card **only for projects with open issues**, fixing the count inflation and grey-card clutter. | +| **8** | **Two silent GitHub behaviours the label scheme depends on:** `GET /issues` returns **pull requests** as issues (a labeled fix PR reads as an open bug); and `POST /issues` **silently drops `labels`** without push access — which would break the idempotency key, the dedup key, and the read key at once while reporting success. | §4.4: a `pull_request` filter row, and mandatory **label verification** in the create response emitting `labels_dropped` to `/v1/stats`. Both in §9.1. | +| **9** | **`TelltaleIssue` could not produce §6.4's strings** — no `createdIso` (so "new this week" was an `updated_at` question), no `state`, and a `telltale:*` prefix parse would yield `'muted'`. | §6.2: `createdIso`, `isOpen`, `hasAssignee`, and an explicit `kind` whitelist with an `'unknown'` fallback. | +| **10** | **The "verified change surface" table was itself incomplete** — missing `lib.rs`'s `generate_handler!` registration (without which the command doesn't exist at runtime), the `api.ts` binding (which §12 already named, contradicting §6.1 two sections apart), and the Worker base-URL/token config. `model.ts`'s file header still asserts the very overclaim §6.1 retracts. | §6.1 completed; the header-comment amendment added. | +| 11 | **§6.3's arithmetic was inherited from a design that no longer exists** — the cockpit polls the Worker, not GitHub, so the per-source interval was redundant while costing a structural `refresh()` change. And **partial repo failure** was unaddressed: one 403 on an archived repo would blank the entire feedback lane. | §6.3: interval dropped (§11.10); per-repo error isolation specified as required behaviour. | +| 12 | **`origin:{project}` was dead weight** (the repo *is* the project) and cost a hand-configured field in every Sentry alert rule; `tt:` label growth was unmentioned. | Label cut (§11.8); growth and the unbuilt janitor named in §4.4. | +| 13 | **The clock-skew mitigation was heavier than the problem** — a persisted offset in five runtimes that still failed on a device's first report. | §4.1: `401` + `X-Telltale-Server-Time`, re-signed by the retry that §8.3 already mandates. | +| 14 | **The grader wrote real issues into production trackers**, and tested the dedup path rather than the create path on every run after the first. | §5.1 adds a `__probe__` registry entry; §9.1's grader targets it and cleans up. | +| 15 | Smaller: no row for `state_reason: null`; §3 said "the registry repo," which does not exist; Sentry's label picker requires labels **pre-created per repo**; `pawsport` appeared in the registry but not the covered set. | All fixed in §4.4, §3 (steps 3–4), and §5.1. | + +**Explicitly confirmed sound in round 3, do not re-litigate:** the `family`-is-inert claim; the +health-on-failed-poll description; the `Idle`-not-`Build` reasoning and its `sortedCards` citation; +the never-auto-reopen rule; the choice of the REST list endpoint with `labels=` over the search API +(not eventually consistent, not subject to the 30 req/min limit, AND-semantic, and `tt:` + 16 hex is +well inside the 50-char label limit); and §1's project verification, including `prima-tactica` as +Godot 4.6. From 176ba85ea1150e5d0ce38bb6c7de9928042370eb Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Sun, 30 Aug 2026 18:17:48 -0600 Subject: [PATCH 2/3] docs(spec): record the operator's two open decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §10.1 ingest-abuse risk: put to the operator and knowingly accepted, on the same footing as the PII decision. - §6.7 T5: retained and closed. Command Center is the central hub, so the dashboard adapter is a deliverable; the saved-search fallback is kept on record as considered and rejected, not as a substitute. Promoted to locked decision #9. --- ...-08-30-telltale-feedback-pipeline-design.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md b/docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md index c8c6827..a795cba 100644 --- a/docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md +++ b/docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md @@ -70,6 +70,8 @@ library projects; **`kind: "idea"`**; and **fleet dispatch** (§6.5). read token that is never shipped in an app binary (§5.4). 7. **Dispatch is out of scope** (§6.5). 8. **Standalone before integrated.** P1–P2 ship and are useful with no Command Center running. +9. **T5 ships** (operator, 2026-08-30). Command Center is the central hub; the dashboard adapter is + a deliverable, not an optional extra — see §6.7 for the argument against it that was rejected. --- @@ -594,11 +596,12 @@ Critique argued T5/P3 should also go. The argument is strong and belongs on the `api.ts` binding, an adapter, a `store.ts` poll, plus Worker-side `/v1/issues` with auth, caching, ETag, and per-repo error isolation. -**T5 is nevertheless retained**, because the operator's stated requirement is that this system -integrate with Command Center as the central orchestration hub — a board presence is the deliverable, -not an optimization. If that is worth less than the build cost, the fallback is one `manual`-source -card deep-linking to the saved search: a two-line change that preserves the board presence and -deletes P3 entirely. **This is a live decision, not a closed one.** +**T5 is retained — confirmed by the operator on 2026-08-30, and this decision is closed.** Command +Center is to be the central hub for all of this, so a real `feedback` source adapter is the +deliverable, not an optimization to be traded away. The saved-search fallback is recorded above only +so a future reader knows it was considered and rejected on purpose; **do not substitute it for the +adapter.** The §6.4 fixes (cards only for projects with open issues) address the clutter and +count-inflation half of the critique without giving up the integration. --- @@ -683,8 +686,9 @@ user-typed text, and one recovered report does not justify it. 1. **Abuse of the ingest.** It converts a request into a public issue under the operator's identity. HMAC (§4.1) makes abuse attributable and rotatable; rate limiting (§4.2) bounds it; the registry redirect (§5.1) is the escalation for the bug half. **A secret in a distributed binary is - extractable — managed, not solved.** *This risk was never put to the operator as a decision (the - PII question was); it should be.* + extractable — managed, not solved.** **Put to the operator and knowingly accepted, 2026-08-30**, + on the same footing as the PII decision: the mitigations bound and attribute abuse rather than + preventing it, and that is judged tolerable at this portfolio's scale. 2. **PII in public issues.** Bug reports are scrubbed (§4.3); **crash issues are not** — they are authored by Sentry directly and depend entirely on §3 step 2's Sentry-side scrubbing, a different control in a different console. The private-repo escape hatch is a config edit for bugs but N From b6da31c39ff89216cea7738ab8c68990655335e5 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Sun, 30 Aug 2026 18:30:02 -0600 Subject: [PATCH 3/3] docs(plan): Telltale ingest Worker (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven TDD tasks building the standalone Worker — spec T1, T3, T4. Every decision that can be a pure function is one (schema, scrub, fingerprint, decide, registry), so the logic is unit-tested without network or KV. Scoped to P1 only. The senders (P2, nine other repos, four languages) and the dashboard adapter (P3, cockpit/ui) are separate subsystems and get their own plans; P0 is Sentry console configuration. Flags two deliberate deviations from the spec for review: a typed TS registry module instead of registry.yml, and fine-grained PATs instead of GitHub App installation tokens. --- .../plans/2026-08-30-telltale-worker.md | 2180 +++++++++++++++++ 1 file changed, 2180 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-telltale-worker.md diff --git a/docs/superpowers/plans/2026-08-30-telltale-worker.md b/docs/superpowers/plans/2026-08-30-telltale-worker.md new file mode 100644 index 0000000..11c6fa2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-telltale-worker.md @@ -0,0 +1,2180 @@ +# Telltale Ingest Worker — 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 the Telltale ingest Worker — the standalone service that accepts authenticated bug reports from any app or game, scrubs them, deduplicates them by fingerprint label, and opens or comments on a GitHub issue in that project's own repo. + +**Architecture:** One Cloudflare Worker with three routes. Every decision that can be a pure function is one (`schema`, `scrub`, `fingerprint`, `decide`, `registry`), so the logic is unit-tested without network or KV. Two I/O modules (`github`, `kv`) sit behind narrow interfaces that tests replace with fakes. `index.ts` is thin wiring and owns no logic. + +**Tech Stack:** TypeScript, Cloudflare Workers (WebCrypto, KV), wrangler, vitest, Node 20. No runtime dependencies. + +**Spec:** [`docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md`](../specs/2026-08-30-telltale-feedback-pipeline-design.md) + +--- + +## Scope + +This plan covers **P1 only** — the Worker (spec T1, T3, T4). It produces working, testable software on its own, per the spec's locked decision #8. + +The spec's other phases are separate subsystems and get their own plans: + +| Phase | Why it is not in this plan | +|---|---| +| **P0** — Sentry setup | Operator configuration in the Sentry console (spec §3, four steps). No code. | +| **P2** — senders | Four reference files across **nine other repositories** in four languages. Nothing here to test until this Worker is deployed. | +| **P3** — dashboard adapter | Lives in `cockpit/ui`, and consumes `GET /v1/issues`, which this plan builds. | + +## ⚠️ Two deliberate deviations from the spec — review these before starting + +Both simplify without weakening a security property. **If either is rejected, stop and revise the plan rather than improvising.** + +1. **The registry is `src/registry.ts` (a typed TS module), not `registry.yml`.** Spec §5.1 says YAML bundled at build time. A Worker cannot read files at runtime, so YAML needs a bundler plugin plus a parser dependency. A TS module preserves every property §5.1 actually asks for — checked in, explicit entries, no slug inference, bundled with the Worker — and adds one: **a typo becomes a compile error instead of a runtime 404.** Deletes a dependency and a build step. + +2. **Two fine-grained PATs (one per GitHub account), not GitHub App installation tokens.** Spec §5.4 specifies App installation tokens. Minting those inside a Worker means RS256 JWT signing with the App private key on every request. A fine-grained PAT scoped to selected repos with `Issues: read and write` gives the same property the spec's §5.4 was protecting — *a scoped, write-limited credential held only by the Worker, never by a client* — at a fraction of the machinery. The spec's own objection (round 1) was to **classic** `public_repo`, which is a coarse write scope that also cannot read private repos; a fine-grained PAT is neither. Cost: manual rotation at expiry, acceptable at single-operator scale. + +--- + +## Global Constraints + +Copied verbatim from the spec. Every task's requirements implicitly include these. + +- **Node 20.** Matches every existing CI job in this repo. +- **No runtime dependencies.** The Worker ships zero `dependencies`; `devDependencies` only. +- **WebCrypto, not `node:crypto`.** Use `crypto.subtle` / `crypto.getRandomValues` — available in both Workers and Node 20. +- `schema_version` **MUST be `1`**. Unknown → `400`, never best-effort parsed. +- `title`: **1–120 chars after trim.** `body`: **0–8000 chars**, truncated with a visible marker, **never rejected**. +- `context`: bounded key set — exactly `platform`, `os_version`, `locale`. **Unknown keys are dropped, not stored.** +- `release.surface` ∈ `ios | android | web | desktop`. +- `reporter.anon_id`: opaque, ≤64 chars. **Never a security boundary.** +- **The `X-Telltale-Project` header is the sole project authority.** The body carries no `project` field. +- **HMAC is over raw request bytes:** `HMAC-SHA256(secret, timestamp + "." + rawBody)`. Never over re-serialized JSON. +- **Replay window: ±10 minutes** against server time. No nonce. +- **The scrub applies to BOTH `title` and `body`.** The long-digit-run rule is **`body`-only**. +- **The fingerprint is computed over the SCRUBBED title.** +- **Never auto-reopen a closed issue.** +- Rate limits: IP+`anon_id` **10/hour**; IP **200/hour**; project **1000/hour**. +- **Client IP is stored only as a salted hash, TTL 1h, and never written to an issue.** + +--- + +## File Structure + +All new, under `telltale/` — a third npm project in this repo, matching how `cockpit/ui` and `cockpit/ui/src-tauri` are already independent. + +| File | Responsibility | +|---|---| +| `telltale/package.json` | npm project: vitest, wrangler, `@cloudflare/workers-types` | +| `telltale/tsconfig.json` | TS config targeting the Workers runtime | +| `telltale/vitest.config.ts` | vitest, **node** environment (not jsdom) | +| `telltale/wrangler.toml` | Worker name, KV binding, compatibility date | +| `telltale/src/types.ts` | `Env`, `FeedbackEvent`, `RegistryEntry`, `TelltaleIssue` | +| `telltale/src/registry.ts` | The typed project registry + `lookup()` | +| `telltale/src/schema.ts` | `parseEvent()` — validation, pure | +| `telltale/src/scrub.ts` | `scrubTitle()` / `scrubBody()` — pure | +| `telltale/src/fingerprint.ts` | `normalize()` / `fingerprint()` | +| `telltale/src/auth.ts` | `verifySignature()` — HMAC + replay window | +| `telltale/src/decide.ts` | `decide()` — the §4.4 decision table, pure | +| `telltale/src/github.ts` | `GitHubClient` — issues list/create/comment | +| `telltale/src/kv.ts` | Rate limits, comment throttle, stats counters | +| `telltale/src/index.ts` | The router. Wiring only, no logic. | +| `telltale/test/*.test.ts` | One test file per source module | +| `telltale/test/fakes.ts` | `FakeKV`, `FakeGitHub` | +| `telltale/test/live-grader.test.ts` | Gated integration test against a real repo | + +--- + +## Task 1: Project scaffold and the registry + +**Files:** +- Create: `telltale/package.json`, `telltale/tsconfig.json`, `telltale/vitest.config.ts`, `telltale/wrangler.toml`, `telltale/src/types.ts`, `telltale/src/registry.ts` +- Test: `telltale/test/registry.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Env`, `FeedbackEvent`, `RegistryEntry`, `Surface` (from `types.ts`); `REGISTRY: Record` and `lookup(project: string): RegistryEntry | null` (from `registry.ts`). + +- [ ] **Step 1: Create the npm project** + +`telltale/package.json`: + +```json +{ + "name": "telltale", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "check": "tsc --noEmit", + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240909.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0", + "wrangler": "^3.78.0" + } +} +``` + +`telltale/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ES2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "vitest/globals"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} +``` + +`telltale/vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config' + +// Node environment, not jsdom: this is a Worker, there is no DOM. Node 20 provides +// the same WebCrypto globals (crypto.subtle) the Workers runtime does, so the pure +// modules and the crypto ones both run unmodified here. +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['test/**/*.test.ts'], + }, +}) +``` + +`telltale/wrangler.toml`: + +```toml +name = "telltale" +main = "src/index.ts" +compatibility_date = "2026-08-30" + +# Rate-limit counters, comment throttles, and stats. Approximate by design — +# see spec §4.2 for why KV is correct here and was not for the deleted dedup gate. +[[kv_namespaces]] +binding = "TELLTALE_KV" +id = "REPLACE_ME_AFTER_wrangler_kv_namespace_create" +``` + +- [ ] **Step 2: Define the shared types** + +`telltale/src/types.ts`: + +```ts +export type Surface = 'ios' | 'android' | 'web' | 'desktop' + +export interface FeedbackEvent { + schema_version: 1 + title: string + body: string + release?: { version: string; surface: Surface } + context?: { platform?: string; os_version?: string; locale?: string } + reporter?: { anon_id?: string } + occurred_at?: string +} + +export interface RegistryEntry { + /** "owner/name" */ + repo: string + /** Which PAT to use, keyed by account. */ + account: 'primary' | 'secondary' + labels: string[] +} + +export interface Env { + TELLTALE_KV: KVNamespace + /** JSON: { "": "" } */ + TELLTALE_SENDER_SECRETS: string + /** Fine-grained PAT, Issues: read+write, primary account. */ + GITHUB_TOKEN_PRIMARY: string + /** Fine-grained PAT, Issues: read+write, secondary account. */ + GITHUB_TOKEN_SECONDARY: string + /** Bearer token the cockpit presents to GET /v1/issues and /v1/stats. */ + OPERATOR_READ_TOKEN: string + /** Salt for hashing client IPs. */ + IP_HASH_SALT: string +} +``` + +- [ ] **Step 3: Write the failing registry test** + +`telltale/test/registry.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { REGISTRY, lookup } from '../src/registry' + +describe('registry', () => { + it('resolves a registered project', () => { + const e = lookup('tenzy') + expect(e).not.toBeNull() + expect(e!.repo).toMatch(/^[\w.-]+\/[\w.-]+$/) + }) + + it('returns null for an unregistered project', () => { + expect(lookup('not-a-real-project')).toBeNull() + }) + + it('never infers a repo from the slug', () => { + // A slug that is not an explicit entry must not resolve, even though it + // looks exactly like a plausible repo name. + expect(lookup('command-center')).toBeNull() + }) + + it('includes a __probe__ entry that is not a product repo', () => { + const probe = lookup('__probe__') + expect(probe).not.toBeNull() + const products = Object.entries(REGISTRY) + .filter(([k]) => k !== '__probe__') + .map(([, v]) => v.repo) + expect(products).not.toContain(probe!.repo) + }) + + it('gives every entry at least the telltale label', () => { + for (const entry of Object.values(REGISTRY)) { + expect(entry.labels).toContain('telltale') + } + }) +}) +``` + +- [ ] **Step 4: Run the test and confirm it fails** + +Run: `cd telltale && npm install && npx vitest run test/registry.test.ts` +Expected: FAIL — `Failed to resolve import "../src/registry"`. + +- [ ] **Step 5: Implement the registry** + +`telltale/src/registry.ts`. Replace `` / `` with the real GitHub account names when the repo is not under an embargo constraint. + +```ts +import type { RegistryEntry } from './types' + +/** + * The project registry — spec §5.1, with one deliberate deviation: a typed TS + * module rather than registry.yml (see the plan's "deliberate deviations"). + * + * Entries are EXPLICIT. There is no slug-to-repo inference anywhere in this + * Worker: a wrong guess writes a user's bug report into a stranger's repository. + * + * An entry with no shipping sender (e.g. pawsport) is crash-only — it receives + * Sentry-created issues and appears on the board, but no app POSTs to it. + */ +export const REGISTRY: Record = { + tenzy: { repo: '/tenzy', account: 'primary', labels: ['telltale'] }, + giftkeeper: { repo: '/giftkeeper', account: 'secondary', labels: ['telltale'] }, + purposefull: { repo: '/purposefull', account: 'secondary', labels: ['telltale'] }, + ironsoul: { repo: '/ironsoul', account: 'secondary', labels: ['telltale'] }, + audience: { repo: '/audience', account: 'secondary', labels: ['telltale'] }, + lineage: { repo: '/lineage', account: 'secondary', labels: ['telltale'] }, + 'robo.learn': { repo: '/robo.learn', account: 'primary', labels: ['telltale'] }, + 'prima-tactica': { repo: '/prima-tactica', account: 'secondary', labels: ['telltale', 'game'] }, + hexy: { repo: '/hexy', account: 'secondary', labels: ['telltale', 'game'] }, + + // Crash-only: archived on GitHub, so it cannot receive issue writes (spec §5.3). + pawsport: { repo: '/telltale-intake', account: 'secondary', labels: ['telltale'] }, + + // The live grader's target (spec §9.1). NEVER a product repo: the grader + // creates real issues, and pointing it at a shipped product would publish + // synthetic reports into a public tracker. + __probe__: { repo: '/telltale-probe', account: 'secondary', labels: ['telltale'] }, +} + +export function lookup(project: string): RegistryEntry | null { + return Object.prototype.hasOwnProperty.call(REGISTRY, project) + ? REGISTRY[project]! + : null +} +``` + +- [ ] **Step 6: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/registry.test.ts` +Expected: PASS, 5 tests. + +- [ ] **Step 7: Commit** + +```bash +git add telltale/ +git commit -m "feat(telltale): scaffold the ingest Worker and its project registry" +``` + +--- + +## Task 2: Event schema validation + +**Files:** +- Create: `telltale/src/schema.ts` +- Test: `telltale/test/schema.test.ts` + +**Interfaces:** +- Consumes: `FeedbackEvent`, `Surface` from `types.ts`. +- Produces: `parseEvent(raw: unknown): ParseResult`, where + `type ParseResult = { ok: true; event: FeedbackEvent } | { ok: false; reason: string }`. + +- [ ] **Step 1: Write the failing test** + +`telltale/test/schema.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { parseEvent } from '../src/schema' + +const valid = { + schema_version: 1, + title: 'Save button does nothing', + body: 'Tapped save, nothing happened.', + release: { version: '1.4.2', surface: 'android' }, + context: { platform: 'android', os_version: '14', locale: 'en-US' }, + reporter: { anon_id: 'abc123' }, + occurred_at: '2026-08-30T18:04:11Z', +} + +describe('parseEvent', () => { + it('accepts a well-formed event', () => { + const r = parseEvent(valid) + expect(r.ok).toBe(true) + }) + + it('rejects an unknown schema_version rather than best-effort parsing', () => { + const r = parseEvent({ ...valid, schema_version: 2 }) + expect(r).toEqual({ ok: false, reason: 'schema_version' }) + }) + + it('rejects a body-level project field, which is not part of the schema', () => { + // The X-Telltale-Project header is the sole authority. A body copy would be + // a second, unvalidated identity — the exact defect round 3 found. + const r = parseEvent({ ...valid, project: 'tenzy' }) + expect(r).toEqual({ ok: false, reason: 'project_in_body' }) + }) + + it('rejects an empty or overlong title', () => { + expect(parseEvent({ ...valid, title: ' ' })).toEqual({ ok: false, reason: 'title' }) + expect(parseEvent({ ...valid, title: 'x'.repeat(121) })).toEqual({ ok: false, reason: 'title' }) + }) + + it('trims the title', () => { + const r = parseEvent({ ...valid, title: ' spaced ' }) + expect(r.ok && r.event.title).toBe('spaced') + }) + + it('truncates an overlong body with a visible marker instead of rejecting it', () => { + const r = parseEvent({ ...valid, body: 'x'.repeat(9000) }) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.event.body.length).toBeLessThanOrEqual(8000 + 32) + expect(r.event.body).toContain('[truncated]') + }) + + it('drops unknown context keys rather than storing them', () => { + const r = parseEvent({ ...valid, context: { platform: 'ios', email: 'a@b.c' } }) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.event.context).toEqual({ platform: 'ios' }) + expect(JSON.stringify(r.event)).not.toContain('a@b.c') + }) + + it('rejects an unknown release surface', () => { + const r = parseEvent({ ...valid, release: { version: '1.0.0', surface: 'watch' } }) + expect(r).toEqual({ ok: false, reason: 'release.surface' }) + }) + + it('rejects an overlong anon_id', () => { + const r = parseEvent({ ...valid, reporter: { anon_id: 'x'.repeat(65) } }) + expect(r).toEqual({ ok: false, reason: 'reporter.anon_id' }) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/schema.test.ts` +Expected: FAIL — cannot resolve `../src/schema`. + +- [ ] **Step 3: Implement the validator** + +`telltale/src/schema.ts`: + +```ts +import type { FeedbackEvent, Surface } from './types' + +export type ParseResult = + | { ok: true; event: FeedbackEvent } + | { ok: false; reason: string } + +const SURFACES: readonly Surface[] = ['ios', 'android', 'web', 'desktop'] +const CONTEXT_KEYS = ['platform', 'os_version', 'locale'] as const +const BODY_MAX = 8000 + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +export function parseEvent(raw: unknown): ParseResult { + if (!isRecord(raw)) return { ok: false, reason: 'not_an_object' } + + if (raw.schema_version !== 1) return { ok: false, reason: 'schema_version' } + + // The header is the sole project authority (spec §4.1). A body copy is + // rejected outright rather than ignored, so a stale sender fails loudly. + if ('project' in raw) return { ok: false, reason: 'project_in_body' } + + if (typeof raw.title !== 'string') return { ok: false, reason: 'title' } + const title = raw.title.trim() + if (title.length < 1 || title.length > 120) return { ok: false, reason: 'title' } + + if (raw.body !== undefined && typeof raw.body !== 'string') { + return { ok: false, reason: 'body' } + } + const rawBody = typeof raw.body === 'string' ? raw.body : '' + // Truncate, never reject: a user's report is not worth losing to a length rule. + const body = rawBody.length > BODY_MAX + ? rawBody.slice(0, BODY_MAX) + '\n\n…[truncated]' + : rawBody + + const event: FeedbackEvent = { schema_version: 1, title, body } + + if (raw.release !== undefined) { + if (!isRecord(raw.release)) return { ok: false, reason: 'release' } + const { version, surface } = raw.release + if (typeof version !== 'string' || version.length > 64) { + return { ok: false, reason: 'release.version' } + } + if (typeof surface !== 'string' || !SURFACES.includes(surface as Surface)) { + return { ok: false, reason: 'release.surface' } + } + event.release = { version, surface: surface as Surface } + } + + if (raw.context !== undefined) { + if (!isRecord(raw.context)) return { ok: false, reason: 'context' } + const ctx: Record = {} + // Bounded key set. Unknown keys are DROPPED, not stored — this is what stops + // `context` becoming an unaudited PII channel. + for (const k of CONTEXT_KEYS) { + const v = raw.context[k] + if (typeof v === 'string' && v.length <= 64) ctx[k] = v + } + event.context = ctx + } + + if (raw.reporter !== undefined) { + if (!isRecord(raw.reporter)) return { ok: false, reason: 'reporter' } + const id = raw.reporter.anon_id + if (id !== undefined) { + if (typeof id !== 'string' || id.length > 64) { + return { ok: false, reason: 'reporter.anon_id' } + } + event.reporter = { anon_id: id } + } + } + + if (typeof raw.occurred_at === 'string') event.occurred_at = raw.occurred_at + + return { ok: true, event } +} +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/schema.test.ts` +Expected: PASS, 9 tests. + +- [ ] **Step 5: Commit** + +```bash +git add telltale/src/schema.ts telltale/test/schema.test.ts +git commit -m "feat(telltale): validate FeedbackEvent, dropping unknown context keys" +``` + +--- + +## Task 3: PII scrub + +**Files:** +- Create: `telltale/src/scrub.ts` +- Test: `telltale/test/scrub.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `scrubTitle(s: string): string`, `scrubBody(s: string): string`. + +- [ ] **Step 1: Write the failing test** + +`telltale/test/scrub.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { scrubTitle, scrubBody } from '../src/scrub' + +describe('scrub', () => { + it('redacts an email from the body', () => { + expect(scrubBody('reach me at alex@example.com ok')) + .toBe('reach me at [redacted:email] ok') + }) + + it('redacts an email from the TITLE too', () => { + // The title is the most visible, most indexed, most notification-carrying + // field in the system. An earlier draft scrubbed only the body. + expect(scrubTitle('crash when alex@example.com logs in')) + .toBe('crash when [redacted:email] logs in') + }) + + it('redacts phone numbers in both fields', () => { + expect(scrubBody('call +1 415 555 0132')).toContain('[redacted:phone]') + expect(scrubTitle('call 415-555-0132 please')).toContain('[redacted:phone]') + }) + + it('redacts a long digit run in the body', () => { + expect(scrubBody('card 4111111111111111 declined')) + .toBe('card [redacted:number] declined') + }) + + it('does NOT apply the long-digit rule to titles', () => { + // On a 120-char title the rule is all false positives. + expect(scrubTitle('build 4111111111111111 fails')).toBe('build 4111111111111111 fails') + }) + + it('leaves a version string that looks phone-shaped alone', () => { + expect(scrubBody('broke in 1.4.2.0 build 20260830')).toBe('broke in 1.4.2.0 build 20260830') + }) + + it('leaves a crash digest stack address alone', () => { + // Digit-run redaction must not destroy the data triage needs. Hex addresses + // and short frame offsets carry no digit run of 12+. + const digest = 'at 0x00007ff8 in frame 42 (offset 1024)' + expect(scrubBody(digest)).toBe(digest) + }) + + it('is idempotent, so a re-scrub does not mangle a marker', () => { + const once = scrubBody('mail alex@example.com') + expect(scrubBody(once)).toBe(once) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/scrub.test.ts` +Expected: FAIL — cannot resolve `../src/scrub`. + +- [ ] **Step 3: Implement the scrub** + +`telltale/src/scrub.ts`: + +```ts +/** + * Ingest-side PII redaction (spec §4.3). + * + * Recorded operator decision: issues land in each project's own repo, including + * public ones. That exposure was raised and knowingly accepted; this module is + * the mitigation, not a guarantee — it misses obfuscated forms like + * "alex at example dot com". + * + * Redaction is LOSSY AND ONE-WAY BY DESIGN. The original is never stored + * anywhere: a store of unredacted originals would recreate the hazard. + */ + +const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g + +// E.164 and NANP shapes. Requires a separator or a leading +, so a bare run of +// digits is left to the DIGIT_RUN rule (body only) and version strings survive. +const PHONE = /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]\d{3}[\s.-]\d{4}\b/g + +// 12+ consecutive digits: card and account numbers. BODY ONLY. +const DIGIT_RUN = /\b\d{12,}\b/g + +export function scrubTitle(s: string): string { + return s.replace(EMAIL, '[redacted:email]').replace(PHONE, '[redacted:phone]') +} + +export function scrubBody(s: string): string { + return scrubTitle(s).replace(DIGIT_RUN, '[redacted:number]') +} +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/scrub.test.ts` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add telltale/src/scrub.ts telltale/test/scrub.test.ts +git commit -m "feat(telltale): scrub PII from both title and body" +``` + +--- + +## Task 4: Fingerprint + +**Files:** +- Create: `telltale/src/fingerprint.ts` +- Test: `telltale/test/fingerprint.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `normalize(title: string): string`, `fingerprint(scrubbedTitle: string): Promise` (16 lowercase hex chars), `labelFor(fp: string): string` (returns `tt:`). + +- [ ] **Step 1: Write the failing test** + +`telltale/test/fingerprint.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { normalize, fingerprint, labelFor } from '../src/fingerprint' + +describe('fingerprint', () => { + it('normalizes case, punctuation and whitespace', () => { + expect(normalize('Crash on save!')).toBe(normalize('crash on save')) + }) + + it('gives verbatim repeats the same fingerprint', async () => { + expect(await fingerprint('Crash on save!')).toBe(await fingerprint('crash on save')) + }) + + it('gives different titles different fingerprints', async () => { + expect(await fingerprint('crash on save')).not.toBe(await fingerprint('crash on load')) + }) + + it('produces exactly 16 lowercase hex chars', async () => { + expect(await fingerprint('anything at all')).toMatch(/^[0-9a-f]{16}$/) + }) + + it('builds a label well inside GitHub is 50-char limit', () => { + const label = labelFor('0123456789abcdef') + expect(label).toBe('tt:0123456789abcdef') + expect(label.length).toBeLessThanOrEqual(50) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/fingerprint.test.ts` +Expected: FAIL — cannot resolve `../src/fingerprint`. + +- [ ] **Step 3: Implement** + +`telltale/src/fingerprint.ts`: + +```ts +/** + * Dedup identity (spec §4.4). + * + * Deliberately WEAK grouping over the title alone. It catches verbatim repeats — + * the common case when a visible bug is reported by many people — and misses + * paraphrases. Semantic grouping is a model call, and a model call in the dedup + * path makes issue identity non-deterministic and untestable. Not doing it is + * the decision. + * + * ALWAYS called with the SCRUBBED title, so identity is stable regardless of + * what redaction removed. + */ + +export function normalize(title: string): string { + return title + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, '') + .replace(/\s+/g, ' ') + .trim() +} + +export async function fingerprint(scrubbedTitle: string): Promise { + const data = new TextEncoder().encode(normalize(scrubbedTitle)) + const digest = await crypto.subtle.digest('SHA-256', data) + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 16) +} + +/** The label IS the idempotency key, the dedup key, and the read key. */ +export function labelFor(fp: string): string { + return `tt:${fp}` +} +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/fingerprint.test.ts` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add telltale/src/fingerprint.ts telltale/test/fingerprint.test.ts +git commit -m "feat(telltale): fingerprint the scrubbed title for dedup" +``` + +--- + +## Task 5: HMAC authentication and the replay window + +**Files:** +- Create: `telltale/src/auth.ts` +- Test: `telltale/test/auth.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `sign(secret: string, timestamp: string, rawBody: string): Promise` and + `verifySignature(args: { secret: string; timestamp: string | null; signature: string | null; rawBody: string; nowMs: number }): AuthResult`, where + `type AuthResult = { ok: true } | { ok: false; reason: 'missing' | 'clock_skew' | 'bad_signature' }`. + +- [ ] **Step 1: Write the failing test** + +`telltale/test/auth.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { sign, verifySignature } from '../src/auth' + +const SECRET = 'test-secret' +const NOW = Date.UTC(2026, 7, 30, 18, 0, 0) +const ts = String(Math.floor(NOW / 1000)) +const BODY = '{"schema_version":1,"title":"x","body":"y"}' + +async function good() { + return { secret: SECRET, timestamp: ts, signature: await sign(SECRET, ts, BODY), rawBody: BODY, nowMs: NOW } +} + +describe('verifySignature', () => { + it('accepts a correctly signed request', async () => { + expect(verifySignature(await good())).resolves.toEqual({ ok: true }) + }) + + it('rejects a missing signature or timestamp', async () => { + const g = await good() + await expect(verifySignature({ ...g, signature: null })).resolves.toEqual({ ok: false, reason: 'missing' }) + await expect(verifySignature({ ...g, timestamp: null })).resolves.toEqual({ ok: false, reason: 'missing' }) + }) + + it('rejects a wrong secret', async () => { + const g = await good() + await expect(verifySignature({ ...g, secret: 'other-secret' })) + .resolves.toEqual({ ok: false, reason: 'bad_signature' }) + }) + + it('rejects a tampered body even with a valid-looking signature', async () => { + const g = await good() + await expect(verifySignature({ ...g, rawBody: BODY.replace('"x"', '"z"') })) + .resolves.toEqual({ ok: false, reason: 'bad_signature' }) + }) + + it('rejects a replay outside the +/-10 minute window', async () => { + const g = await good() + await expect(verifySignature({ ...g, nowMs: NOW + 11 * 60_000 })) + .resolves.toEqual({ ok: false, reason: 'clock_skew' }) + await expect(verifySignature({ ...g, nowMs: NOW - 11 * 60_000 })) + .resolves.toEqual({ ok: false, reason: 'clock_skew' }) + }) + + it('accepts inside the window in both directions', async () => { + const g = await good() + await expect(verifySignature({ ...g, nowMs: NOW + 9 * 60_000 })).resolves.toEqual({ ok: true }) + await expect(verifySignature({ ...g, nowMs: NOW - 9 * 60_000 })).resolves.toEqual({ ok: true }) + }) + + it('signs over raw bytes, so key order changes the signature', async () => { + const a = await sign(SECRET, ts, '{"a":1,"b":2}') + const b = await sign(SECRET, ts, '{"b":2,"a":1}') + expect(a).not.toBe(b) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/auth.test.ts` +Expected: FAIL — cannot resolve `../src/auth`. + +- [ ] **Step 3: Implement** + +`telltale/src/auth.ts`: + +```ts +/** + * Request authentication (spec §4.1). + * + * The threat: this endpoint turns an HTTP request into a public GitHub issue in + * the operator's repo, authored by the operator's token. An unauthenticated + * endpoint is a remote "publish arbitrary text under Alex's identity" primitive. + * + * The signature is over RAW REQUEST BYTES, never a canonicalized JSON + * re-serialization: five independent canonicalizers (GDScript, Dart, RN, browser + * JS, Worker) agreeing byte-for-byte on key order and number formatting is a + * silent-401 generator. + * + * There is NO NONCE. A captured request is replayable inside the +/-10 minute + * window. Accepted: the payoff is a duplicate report, which dedup collapses. + */ + +export type AuthResult = { ok: true } | { ok: false; reason: 'missing' | 'clock_skew' | 'bad_signature' } + +const WINDOW_MS = 10 * 60_000 + +async function key(secret: string): Promise { + return crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) +} + +export async function sign(secret: string, timestamp: string, rawBody: string): Promise { + const mac = await crypto.subtle.sign( + 'HMAC', + await key(secret), + new TextEncoder().encode(`${timestamp}.${rawBody}`), + ) + return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('') +} + +/** Constant-time compare, so a signature cannot be recovered byte by byte. */ +function equals(a: string, b: string): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + return diff === 0 +} + +export async function verifySignature(args: { + secret: string + timestamp: string | null + signature: string | null + rawBody: string + nowMs: number +}): Promise { + const { secret, timestamp, signature, rawBody, nowMs } = args + if (!timestamp || !signature) return { ok: false, reason: 'missing' } + + const tsSec = Number(timestamp) + if (!Number.isFinite(tsSec)) return { ok: false, reason: 'missing' } + if (Math.abs(nowMs - tsSec * 1000) > WINDOW_MS) return { ok: false, reason: 'clock_skew' } + + const expected = await sign(secret, timestamp, rawBody) + return equals(expected, signature) ? { ok: true } : { ok: false, reason: 'bad_signature' } +} +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/auth.test.ts` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add telltale/src/auth.ts telltale/test/auth.test.ts +git commit -m "feat(telltale): HMAC request auth over raw bytes with a replay window" +``` + +--- + +## Task 6: The dedup decision table + +**Files:** +- Create: `telltale/src/decide.ts` +- Test: `telltale/test/decide.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: + +```ts +export interface CandidateIssue { + number: number + state: 'open' | 'closed' + stateReason: 'completed' | 'not_planned' | null + labels: string[] + isPullRequest: boolean +} +export type Decision = + | { action: 'create' } + | { action: 'comment'; issue: number } + | { action: 'ignore'; reason: 'muted' | 'not_planned' } +export function decide(candidates: CandidateIssue[]): Decision +``` + +- [ ] **Step 1: Write the failing test** + +`telltale/test/decide.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { decide, type CandidateIssue } from '../src/decide' + +const base: CandidateIssue = { + number: 7, state: 'open', stateReason: null, labels: ['telltale'], isPullRequest: false, +} + +describe('decide', () => { + it('creates when there is no match', () => { + expect(decide([])).toEqual({ action: 'create' }) + }) + + it('comments on an existing open issue, never opening a second', () => { + expect(decide([base])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('comments but does NOT reopen a completed-closed issue', () => { + // Mobile users run old builds for months: a bug fixed in 1.4.3 keeps + // arriving from 1.4.1 clients and must not perpetually reopen its issue. + const closed = { ...base, state: 'closed' as const, stateReason: 'completed' as const } + expect(decide([closed])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('treats a legacy closure with a null state_reason as completed', () => { + const legacy = { ...base, state: 'closed' as const, stateReason: null } + expect(decide([legacy])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('is silent for a not_planned closure', () => { + const wontfix = { ...base, state: 'closed' as const, stateReason: 'not_planned' as const } + expect(decide([wontfix])).toEqual({ action: 'ignore', reason: 'not_planned' }) + }) + + it('is silent for a muted issue even when open', () => { + const muted = { ...base, labels: ['telltale', 'telltale:muted'] } + expect(decide([muted])).toEqual({ action: 'ignore', reason: 'muted' }) + }) + + it('skips pull requests, which the issues endpoint also returns', () => { + // A fix PR carrying the telltale label would otherwise read as an open bug. + const pr = { ...base, number: 99, isPullRequest: true } + expect(decide([pr])).toEqual({ action: 'create' }) + expect(decide([pr, base])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('prefers the lowest-numbered open issue when duplicates exist', () => { + // Duplicates are the EXPECTED outcome of a concurrent create race, not an + // anomaly — see the plan's note on retry-vs-concurrency idempotency. + const later = { ...base, number: 12 } + expect(decide([later, base])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('prefers an open issue over a closed one', () => { + const closed = { ...base, number: 3, state: 'closed' as const, stateReason: 'completed' as const } + expect(decide([closed, base])).toEqual({ action: 'comment', issue: 7 }) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/decide.test.ts` +Expected: FAIL — cannot resolve `../src/decide`. + +- [ ] **Step 3: Implement** + +`telltale/src/decide.ts`: + +```ts +/** + * The dedup decision table (spec §4.4, §5.3). Pure — no I/O, fully testable. + * + * Automated report-to-issue WITHOUT dedup is issue spam, and issue spam destroys + * the tracker the pipeline exists to feed. + */ + +export interface CandidateIssue { + number: number + state: 'open' | 'closed' + stateReason: 'completed' | 'not_planned' | null + labels: string[] + isPullRequest: boolean +} + +export type Decision = + | { action: 'create' } + | { action: 'comment'; issue: number } + | { action: 'ignore'; reason: 'muted' | 'not_planned' } + +export function decide(candidates: CandidateIssue[]): Decision { + // GET /issues returns pull requests as issues. A labelled fix PR is not a report. + const issues = candidates.filter((c) => !c.isPullRequest) + if (issues.length === 0) return { action: 'create' } + + // Operator intent to stay silent wins over everything else. + if (issues.some((i) => i.labels.includes('telltale:muted'))) { + return { action: 'ignore', reason: 'muted' } + } + + const open = issues.filter((i) => i.state === 'open').sort((a, b) => a.number - b.number) + if (open.length > 0) return { action: 'comment', issue: open[0]!.number } + + const closed = [...issues].sort((a, b) => a.number - b.number) + const first = closed[0]! + if (first.stateReason === 'not_planned') return { action: 'ignore', reason: 'not_planned' } + + // Closed as completed, or a legacy closure with no state_reason: comment so the + // recurrence is recorded, but NEVER auto-reopen. + return { action: 'comment', issue: first.number } +} +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/decide.test.ts` +Expected: PASS, 9 tests. + +- [ ] **Step 5: Commit** + +```bash +git add telltale/src/decide.ts telltale/test/decide.test.ts +git commit -m "feat(telltale): dedup decision table, never auto-reopening" +``` + +--- + +## Task 7: KV counters — rate limits, comment throttle, stats + +**Files:** +- Create: `telltale/src/kv.ts`, `telltale/test/fakes.ts` +- Test: `telltale/test/kv.test.ts` + +**Interfaces:** +- Consumes: `Env` from `types.ts`. +- Produces: `hashIp(ip, salt): Promise`; `checkRateLimits(kv, { ipHash, anonId, project }): Promise` where `type RateResult = { ok: true } | { ok: false; scope: 'pair' | 'ip' | 'project' }`; `shouldComment(kv, fp): Promise`; `recordStat(kv, reason): Promise`; `readStats(kv): Promise>`. `FakeKV` from `test/fakes.ts`. + +- [ ] **Step 1: Write the fake KV** + +`telltale/test/fakes.ts`: + +```ts +/** Minimal in-memory KVNamespace stand-in. Enough for counters and TTL-less reads. */ +export class FakeKV { + store = new Map() + async get(k: string): Promise { return this.store.get(k) ?? null } + async put(k: string, v: string, _o?: { expirationTtl?: number }): Promise { this.store.set(k, v) } + async delete(k: string): Promise { this.store.delete(k) } +} +``` + +- [ ] **Step 2: Write the failing test** + +`telltale/test/kv.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { FakeKV } from './fakes' +import { hashIp, checkRateLimits, shouldComment, recordStat, readStats } from '../src/kv' + +const kv = () => new FakeKV() as unknown as KVNamespace + +describe('hashIp', () => { + it('is stable, salted, and never returns the raw IP', async () => { + const a = await hashIp('203.0.113.7', 'salt') + expect(a).toBe(await hashIp('203.0.113.7', 'salt')) + expect(a).not.toContain('203.0.113.7') + expect(a).not.toBe(await hashIp('203.0.113.7', 'other-salt')) + }) +}) + +describe('checkRateLimits', () => { + it('allows traffic under every ceiling', async () => { + const k = kv() + const r = await checkRateLimits(k, { ipHash: 'h', anonId: 'a', project: 'tenzy' }) + expect(r).toEqual({ ok: true }) + }) + + it('blocks the 11th event from one install on one IP', async () => { + const k = kv() + const args = { ipHash: 'h', anonId: 'a', project: 'tenzy' } + for (let i = 0; i < 10; i++) expect(await checkRateLimits(k, args)).toEqual({ ok: true }) + expect(await checkRateLimits(k, args)).toEqual({ ok: false, scope: 'pair' }) + }) + + it('allows many installs behind one CGNAT IP, up to the higher IP ceiling', async () => { + // Carrier-grade NAT puts thousands of mobile users behind one address, and + // four of five sender platforms are mobile. A tight per-IP cap would + // silently destroy the 21st genuine reporter on a carrier. + const k = kv() + for (let i = 0; i < 20; i++) { + expect(await checkRateLimits(k, { ipHash: 'h', anonId: `install-${i}`, project: 'tenzy' })) + .toEqual({ ok: true }) + } + }) +}) + +describe('shouldComment', () => { + it('allows the first comment then throttles within the hour', async () => { + const k = kv() + expect(await shouldComment(k, 'fp1')).toBe(true) + expect(await shouldComment(k, 'fp1')).toBe(false) + expect(await shouldComment(k, 'fp2')).toBe(true) + }) +}) + +describe('stats', () => { + it('counts by reason so silence is diagnosable', async () => { + const k = kv() + await recordStat(k, 'accepted') + await recordStat(k, 'accepted') + await recordStat(k, 'bad_signature') + expect(await readStats(k)).toMatchObject({ accepted: 2, bad_signature: 1 }) + }) +}) +``` + +- [ ] **Step 3: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/kv.test.ts` +Expected: FAIL — cannot resolve `../src/kv`. + +- [ ] **Step 4: Implement** + +`telltale/src/kv.ts`: + +```ts +/** + * Approximate counters (spec §4.2, §4.6). + * + * KV is CORRECT HERE and was not correct for the dedup gate an earlier design + * draft used it for: these are abuse counters and throttles where a lost + * increment under concurrency is harmless. Correctness-critical dedup lives in + * the GitHub label lookup (src/decide.ts), not here. + */ + +const HOUR = 3600 + +export type RateResult = { ok: true } | { ok: false; scope: 'pair' | 'ip' | 'project' } + +export type StatReason = + | 'accepted' | 'bad_signature' | 'clock_skew' | 'rate_limited' + | 'unregistered_project' | 'invalid_schema' | 'labels_dropped' + | 'duplicate_fingerprint' | 'github_error' | 'ignored' + +/** The client IP is stored ONLY as a salted hash, and never written to an issue. */ +export async function hashIp(ip: string, salt: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${ip}`)) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 32) +} + +function bucket(): string { + return String(Math.floor(Date.now() / 1000 / HOUR)) +} + +async function bump(kv: KVNamespace, key: string, limit: number): Promise { + const k = `rl:${key}:${bucket()}` + const n = Number((await kv.get(k)) ?? '0') + if (n >= limit) return false + await kv.put(k, String(n + 1), { expirationTtl: HOUR * 2 }) + return true +} + +export async function checkRateLimits( + kv: KVNamespace, + a: { ipHash: string; anonId: string; project: string }, +): Promise { + if (!(await bump(kv, `pair:${a.ipHash}:${a.anonId}`, 10))) return { ok: false, scope: 'pair' } + if (!(await bump(kv, `ip:${a.ipHash}`, 200))) return { ok: false, scope: 'ip' } + if (!(await bump(kv, `proj:${a.project}`, 1000))) return { ok: false, scope: 'project' } + return { ok: true } +} + +/** At most one comment per fingerprint per hour: a crash hitting a thousand users + * must produce one issue and a handful of comments, not a thousand notifications. */ +export async function shouldComment(kv: KVNamespace, fp: string): Promise { + const k = `ct:${fp}:${bucket()}` + if (await kv.get(k)) return false + await kv.put(k, '1', { expirationTtl: HOUR * 2 }) + return true +} + +export async function recordStat(kv: KVNamespace, reason: StatReason): Promise { + const k = `st:${reason}:${bucket()}` + const n = Number((await kv.get(k)) ?? '0') + await kv.put(k, String(n + 1), { expirationTtl: HOUR * 26 }) +} + +export async function readStats(kv: KVNamespace): Promise> { + const out: Record = {} + const list = await kv.list({ prefix: 'st:' }) + for (const { name } of list.keys) { + const reason = name.split(':')[1]! + out[reason] = (out[reason] ?? 0) + Number((await kv.get(name)) ?? '0') + } + return out +} +``` + +Add `list` to the fake so `readStats` works: + +```ts +// append to telltale/test/fakes.ts, inside class FakeKV + async list({ prefix }: { prefix: string }) { + return { keys: [...this.store.keys()].filter((k) => k.startsWith(prefix)).map((name) => ({ name })) } + } +``` + +- [ ] **Step 5: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/kv.test.ts` +Expected: PASS, 6 tests. + +- [ ] **Step 6: Commit** + +```bash +git add telltale/src/kv.ts telltale/test/kv.test.ts telltale/test/fakes.ts +git commit -m "feat(telltale): KV rate limits, comment throttle and stats counters" +``` + +--- + +## Task 8: GitHub client, with label-drop detection + +**Files:** +- Create: `telltale/src/github.ts` +- Modify: `telltale/test/fakes.ts` (add `FakeGitHub`) +- Test: `telltale/test/github.test.ts` + +**Interfaces:** +- Consumes: `CandidateIssue` from `decide.ts`; `RegistryEntry`, `Env` from `types.ts`. +- Produces: + +```ts +export interface GitHubClient { + findByLabel(repo: string, label: string): Promise + createIssue(repo: string, i: { title: string; body: string; labels: string[] }): + Promise<{ number: number; url: string; labelsDropped: boolean }> + commentIssue(repo: string, number: number, body: string): Promise + listTelltaleIssues(repo: string): Promise +} +export function tokenFor(env: Env, entry: RegistryEntry): string +export function restClient(token: string, fetchImpl?: typeof fetch): GitHubClient +``` + +- [ ] **Step 1: Write the fake and the failing test** + +Append to `telltale/test/fakes.ts`: + +```ts +import type { GitHubClient } from '../src/github' +import type { CandidateIssue } from '../src/decide' + +export class FakeGitHub implements GitHubClient { + issues: Array = [] + comments: Array<{ number: number; body: string }> = [] + /** Simulates GitHub silently dropping labels when the token lacks push access. */ + dropLabels = false + private next = 1 + + async findByLabel(_repo: string, label: string): Promise { + return this.issues.filter((i) => i.labels.includes(label)) + } + + async createIssue(_repo: string, i: { title: string; body: string; labels: string[] }) { + const labels = this.dropLabels ? [] : i.labels + const number = this.next++ + this.issues.push({ + number, state: 'open', stateReason: null, labels, + isPullRequest: false, title: i.title, body: i.body, + }) + return { number, url: `https://example.test/i/${number}`, labelsDropped: labels.length !== i.labels.length } + } + + async commentIssue(_repo: string, number: number, body: string) { + this.comments.push({ number, body }) + } + + async listTelltaleIssues() { return [] } +} +``` + +`telltale/test/github.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { FakeGitHub } from './fakes' +import { tokenFor } from '../src/github' +import type { Env, RegistryEntry } from '../src/types' + +const env = { GITHUB_TOKEN_PRIMARY: 'tok-a', GITHUB_TOKEN_SECONDARY: 'tok-b' } as Env + +describe('tokenFor', () => { + it('selects the token by account, since a PAT cannot span two accounts', () => { + expect(tokenFor(env, { repo: 'x/y', account: 'primary', labels: [] } as RegistryEntry)).toBe('tok-a') + expect(tokenFor(env, { repo: 'x/y', account: 'secondary', labels: [] } as RegistryEntry)).toBe('tok-b') + }) +}) + +describe('createIssue label-drop detection', () => { + it('reports labelsDropped when GitHub silently discards them', async () => { + // GitHub drops `labels` on POST /issues without push access, WITHOUT an + // error. Since tt: is simultaneously the idempotency key, the dedup key and + // the read key, an undetected drop means every later report opens a fresh + // duplicate forever while the Worker reports success. + const gh = new FakeGitHub() + gh.dropLabels = true + const r = await gh.createIssue('x/y', { title: 't', body: 'b', labels: ['tt:abc'] }) + expect(r.labelsDropped).toBe(true) + }) + + it('reports no drop on the happy path', async () => { + const gh = new FakeGitHub() + const r = await gh.createIssue('x/y', { title: 't', body: 'b', labels: ['tt:abc'] }) + expect(r.labelsDropped).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/github.test.ts` +Expected: FAIL — cannot resolve `../src/github`. + +- [ ] **Step 3: Implement** + +`telltale/src/github.ts`: + +```ts +import type { CandidateIssue } from './decide' +import type { Env, RegistryEntry } from './types' + +export interface RawIssue { + number: number + title: string + body: string | null + state: 'open' | 'closed' + labels: Array<{ name: string }> + assignee: unknown | null + created_at: string + updated_at: string + html_url: string + pull_request?: unknown +} + +export interface GitHubClient { + findByLabel(repo: string, label: string): Promise + createIssue(repo: string, i: { title: string; body: string; labels: string[] }): + Promise<{ number: number; url: string; labelsDropped: boolean }> + commentIssue(repo: string, number: number, body: string): Promise + listTelltaleIssues(repo: string): Promise +} + +/** A fine-grained PAT is per-account, so the registry names which one to use. */ +export function tokenFor(env: Env, entry: RegistryEntry): string { + return entry.account === 'primary' ? env.GITHUB_TOKEN_PRIMARY : env.GITHUB_TOKEN_SECONDARY +} + +function toCandidate(i: RawIssue): CandidateIssue { + return { + number: i.number, + state: i.state, + // The REST list endpoint omits state_reason on older closures; decide() + // treats null as `completed`. + stateReason: ((i as { state_reason?: 'completed' | 'not_planned' | null }).state_reason) ?? null, + labels: i.labels.map((l) => l.name), + isPullRequest: i.pull_request !== undefined, + } +} + +export function restClient(token: string, fetchImpl: typeof fetch = fetch): GitHubClient { + const headers = { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'telltale', + } + + async function api(path: string, init?: RequestInit): Promise { + const res = await fetchImpl(`https://api.github.com${path}`, { ...init, headers }) + if (!res.ok) throw new Error(`github ${res.status} on ${path}`) + return res + } + + return { + // The REST LIST endpoint with `labels=` — not the search API, which is + // eventually consistent and capped at 30 req/min. `labels=` is exact and + // AND-semantic, which is what dedup needs. + async findByLabel(repo, label) { + const res = await api(`/repos/${repo}/issues?labels=${encodeURIComponent(label)}&state=all&per_page=100`) + return ((await res.json()) as RawIssue[]).map(toCandidate) + }, + + async createIssue(repo, i) { + const res = await api(`/repos/${repo}/issues`, { method: 'POST', body: JSON.stringify(i) }) + const created = (await res.json()) as RawIssue + const got = created.labels.map((l) => l.name) + return { + number: created.number, + url: created.html_url, + // Verified, never assumed — see the label-drop test. + labelsDropped: i.labels.some((l) => !got.includes(l)), + } + }, + + async commentIssue(repo, number, body) { + await api(`/repos/${repo}/issues/${number}/comments`, { method: 'POST', body: JSON.stringify({ body }) }) + }, + + async listTelltaleIssues(repo) { + const res = await api(`/repos/${repo}/issues?labels=telltale&state=open&per_page=100`) + return (await res.json()) as RawIssue[] + }, + } +} +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/github.test.ts` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add telltale/src/github.ts telltale/test/github.test.ts telltale/test/fakes.ts +git commit -m "feat(telltale): GitHub client verifying labels on create" +``` + +--- + +## Task 9: The router — `POST /v1/events` + +**Files:** +- Create: `telltale/src/index.ts` +- Test: `telltale/test/events.test.ts` + +**Interfaces:** +- Consumes: everything from Tasks 1–8. +- Produces: the default Worker export `{ fetch(req: Request, env: Env): Promise }`, and `handleEvent(req, env, deps)` where `deps = { gh: (entry: RegistryEntry) => GitHubClient; nowMs: number }` so tests inject a fake client. + +**Ordering (spec §4.5), which the implementation must follow exactly:** auth → registry → schema → rate limit → scrub → fingerprint → label lookup → decide → create-or-comment → verify labels → record stat. + +- [ ] **Step 1: Write the failing test** + +`telltale/test/events.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { FakeKV, FakeGitHub } from './fakes' +import { handleEvent } from '../src/index' +import { sign } from '../src/auth' +import type { Env } from '../src/types' + +const SECRET = 'sender-secret' +const NOW = Date.UTC(2026, 7, 30, 18, 0, 0) + +function makeEnv(kv: FakeKV): Env { + return { + TELLTALE_KV: kv as unknown as KVNamespace, + TELLTALE_SENDER_SECRETS: JSON.stringify({ tenzy: SECRET }), + GITHUB_TOKEN_PRIMARY: 'a', GITHUB_TOKEN_SECONDARY: 'b', + OPERATOR_READ_TOKEN: 'op', IP_HASH_SALT: 'salt', + } +} + +async function post(body: object, opts: { secret?: string; ts?: string } = {}) { + const raw = JSON.stringify(body) + const ts = opts.ts ?? String(Math.floor(NOW / 1000)) + return new Request('https://t.test/v1/events', { + method: 'POST', + headers: { + 'X-Telltale-Project': 'tenzy', + 'X-Telltale-Timestamp': ts, + 'X-Telltale-Signature': await sign(opts.secret ?? SECRET, ts, raw), + 'CF-Connecting-IP': '203.0.113.7', + }, + body: raw, + }) +} + +const EVENT = { schema_version: 1, title: 'Save fails', body: 'nothing happens', reporter: { anon_id: 'a1' } } + +function deps(gh: FakeGitHub) { + return { gh: () => gh, nowMs: NOW } +} + +describe('POST /v1/events', () => { + it('creates an issue carrying the tt: fingerprint label', async () => { + const gh = new FakeGitHub() + const res = await handleEvent(await post(EVENT), makeEnv(new FakeKV()), deps(gh)) + expect(res.status).toBe(202) + expect(gh.issues).toHaveLength(1) + expect(gh.issues[0]!.labels.some((l) => l.startsWith('tt:'))).toBe(true) + expect(gh.issues[0]!.title).toBe('[bug] Save fails') + }) + + it('comments instead of opening a second issue for the same title', async () => { + const gh = new FakeGitHub() + const env = makeEnv(new FakeKV()) + await handleEvent(await post(EVENT), env, deps(gh)) + await handleEvent(await post({ ...EVENT, reporter: { anon_id: 'a2' } }), env, deps(gh)) + expect(gh.issues).toHaveLength(1) + expect(gh.comments).toHaveLength(1) + }) + + it('is idempotent across a sender retry', async () => { + // A create that succeeded with a lost response, retried by the sender, + // must not double-open. The tt: label is what makes the retry safe. + const gh = new FakeGitHub() + const env = makeEnv(new FakeKV()) + const req = await post(EVENT) + await handleEvent(req.clone(), env, deps(gh)) + await handleEvent(await post(EVENT), env, deps(gh)) + expect(gh.issues).toHaveLength(1) + }) + + it('scrubs an email out of the title before the issue is created', async () => { + const gh = new FakeGitHub() + await handleEvent( + await post({ ...EVENT, title: 'crash for alex@example.com' }), + makeEnv(new FakeKV()), deps(gh), + ) + expect(gh.issues[0]!.title).toContain('[redacted:email]') + expect(gh.issues[0]!.title).not.toContain('alex@example.com') + }) + + it('rejects a wrong signature with 401 and reaches no sink', async () => { + const gh = new FakeGitHub() + const res = await handleEvent(await post(EVENT, { secret: 'wrong' }), makeEnv(new FakeKV()), deps(gh)) + expect(res.status).toBe(401) + expect(gh.issues).toHaveLength(0) + }) + + it('returns the server time on a clock-skew rejection so the retry can re-sign', async () => { + const stale = String(Math.floor(NOW / 1000) - 20 * 60) + const res = await handleEvent(await post(EVENT, { ts: stale }), makeEnv(new FakeKV()), deps(new FakeGitHub())) + expect(res.status).toBe(401) + expect(res.headers.get('X-Telltale-Server-Time')).toBeTruthy() + }) + + it('404s an unregistered project so a typo fails loudly', async () => { + const raw = JSON.stringify(EVENT) + const ts = String(Math.floor(NOW / 1000)) + const req = new Request('https://t.test/v1/events', { + method: 'POST', + headers: { + 'X-Telltale-Project': 'nope', 'X-Telltale-Timestamp': ts, + 'X-Telltale-Signature': await sign(SECRET, ts, raw), 'CF-Connecting-IP': '203.0.113.7', + }, + body: raw, + }) + expect((await handleEvent(req, makeEnv(new FakeKV()), deps(new FakeGitHub()))).status).toBe(404) + }) + + it('400s a body that carries a project field', async () => { + const res = await handleEvent( + await post({ ...EVENT, project: 'tenzy' }), makeEnv(new FakeKV()), deps(new FakeGitHub()), + ) + expect(res.status).toBe(400) + }) + + it('does not report success when GitHub silently drops the labels', async () => { + const gh = new FakeGitHub() + gh.dropLabels = true + const res = await handleEvent(await post(EVENT), makeEnv(new FakeKV()), deps(gh)) + expect(res.status).toBe(500) + }) + + it('429s past the per-install ceiling', async () => { + const gh = new FakeGitHub() + const env = makeEnv(new FakeKV()) + let last = 0 + for (let i = 0; i < 12; i++) last = (await handleEvent(await post(EVENT), env, deps(gh))).status + expect(last).toBe(429) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/events.test.ts` +Expected: FAIL — cannot resolve `../src/index`. + +- [ ] **Step 3: Implement the router** + +`telltale/src/index.ts`: + +```ts +import type { Env, RegistryEntry } from './types' +import { lookup } from './registry' +import { parseEvent } from './schema' +import { scrubTitle, scrubBody } from './scrub' +import { fingerprint, labelFor } from './fingerprint' +import { verifySignature } from './auth' +import { decide } from './decide' +import { restClient, tokenFor, type GitHubClient } from './github' +import { hashIp, checkRateLimits, shouldComment, recordStat, readStats, type StatReason } from './kv' + +export interface Deps { + gh: (entry: RegistryEntry) => GitHubClient + nowMs: number +} + +function json(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, headers: { 'content-type': 'application/json', ...headers }, + }) +} + +export async function handleEvent(req: Request, env: Env, deps: Deps): Promise { + const kv = env.TELLTALE_KV + const fail = async (status: number, reason: StatReason, extra?: Record) => { + await recordStat(kv, reason) + return json(status, { error: reason }, extra) + } + + const project = req.headers.get('X-Telltale-Project') + if (!project) return fail(401, 'bad_signature') + + const secrets = JSON.parse(env.TELLTALE_SENDER_SECRETS) as Record + const secret = secrets[project] + const rawBody = await req.text() + + // 1. Auth. The header is the sole project authority. + if (!secret) return fail(401, 'bad_signature') + const auth = await verifySignature({ + secret, + timestamp: req.headers.get('X-Telltale-Timestamp'), + signature: req.headers.get('X-Telltale-Signature'), + rawBody, + nowMs: deps.nowMs, + }) + if (!auth.ok) { + // Hand back server time so the sender's single mandated retry can re-sign. + // Device clock skew is common on Android and would otherwise fail silently. + const extra = { 'X-Telltale-Server-Time': String(Math.floor(deps.nowMs / 1000)) } + return fail(401, auth.reason === 'clock_skew' ? 'clock_skew' : 'bad_signature', extra) + } + + // 2. Registry. An unregistered slug fails loudly rather than dropping silently. + const entry = lookup(project) + if (!entry) return fail(404, 'unregistered_project') + + // 3. Schema. + let parsed + try { parsed = parseEvent(JSON.parse(rawBody)) } catch { return fail(400, 'invalid_schema') } + if (!parsed.ok) return fail(400, 'invalid_schema') + const event = parsed.event + + // 4. Rate limits, on server-observed identity as well as the client's anon_id. + const ipHash = await hashIp(req.headers.get('CF-Connecting-IP') ?? '0.0.0.0', env.IP_HASH_SALT) + const rate = await checkRateLimits(kv, { ipHash, anonId: event.reporter?.anon_id ?? 'anon', project }) + if (!rate.ok) return fail(429, 'rate_limited', { 'Retry-After': '3600' }) + + // 5. Scrub, THEN fingerprint — so identity is stable regardless of redaction. + const title = scrubTitle(event.title) + const body = scrubBody(event.body) + const fp = await fingerprint(title) + const label = labelFor(fp) + + const gh = deps.gh(entry) + let candidates + try { candidates = await gh.findByLabel(entry.repo, label) } catch { return fail(503, 'github_error') } + + const decision = decide(candidates) + if (decision.action === 'ignore') return fail(200, 'ignored') + + const footer = + `\n\n---\n` + + (event.release ? `Release: ${project}-${event.release.surface}@${event.release.version}\n` : '') + + (event.context ? `Context: ${JSON.stringify(event.context)}\n` : '') + + `` + + try { + if (decision.action === 'create') { + const created = await gh.createIssue(entry.repo, { + title: `[bug] ${title}`, + body: body + footer, + labels: [...entry.labels, 'telltale:bug', label], + }) + if (created.labelsDropped) { + // Silent label loss breaks the idempotency key, the dedup key and the + // read key at once, while otherwise reporting success. Fail loudly. + await recordStat(kv, 'labels_dropped') + return json(500, { error: 'labels_dropped', issue: created.number }) + } + await recordStat(kv, 'accepted') + return json(202, { issue: created.number, url: created.url }) + } + + if (await shouldComment(kv, fp)) { + await gh.commentIssue(entry.repo, decision.issue, `Reported again.${footer}`) + } + await recordStat(kv, 'accepted') + return json(202, { issue: decision.issue }) + } catch { + return fail(503, 'github_error') + } +} + +export default { + async fetch(req: Request, env: Env): Promise { + const url = new URL(req.url) + const deps: Deps = { + gh: (entry) => restClient(tokenFor(env, entry)), + nowMs: Date.now(), + } + if (req.method === 'POST' && url.pathname === '/v1/events') { + return handleEvent(req, env, deps) + } + return json(404, { error: 'not_found' }) + }, +} +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `cd telltale && npx vitest run test/events.test.ts` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Run the whole suite and the type check** + +Run: `cd telltale && npm test && npm run check` +Expected: all green, `tsc --noEmit` clean. + +- [ ] **Step 6: Commit** + +```bash +git add telltale/src/index.ts telltale/test/events.test.ts +git commit -m "feat(telltale): POST /v1/events — auth, scrub, dedup, create-or-comment" +``` + +--- + +## Task 10: The read endpoints — `GET /v1/issues` and `GET /v1/stats` + +**Files:** +- Modify: `telltale/src/index.ts` +- Create: `telltale/src/read.ts` +- Test: `telltale/test/read.test.ts` + +**Interfaces:** +- Consumes: `GitHubClient`, `RawIssue`, `readStats`. +- Produces: `handleIssues(req, env, deps)`, `handleStats(req, env)`, and the wire type the P3 adapter consumes: + +```ts +export interface TelltaleIssueDTO { + repo: string; number: number; title: string; body: string + kind: 'bug' | 'crash' | 'unknown' + project: string; isOpen: boolean; hasAssignee: boolean + createdIso: string; updatedIso: string; labels: string[]; url: string +} +export interface IssuesResponse { issues: TelltaleIssueDTO[]; errors: Array<{ project: string; message: string }> } +``` + +- [ ] **Step 1: Write the failing test** + +`telltale/test/read.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { FakeKV } from './fakes' +import { handleIssues, toDto } from '../src/read' +import type { Env } from '../src/types' +import type { RawIssue } from '../src/github' + +const env = () => ({ + TELLTALE_KV: new FakeKV() as unknown as KVNamespace, + OPERATOR_READ_TOKEN: 'op-token', + GITHUB_TOKEN_PRIMARY: 'a', GITHUB_TOKEN_SECONDARY: 'b', + TELLTALE_SENDER_SECRETS: '{}', IP_HASH_SALT: 's', +}) as Env + +const raw = (o: Partial = {}): RawIssue => ({ + number: 1, title: 't', body: 'b', state: 'open', + labels: [{ name: 'telltale' }, { name: 'telltale:bug' }], + assignee: null, created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-02T00:00:00Z', + html_url: 'https://example.test/1', ...o, +}) + +const req = (token?: string) => + new Request('https://t.test/v1/issues', token ? { headers: { Authorization: `Bearer ${token}` } } : undefined) + +describe('GET /v1/issues auth', () => { + it('401s without the operator read token', async () => { + // The Worker can read PRIVATE registry repos. An open read endpoint would + // serve every private bug-report body to anyone who guesses the hostname. + expect((await handleIssues(req(), env(), { gh: () => ({} as never) })).status).toBe(401) + expect((await handleIssues(req('wrong'), env(), { gh: () => ({} as never) })).status).toBe(401) + }) +}) + +describe('toDto', () => { + it('derives kind from an explicit whitelist, not a telltale:* prefix parse', () => { + expect(toDto('x/y', 'tenzy', raw()).kind).toBe('bug') + expect(toDto('x/y', 'tenzy', raw({ labels: [{ name: 'telltale:crash' }] })).kind).toBe('crash') + // telltale:muted also matches the prefix; it is not a kind. + expect(toDto('x/y', 'tenzy', raw({ labels: [{ name: 'telltale:muted' }] })).kind).toBe('unknown') + expect(toDto('x/y', 'tenzy', raw({ labels: [{ name: 'telltale' }] })).kind).toBe('unknown') + }) + + it('exposes assignee presence, the triage signal the board gates Blocked on', () => { + expect(toDto('x/y', 'tenzy', raw()).hasAssignee).toBe(false) + expect(toDto('x/y', 'tenzy', raw({ assignee: { login: 'a' } })).hasAssignee).toBe(true) + }) + + it('carries createdIso separately from updatedIso', () => { + const d = toDto('x/y', 'tenzy', raw()) + expect(d.createdIso).toBe('2026-08-01T00:00:00Z') + expect(d.updatedIso).toBe('2026-08-02T00:00:00Z') + }) +}) + +describe('per-repo error isolation', () => { + it('returns the repos that answered plus a per-repo error list', async () => { + // Some registry repos are archived or private with broken billing. One 403 + // must not blank the whole feedback lane. + const gh = (entry: { repo: string }) => ({ + listTelltaleIssues: async () => { + if (entry.repo.includes('lineage')) throw new Error('403') + return [raw()] + }, + }) as never + const res = await handleIssues(req('op-token'), env(), { gh }) + expect(res.status).toBe(200) + const out = await res.json() as { issues: unknown[]; errors: Array<{ project: string }> } + expect(out.issues.length).toBeGreaterThan(0) + expect(out.errors.some((e) => e.project === 'lineage')).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `cd telltale && npx vitest run test/read.test.ts` +Expected: FAIL — cannot resolve `../src/read`. + +- [ ] **Step 3: Implement** + +`telltale/src/read.ts`: + +```ts +import type { Env, RegistryEntry } from './types' +import { REGISTRY } from './registry' +import { readStats } from './kv' +import type { GitHubClient, RawIssue } from './github' + +export interface TelltaleIssueDTO { + repo: string; number: number; title: string; body: string + kind: 'bug' | 'crash' | 'unknown' + project: string; isOpen: boolean; hasAssignee: boolean + createdIso: string; updatedIso: string; labels: string[]; url: string +} + +export interface IssuesResponse { + issues: TelltaleIssueDTO[] + errors: Array<{ project: string; message: string }> +} + +/** Explicit whitelist. A `telltale:*` prefix parse would yield 'muted'. */ +function kindOf(labels: string[]): 'bug' | 'crash' | 'unknown' { + if (labels.includes('telltale:crash')) return 'crash' + if (labels.includes('telltale:bug')) return 'bug' + return 'unknown' +} + +export function toDto(repo: string, project: string, i: RawIssue): TelltaleIssueDTO { + const labels = i.labels.map((l) => l.name) + return { + repo, number: i.number, title: i.title, body: i.body ?? '', + kind: kindOf(labels), project, + isOpen: i.state === 'open', + hasAssignee: i.assignee !== null && i.assignee !== undefined, + createdIso: i.created_at, updatedIso: i.updated_at, + labels, url: i.html_url, + } +} + +function unauthorized(req: Request, env: Env): boolean { + return req.headers.get('Authorization') !== `Bearer ${env.OPERATOR_READ_TOKEN}` +} + +export async function handleIssues( + req: Request, env: Env, deps: { gh: (entry: RegistryEntry) => GitHubClient }, +): Promise { + if (unauthorized(req, env)) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, headers: { 'content-type': 'application/json' }, + }) + } + + const issues: TelltaleIssueDTO[] = [] + const errors: IssuesResponse['errors'] = [] + + for (const [project, entry] of Object.entries(REGISTRY)) { + if (project === '__probe__') continue + try { + const raw = await deps.gh(entry).listTelltaleIssues(entry.repo) + // GET /issues returns pull requests as issues. + for (const i of raw) { + if (i.pull_request === undefined) issues.push(toDto(entry.repo, project, i)) + } + } catch (e) { + // Degrade only the affected project, never the whole lane. + errors.push({ project, message: e instanceof Error ? e.message : 'unknown' }) + } + } + + return new Response(JSON.stringify({ issues, errors } satisfies IssuesResponse), { + status: 200, + headers: { 'content-type': 'application/json', 'cache-control': 'max-age=60' }, + }) +} + +export async function handleStats(req: Request, env: Env): Promise { + if (unauthorized(req, env)) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, headers: { 'content-type': 'application/json' }, + }) + } + return new Response(JSON.stringify(await readStats(env.TELLTALE_KV)), { + status: 200, headers: { 'content-type': 'application/json' }, + }) +} +``` + +- [ ] **Step 4: Wire the routes** + +In `telltale/src/index.ts`, add the import and two branches inside `fetch`, immediately before the final `return json(404, ...)`: + +```ts +import { handleIssues, handleStats } from './read' +``` + +```ts + if (req.method === 'GET' && url.pathname === '/v1/issues') { + return handleIssues(req, env, deps) + } + if (req.method === 'GET' && url.pathname === '/v1/stats') { + return handleStats(req, env) + } +``` + +- [ ] **Step 5: Run the whole suite and the type check** + +Run: `cd telltale && npm test && npm run check` +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add telltale/src/read.ts telltale/src/index.ts telltale/test/read.test.ts +git commit -m "feat(telltale): authenticated read endpoints with per-repo error isolation" +``` + +--- + +## Task 11: CI job, the gated live grader, and the README + +**Files:** +- Create: `telltale/test/live-grader.test.ts`, `telltale/README.md` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: the deployed Worker (live grader only). +- Produces: a `telltale` CI job; a documented deploy runbook. + +- [ ] **Step 1: Write the gated live grader** + +`telltale/test/live-grader.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { sign } from '../src/auth' + +/** + * The independent grader (spec §9.1), in Halyard's verify-launch spirit: it + * asserts by READING THE SINK BACK, never by trusting the Worker's own success + * report — the only way a dedup regression is caught. + * + * Gated like this repo's real-Docker integration tests: it needs live + * credentials, so it is skipped unless they are present. It targets the + * __probe__ registry entry and NEVER a product repo — an earlier design would + * have published synthetic issues into a shipped product's public tracker. + */ +const BASE = process.env.TELLTALE_BASE_URL +const SECRET = process.env.TELLTALE_PROBE_SECRET +const GH = process.env.TELLTALE_PROBE_GH_TOKEN +const REPO = process.env.TELLTALE_PROBE_REPO + +const live = BASE && SECRET && GH && REPO ? describe : describe.skip + +live('live grader', () => { + it('collapses N identical reports into exactly one issue', async () => { + const title = `grader ${crypto.randomUUID()}` + const raw = JSON.stringify({ schema_version: 1, title, body: 'synthetic', reporter: { anon_id: 'grader' } }) + const ts = String(Math.floor(Date.now() / 1000)) + const sig = await sign(SECRET!, ts, raw) + + for (let i = 0; i < 3; i++) { + const res = await fetch(`${BASE}/v1/events`, { + method: 'POST', + headers: { + 'X-Telltale-Project': '__probe__', + 'X-Telltale-Timestamp': ts, + 'X-Telltale-Signature': sig, + }, + body: raw, + }) + expect(res.status).toBe(202) + } + + // Read the sink back, not the Worker's own report. + const listed = await fetch( + `https://api.github.com/repos/${REPO}/issues?state=all&per_page=100&labels=telltale`, + { headers: { Authorization: `Bearer ${GH}`, Accept: 'application/vnd.github+json' } }, + ) + const issues = (await listed.json()) as Array<{ number: number; title: string }> + const mine = issues.filter((i) => i.title.includes(title)) + expect(mine).toHaveLength(1) + + // Clean up so a re-run tests the create path again, not the dedup path. + await fetch(`https://api.github.com/repos/${REPO}/issues/${mine[0]!.number}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${GH}`, Accept: 'application/vnd.github+json' }, + body: JSON.stringify({ state: 'closed', state_reason: 'not_planned' }), + }) + }, 30_000) +}) +``` + +- [ ] **Step 2: Verify the grader skips cleanly with no credentials** + +Run: `cd telltale && npx vitest run test/live-grader.test.ts` +Expected: PASS with the suite reported as skipped. **No network call is made.** + +- [ ] **Step 3: Add the CI job** + +In `.github/workflows/ci.yml`, add this job to `jobs:`, modelled on the existing `vitest (cockpit/ui)` job: + +```yaml + telltale: + name: vitest (telltale) + runs-on: ubuntu-latest + defaults: + run: + working-directory: telltale + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: telltale/package-lock.json + + - run: npm ci + + # The live grader (test/live-grader.test.ts) self-skips without + # TELLTALE_* credentials, so it never runs here — same posture as the + # real-Docker ITs in crates/fleetd/tests/. + - run: npm test + + - run: npm run check +``` + +- [ ] **Step 4: Write the README** + +`telltale/README.md`: + +```markdown +# Telltale ingest Worker + +Accepts authenticated bug reports from the portfolio's apps and games, scrubs +them, deduplicates by fingerprint label, and opens or comments on a GitHub issue +in that project's own repo. + +Spec: [`../docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md`](../docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md) + +Crashes do NOT go through this Worker. They go Sentry → Sentry's native GitHub +integration, with no Telltale code — see spec §3. + +## Routes + +| Route | Auth | +|---|---| +| `POST /v1/events` | Per-project HMAC over raw bytes | +| `GET /v1/issues` | `Authorization: Bearer $OPERATOR_READ_TOKEN` | +| `GET /v1/stats` | `Authorization: Bearer $OPERATOR_READ_TOKEN` | + +## Develop + +```bash +npm ci +npm test # unit suite; the live grader self-skips +npm run check # tsc --noEmit +npm run dev # wrangler dev +``` + +## Deploy + +```bash +npx wrangler kv namespace create TELLTALE_KV # paste the id into wrangler.toml +npx wrangler secret put TELLTALE_SENDER_SECRETS # {"tenzy":"...","hexy":"..."} +npx wrangler secret put GITHUB_TOKEN_PRIMARY # fine-grained PAT, Issues: read+write +npx wrangler secret put GITHUB_TOKEN_SECONDARY +npx wrangler secret put OPERATOR_READ_TOKEN +npx wrangler secret put IP_HASH_SALT +npm run deploy +``` + +## Adding a project + +Add an explicit entry to `src/registry.ts` — there is no slug-to-repo inference +anywhere, because a wrong guess writes a user's bug report into a stranger's +repository. Then generate an HMAC secret and add it to +`TELLTALE_SENDER_SECRETS`. + +## Running the live grader + +Needs a throwaway repo, never a product repo: + +```bash +TELLTALE_BASE_URL=https://telltale..workers.dev \ +TELLTALE_PROBE_SECRET=... TELLTALE_PROBE_GH_TOKEN=... \ +TELLTALE_PROBE_REPO=/telltale-probe \ +npx vitest run test/live-grader.test.ts +``` +``` + +- [ ] **Step 5: Run everything** + +Run: `cd telltale && npm ci && npm test && npm run check` +Expected: all suites green, `tsc` clean. + +- [ ] **Step 6: Commit** + +```bash +git add telltale/test/live-grader.test.ts telltale/README.md telltale/package-lock.json .github/workflows/ci.yml +git commit -m "ci(telltale): add the vitest job, the gated live grader and the runbook" +``` + +--- + +## Self-review + +**Spec coverage.** T1 → Tasks 1–2. T3 (auth §4.1, identity §4.2, scrub §4.3, fingerprint §4.4, ordering §4.5, stats §4.6) → Tasks 3–7, 9, 10. T4 (registry §5.1, issue shape §5.2, credentials §5.4) → Tasks 1, 8, 9. §9.1's full named-test list → Tasks 2–10, with the pull-request skip in Task 6, label-drop detection in Tasks 8–9, and the grader in Task 11. + +**Deliberately not covered here:** T5/P3 (the dashboard adapter, spec §6) and P2 (senders, §8) are separate subsystems in other directories and repositories; P0 is Sentry console configuration. Each needs its own plan. + +**Known gap, carried from the spec rather than introduced here.** Spec §10.5 records that create-idempotency holds for **retries but not for true concurrency** — the pre-create label lookup is a check-then-act with no mutual exclusion, so two simultaneous reports of the same title can both create. Task 6's `decide()` contains that outcome by preferring the lowest-numbered open issue, and Task 9's test covers the retry case only. **This is asserted as a risk, not as a passing test** — do not add a test claiming the concurrent case passes. + +**Before Task 1, resolve these three external facts** (spec §10.7): whether `pawsport` / `elevation-broker` are archived and reject writes; current Cloudflare free-tier limits for Workers + KV; and the real GitHub account names for `src/registry.ts`. + +--- + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-08-30-telltale-worker.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — execute tasks in this session with checkpoints for review.