docs(phase-16): resolve decisions D1-D7; re-sequence waves bottom-up, card last

This commit is contained in:
daniel-c-harvey
2026-06-19 11:32:24 -04:00
parent 62007a6517
commit 25aba1cbb7
2 changed files with 169 additions and 108 deletions
+8 -7
View File
@@ -245,16 +245,17 @@ The phase deferred behind the home-hero **Plays** stat card (`NowPlayingStats.ra
**Architectural spine.** Plays are instrumented at **one seam** — the `StreamingAudioPlayerService` playback lifecycle (not the UI, not the HTTP/media-client layer, which fires multiple times per play via seek-beyond-buffer). A small play-session tracker opens on playback-start, advances a high-water position on the existing progress callback, and closes (classifying the §1 completion bucket) on track-switch / stop / organic-end / page-unload. Shares instrument at the **real** share surface (`SharePopover`'s Copy-link / Copy-embed actions — clipboard writes that record nothing today). Events ship **fire-and-forget via `sendBeacon`** to new `POST api/event/{play,share}` endpoints (proxied through `DeepDrftPublic`, same hop as `api/track/*`), land in an **append-only SQL event log + incremental counter rollup** in `DeepDrftData`, and the home card reads the total through the **existing** `GET api/stats/home` / `HomeStatsDto` / `IStatsDataService` path (the same single persistent-state-bridged round-trip the other two cards use). Release attribution is **resolved server-side** from the track→release join (client sends only the track key); release plays are **derived** (sum of their tracks' plays). All SQL — the FileDatabase vault is not involved. **Architectural spine.** Plays are instrumented at **one seam** — the `StreamingAudioPlayerService` playback lifecycle (not the UI, not the HTTP/media-client layer, which fires multiple times per play via seek-beyond-buffer). A small play-session tracker opens on playback-start, advances a high-water position on the existing progress callback, and closes (classifying the §1 completion bucket) on track-switch / stop / organic-end / page-unload. Shares instrument at the **real** share surface (`SharePopover`'s Copy-link / Copy-embed actions — clipboard writes that record nothing today). Events ship **fire-and-forget via `sendBeacon`** to new `POST api/event/{play,share}` endpoints (proxied through `DeepDrftPublic`, same hop as `api/track/*`), land in an **append-only SQL event log + incremental counter rollup** in `DeepDrftData`, and the home card reads the total through the **existing** `GET api/stats/home` / `HomeStatsDto` / `IStatsDataService` path (the same single persistent-state-bridged round-trip the other two cards use). Release attribution is **resolved server-side** from the track→release join (client sends only the track key); release plays are **derived** (sum of their tracks' plays). All SQL — the FileDatabase vault is not involved.
**Completion buckets (agreed thresholds).** `partial` < 30%, `complete` > 80%; the 3080% middle band is proposed as its own `sampled` bucket so the three are exhaustive and non-overlapping (headline plays = sum of all three) — see spec §1a / decision **D1**. **Completion buckets (settled).** `partial` < 30%, `sampled` 3080%, `complete` > 80% — exhaustive, non-overlapping; headline plays = sum of all three (D1 resolved). The engagement floor (D2) drops trivial skips: a listen counts only at **≥3s OR ≥5% of duration, whichever smaller**.
**Sequenced as four waves.** `16.A → 16.B`, `16.A → 16.C`, `16.A → 16.D`. 16.A is the only cold-start wave. **Sequenced BOTTOM-UP (Daniel directive 2026-06-19): foundation first, the live card LAST.** Reverses the earlier "visible win early" framing — Daniel does not care about the live card until the whole substrate and all metrics are finished. Strict chain: `16.1 → 16.2 → 16.3(16.4 optional) → 16.5`. 16.1 is the only cold-start wave; 16.5 (the card) is the capstone built last.
- **16.APlay & share counters (core).** Player-service play tracker (three-bucket classification + engagement floor), share tracker in `SharePopover`, `sendBeacon` interop + `POST api/event/{play,share}` (rate-limited), `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution. **No `anonId` yet.** Free-floating cold-start wave. - **16.1Foundation: capture seam + transport + event log (nothing reads it yet).** Player-service play tracker (high-water mark + engagement floor), share tracker in `SharePopover` (debounced), `sendBeacon` interop + unload handler, `POST api/event/{play,share}` (proxied, rate-limited), append-only `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution + derived release totals. **No `anonId` yet; no card consumption.** Cold-start wave.
- **16.BHome Plays-card payoff.** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` (+ a secondary line, D7); flip the third card from placeholder to live. **Depends on 16.A.** The visible win — sequence immediately after 16.A. - **16.2Completion-bucket classification + shares.** Three-bucket classification (D1) correct and exhaustive end to end (tracker → payload → log → per-bucket counter columns); share-channel split (link/embed). **Depends on 16.1.**
- **16.C — Unique listeners (stretch / "plus").** The anonymity mechanism (recommend a client-minted random first-party `localStorage` id; alt: server-derived salted daily token; fingerprinting rejected — see §3 / **D5**): thread an `anonId` onto event payloads, count distinct server-side, expose per-target and/or as the card's secondary line. **Depends on 16.A. Explicitly lower priority** — defers indefinitely without stranding 16.A/16.B. - **16.3 — Unique-listener `anonId` layer (lowest-priority metric, D5).** Option A: mint/read client first-party `localStorage` id, thread `anonId` onto payloads (nullable in the log), count distinct server-side (all-time, D3). **The last metric layer** — folded into "everything finished," explicitly last-built of the substrate. **Depends on 16.1; builds on 16.2.**
- **16.D — Per-target stats surfaces.** `[speculative]`detail-page play/share/listener display + CMS analytics views (bucket/channel splits, leaderboards). Not in the agreed scope; the event log already supports it. Build when a surface wants it. - **16.4 — Per-target / CMS stats surfaces.** `[speculative]``GET api/stats/{track,release}/{key}` + CMS analytics views (bucket/channel splits, leaderboards). Not committed; the event log already supports it. Off the critical path to the card; build before the capstone only if a surface wants it. **Depends on 16.116.3.**
- **16.5 — Home Plays-card payoff (CAPSTONE, built LAST).** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` (+ secondary line = unique listeners, D7); flip `NowPlayingStats`'s third card from placeholder to live through the existing `GET api/stats/home` round-trip. **The final wave — built only once 16.116.3 are in place.**
**Open product decisions (D1D7, spec §10) — unresolved, awaiting Daniel.** Headline is **D5** (unique-listener anonymity mechanism). Others: D1 (middle-band bucket), D2 (engagement floor before a play counts), D3 (unique-listener window), D4 (release plays derived vs. counted), D6 (rollup strategy), D7 (card secondary line). Resolve before 16.A is decomposed; recommendations are carried in the spec. **Product decisions D1D7 — RESOLVED (Daniel 2026-06-19, spec §10).** D1 (three-bucket sampled), D2 (engagement floor on, ≥3s/≥5%), D4 (release plays derived server-side), D5 (Option A — client-minted first-party `localStorage` id, metric labelled "listeners," fingerprinting rejected) resolved on explicit pick. D3 (all-time window), D6 (incremental-on-write rollup), D7 (card secondary = unique listeners) resolved-by-default — low-risk to revisit during their wave. Road-not-taken preserved in the spec.
**Adjacency to deferred Identity / accounts (the un-phased backlog item above).** This phase is the deliberate **anonymous** answer to "how many plays" — it does **not** need the accounts/identity work and must not be entangled with it. If identity ever lands, per-user listening history is an additive layer above this anonymous substrate, not a replacement. **Adjacency to deferred Identity / accounts (the un-phased backlog item above).** This phase is the deliberate **anonymous** answer to "how many plays" — it does **not** need the accounts/identity work and must not be entangled with it. If identity ever lands, per-user listening history is an additive layer above this anonymous substrate, not a replacement.
+161 -101
View File
@@ -1,9 +1,16 @@
# Phase 16 — Anonymous Play & Share Tracking (Design Spec) # Phase 16 — Anonymous Play & Share Tracking (Design Spec)
Status: **design-draft, open for Daniel review** (decision points in §10 are unresolved). Author: Status: **design-complete — decisions D1D7 resolved by Daniel 2026-06-19.** Author:
product-designer. Date: 2026-06-18. **No code has been written by this doc.** This is the phase product-designer. Drafted 2026-06-18; decisions resolved and phasing re-sequenced 2026-06-19.
deferred behind the home-hero "Plays" stat card, which today renders a static **No code has been written by this doc.** This is the phase deferred behind the home-hero "Plays"
`XXX / Plays (Coming Soon)` odometer placeholder in `NowPlayingStats.razor`. stat card, which today renders a static `XXX / Plays (Coming Soon)` odometer placeholder in
`NowPlayingStats.razor`.
**Phasing note (Daniel directive, 2026-06-19):** the waves run **bottom-up** — foundation first,
metrics stacked on the substrate, the user-visible Plays-card flip is the **capstone (built last)**.
This deliberately **reverses** the earlier "visible win comes early" framing: Daniel does not care
about the live card until everything underneath it is finished, so the whole telemetry substrate and
all metrics (including unique listeners) land before the card lights up. See §6.
This spec adds a **privacy-light, anonymous play & share telemetry layer** to the public site: This spec adds a **privacy-light, anonymous play & share telemetry layer** to the public site:
counting plays (bucketed by completion) and shares, tied to individual tracks and releases, with an counting plays (bucketed by completion) and shares, tied to individual tracks and releases, with an
@@ -65,17 +72,16 @@ track-listen, classified by how far the listener got:
- **partial** — playback reached **< 30%** of the track's duration before the session ended - **partial** — playback reached **< 30%** of the track's duration before the session ended
(switched track, stopped, navigated away, closed tab). (switched track, stopped, navigated away, closed tab).
- **complete** — playback reached **> 80%** of duration. - **complete** — playback reached **> 80%** of duration.
- **middle band (30%80%)** — see decision **D1** below. **Recommendation: count the middle band as - **middle band (30%80%)** — **D1 RESOLVED (Daniel 2026-06-19): three-bucket `sampled`.** The
its own bucket, `sampled`** (a real listen that wasn't a skip and wasn't a finish), so the three middle band is its own bucket, `sampled` (a real listen that wasn't a skip and wasn't a finish), so
buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%], `complete` the three buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%],
(80%, 100%]. The headline "Plays" number is the **sum of all three** (every started listen counts `complete` (80%, 100%]. The headline "Plays" number is the **sum of all three** (every started
as a play); the buckets are the texture beneath it. listen counts as a play); the buckets are the texture beneath it.
Alternative considered: fold the middle into "complete" (threshold becomes "≥30% = a real play, *Road not taken:* folding the middle into "complete" (threshold "≥30% = a real play, else partial")
else partial"). Simpler, two buckets but it throws away the distinction between "listened to half" — simpler, two buckets, but it discards the "listened to half" vs. "listened to the end"
and "listened to the end," which is the most editorially interesting signal for a music collective distinction, which is the most editorially interesting signal for a music collective ("which mixes
("which mixes do people actually finish?"). Rejected in favour of three buckets, but it's a genuine do people actually finish?"). Three buckets chosen for that texture.
Daniel call (**D1**).
**What starts a play candidate:** a track's audio actually begins streaming for playback — i.e. **What starts a play candidate:** a track's audio actually begins streaming for playback — i.e.
`SelectTrackStreaming` reaches the point where `StartStreamingPlayback` succeeds and `IsPlaying` `SelectTrackStreaming` reaches the point where `StartStreamingPlayback` succeeds and `IsPlaying`
@@ -113,10 +119,13 @@ per session. Cheap, prevents the obvious gaming, and matches how "copied!" alrea
### 1c. Unique listeners (stretch / "plus" — lower priority) ### 1c. Unique listeners (stretch / "plus" — lower priority)
A **unique listener** is an approximate distinct-listener count over a window (all-time, or rolling A **unique listener** is an approximate distinct-listener count, tied to a track or release. **D3
30 days — **D3**), tied to a track or release. This is the metric most in tension with the no-PII RESOLVED (Daniel 2026-06-19, by default): all-time window** — not rolling-30-day. All-time fits the
constraint, and it is explicitly the **last** thing to build6). It is approximate by design — we "N listeners reached" framing and the Option-A mechanism3). This is the metric most in tension
are not building identity, we are estimating reach. Mechanism options and recommendation: **§3**. with the no-PII constraint; it is approximate by design (we estimate reach, we do not build
identity). Mechanism: **§3**. *Low-risk to revisit during its wave (§6) if implementation surfaces a
reason* — a rolling window is an additive aggregation, not a reshape. Built as part of the substrate
(no longer an indefinite tail — see §6).
### 1d. Edge cases (apply to plays unless noted) ### 1d. Edge cases (apply to plays unless noted)
@@ -137,13 +146,14 @@ are not building identity, we are estimating reach. Mechanism options and recomm
v1. (If "complete" on a 4s clip feels too cheap, a minimum-absolute-seconds floor is an additive v1. (If "complete" on a 4s clip feels too cheap, a minimum-absolute-seconds floor is an additive
tweak — flag as a tuning knob, not a v1 requirement.) tweak — flag as a tuning knob, not a v1 requirement.)
- **Rapid skips.** Listener clicks through ten tracks in five seconds. Each reaches `< 30%` → - **Rapid skips.** Listener clicks through ten tracks in five seconds. Each reaches `< 30%` →
ten `partial` plays. **Recommendation (D2): apply a minimum-engagement floor before a play counts ten `partial` plays. **D2 RESOLVED (Daniel 2026-06-19): apply a minimum-engagement floor.** A
at all** — e.g. playback must reach **≥ 3 seconds OR ≥ 5% of duration** (whichever is smaller) for listen registers as a play only once playback reaches **≥ 3 seconds OR ≥ 5% of duration, whichever
the listen to register as a play. Below the floor it's a *preview/skip*, not a play, and is dropped is smaller** (so a sub-60s clip floors on the percentage; anything longer floors on the 3-second
entirely. This keeps the headline number honest (a skim through the archive isn't 40 plays) while wall). Below the floor it is a *preview/skip*, dropped entirely (no event sent). This keeps the
still capturing genuine short partial listens. The floor is a single tunable constant. **D2 is a headline number honest — a skim through the archive isn't 40 plays — while still capturing genuine
Daniel call** — the alternative is "every started playback counts, floor = 0," which is simpler and short partial listens. The floor is a single tunable constant (one place to change if the band
defensible ("they hit play, it's a play") but inflates the number on a browsing session. later wants it looser/tighter). *Road not taken:* floor = 0 ("they hit play, it's a play") —
simpler and defensible, but inflates the count on a browsing session.
- **Tab close / navigation mid-play.** The play must still be recorded with its high-water bucket. - **Tab close / navigation mid-play.** The play must still be recorded with its high-water bucket.
This is the hardest delivery case and drives the beacon recommendation in §2.2. This is the hardest delivery case and drives the beacon recommendation in §2.2.
- **Embedded (`FramePlayer`) plays.** A play inside a third-party iframe is a real play and should - **Embedded (`FramePlayer`) plays.** A play inside a third-party iframe is a real play and should
@@ -236,14 +246,14 @@ wants both (the metric is "tied to individual tracks and releases"). Options:
client plumbing; the release dimension is always correct even for plays that started without client plumbing; the release dimension is always correct even for plays that started without
release context (e.g. StreamNow random track). release context (e.g. StreamNow random track).
**Recommendation: option 2 (resolve server-side).** The track→release join is authoritative and **RESOLVED: option 2 (resolve server-side).** The track→release join is authoritative and already in
already in `DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal, `DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal, and a
and it means a random-track play still gets correctly attributed to its release without the client random-track play still gets correctly attributed to its release without the client knowing. The
knowing. The client sends what it cheaply knows (track key); the server enriches. This also means client sends what it cheaply knows (track key); the server enriches. **D4 RESOLVED (Daniel
**release play counts are derived** (sum of plays of the release's tracks) rather than separately 2026-06-19): release plays are derived** (sum of plays of the release's tracks), not a
counted — which is exactly right for multi-track Cuts and trivially correct for single-track separately-counted "release was played" event — exactly right for multi-track Cuts and trivially
Session/Mix. (**D4**: confirm release plays = sum-of-track-plays, not a separately-counted "release correct for single-track Session/Mix. The client sends only the track key; release attribution and
was played" event. Recommend derived.) release-total derivation are both server-side.
Shares already carry the right target directly (`SharePopover` knows track vs. release), so share Shares already carry the right target directly (`SharePopover` knows track vs. release), so share
attribution needs no resolution step. attribution needs no resolution step.
@@ -273,9 +283,11 @@ not ad-revenue telemetry — the "90s visitor counter vibe" Daniel wants). Measu
## 3. Privacy-light anonymity mechanism (unique listeners) ## 3. Privacy-light anonymity mechanism (unique listeners)
This is the decision most in tension with "no PII / anonymous only," and the one most wanting Daniel's **D5 RESOLVED (Daniel 2026-06-19): Option A — client-minted random first-party `localStorage` id,
eyes (**D5**). The unique-listener metric needs *some* notion of "the same anonymous listener seen metric labelled "listeners," fingerprinting (Option C) rejected.** Rationale and the road not taken
again" without identifying who they are. Three mechanism families, with trade-offs: are preserved below — the three mechanism families and their trade-offs are kept as the record of
why A was chosen over B and C. The unique-listener metric needs *some* notion of "the same anonymous
listener seen again" without identifying who they are.
### Option A — Anonymous client-minted id (random GUID in localStorage) ### Option A — Anonymous client-minted id (random GUID in localStorage)
@@ -325,10 +337,10 @@ Derive an id from canvas/font/hardware fingerprinting signals.
actively killing; it tracks across sites and survives storage-clearing. **Directly contradicts actively killing; it tracks across sites and survives storage-clearing. **Directly contradicts
"privacy-light."** Rejected outright — listed only for completeness. "privacy-light."** Rejected outright — listed only for completeness.
### Recommendation ### Resolution (D5 — Option A)
**Option A (client-minted random first-party id) as the primary mechanism, with the metric honestly **Option A (client-minted random first-party id) is the chosen mechanism, with the metric honestly
labelled.** Reasons: labelled "listeners."** Reasons:
- It fits the product: a band wants an *all-time* "N listeners reached" figure, which A supports and - It fits the product: a band wants an *all-time* "N listeners reached" figure, which A supports and
B (daily-only) structurally cannot. B (daily-only) structurally cannot.
@@ -339,12 +351,18 @@ labelled.** Reasons:
- We label the metric **"listeners," not "people,"** and accept the known over-count. For a vanity - We label the metric **"listeners," not "people,"** and accept the known over-count. For a vanity
texture stat this is the right honesty/effort trade. texture stat this is the right honesty/effort trade.
**If Daniel wants the stronger "stores nothing" posture, Option B is the fallback** — at the cost of *Road not taken:* Option B (server-derived salted daily token — stores nothing client-side) was the
the metric becoming daily-unique and noisier. The two are not mutually exclusive long-term (A for fallback for a stronger "stores nothing" posture, at the cost of the metric becoming daily-unique and
all-time reach, B-style for daily actives) but v1 should pick one. **This is D5.** noisier; rejected because the product wants an *all-time* reach figure, which B structurally cannot
give. Option C (fingerprint) rejected outright — it is exactly what privacy regulation and browser
vendors are killing, and contradicts "privacy-light." A and B are not mutually exclusive long-term (A
for all-time reach, B-style for daily actives), but v1 commits to A.
**Either way, unique-listeners is the deferred §6 stretch** — the play/share counters (which need no **Sequencing note (changed 2026-06-19):** unique-listeners is **no longer an indefinite stretch
`anonId` at all) ship first and stand alone. tail.** Under the bottom-up re-sequencing, Daniel wants *everything* finished before the card lights
up — so the `anonId` layer is folded into the substrate build (§6, wave 16.3) as the lowest-priority
/ last of the metric layers, not an optional dangler. The play/share counters still need no `anonId`
and land first; unique-listeners stacks on top before the capstone card.
--- ---
@@ -373,11 +391,12 @@ small rolled-up counter table for the home-card hot read.** Reasoning:
- (3) gets both without forcing a premature choice, and matches the existing `HomeStatsDto` pattern - (3) gets both without forcing a premature choice, and matches the existing `HomeStatsDto` pattern
(the card reads a pre-aggregated DTO; it never queries raw). (the card reads a pre-aggregated DTO; it never queries raw).
For v1 the rollup can be **incremental-on-write** (the event-write transaction also bumps the counter **D6 RESOLVED (Daniel 2026-06-19, by default): incremental-on-write rollup.** The event-write
row) to avoid standing up a background job. If write volume ever makes that contended, a periodic transaction also bumps the counter row — no background job to stand up. *Road not taken:* periodic
aggregation pass is the escape hatch — but for a collective-scale site, incremental is fine and batch aggregation, the escape hatch if write volume ever makes the incremental bump contended; for a
simplest. (**D6**: incremental-on-write rollup vs. periodic-batch rollup. Recommend incremental for collective-scale site incremental is fine and simplest. *Low-risk to revisit during its wave (§6) if
v1.) implementation surfaces a reason* — switching to a periodic pass later doesn't change the schema, only
how the counter is fed.
### 4.2 Conceptual SQL shape (not full schema — that's staff-engineer's) ### 4.2 Conceptual SQL shape (not full schema — that's staff-engineer's)
@@ -431,11 +450,14 @@ odometer treatment (the "90s visitor counter" vibe is *already the intended aest
placeholder is literally styled as an odometer). The minimal, correct payoff: placeholder is literally styled as an odometer). The minimal, correct payoff:
- **Primary figure:** total plays site-wide (sum across all tracks), rendered in the odometer. - **Primary figure:** total plays site-wide (sum across all tracks), rendered in the odometer.
- **Secondary line (optional, recommend yes):** something with texture that fits the card's existing - **Secondary line: D7 RESOLVED (Daniel 2026-06-19, by default).** The card gets a secondary line
two-line shape (the other two cards both have a primary + secondary). Candidates: total shares (the other two cards both have primary + secondary). **Because the bottom-up re-sequencing now lands
("N shared"), or completion rate ("N% finished" = complete / total), or unique listeners once the unique listeners *before* the card (§6), the secondary line can be unique listeners ("N listeners")
stretch lands. **Recommend completion-rate or share-count for v1**, swapping to listeners if/when from day one** — the metric the card was always reaching for. Completion-rate ("N% finished") and
§6 ships. (**D7** — what's the card's secondary line.) total-shares ("N shared") remain available alternatives if listeners reads oddly in the odometer
treatment; final pick is a small render-time call during the capstone wave, low-risk to revisit.
*(Under the prior early-card sequencing this had to be completion-rate or shares because listeners
shipped later; the re-sequencing removes that constraint.)*
Mechanically: add `TotalPlays` (and the chosen secondary) to `HomeStatsDto`, populate it in Mechanically: add `TotalPlays` (and the chosen secondary) to `HomeStatsDto`, populate it in
`TrackRepository.GetHomeStatsAsync` from `play_counter`, and the card reads it through the existing `TrackRepository.GetHomeStatsAsync` from `play_counter`, and the card reads it through the existing
@@ -447,67 +469,105 @@ its keep the moment the home number goes live, before any per-track surface or u
--- ---
## 6. Suggested phasing ## 6. Phasing — bottom-up (Daniel directive, 2026-06-19)
Sequenced so the visible payoff lands early and the privacy-sensitive stretch lands last. Each wave is **Re-sequenced bottom-up: foundation first, metrics stacked on the substrate, the user-visible
independently shippable. Plays-card flip is the capstone built LAST.** This reverses the earlier "visible win comes early"
framing. Daniel: *"do the phasing from the bottom up, that seems more stable. I won't care about the
live card until everything is finished."* So the entire telemetry substrate and **all** metrics
(including unique listeners, which is no longer an indefinite tail) land before the card lights up.
- **16.A — Play & share counters (core).** The whole spine: player-service play tracker (§2.1) with Waves run in strict sequence — each builds on the layer beneath it.
the three-bucket classification (§1a) and engagement floor (§1d/D2); share tracker in `SharePopover`
(§1b); `sendBeacon` interop + `POST api/event/{play,share}` endpoints (§2.2) with rate-limiting
(§2.5); `play_event`/`share_event` log + incremental `play_counter` rollup (§4); server-side release
resolution (§2.3, D4). **No `anonId` written yet** (unique listeners is 16.C). **Free-floating —
the cold-start wave; nothing gates it.**
- **16.B — Home Plays-card payoff (§5).** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays`
(+ chosen secondary, D7); flip `NowPlayingStats`'s third card from placeholder to live. **Depends on
16.A** (needs the counter to read). This is the visible win — sequence it immediately after 16.A.
- **16.C — Unique listeners (stretch / "plus").** The anonymity mechanism (§3, D5 — recommend Option
A): mint/read the anon token, thread it onto event payloads, count distinct server-side, expose via
the per-target stats reads and/or as the home card's secondary line. **Depends on 16.A** (extends the
event payload + storage). **Explicitly lower priority** — 16.A+16.B deliver the agreed core; 16.C is
the agreed stretch. Can be deferred indefinitely without stranding anything.
- **16.D — Per-target stats surfaces (adjacent, not committed).** Detail-page play/share/listener
display via `GET api/stats/{track,release}/{key}`; CMS analytics views (bucket splits, channel
splits, leaderboards). **Speculative** — flagged for when a surface actually wants these. Not part of
the agreed scope; listed so the substrate (the event log) is understood to already support it.
**Hard dependencies:** `16.A → 16.B`; `16.A → 16.C`; `16.A → 16.D`. 16.A is the only cold-start wave. - **16.1 — Foundation: capture seam + transport + event log (no card consumption).** The substrate,
16.B and 16.C are parallel after 16.A (B is the priority; C is the stretch). end to end, with nothing reading it yet:
- Player-service **play-session tracker** (§2.1): opens on playback-start, advances the high-water
mark on the existing progress callback, closes on track-switch / stop / organic-end / page-unload.
Applies the §1d/D2 **engagement floor** (≥3s or ≥5%, whichever smaller).
- Share tracker in `SharePopover` (§1b) with the per-(target,channel) debounce.
- **`sendBeacon` interop** + `pagehide`/`visibilitychange` unload handler (§2.2).
- **`POST api/event/{play,share}`** endpoints, **proxied through `DeepDrftPublic`** (§2.2), with
IP rate-limiting + payload validation (§2.5).
- **Append-only `play_event` / `share_event` SQL log** + **incremental `play_counter` rollup** (§4,
D6) in `DeepDrftData`.
- **Server-side release resolution** + derived release totals (§2.3, D4) — client sends only the
track key.
- At this point events flow and counters accumulate, but **no `anonId` is written** and **nothing
reads the counters**. This is the stable base everything stacks on. **Cold-start wave — nothing
gates it.**
- **16.2 — Completion-bucket classification + shares.** The metric texture on top of the raw capture:
the three-bucket classification (§1a/D1 — `partial`/`sampled`/`complete`, headline = sum) wired
through the tracker → event payload → log → counter columns, and the share-channel split
(`link`/`embed`) landing in `share_event`. (Much of the bucket plumbing is natural to build
alongside 16.1; 16.2 is the boundary where the classification is *correct and exhaustive* end to
end, including counter columns per bucket.) **Depends on 16.1.**
- **16.3 — Unique-listener `anonId` layer (D5, lowest-priority metric).** The anonymity mechanism
(§3, Option A): mint/read the client first-party `localStorage` id, thread `anonId` onto the event
payloads, store it nullable on the event log, count distinct server-side (all-time, D3). **The last
of the metric layers** — folded into "everything finished" per the directive, but explicitly the
lowest-priority and last-built of the substrate. **Depends on 16.1 (event payload + storage); builds
on 16.2.**
- **16.4 — Per-target / CMS stats surfaces** `[speculative]`**.** `GET api/stats/{track,release}/{key}`
per-target reads; CMS analytics views (bucket splits, channel splits, leaderboards). **Speculative,
not committed scope** — the event log already supports it. Position: **before the capstone** if a
per-target surface is wanted (e.g. to validate the metrics visually in the CMS before the public
card goes live), otherwise skippable. Does not gate the card. **Depends on 16.116.3** for the data
it would read.
- **16.5 — Home Plays-card payoff (CAPSTONE, built LAST).** Extend `HomeStatsDto` +
`GetHomeStatsAsync` with `TotalPlays` (+ the secondary line — unique listeners now available, D7);
flip `NowPlayingStats`'s third card from the `XXX / Plays (Coming Soon)` placeholder to live,
through the existing persistent-state-bridged `GET api/stats/home` round-trip (§5). **The final
wave — built only once the full substrate and all metrics (16.116.3) are in place.** Per Daniel,
the live card is explicitly the last thing built; there is no early-payoff intermediate.
**Verify-before-build:** confirm the `DeepDrftPublic` proxy can host a `POST api/event/*` write route **Hard dependencies (strict bottom-up chain):**
with the same proxy idiom as `api/track/*` (the WASM client cannot reach `DeepDrftAPI` directly). This `16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`.
is the one infrastructural assumption the spec rests on; cheap to confirm, blocking if wrong. 16.1 is the only cold-start wave. 16.5 (the card) sits at the top and depends on the whole stack
beneath it being finished. 16.4 is speculative and off the critical path to the card.
**Verify-before-build (gates 16.1):** confirm the `DeepDrftPublic` proxy can host a `POST api/event/*`
write route with the same proxy idiom as `api/track/*` (the WASM client cannot reach `DeepDrftAPI`
directly). This is the one infrastructural assumption the spec rests on; cheap to confirm, blocking if
wrong.
--- ---
## 10. Open product decisions (for Daniel) ## 10. Product decisions — RESOLVED (Daniel 2026-06-19)
Resolve these before 16.A is decomposed. Recommendations carried from the body; the call is Daniel's. All seven decisions are settled. D1, D2, D4, D5 were resolved on Daniel's explicit pick of the
recommendation; D3, D6, D7 are **resolved-by-default** (recommendation adopted) and remain low-risk
to revisit during their wave if implementation surfaces a reason. The "road not taken" for each is
preserved in the body section so future implementers know what was rejected and why.
- **D1 — Middle-band (3080%) treatment.** Recommend a third `sampled` bucket (exhaustive - **D1 — Middle-band (3080%) treatment. → RESOLVED: three-bucket `sampled`.** Exhaustive
partial/sampled/complete); headline plays = sum of all three. Alt: fold middle into a binary partial/sampled/complete; headline plays = sum of all three. *Rejected:* binary partial/complete
partial/complete. (§1a) fold. (§1a)
- **D2 — Engagement floor before a play counts.** Recommend a floor (~≥3s or ≥5% of duration) so - **D2 — Engagement floor before a play counts. → RESOLVED: floor on.** A listen counts only at
archive-skimming doesn't inflate the count. Alt: floor = 0, every started playback counts. (§1d) **≥3s OR ≥5% of duration, whichever is smaller**; below that it's a dropped preview/skip. *Rejected:*
- **D3 — Unique-listener window.** All-time vs. rolling-30-day. Recommend all-time (fits the "N floor = 0. Single tunable constant. (§1d)
listeners reached" framing and Option-A mechanism). (§1c) — only bites if 16.C is built. - **D3 — Unique-listener window. → RESOLVED (by default): all-time.** Fits "N listeners reached" and
- **D4 — Release plays: derived or separately counted.** Recommend derived (release plays = sum of its the Option-A mechanism. *Rejected:* rolling-30-day. Low-risk to revisit in wave 16.3. (§1c)
tracks' plays), correct for multi-track Cuts and single-track Session/Mix alike. (§2.3) - **D4 — Release plays: derived or separately counted. → RESOLVED: derived** (release plays = sum of
- **D5 — Unique-listener anonymity mechanism.** Recommend Option A (client-minted random first-party its tracks' plays), server-side. Correct for multi-track Cuts and single-track Session/Mix alike.
localStorage id, metric honestly labelled "listeners," all-time). Fallback Option B (server-derived *Rejected:* separate "release was played" event. (§2.3)
salted daily token — stores nothing client-side but only yields daily-unique). Option C - **D5 — Unique-listener anonymity mechanism. → RESOLVED: Option A** — client-minted random
(fingerprint) rejected. **This is the headline decision.** (§3) first-party `localStorage` id, metric honestly labelled **"listeners,"** all-time. *Rejected:*
- **D6 — Rollup strategy.** Recommend incremental-on-write counter for v1 (no background job). Alt: Option B (server-derived salted daily token — stores nothing but only daily-unique); Option C
periodic batch aggregation. (§4.1) (fingerprint — contradicts privacy-light) rejected outright. Headline decision. (§3)
- **D7Home Plays-card secondary line.** Recommend completion-rate or total-shares for v1, swapping - **D6Rollup strategy. → RESOLVED (by default): incremental-on-write** counter, no background job.
to unique listeners if/when 16.C ships. (§5) *Rejected:* periodic batch aggregation (kept as the escape hatch if write volume contends). Low-risk
to revisit in wave 16.1. (§4.1)
- **D7 — Home Plays-card secondary line. → RESOLVED (by default): unique listeners ("N listeners").**
The bottom-up re-sequence lands listeners (16.3) before the card (16.5), so the card can show the
metric it was always reaching for. *Alternatives kept available:* completion-rate or total-shares,
a small render-time call during 16.5. Low-risk to revisit in the capstone wave. (§5)
## Working with this spec ## Working with this spec
- Mirrors the established product-notes convention (cross-refs up top, numbered sections, decisions - Mirrors the established product-notes convention (cross-refs up top, numbered sections, decisions
called out, phasing with explicit dependencies) — see `phase-15-visualizer-controls-enhancements.md` called out, phasing with explicit dependencies) — see `phase-15-visualizer-controls-enhancements.md`
/ `phase-11-public-site-enhancements.md`. / `phase-11-public-site-enhancements.md`.
- When Daniel resolves the D-decisions, fold the resolutions inline (mark the section "resolved by - Decisions D1D7 are **resolved** (Daniel 2026-06-19) and folded inline; status is design-complete.
Daniel YYYY-MM-DD") and flip the status to design-complete, the same way Phase 15 did. Waves are re-sequenced bottom-up (16.1 → 16.5, card last) per Daniel's directive of the same date.
- When waves land, doc-keeper moves them to `COMPLETED.md`; the per-wave bodies are written to travel - When waves land, doc-keeper moves them to `COMPLETED.md`; the per-wave bodies are written to travel
cleanly. cleanly.