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.
**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.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.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.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.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.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.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.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.
+161 -101
View File
@@ -1,9 +1,16 @@
# Phase 16 — Anonymous Play & Share Tracking (Design Spec)
Status: **design-draft, open for Daniel review** (decision points in §10 are unresolved). Author:
product-designer. Date: 2026-06-18. **No code has been written by this doc.** This is the phase
deferred behind the home-hero "Plays" stat card, which today renders a static
`XXX / Plays (Coming Soon)` odometer placeholder in `NowPlayingStats.razor`.
Status: **design-complete — decisions D1D7 resolved by Daniel 2026-06-19.** Author:
product-designer. Drafted 2026-06-18; decisions resolved and phasing re-sequenced 2026-06-19.
**No code has been written by this doc.** This is the phase deferred behind the home-hero "Plays"
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:
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
(switched track, stopped, navigated away, closed tab).
- **complete** — playback reached **> 80%** of duration.
- **middle band (30%80%)** — see decision **D1** below. **Recommendation: count the middle band as
its own bucket, `sampled`** (a real listen that wasn't a skip and wasn't a finish), so the three
buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%], `complete`
(80%, 100%]. The headline "Plays" number is the **sum of all three** (every started listen counts
as a play); the buckets are the texture beneath it.
- **middle band (30%80%)** — **D1 RESOLVED (Daniel 2026-06-19): three-bucket `sampled`.** The
middle band is its own bucket, `sampled` (a real listen that wasn't a skip and wasn't a finish), so
the three buckets are exhaustive and non-overlapping: `partial` [0, 30%), `sampled` [30%, 80%],
`complete` (80%, 100%]. The headline "Plays" number is the **sum of all three** (every started
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,
else partial"). Simpler, two buckets but it throws away the distinction between "listened to half"
and "listened to the end," which is the most editorially interesting signal for a music collective
("which mixes do people actually finish?"). Rejected in favour of three buckets, but it's a genuine
Daniel call (**D1**).
*Road not taken:* folding the middle into "complete" (threshold "≥30% = a real play, else partial")
— simpler, two buckets, but it discards the "listened to half" vs. "listened to the end"
distinction, which is the most editorially interesting signal for a music collective ("which mixes
do people actually finish?"). Three buckets chosen for that texture.
**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`
@@ -113,10 +119,13 @@ per session. Cheap, prevents the obvious gaming, and matches how "copied!" alrea
### 1c. Unique listeners (stretch / "plus" — lower priority)
A **unique listener** is an approximate distinct-listener count over a window (all-time, or rolling
30 days — **D3**), tied to a track or release. This is the metric most in tension with the no-PII
constraint, and it is explicitly the **last** thing to build6). It is approximate by design — we
are not building identity, we are estimating reach. Mechanism options and recommendation: **§3**.
A **unique listener** is an approximate distinct-listener count, tied to a track or release. **D3
RESOLVED (Daniel 2026-06-19, by default): all-time window** — not rolling-30-day. All-time fits the
"N listeners reached" framing and the Option-A mechanism3). This is the metric most in tension
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)
@@ -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
tweak — flag as a tuning knob, not a v1 requirement.)
- **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
at all** — e.g. playback must reach **≥ 3 seconds OR ≥ 5% of duration** (whichever is smaller) for
the listen to register as a play. Below the floor it's a *preview/skip*, not a play, and is dropped
entirely. This keeps the headline number honest (a skim through the archive isn't 40 plays) while
still capturing genuine short partial listens. The floor is a single tunable constant. **D2 is a
Daniel call** — the alternative is "every started playback counts, floor = 0," which is simpler and
defensible ("they hit play, it's a play") but inflates the number on a browsing session.
ten `partial` plays. **D2 RESOLVED (Daniel 2026-06-19): apply a minimum-engagement floor.** A
listen registers as a play only once playback reaches **≥ 3 seconds OR ≥ 5% of duration, whichever
is smaller** (so a sub-60s clip floors on the percentage; anything longer floors on the 3-second
wall). Below the floor it is a *preview/skip*, dropped entirely (no event sent). This keeps the
headline number honest — a skim through the archive isn't 40 plays — while still capturing genuine
short partial listens. The floor is a single tunable constant (one place to change if the band
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.
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
@@ -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
release context (e.g. StreamNow random track).
**Recommendation: option 2 (resolve server-side).** The track→release join is authoritative and
already in `DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal,
and it means a random-track play still gets correctly attributed to its release without the client
knowing. The client sends what it cheaply knows (track key); the server enriches. This also means
**release play counts are derived** (sum of plays of the release's tracks) rather than separately
counted — which is exactly right for multi-track Cuts and trivially correct for single-track
Session/Mix. (**D4**: confirm release plays = sum-of-track-plays, not a separately-counted "release
was played" event. Recommend derived.)
**RESOLVED: option 2 (resolve server-side).** The track→release join is authoritative and already in
`DeepDrftData`; sending only the track key keeps the client dumb and the payload minimal, and a
random-track play still gets correctly attributed to its release without the client knowing. The
client sends what it cheaply knows (track key); the server enriches. **D4 RESOLVED (Daniel
2026-06-19): release plays are derived** (sum of plays of the release's tracks), not a
separately-counted "release was played" event — exactly right for multi-track Cuts and trivially
correct for single-track Session/Mix. The client sends only the track key; release attribution and
release-total derivation are both server-side.
Shares already carry the right target directly (`SharePopover` knows track vs. release), so share
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)
This is the decision most in tension with "no PII / anonymous only," and the one most wanting Daniel's
eyes (**D5**). The unique-listener metric needs *some* notion of "the same anonymous listener seen
again" without identifying who they are. Three mechanism families, with trade-offs:
**D5 RESOLVED (Daniel 2026-06-19): Option A — client-minted random first-party `localStorage` id,
metric labelled "listeners," fingerprinting (Option C) rejected.** Rationale and the road not taken
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)
@@ -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
"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
labelled.** Reasons:
**Option A (client-minted random first-party id) is the chosen mechanism, with the metric honestly
labelled "listeners."** Reasons:
- It fits the product: a band wants an *all-time* "N listeners reached" figure, which A supports and
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
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
the metric becoming daily-unique and noisier. The two are not mutually exclusive long-term (A for
all-time reach, B-style for daily actives) but v1 should pick one. **This is D5.**
*Road not taken:* Option B (server-derived salted daily token — stores nothing client-side) was the
fallback for a stronger "stores nothing" posture, at the cost of the metric becoming daily-unique and
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
`anonId` at all) ship first and stand alone.
**Sequencing note (changed 2026-06-19):** unique-listeners is **no longer an indefinite stretch
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
(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
row) to avoid standing up a background job. If write volume ever makes that contended, a periodic
aggregation pass is the escape hatch — but for a collective-scale site, incremental is fine and
simplest. (**D6**: incremental-on-write rollup vs. periodic-batch rollup. Recommend incremental for
v1.)
**D6 RESOLVED (Daniel 2026-06-19, by default): incremental-on-write rollup.** The event-write
transaction also bumps the counter row — no background job to stand up. *Road not taken:* periodic
batch aggregation, the escape hatch if write volume ever makes the incremental bump contended; for a
collective-scale site incremental is fine and simplest. *Low-risk to revisit during its wave (§6) if
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)
@@ -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:
- **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
two-line shape (the other two cards both have a primary + secondary). Candidates: total shares
("N shared"), or completion rate ("N% finished" = complete / total), or unique listeners once the
stretch lands. **Recommend completion-rate or share-count for v1**, swapping to listeners if/when
§6 ships. (**D7** — what's the card's secondary line.)
- **Secondary line: D7 RESOLVED (Daniel 2026-06-19, by default).** The card gets a secondary line
(the other two cards both have primary + secondary). **Because the bottom-up re-sequencing now lands
unique listeners *before* the card (§6), the secondary line can be unique listeners ("N listeners")
from day one** — the metric the card was always reaching for. Completion-rate ("N% finished") and
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
`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
independently shippable.
**Re-sequenced bottom-up: foundation first, metrics stacked on the substrate, the user-visible
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
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.
Waves run in strict sequence — each builds on the layer beneath it.
**Hard dependencies:** `16.A → 16.B`; `16.A → 16.C`; `16.A → 16.D`. 16.A is the only cold-start wave.
16.B and 16.C are parallel after 16.A (B is the priority; C is the stretch).
- **16.1 — Foundation: capture seam + transport + event log (no card consumption).** The substrate,
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
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.
**Hard dependencies (strict bottom-up chain):**
`16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`.
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
partial/sampled/complete); headline plays = sum of all three. Alt: fold middle into a binary
partial/complete. (§1a)
- **D2 — Engagement floor before a play counts.** Recommend a floor (~≥3s or ≥5% of duration) so
archive-skimming doesn't inflate the count. Alt: floor = 0, every started playback counts. (§1d)
- **D3 — Unique-listener window.** All-time vs. rolling-30-day. Recommend all-time (fits the "N
listeners reached" framing and Option-A mechanism). (§1c) — only bites if 16.C is built.
- **D4 — Release plays: derived or separately counted.** Recommend derived (release plays = sum of its
tracks' plays), correct for multi-track Cuts and single-track Session/Mix alike. (§2.3)
- **D5 — Unique-listener anonymity mechanism.** Recommend Option A (client-minted random first-party
localStorage id, metric honestly labelled "listeners," all-time). Fallback Option B (server-derived
salted daily token — stores nothing client-side but only yields daily-unique). Option C
(fingerprint) rejected. **This is the headline decision.** (§3)
- **D6 — Rollup strategy.** Recommend incremental-on-write counter for v1 (no background job). Alt:
periodic batch aggregation. (§4.1)
- **D7Home Plays-card secondary line.** Recommend completion-rate or total-shares for v1, swapping
to unique listeners if/when 16.C ships. (§5)
- **D1 — Middle-band (3080%) treatment. → RESOLVED: three-bucket `sampled`.** Exhaustive
partial/sampled/complete; headline plays = sum of all three. *Rejected:* binary partial/complete
fold. (§1a)
- **D2 — Engagement floor before a play counts. → RESOLVED: floor on.** A listen counts only at
**≥3s OR ≥5% of duration, whichever is smaller**; below that it's a dropped preview/skip. *Rejected:*
floor = 0. Single tunable constant. (§1d)
- **D3 — Unique-listener window. → RESOLVED (by default): all-time.** Fits "N listeners reached" and
the Option-A mechanism. *Rejected:* rolling-30-day. Low-risk to revisit in wave 16.3. (§1c)
- **D4 — Release plays: derived or separately counted. → RESOLVED: derived** (release plays = sum of
its tracks' plays), server-side. Correct for multi-track Cuts and single-track Session/Mix alike.
*Rejected:* separate "release was played" event. (§2.3)
- **D5 — Unique-listener anonymity mechanism. → RESOLVED: Option A** — client-minted random
first-party `localStorage` id, metric honestly labelled **"listeners,"** all-time. *Rejected:*
Option B (server-derived salted daily token — stores nothing but only daily-unique); Option C
(fingerprint — contradicts privacy-light) rejected outright. Headline decision. (§3)
- **D6Rollup strategy. → RESOLVED (by default): incremental-on-write** counter, no background job.
*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
- 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`
/ `phase-11-public-site-enhancements.md`.
- When Daniel resolves the D-decisions, fold the resolutions inline (mark the section "resolved by
Daniel YYYY-MM-DD") and flip the status to design-complete, the same way Phase 15 did.
- Decisions D1D7 are **resolved** (Daniel 2026-06-19) and folded inline; status is design-complete.
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
cleanly.