diff --git a/.github/skills/oncall-weekly-telemetry-report/SKILL.md b/.github/skills/oncall-weekly-telemetry-report/SKILL.md
index 37d2654a..75dc6b2b 100644
--- a/.github/skills/oncall-weekly-telemetry-report/SKILL.md
+++ b/.github/skills/oncall-weekly-telemetry-report/SKILL.md
@@ -52,8 +52,10 @@ crash layer, which needs a secret), `--end YYYY-MM-DD` (see § Reporting window)
Resolved reporting window (UTC): # example values for a run on 2026-07-15
Last 7 days: 2026-07-08 -> 2026-07-15 (exclusive upper bound)
Baseline: 2026-07-01 -> 2026-07-08
- 60-day trend: 2026-05-16 -> 2026-07-15 (literal 60d ending today; chart includes current partial week)
- Trend delta cutoff: weeks < 2026-07-12 (startofweek(curEnd); pass as bucket-trends.js --end)
+ 60-day trend: 2026-05-16 -> 2026-07-15 (literal 60d ending today; rolling 7d buckets anchored at curEnd)
+ Trend buckets: 8 complete rolling weeks; final bucket == the Last-7-days window above (classifier WoW == displayed WoW)
+ bucket-trends.js: --start=2026-05-16 --end=2026-07-15 (pass BOTH; --end disables the partial-end auto-drop heuristic)
+ Sparkline (8 rolling weeks): 2026-05-20 -> 2026-07-15 (SPARK_START -> SPARK_END, exclusive; no Sunday alignment needed)
```
These dates are stamped into each report's `
`, `
`, and Generated banner
during bootstrap — you do not hand-edit them.
@@ -85,17 +87,32 @@ crash layer, which needs a secret), `--end YYYY-MM-DD` (see § Reporting window)
(`[curEnd - 14d, curEnd - 7d)`). No user input.
3. **60-day trend window** — auto-computed as the **literal last 60 days ending today**
- (`[curEnd - 60d, curEnd)`), so both bounds move with `-EndDate`. Trend sections are Sun-Sat
- weekly-bucketed (Kusto `startofweek()` is Sunday-aligned) because the trend needs stable weekly
- denominators, but the final bar is the **current in-progress (partial) week** — the chart ends
- today. Regression/improvement **delta classification is still computed on complete weeks only**
- (`bucket-trends.js --end=startofweek(curEnd) --include-partial-end`); a partial week as "last"
- would read as a fake −99% improvement, so it is charted but excluded from the delta math.
-
-**Kusto note (weekly-bucketed queries only):** `startofweek()` is Sunday-aligned, so
-`startofweek('2026-05-09') == 2026-05-03T00:00:00Z`. When authoring weekly-bucketed queries, verify
-by printing the distinct week values from your first query. Off-by-one-week is the #1 silent error
-in weekly-bucket queries.
+ (`[curEnd - 60d, curEnd)`), so both bounds move with `-EndDate`. Trend and sparkline sections are
+ bucketed into **rolling 7-day windows anchored at `curEnd`** (`bin_at(t, 7d, datetime(curEnd))`),
+ **not** Sun-Sat calendar weeks. The final bucket is therefore `[curEnd - 7d, curEnd)` — byte-for-byte
+ the same window as the headline WoW numbers — so **the novelty classifier's WoW equals the WoW the
+ report prints**, by construction. Every bucket is a complete 7 days; there is no partial end bar and
+ no separate classification cutoff. Invoke as
+ `bucket-trends.js --start= --end=` (**pass both** — see the note below).
+
+ Because 60 is not a multiple of 7, the **oldest** bucket (`curEnd - 63d`) covers 4 days and is the
+ partial one — the safe end to be partial on. `--start` drops it, leaving 8 complete rolling weeks.
+
+> **⚠️ Why not `startofweek()`.** Calendar-week bucketing cut off at `startofweek(curEnd)` lagged the
+> report's rolling window by up to a full week, so anything that turned in the last ~6 days was
+> structurally invisible to the noise gate — exactly the period an on-call engineer cares about most.
+> On the 2026-08-01 run the gate's "current" week was 07/19–07/26 against a report window of
+> 07/25–08/01 (**one day of overlap**): `authorization_pending` read **+63.2%** in the report and
+> **−37.1%** to the classifier, and was filed *"ONGOING — do not re-triage"*. Same for `expired_token`
+> (+26.7% vs −51.0%). Re-bucketing on `bin_at` promoted both to ACCELERATING **and** demoted
+> `access_denied`, a former false positive (actually −53.2%). Alignment adds real signal *and* removes
+> phantom signal — it is not merely "more alerts". **Do not reintroduce `startofweek()`.**
+
+> **⚠️ Always pass `--end=` to `bucket-trends.js`, not just `--start`.** Its partial-end
+> auto-drop heuristic is guarded by `if (!endArg …)`. Under rolling alignment the newest bucket is
+> genuinely complete, so omitting `--end` would let a **real** 70% collapse be silently discarded as
+> "looks partial". Passing `--end` filters nothing (every bucket label is `< curEnd` by construction)
+> and disables the heuristic. The script now warns if you omit it.
---
@@ -210,6 +227,19 @@ Never carry a convention across the two playbooks.
- **A moved metric is a question, not a verdict.** Never publish a regression verdict without the
app's diagnostic ladder having been walked (Broker: Originator pre-check + dim slicing;
Authenticator: volume floor + rate normalisation + error-reason decomposition).
+- **Every red/amber table pill must be reconciled.** The scoreboard / WoW tables colour a row from
+ its own rolling delta; the attention section is populated from `classify-novelty.js`'s **novelty**
+ verdict. Those answer different questions, so a row can be legitimately red in the table and
+ legitimately absent from attention — but a reader who sees that mismatch with no explanation
+ concludes the report is broken. Precedent: `Passkey WebAuthN Registration` shipped carrying
+ `tag-bad` (−1.27 pts, worst delta in the table) directly above the words *"Quiet week — 0 NEW or
+ ACCELERATING"*. Both statements were true: the scenario peaks at ~732 bad-outcome devices, under
+ the 1,000-device peak-floor, so it is **structurally excluded** from classification and can never
+ appear in attention however sharply it moves. Every `tag-bad`/`tag-warn` row must therefore be
+ **either** promoted into attention **or** named in a muted `
` giving
+ the reason it is not escalated — test the reasons in this order: (1) below the classification
+ floor, (2) within its own normal band, (3) ONGOING and flat. `validate-report.ps1` check 19
+ hard-fails an unreconciled pill.
- **Filename collision rule.** If a report already exists for the same end-date, do not silently
overwrite. Open it, list its top-3 findings, and explicitly state in chat what changed in the new
data before regenerating. A second run on the same window without a delta is wasted work.
diff --git a/.github/skills/oncall-weekly-telemetry-report/assets/docs/authapp-kusto-cheatsheet.md b/.github/skills/oncall-weekly-telemetry-report/assets/docs/authapp-kusto-cheatsheet.md
index b2b2eddd..936b0a55 100644
--- a/.github/skills/oncall-weekly-telemetry-report/assets/docs/authapp-kusto-cheatsheet.md
+++ b/.github/skills/oncall-weekly-telemetry-report/assets/docs/authapp-kusto-cheatsheet.md
@@ -47,8 +47,8 @@ same shape `bucket-trends.js` and `agg.js` already parse.
Outcome columns each have a `…DCount` distinct-device twin.
**Registration / Authentication MVs expose only `Initiated / Succeeded / Failed` (+`DCount`) and
-`TotalUniqueDevices`. There is NO `Cancelled` and NO `PartiallySucceeded` column** — do not
-invent one. PN MVs carry only an initiated counter; the terminal outcome lives in the paired
+`TotalUniqueDevices`. There is NO `Cancelled` column and NO separate partly-succeeded outcome column**
+— do not invent one. PN MVs carry only an initiated counter; the terminal outcome lives in the paired
`_Results_MV_V1`.
| Scenario | Outcome MV | Initiate column |
@@ -223,13 +223,17 @@ estimating a crash rate from Kusto. There is no Kusto proxy for crash rate; do n
## 10. Weekly bucketing
-`startofweek()` is **Sunday-aligned**, same as on the Broker side:
-`startofweek('2026-05-09') == 2026-05-03T00:00:00Z`. Print the distinct week values from the
-first weekly query of the run and eyeball them. Off-by-one-week is the most common silent error
-in weekly-bucketed KQL and it survives every other check in the pipeline.
+Weekly trend queries are rolling 7-day buckets anchored at `curEnd`, not calendar weeks. Use the
+view's time column with `bin_at(EventDate, 7d, datetime())` for scenario MVs (or the
+appropriate raw-table time column for non-MV queries). The newest bucket is `[curEnd - 7d, curEnd)`,
+which is exactly the same window as the report's displayed WoW numbers.
-The 60-day trend deliberately **includes** the partial current week (it is the chart's final bar)
-and excludes it from delta classification via
-`bucket-trends.js --end= --include-partial-end`.
-The 8-week sparkline series deliberately **excludes** it at the source. Both behaviours are
-intentional and are not the same thing.
+The 60-day trend filters `[curEnd - 60d, curEnd)`. Because 60 is not a multiple of 7, the **oldest**
+bucket label falls before `` and covers only 4 days of the filtered data. Invoke
+`bucket-trends.js --start= --end=` so `--start` drops that oldest short
+bucket and `--end` disables the legacy partial-end auto-drop heuristic. The 8-week sparkline series
+uses the same `curEnd` anchor (` = curEnd - 56d`, ` = curEnd`).
+
+> **⚠️ Historical note:** these queries used to use `startofweek()` calendar buckets and a separate
+> classification cutoff. Do not restore that model; it can lag the rolling report window by up to a
+> full week.
diff --git a/.github/skills/oncall-weekly-telemetry-report/assets/docs/kusto-cheatsheet.md b/.github/skills/oncall-weekly-telemetry-report/assets/docs/kusto-cheatsheet.md
index c09c2a6c..9e425a5f 100644
--- a/.github/skills/oncall-weekly-telemetry-report/assets/docs/kusto-cheatsheet.md
+++ b/.github/skills/oncall-weekly-telemetry-report/assets/docs/kusto-cheatsheet.md
@@ -40,7 +40,7 @@ Time filter on materialized views is always **`EventInfo_Time`**. Use `PipelineI
## 3. Rolling 7-day WoW window (PRIMARY / attribution / latency)
-**The report's primary window is a **rolling 7-day window** ending at start-of-day UTC on `-EndDate` (default: today), NOT a Sun→Sat calendar week.** Only the 60-day trend section (§ 7 below) still uses `startofweek()` bucketing.
+**The report's primary window is a **rolling 7-day window** ending at start-of-day UTC on `-EndDate` (default: today), NOT a Sun→Sat calendar week.** The 60-day trend and sparkline sections use the same `curEnd` anchor with `bin_at(..., 7d, datetime())`, so classifier WoW and displayed WoW are aligned by construction.
Canonical template — two rows per key (`week` in `{prevStart, curStart}`):
@@ -69,11 +69,10 @@ Compute the placeholder values via `bootstrap-report.ps1` (which prints them to
| `` | `-EndDate` | `2026-07-09` |
| `` | ` - 7d` | `2026-07-02` |
| `` | ` - 14d` | `2026-06-25` |
-| `` | `` (today) — literal 60d trend upper bound, exclusive | `2026-07-09` |
| `` | ` - 60d` | `2026-05-10` |
-| `` | `startofweek()` — delta cutoff, `bucket-trends.js --end` | `2026-07-05` |
-| `` | `startofweek()` — WoW-sparkline upper bound (8 complete weeks) | `2026-07-05` |
-| `` | ` - 56d` | `2026-05-10` |
+| `` | `` (today) — literal 60d trend upper bound, exclusive, and the `bin_at` anchor | `2026-07-09` |
+| `` | ` - 56d` — first label for the 8 rolling-week sparkline window | `2026-05-14` |
+| `` | `` — exclusive upper bound and `bin_at` anchor for sparklines | `2026-07-09` |
---
@@ -113,14 +112,16 @@ The 7-dimension attribution slicing is **fully achievable from `ErrorStatsMetric
Latency is stored as a TDigest sketch. **Percentiles are not additive** — averaging p95 across rows is meaningless. Always merge first:
```kql
+let _startTime = datetime();
+let _endTime = datetime();
materialized_view('PerfStatsUpdated')
-| where EventInfo_Time between ((_startTime) .. (_endTime))
+| where EventInfo_Time >= _startTime and EventInfo_Time < _endTime
| where span_name in ('AcquireTokenSilent','GetAccounts','RemoveAccount','ProcessWebsiteRequest')
| where span_status == 'OK'
| summarize p50 = percentile_tdigest(tdigest_merge(responseTimeTDigest), 50, typeof(long)),
p95 = percentile_tdigest(tdigest_merge(responseTimeTDigest), 95, typeof(long)),
p99 = percentile_tdigest(tdigest_merge(responseTimeTDigest), 99, typeof(long))
- by week=startofweek(EventInfo_Time), span_name
+ by week = bin_at(EventInfo_Time, 7d, _endTime), span_name
```
**Note:** there is also a `PerfStatsMetrics` view, but it does **not** expose per-percentile columns directly — it has the merged TDigest. Use `PerfStatsUpdated` (preferred by the dashboard) and `percentile_tdigest(tdigest_merge(...), N, typeof(long))`.
@@ -138,16 +139,18 @@ materialized_view('PerfStatsUpdated')
---
-## 7. Week alignment — Kusto `startofweek()` is **Sunday-aligned**
+## 7. Week alignment — use `bin_at()` anchored at `curEnd`
-> **Scope:** only the 60-day trend section (§ 3 of the report / `bucket-trends.js` pipeline) still uses `startofweek()` weekly buckets. The primary/WoW section uses a rolling 7-day window — see § 3 of this cheatsheet.
+All weekly-bucketed trend queries use `bin_at(EventInfo_Time, 7d, datetime())` (or the equivalent time column for the view) where ` = `. The buckets step backward from `curEnd` in exact 7-day increments, so the newest bucket is `[curEnd - 7d, curEnd)` — exactly the same window as the report's headline WoW numbers.
-If a user says "the week of May 2 → May 9", Kusto buckets it as `startofweek('2026-05-09') == 2026-05-03T00:00:00Z`. When writing weekly-bucketed queries (60-day trend, `wow-table-sparkline-series.kql`), **always confirm**: print the distinct `startofweek(EventInfo_Time)` values from your first query and verify the bucket labels match your intended range. Off-by-one-week is the #1 silent error in weekly-bucket queries.
+For the literal-60-day trend window ending today (say ` = 2026-07-09`, so ` = 2026-05-10`), `bin_at(..., 7d, datetime())` produces labels:
+`2026-05-07, 05-14, 05-21, 05-28, 06-04, 06-11, 06-18, 06-25, 07-02`. Because 60 is not a multiple of 7, the **oldest** bucket (`05-07`) only overlaps 4 days of the filtered window; pass `bucket-trends.js --start=` to drop it, leaving 8 complete rolling weeks. The newest bucket (`07-02`) is complete and must stay in both the chart and the classifier.
-For the literal-60-day trend window ending today (say ` = 2026-07-09`, so ` = 2026-05-10`), `startofweek()` produces buckets:
-`2026-05-03, 05-10, 05-17, 05-24, 05-31, 06-07, 06-14, 06-21, 06-28, 07-05` — the first (`05-03`) is a partial start (window opens mid-week `05-10`) and is dropped by `bucket-trends.js --start`; the last (`07-05`) is the **partial current week** — it is charted as the final bar but excluded from delta classification via `--end=` (= `startofweek(today)` = `2026-07-05`) `--include-partial-end`.
+Always invoke the bucketer with **both** `--start= --end=`. `--end` filters no rows because every bucket label is `< curEnd` by construction; its purpose is to disable the script's legacy partial-end auto-drop heuristic, which is wrong under rolling alignment.
-The `wow-table-sparkline-series.kql` per-row sparklines are different: they stay on the last **8 complete** weeks (` = startofweek(today)`, exclusive, partial week filtered at the source) so no WoW row ends on a misleading partial dip.
+The `wow-table-sparkline-series.kql` per-row sparklines use the same curEnd-anchored model: ` = curEnd - 56d`, ` = curEnd`, and 8 complete rolling weeks. There is no calendar-week alignment to reason about.
+
+> **⚠️ Historical note:** these queries used to rely on `startofweek()` calendar buckets. That lagged the rolling report window by up to a full week and made late-week regressions invisible to the noise gate. Do not reintroduce `startofweek()` here.
---
@@ -156,16 +159,18 @@ The `wow-table-sparkline-series.kql` per-row sparklines are different: they stay
### 8a. Reliability (auth-only denominator)
```kql
+let tEnd = datetime();
+let tStart = tEnd - 56d;
let all = materialized_view('SilentAuthStatsAllRequestsMetrics')
- | where EventInfo_Time > ago(70d)
+ | where EventInfo_Time >= tStart and EventInfo_Time < tEnd
| summarize allReq = sum(countRequests),
allDev = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time);
+ by week = bin_at(EventInfo_Time, 7d, tEnd);
let ok = materialized_view('SilentAuthStatsRequestsWithoutExpectedErrorMetrics')
- | where EventInfo_Time > ago(70d)
+ | where EventInfo_Time >= tStart and EventInfo_Time < tEnd
| summarize okReq = sum(countRequests),
okDev = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time);
+ by week = bin_at(EventInfo_Time, 7d, tEnd);
all | join kind=inner ok on week
| project week,
reqRel = round(100.0 * okReq / allReq, 3),
@@ -176,22 +181,23 @@ all | join kind=inner ok on week
**Auth-only device union** (Silent ∪ Interactive — what the report uses for the "real fleet" KPI). The natural reach for `hll_merge_array` to combine two pre-merged HLL sketches **does not exist in Kusto** (`SEM0260: Unknown function`). Instead, project the raw `countDevicesHll` rows from both views, `union` them, and `hll_merge` once at the end:
```kql
+let tStart = datetime();
+let tEnd = datetime();
let s = materialized_view('SilentAuthStatsAllRequestsMetrics')
- | where EventInfo_Time between (datetime() .. datetime())
+ | where EventInfo_Time >= tStart and EventInfo_Time < tEnd
| project EventInfo_Time, countDevicesHll;
let i = materialized_view('InteractiveAuthStatsAllRequestsMetrics')
- | where EventInfo_Time between (datetime() .. datetime())
+ | where EventInfo_Time >= tStart and EventInfo_Time < tEnd
| project EventInfo_Time, countDevicesHll;
union s, i
| summarize authDev = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time)
-| where week < datetime()
+ by week = bin_at(EventInfo_Time, 7d, tEnd)
| order by week asc
```
### 8b. 60-day error trend (feeds `bucket-trends.js`)
-Literal last 60 days ending today (` = CUR_END - 60d`, ` = CUR_END`). Do NOT drop the partial current week here — it's the chart's final bar. Exclude it from deltas in the bucketer instead: `bucket-trends.js --end= --include-partial-end` where ` = startofweek(today)`.
+Literal last 60 days ending today (` = CUR_END - 60d`, ` = CUR_END`). Bucket with `bin_at(EventInfo_Time, 7d, datetime())`, not calendar weeks. The newest bucket is `[CUR_END - 7d, CUR_END)` and is the same complete window used by the headline WoW numbers. Because the oldest bucket is the only short bucket, invoke the bucketer as `bucket-trends.js --start= --end=`.
```kql
materialized_view('ErrorStatsMetrics')
@@ -199,7 +205,7 @@ materialized_view('ErrorStatsMetrics')
| where isnotempty(error_code) and error_code != 'success'
| summarize errs = sum(countOverall),
devs = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time), error_code
+ by week = bin_at(EventInfo_Time, 7d, datetime()), error_code
| order by error_code asc, week asc
```
@@ -231,17 +237,19 @@ Run once each with the trailing dim set to: `span_name`, `calling_package_name`,
### 8e. Broker version share
```kql
+let tEnd = datetime();
+let tStart = tEnd - 21d;
materialized_view('BrokerAdoptionStatsUpdated')
-| where EventInfo_Time > ago(21d)
+| where EventInfo_Time >= tStart and EventInfo_Time < tEnd
| summarize req = sum(countRequests),
dev = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time), broker_version
+ by week = bin_at(EventInfo_Time, 7d, tEnd), broker_version
| order by week asc, req desc
```
> **⚠️ Share/snapshot views are window-parameterized — don't assume 60-day coverage.** The share queries — `BrokerAdoptionStatsUpdated` (version share, 8e), `AppStatsUpdated` (calling-app share), `SkuStatsUpdated` (SKU share), and the [`broker-version-share-wow.kql`](../queries/broker-version-share-wow.kql) / [`app-share.kql`](../queries/app-share.kql) templates — all take an explicit `..` (or `ago(Nd)`) window. They return **exactly the weeks you ask for, nothing more.** The adoption / app-share sections of the report typically only need a short **2–3 week** WoW window, so that's what these templates default to (`ago(21d)` above).
>
-> The trap: if you then try to draw a **9-week sparkline** for version/app/SKU adoption from that same short pull, you'll only have 2–3 real points and the rest will look flat or fabricated (the validator's low-peak `data-trend` heuristic may flag it). If you genuinely need a multi-week adoption sparkline, **re-run the share query with the full 60-day window** (`` = reporting-Sunday − 56d) — don't pad a short result. If you don't need the sparkline, don't build one from a 2–3 week pull and pretend it's a trend.
+> The trap: if you then try to draw an **8-week sparkline** for version/app/SKU adoption from that same short pull, you'll only have 2–3 real points and the rest will look flat or fabricated (the validator's low-peak `data-trend` heuristic may flag it). If you genuinely need a multi-week adoption sparkline, **re-run the share query with the full rolling sparkline window** (`` = `curEnd - 56d`, `` = `curEnd`) — don't pad a short result. If you don't need the sparkline, don't build one from a 2–3 week pull and pretend it's a trend.
---
@@ -249,7 +257,7 @@ materialized_view('BrokerAdoptionStatsUpdated')
| Script | Purpose |
|---|---|
-| [`bucket-trends.js`](bucket-trends.js) | Bucket every error code into regression / spike / improvement / flat across an N-week window. Pass `--end=YYYY-MM-DD` (= `startofweek(today)`, exclusive) to exclude the partial in-progress week from delta classification, plus `--include-partial-end` to still chart it as the final bar. |
+| [`bucket-trends.js`](bucket-trends.js) | Bucket every error code into regression / spike / improvement / flat across the 8 complete rolling weeks ending at `curEnd`. Pass **both** `--start= --end=`; `--start` drops the 4-day oldest bucket, and `--end` disables the legacy partial-end auto-drop heuristic without filtering rows. |
| [`agg.js`](agg.js) | Per-error per-dim top-N rollup with WoW deltas. Feeds spike-attribution dim blocks. |
| [`summarize-attribution.js`](summarize-attribution.js) | Roll up 7-dim attribution slices per (error_code, week) — feeds the spike-attribution cards |
| [`queries/`](queries/) | Canonical KQL templates, one per query — see [`queries/README.md`](queries/README.md) |
diff --git a/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/authapp.md b/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/authapp.md
index 0d6b108a..794d97bb 100644
--- a/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/authapp.md
+++ b/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/authapp.md
@@ -109,21 +109,7 @@ both sides of the init↔results join or the two funnels quietly contaminate eac
rate, overall **Unknown rate**, Broker-API success rate, crashes per 1,000 devices. WoW delta
on each, inline SVG sparkline on each.
-2. **Scenario scoreboard** — one table, **all 13 scenarios, every week, no exceptions**. Even
- scenarios that did not move get a row; a silent scenario disappearing from the table is
- indistinguishable from a scenario that was never checked. Columns: scenario, initiated,
- success rate, Δ success (pts), failure rate, unknown rate, Δ unknown (pts), devices, 8-week
- sparkline, status pill. Rows under the volume floor carry a `low-volume` tag.
-
- > **The 4 push-notification rows cannot fill the success/failure/unknown columns — that is
- > expected, not a gap.** PN has no success/failure/Unknown model (see the outcome-model warning
- > above; `Denied` is a healthy outcome, so a "failure rate" would be a lie). For the 4 PN rows
- > put the **completion rate** in the success-rate column, the **error rate** in the failure
- > column, and a literal `n/a` in the unknown-rate and Δ-unknown cells. Do **not** leave the cells
- > blank (blank reads as "not measured") and do **not** synthesise an Unknown bucket for them.
- > Footnote the table once: *"PN scenarios report completion/error; they have no Unknown state."*
-
-3. **Needs attention** — callouts using the `.item` flat-row pattern, ordered by **novelty, not
+2. **Needs attention** — callouts using the `.item` flat-row pattern, ordered by **novelty, not
volume** (see Step 4b). Render the classifier's `attention` set (`NEW` + `ACCELERATING`) at the
top level, plus **at most 2** wins, and nothing else; `ONGOING` goes in a collapsed fold.
Budget **≤ 8 visible rows total, wins included** (`validate-report.ps1` check 17 warns above it
@@ -135,14 +121,16 @@ both sides of the init↔results join or the two funnels quietly contaminate eac
the only multi-week bucket that stays visible, because "is it getting worse?" is the one
question a known issue can still answer usefully. Delete the callout if the set is empty.
- > **When the classifier and the headline delta disagree, keep the row here and show both.**
- > The classifier's "not falling" gate runs on **complete Sun–Sat calendar weeks**; the headline
- > percentage-point delta runs on the **rolling 7-day** window. Different bases, and they
- > legitimately disagree. That is not a reason to demote the row, rename the group, or hedge the
- > heading — keep it exactly **"Getting worse"** and resolve it *in the row body*: *"Down 2.1 pp
- > across the last three complete weeks; the rolling window shows +0.4 pp as the slide flattens.
- > Still 3.8 pp below its own 60-day median."* The sparkline settles it visually. Do **not**
- > invent a "needs verification" group.
+ > **The classifier's WoW and the headline delta are the SAME basis** — both the rolling 7-day
+ > window, since the 60-day trend's final `bin_at` bucket *is* that window. They should agree in
+ > sign. If they don't, `--start`/`--end` were passed wrong; fix the invocation instead of
+ > hedging the report. (They used to run on different bases — calendar weeks vs rolling — and
+ > could disagree by 100 points. That was the bug this section used to describe as a feature.)
+ >
+ > A row can still be `ACCELERATING` while its **multi-week** slide outpaces this week's step.
+ > Keep the heading exactly **"Getting worse"** and resolve it *in the row body*: *"Down 2.1 pp
+ > over three weeks, −0.3 pp this week as the slide flattens. Still 3.8 pp below its own 60-day
+ > median."* The sparkline settles it visually. Do **not** invent a "needs verification" group.
- **🔵 Ongoing / known** — label `ONGOING`: degraded but level or easing. **Collapse into a
``** with a one-line summary ("N scenarios still below baseline, none
accelerating") and each row's `weeksElevated`. Still in the report, no longer competing with
@@ -179,6 +167,49 @@ both sides of the init↔results join or the two funnels quietly contaminate eac
> and whether it's news. One generic sentence repeated across rows makes the section unreadable;
> `validate-report.ps1` fails the report for it.
+3. **Scenario scoreboard** — one table, **all 13 scenarios, every week, no exceptions**. Even
+ scenarios that did not move get a row; a silent scenario disappearing from the table is
+ indistinguishable from a scenario that was never checked. Columns: scenario, initiated,
+ success rate, Δ success (pts), failure rate, unknown rate, Δ unknown (pts), devices, 8-week
+ sparkline, status pill. Rows under the volume floor carry a `low-volume` tag.
+
+ > **The 4 push-notification rows cannot fill the success/failure/unknown columns — that is
+ > expected, not a gap.** PN has no success/failure/Unknown model (see the outcome-model warning
+ > above; `Denied` is a healthy outcome, so a "failure rate" would be a lie). For the 4 PN rows
+ > put the **completion rate** in the success-rate column, the **error rate** in the failure
+ > column, and a literal `n/a` in the unknown-rate and Δ-unknown cells. Do **not** leave the cells
+ > blank (blank reads as "not measured") and do **not** synthesise an Unknown bucket for them.
+ > Footnote the table once: *"PN scenarios report completion/error; they have no Unknown state."*
+
+ > **⚠️ MANDATORY — every red/amber pill must be reconciled.** The scoreboard colours a row from
+ > its own rolling delta; "Needs attention" is populated from the **novelty** classifier. Those
+ > answer different questions, so a row can be legitimately red here and legitimately absent
+ > there — but the reader sees a contradiction and concludes the report is broken.
+ >
+ > Real precedent from the 2026-08-01 run: **`Passkey WebAuthN Registration`** shipped carrying
+ > `tag-bad` (−1.27 pts, the worst delta in the table) while the attention section directly above
+ > read *"Quiet week — 0 NEW or ACCELERATING"*. Both were true. That scenario peaks at ~732
+ > bad-outcome devices, **under the 1,000-device peak floor**, so it is structurally excluded from
+ > classification and can carry a red pill forever without ever being eligible for attention. No
+ > bucketing change fixes this — it has to be *explained*.
+ >
+ > So for each `tag-bad` / `tag-warn` row, either promote it into attention, **or** emit a muted
+ > `
` naming it and giving the reason. Test the reasons **in this
+ > order** and stop at the first that applies:
+ > 1. **Below the classification floor** — peak < 1,000 bad-outcome devices in the 60-day window.
+ > 2. **Within its own normal band** — the move is inside its 8-week variance (check the sparkline).
+ > 3. **ONGOING and flat** — already known and not accelerating; it lives in the fold.
+ >
+ > ```html
+ >
Flagged in the scoreboard but not escalated: Passkey WebAuthN
+ > Registration and Entra PSI PN+CFA peak below the 1,000-device classification floor,
+ > so novelty is not computed for them. Watch the sparkline; re-evaluate if volume clears the floor.
+ > ```
+ >
+ > Keep it visually muted — it is a footnote, not a finding, and must not compete with `.callout`.
+ > `validate-report.ps1` **check 19 hard-fails** any red/amber pill that is neither promoted nor
+ > named in a `.reconcile-note`.
+
4. **60-day per-scenario trend** — weekly-bucketed sparkline per scenario, first→last delta, and
a classification pill (regression / spike / improvement / flat) from `bucket-trends.js`.
@@ -277,8 +308,8 @@ Run [`scenario-60d-trend.kql`](../queries/authapp/scenario-60d-trend.kql), then:
```pwsh
node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js $data\scenario-60d.json `
- --key=scenario --metric=devs --end= --include-partial-end `
- --peak-floor=1000 --summary
+ --key=scenario --metric=devs --start= --end= `
+ --family-sep=none --peak-floor=1000 --summary
```
The query maps `errs` / `devs` to **bad outcomes** (`Failed + Unknown`), so the bucketer's
@@ -286,8 +317,21 @@ The query maps `errs` / `devs` to **bad outcomes** (`Failed + Unknown`), so the
`--metric=devs` and `--metric=reqs` and report the union of what each flags — a scenario where
device count is flat but event count explodes is a retry storm and only shows on one axis.
-Do **not** filter the partial current week at the source; `--end` excludes it from the delta math
-while `--include-partial-end` keeps it as the chart's final bar.
+**Pass both `--start` and `--end`** (`` = `curEnd − 60d`, `` = `curEnd`;
+`bootstrap-report.ps1` prints both). The query buckets with `bin_at(…, 7d, )`, so the
+newest bucket **is** the report's 7-day window and every bucket is a complete 7 days — there is no
+partial end bar to exclude, and `--include-partial-end` / `TREND_CLASS_END` are obsolete. `--end`
+filters no rows; its job is to **disable the partial-end auto-drop heuristic**, which would
+otherwise be free to discard a genuine collapse in the newest bucket. The script warns if you omit
+it — treat that warning as an error. `--start` drops the one genuinely partial bucket, which under
+this anchoring is the **oldest** (`curEnd − 63d`, 4 days), leaving 8 clean rolling weeks.
+
+> **⚠️ `--peak-floor=1000` silently excludes low-volume scenarios from classification entirely** — a
+> scenario peaking below it can never appear in "Needs attention" no matter how sharply it moves,
+> while the scoreboard will still colour its pill red. That mismatch is not a bug in either
+> component; it is why the **reconciliation rule** exists (see section 3 of § Required sections).
+> After this step, cross-check the classifier's key list against the scoreboard and note which rows
+> were dropped by the floor — you will need them for the `.reconcile-note`.
### Step 4b — Classify novelty (mandatory)
@@ -327,17 +371,17 @@ classifier did not — that is the defect this step exists to prevent.
> **Which series get classified: the outcome funnels only — the PN funnel is NOT run through the
> classifier.** Feed `classify-novelty.js` the **9 outcome-funnel bad-outcome series** and nothing
-> else. The 13 scenarios in Section 5's scoreboard are **9 outcome funnels + 4 push-notification
+> else. The 13 scenarios in Section 3's scoreboard are **9 outcome funnels + 4 push-notification
> families**; only the 9 are classifiable. The PN families (Section 7) are deliberately excluded for
> two reasons: their `FinalResult` set has **two shapes** across the window so a weekly series is not
> comparable week-to-week, and **`Denied` is a healthy outcome** — a rising `Denied` share is a user
> correctly rejecting a prompt, which the classifier would read as a regression. Never let a PN
-> family appear in Section 3's `attention` set.
+> family appear in Section 2's `attention` set.
>
> PN still gets trend treatment, just not novelty classification: chart each family's **completion
> rate** in Section 7 with its own sparkline and report the WoW delta there. If a PN family moves
> enough to be this week's story, say so in Section 7 and, if it warrants top-level visibility,
-> reference it from the Section 1 executive summary — not by inserting it into Section 3.
+> reference it from the Section 1 executive summary — not by inserting it into Section 2.
`weeksElevated` is **derived from the 9-week series, never persisted** — it counts consecutive recent
weeks above the early-window baseline, so it is identical on any machine and needs no state file.
@@ -496,11 +540,11 @@ Then verify by hand:
>
> The rules below are Authenticator-specific and do **not** transfer to the Broker playbook.
-- **Novelty classification is mandatory, and Section 3 is ordered by it — never by volume.** Run
+- **Novelty classification is mandatory, and Section 2 is ordered by it — never by volume.** Run
[`classify-novelty.js`](../scripts/classify-novelty.js) (Step 4b) and lead with `NEW`. Volume-ranking
the attention section is a known, reported defect — it puts flat-but-huge rows above real step-changes.
If `NEW` is empty, write "nothing new this week"; do not backfill it with `ONGOING` scenarios.
-- **Section 3's visible rows are the classifier's `attention` set plus at most 2 wins — nothing else.**
+- **Section 2's visible rows are the classifier's `attention` set plus at most 2 wins — nothing else.**
`NEW` + `ACCELERATING` visible with sparklines; `ONGOING` inside a collapsed ``.
Budget **≤ 8 visible rows total, wins included** (check 17 counts wins). The Broker report this
replaces shipped 13 visible rows with zero charts while the section below it carried 38 — the reader
@@ -522,7 +566,7 @@ Then verify by hand:
shape on both apps. Report the absolute level and its position against the 60-day
median instead. AuthApp scenarios normally sit at cv 0.02–0.2, so a genuinely `VOLATILE` scenario usually
means flapping instrumentation — call that out rather than reporting it as a user-facing failure.
-- **No boilerplate in Section 3.** Every row body must be specific to that scenario — what moved, from what
+- **No boilerplate in Section 2.** Every row body must be specific to that scenario — what moved, from what
to what, and whether it's news. One generic sentence repeated across rows makes the section unreadable and
`validate-report.ps1` fails the report for it.
- **Never `dcount_hll` / `hll_merge` / `percentile_tdigest` / `materialized_view('…')` /
@@ -578,9 +622,13 @@ Then verify by hand:
- [ ] Volume floor applied — `low-volume` rows tagged, excluded from the regression callout, and
any scenario that *dropped into* low-volume is flagged as its own finding.
- [ ] **60-day bucketing run on both axes** (`--metric=devs` AND `--metric=reqs`, `--key=scenario`),
- union of regressions reported, partial week charted but excluded from delta classification.
+ union of regressions reported. Both `--start` and `--end` passed, so the newest `bin_at` bucket
+ is the report's own 7-day window and the classifier grades the period the tables print.
+- [ ] **Every red/amber scoreboard pill reconciled** — each `tag-bad`/`tag-warn` row is either
+ promoted into "Needs attention" or named in a `.reconcile-note` with its reason (below the
+ 1,000-device floor / within its normal band / ONGOING-flat). Validator check 19 enforces this.
- [ ] **Novelty classification run** ([`classify-novelty.js`](../scripts/classify-novelty.js), Step 4b,
- `--family-sep=none`). Section 3 leads with `NEW`, `ACCELERATING` sits in the 🟠 Getting-worse
+ `--family-sep=none`). Section 2 leads with `NEW`, `ACCELERATING` sits in the 🟠 Getting-worse
callout, `ONGOING` is inside a collapsed fold, and no `VOLATILE`/`RECOVERY` row headlines a
percentage. Every row body is specific — no sentence repeats across rows.
- [ ] **Attention section is short and charted.** Visible rows == the classifier's `attention` set
diff --git a/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/broker.md b/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/broker.md
index 9d7d8a46..00b9cf89 100644
--- a/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/broker.md
+++ b/.github/skills/oncall-weekly-telemetry-report/assets/playbooks/broker.md
@@ -61,15 +61,17 @@
over the window, ≥ 10% above its own median, and not falling WoW). This is the "known issue is
deteriorating" bucket and it is the *only* multi-week category that stays visible.
- > **When the classifier and the headline WoW disagree, keep the row here and show both numbers.**
- > The classifier's "not falling" gate runs on **complete Sun–Sat calendar weeks**; the report's
- > headline `Δ WoW` runs on the **rolling 7-day** window. These are different bases and they
- > legitimately disagree — a code can be `ACCELERATING` on calendar weeks while showing a small
- > rolling-window decline. That is *not* a reason to demote it, rename the group, or hedge the
- > heading. Keep the group heading exactly **"Getting worse"**, and resolve it *in the row body*:
- > *"Up 18% over the last three complete weeks; the rolling 7-day window shows −4% as the ramp
- > flattens. Still ~30% above its own 60-day median — watch, don't close."* The sparkline settles
- > it visually, which is why the row has one. Do **not** invent a "needs verification" group.
+ > **The classifier's WoW and the headline WoW are the SAME number** — both are the rolling
+ > 7-day window, since the trend's final `bin_at` bucket is that window. They should agree to
+ > within HLL noise. A sign disagreement means `--start`/`--end` were passed wrong; go fix the
+ > invocation rather than writing a hedge into the report. (Before the `bin_at` fix the two ran
+ > on different bases and could disagree by 100 points — that was the bug, not a feature.)
+ >
+ > A row can still be `ACCELERATING` while its **multi-week** climb outpaces this week's step —
+ > e.g. up 18% over three weeks but +2% in the last one. Keep the group heading exactly
+ > **"Getting worse"** and resolve it *in the row body*: *"Up 18% over three weeks, +2% this
+ > week as the ramp flattens. Still ~30% above its own 60-day median — watch, don't close."*
+ > The sparkline settles it visually. Do **not** invent a "needs verification" group.
3. **🔵 Ongoing / known** — label `ONGOING`: elevated but flat. **These go inside a collapsed
``**, summarised by one line ("N codes remain elevated, none accelerating").
They are still in the report — a reader can open the fold — but they no longer compete with the
@@ -257,11 +259,20 @@ materialized_view('ErrorStatsMetrics')
| where isnotempty(error_code) and error_code != 'success'
| summarize errs = sum(countOverall),
devs = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time), error_code
+ by week = bin_at(EventInfo_Time, 7d, datetime()), error_code
| order by error_code asc, week asc
```
-**Do NOT filter the partial in-progress week here.** The chart wants it as the final bar (the window ends today). The partial week is excluded from the regression/improvement **delta math** by `bucket-trends.js` via `--end= --include-partial-end` (see 3c), not at the source — a partial week driving the delta would read as a fake −99% improvement, which is exactly why classification and display are split in the JS.
+**`bin_at`, not `startofweek` — this is load-bearing.** Anchoring the 7-day bins at ``
+(= `curEnd`) makes the newest bucket exactly `[curEnd − 7d, curEnd)`, i.e. **the report's own WoW
+window**, so the novelty classifier grades the same period the tables print. Under the old
+`startofweek()` bucketing the classifier lagged by up to a week and silently suppressed real risers
+(`authorization_pending` +63.2% in the report, −37.1% to the classifier). See the ⚠️ block in Step 4.
+
+**Every bucket here is a complete 7 days**, so there is no partial end bar and nothing to exclude
+from the delta math — `--include-partial-end` and `TREND_CLASS_END` are obsolete. Because 60 isn't a
+multiple of 7, the *oldest* bucket (`curEnd − 63d`) is the 4-day stub; `--start` drops it, leaving
+8 clean rolling weeks.
#### 3b. Per-error-type trend (same rigor)
@@ -272,7 +283,7 @@ materialized_view('ErrorStatsMetrics')
| where isnotempty(unified_error_type)
| summarize errs = sum(countOverall),
devs = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time), unified_error_type
+ by week = bin_at(EventInfo_Time, 7d, datetime()), unified_error_type
| order by unified_error_type asc, week asc
```
@@ -284,18 +295,22 @@ materialized_view('ErrorStatsMetrics')
```pwsh
# Error codes — by devices, then by requests.
-# TREND_START = curEnd - 60d (literal 60d start)
-# TREND_CLASS_END = startofweek(today) ("Trend delta cutoff" printed by bootstrap)
-# --include-partial-end charts the current partial week while excluding it from deltas.
-node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end= --include-partial-end
-node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end= --include-partial-end --metric=reqs
+# TREND_START = curEnd - 60d TREND_END = curEnd (both printed by bootstrap-report.ps1)
+# Pass BOTH --start and --end. See the note below for why --end is not optional.
+node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end=
+node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end= --metric=reqs
# Error types — by devices, then by requests (note --key)
-node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end= --include-partial-end --key=unified_error_type
-node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end= --include-partial-end --key=unified_error_type --metric=reqs
+node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end= --key=unified_error_type
+node .github\skills\oncall-weekly-telemetry-report\assets\scripts\bucket-trends.js --start= --end= --key=unified_error_type --metric=reqs
```
-`--end` is `` = `startofweek(today)` (exclusive) — the Sunday that opens the current in-progress week. Weeks at or after it (the partial current week) are excluded from delta classification; `--include-partial-end` keeps that week in the emitted `series` so the chart ends today. The script also auto-detects partial end-buckets and warns if `--end` is omitted, but passing it explicitly is safer.
+**⚠️ `--end=` is mandatory even though it filters nothing.** Every bucket label is
+`< curEnd` by construction, so `--end` removes no data — its job is to **disable the partial-end
+auto-drop heuristic**, which is guarded by `if (!endArg …)`. Under rolling alignment the newest
+bucket is genuinely complete, so leaving the heuristic armed means a real 70% collapse could be
+discarded as "looks partial". The script warns if you omit `--end`; treat that warning as an error.
+`--include-partial-end` is a retained no-op — do not add it to new invocations.
Take the **union** of all four regression sets. Both `error_code` and `error_type` regressions get a spike-attribution card in Step 5.
@@ -412,21 +427,39 @@ Three codes, each flat for seven straight weeks, all stepping up in the *same* w
**Families.** The classifier clusters keys sharing a prefix before `_` when ≥2 members share the same label. Report a family as ONE row. Error *types* are CamelCase and produce no families under `_` — that is correct, not a bug.
-**⚠️ Two different WoW bases exist — do not conflate them.** The report headline ΔWoW is a **rolling 7-day** window (`[CUR_START, CUR_END)` vs the 7 days before). The classifier's `WoW` is **calendar Sun–Sat weeks**. They legitimately disagree — `authorization_pending` read **+3.5%** rolling and **−37.1%** weekly on the same data. Use novelty as *history and context* ("flat for seven weeks, first step this week"), **never** as a competing delta number, or the report will appear to contradict its own tables.
+**⚠️ The classifier and the report now share ONE basis. This used to be a bug.** Both the headline
+`Δ WoW` and the classifier's `WoW` are computed on the **same rolling 7-day window**
+(`[CUR_START, CUR_END)` vs the 7 days before), because the 60-day trend is bucketed with
+`bin_at(t, 7d, curEnd)` and its final bucket **is** that window. **If a classifier `WoW` ever
+disagrees in sign or by more than HLL noise from the number in the table, the pipeline is
+misconfigured — stop and check that `--start`/`--end` were passed as bootstrap printed them.**
+
+> **Why this warning exists.** Buckets used to be Sun–Sat calendar weeks cut off at
+> `startofweek(curEnd)`, and the two bases were documented as "legitimately disagreeing". They did
+> not legitimately disagree — the calendar basis lagged the report by up to a full week and was blind
+> to anything that turned in the last ~6 days. On the 2026-08-01 run the classifier's current week
+> was 07/19–07/26 against a report window of 07/25–08/01, **one day of overlap**:
+>
+> | code | report ΔWoW | old classifier WoW | old verdict |
+> |---|---|---|---|
+> | `authorization_pending` | **+63.2%** (171,897 → 280,572) | −37.1% | ONGOING — "do not re-triage" |
+> | `expired_token` | **+26.7%** (86,255 → 109,251) | −51.0% | ONGOING — "do not re-triage" |
+>
+> Both were real risers the on-call engineer had already spotted by hand, and the report silently
+> suppressed both. Re-bucketing on `bin_at` promoted them to ACCELERATING **and** demoted
+> `access_denied` — an ONGOING-worthy code the old basis had wrongly promoted (actually −53.2%).
+> The attention set went 4 → 5 keys, not 4 → 15. **Do not reintroduce `startofweek()` here.**
-> **The division of labour, stated plainly so you do not have to derive it:**
+> **What the classifier still contributes.** Selection and narrative, not numbers:
>
-> | Use the **rolling 7-day** numbers for… | Use the **calendar-week** classifier for… |
+> | Take from the query results | Take from `classify-novelty.js` |
> |---|---|
-> | Every KPI tile, table cell, and Δ% chip | Which rows are promoted (`attention` set) |
-> | Any number a reader can see | Which label a row carries (NEW / ACCELERATING / …) |
-> | The sentence "X rose N% this week" | The sentence "…and it has been climbing for six weeks" |
+> | Every KPI tile, table cell, and Δ% chip | Which rows are promoted (the `attention` set) |
+> | The sentence "X rose N% this week" | Which label a row carries (NEW / ACCELERATING / …) |
+> | | The sentence "…and it has been climbing for six weeks" |
>
-> **Rule: every *number* in the report comes from the rolling window; the classifier contributes
-> *selection and narrative*, never a figure.** The one place the two meet is a row that is
-> `ACCELERATING` on calendar weeks while the rolling delta is flat or negative — keep it in
-> "Getting worse", keep the heading verbatim, and resolve it in the row body by stating both
-> numbers and letting the sparkline settle it. Do not invent a hedged sub-group for these.
+> The classifier's job is to answer *"is this new?"*, which a single delta cannot. It is no longer a
+> second source of truth for *"how much did it move?"* — there is only one answer to that now.
---
@@ -589,13 +622,16 @@ Do this section in three parts. Traffic changes (up *or* down) need the same lev
```kql
materialized_view('BrokerAdoptionStatsUpdated')
-| where EventInfo_Time > ago(70d)
+| where EventInfo_Time >= datetime() and EventInfo_Time < datetime()
| summarize totalReq = sum(countRequests),
totalDev = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time)
+ by week = bin_at(EventInfo_Time, 7d, datetime())
| order by week asc
```
+Same `bin_at` anchoring as 3a/3b, so the final bucket is the report's WoW window and this traffic
+series lines up bucket-for-bucket with the error trends you compare it against.
+
For each of the following, report direction + magnitude:
- Total requests (WoW %, 60d %)
- Total devices (WoW %, 60d %)
@@ -680,7 +716,7 @@ The validator hard-fails on:
6. **Chartless KPI grid** — if more than half the `.kpi` tiles lack a `data-spark` element (catches the v7 regression where the body was rebuilt without sparklines). Also warns when total chart count (sparks + trends + inline svgs) is < 15.
7. **Code-attribution depth** — each `.attr-card`'s "Code attribution" block must contain an `Originator` row (proxy for the full 8-field structure: Originator / Top throw site / Wrapper / Caller hot-spots / Underlying cause / Top error_messages / Likely PRs / Next step). Catches the v7-third-pass regression where cards shipped with a `pr-list`-only stub.
8. **Attribution-card layout guards (v8)** — the CSS must define `.attr-card { margin-bottom: 16px }` AND `.dim-row` overflow rules (`text-overflow: ellipsis` + `min-width: 0`). Catches the "cards touching" and "text bleeding out of dim boxes" regressions from a stale `` block.
-9. **Fabricated-sparkline heuristic (v8)** — warns when a `data-trend` array's peak value is < 100 (almost certainly hand-rolled rather than sourced from real data). See [`assets/queries/wow-table-sparkline-series.kql`](../queries/wow-table-sparkline-series.kql) for the canonical KQL that pulls real 8-week series for every code in the WoW tables. Its `` / `` tokens are the last **8 complete** Sun-Sat weeks (`` = `startofweek(today)`, exclusive) — deliberately distinct from the trend-chart's `` / `` (literal last 60 days ending today). Per-row sparklines stay on complete weeks so a partial final point doesn't create a misleading dip in every WoW row.
+9. **Fabricated-sparkline heuristic (v8)** — warns when a `data-trend` array's peak value is < 100 (almost certainly hand-rolled rather than sourced from real data). See [`assets/queries/wow-table-sparkline-series.kql`](../queries/wow-table-sparkline-series.kql) for the canonical KQL that pulls real 8-week series for every code in the WoW tables. Its `` / `` tokens are the last **8 complete rolling weeks** anchored at `curEnd` (`` = `curEnd`, exclusive; `` = `curEnd − 56d`) — the same `bin_at` basis as the trend chart, just a shorter span. Every point is a full 7 days, so no row can end on a misleading partial dip.
Then:
- **Run the visual smoke test (recommended)** — catches rendered-layout bugs that pure HTML/CSS validation can't see:
@@ -706,8 +742,16 @@ Then:
> They apply here too and are NOT repeated below: never carry a telemetry number forward between
> runs · never hardcode the Generated date · never compose report HTML via a PowerShell `@'...'@`
> heredoc (UTF-8 strip) · never bulk-regex-edit balanced HTML · no `devs`/`reqs` in user-facing
-> text · same-end-date collision requires an explicit delta statement · no separate Markdown
-> summary · never commit the report. **Read them before writing any HTML.**
+> text · **every red/amber table pill is either promoted into attention or explained in a
+> `.reconcile-note`** (validator check 19) · same-end-date collision requires an explicit delta
+> statement · no separate Markdown summary · never commit the report.
+> **Read them before writing any HTML.**
+>
+> The reconciliation rule bites on Broker too, for the same structural reason it does on the
+> Authenticator: a code whose 60-day **peak** sits under the 10,000-device floor is excluded from
+> classification outright, so it can hold a red pill indefinitely while attention says "quiet week".
+> Now that both bases are rolling, disagreement on *direction* is a bug — what legitimately remains
+> is disagreement on **classifiability**. Say which it is.
>
> The rules below are Broker-specific and do **not** transfer to the Authenticator playbook.
@@ -717,7 +761,7 @@ Then:
- **Never sum percentiles.** Latency is a TDigest sketch — `percentile_tdigest(tdigest_merge(responseTimeTDigest), N, typeof(long))` only.
- **Always apply `MergeAccountType` / `MergeIsSharedDevice` / `MergeUiRequiredExceptions`** so this report agrees with the dashboard.
- **Confirm the week bucket label matches the user's intent** before writing the rest of the queries (Sunday-aligned).
-- **Do NOT filter the partial in-progress week at the source in the 60-day trend queries** — the chart ends today and wants that partial week as its final bar. Exclude it from the regression/improvement **delta math** instead by running `bucket-trends.js --end= --include-partial-end`: the `--end` cutoff drops the partial week from first/last/delta classification while `--include-partial-end` keeps it in the emitted `series`. Skipping `--end` (or the cutoff) would make `bucket-trends.js` show every error as a fake −99% improvement. The per-row `wow-table-sparkline-series.kql` is the exception — it keeps 8 complete weeks (`` = `startofweek(today)`, with the partial week filtered at the source) so no WoW row ends on a misleading partial dip.
+- **Bucket the 60-day trend with `bin_at(EventInfo_Time, 7d, datetime())`, never `startofweek()`.** Anchoring at `curEnd` makes the newest bucket exactly the report's WoW window, so the noise gate grades the period the tables print. `startofweek()` bucketing lagged by up to 6 days and structurally suppressed anything that turned late in the window — that is how `authorization_pending` shipped as "ONGOING, do not re-triage" while the report showed it up 63.2%. Every bucket is now a complete 7 days (the 4-day stub is the *oldest* bucket and `--start` drops it), so there is nothing to exclude from the delta math: `--include-partial-end` and `TREND_CLASS_END` are obsolete. **Always pass both `--start` and `--end` to `bucket-trends.js`** — `--end` filters no rows but disables the partial-end auto-drop heuristic, which would otherwise be free to discard a real collapse. `wow-table-sparkline-series.kql` uses the same `bin_at` basis over 8 weeks.
- **Originator pre-check is mandatory.** A card cannot claim `Originator: Broker` without first running [`assets/queries/error-message-and-location.kql`](../queries/error-message-and-location.kql) and reading the throw site + top 3 `error_message` strings. If the throw site is in `common/ExceptionAdapter.{getExceptionFromTokenErrorResponse, exceptionFromAuthorizationResult}` AND the message starts with `AADSTS`, the originator is **eSTS, not broker** — see the AADSTS reference in [`assets/docs/kusto-cheatsheet.md`](../docs/kusto-cheatsheet.md).
- **WoW-movers pass is mandatory.** The 60d bucketer's `--peak-floor` silently drops sub-10K-device codes, so [`assets/queries/wow-movers.kql`](../queries/wow-movers.kql) MUST be run as a separate pass for both `error_code` and `error_type` (per Step 3d). Its output is **merged into the single regression callout** and then grouped by Step 3e's novelty labels. Do not render a separate "emerging" callout. Skipping the pass is how the Apr 26 `Failed to parse JWT` spike (7 → 3,461 devs over 7 weeks) hid for two reports running.
- **Novelty classification is mandatory, and Section 2 is ordered by it — never by volume.** Run [`classify-novelty.js`](../scripts/classify-novelty.js) (Step 3e) and lead with `NEW`. Ranking the attention list by device count is a known, reported defect: it put `IntuneAppProtectionPolicyRequiredException` (ΔWoW **+0.1%**, classifier says `ONGOING` and *falling*) at #1 while the genuinely new `ipc_*` family sat at #6/#9/#10. If the `NEW` bucket is empty, write "nothing new this week" — do not backfill it with `ONGOING` items.
@@ -755,7 +799,7 @@ Then:
- [ ] New `oncall-wow-report-YYYY-MM-DD.html` (where `YYYY-MM-DD` is the resolved `curEnd` — the end-date of the rolling 7-day window) exists at `$env:USERPROFILE\android-oce-reports\` (NOT at repo root). If a file for this end-date already existed, the chat session explicitly stated what changed before regenerating.
- [ ] All sections present and populated (incl. 🚚 Traffic Attribution — even if “None this week”)
-- [ ] **60-day trend bucketing run on the full cross-product** — `{error_code, error_type} × {devices, requests}` = 4 runs — union of regressions reported. Per-request retry storms (e.g. small device pool, exploding request count) are flagged on both axes. Source KQL spans the literal last 60 days ending today (no source-side partial-week filter); the partial current week is excluded from delta classification via `bucket-trends.js --end= --include-partial-end` and charted as the final bar.
+- [ ] **60-day trend bucketing run on the full cross-product** — `{error_code, error_type} × {devices, requests}` = 4 runs — union of regressions reported. Per-request retry storms (e.g. small device pool, exploding request count) are flagged on both axes. Source KQL spans the literal last 60 days ending today and buckets with `bin_at(…, 7d, )`, so the newest bucket **is** the report's WoW window; every `bucket-trends.js` invocation passed **both** `--start` and `--end`.
- [ ] **WoW-movers pass run** ([`wow-movers.kql`](../queries/wow-movers.kql)) for BOTH `error_code` and `error_type`. Its output rows are **merged into the single regression callout in Section 2**. Every row carries throw-site, dominant message, originator, and a next step. If the callout is empty (rare), render "None this week" rather than omit.
- [ ] **Novelty classification run** ([`classify-novelty.js`](../scripts/classify-novelty.js)) on every `bucket-trends.js` sidecar. Section 2 is grouped 🆕 New → 🟠 Getting worse → 🔵 Ongoing (collapsed fold) → 🔁 Volatile → ↩️ Recovery, **not** sorted by device count. No `VOLATILE`/`RECOVERY` row headlines a percentage. Families are reported as one row. Every row body is specific — no sentence repeats across rows.
- [ ] **Attention section is short and charted.** Visible rows == the classifier's `attention` set (`NEW` + `ACCELERATING`), ≤ 8 of them, each with an `.item-spark` 9-week sparkline. `ONGOING` rows live inside a collapsed fold. If `quietWeek: true`, the quiet-week banner is shown and nothing was promoted to fill the gap.
diff --git a/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-codes.kql b/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-codes.kql
index 1e2c6270..d6a3c204 100644
--- a/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-codes.kql
+++ b/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-codes.kql
@@ -1,32 +1,38 @@
// 60-day per-error-code trend (LITERAL last 60 days ending today).
//
-// The 60-day trend spans the literal last 60 days ending at
-// curEnd (today), so BOTH bounds move with the report date. The trend CHART
-// includes the current in-progress week as its final (partial) bar. Per-week
-// bucketing is still Sun-Sat aligned (Kusto startofweek() is Sunday-based), so the
-// first and last buckets are partial by construction.
+// ⚠️ BUCKETS ARE ROLLING 7-DAY WINDOWS ANCHORED AT , **NOT** Sun-Sat
+// calendar weeks. `bin_at(t, 7d, datetime())` walks backwards from
+// curEnd in exact 7-day steps, so the FINAL bucket is [curEnd-7d, curEnd) --
+// byte-for-byte the same window the report's headline WoW numbers are computed
+// over. Every bucket is a COMPLETE 7 days; there is no partial end bar.
//
-// IMPORTANT: do NOT drop the current in-progress week here -- the chart wants it.
-// bucket-trends.js excludes the partial week from the regression/improvement DELTA
-// math via `--end= --include-partial-end` (TREND_CLASS_END =
-// startofweek(today)) while still charting it. A partial week driving the delta
-// would read as a fake -99% improvement -- that's why classification and display
-// are split in the JS, not filtered out here.
+// This alignment is load-bearing, not cosmetic. When this query used
+// startofweek() the trend/classifier basis lagged the report's rolling window by
+// up to a full week (for a 2026-08-01 run the classifier's "current" week was
+// 07/19-07/26 against a report window of 07/25-08/01 -- ONE day of overlap).
+// Codes that turned in the final ~6 days were structurally invisible to the noise
+// gate: authorization_pending read +63.2% in the report and -37.1% to the
+// classifier, which duly filed it "ONGOING -- plateaued, do not re-triage".
+// Aligning the bases makes classifier WoW == displayed WoW and removes that entire
+// false-negative class. Do not "restore" startofweek() here.
+//
+// Because 60 is not a multiple of 7, the OLDEST bucket (curEnd-63d) covers only 4
+// days and IS partial. That is the safe end to be partial on, and it is dropped by
+// `--start=` (its label sorts before TREND_START). Net: 8 complete
+// rolling weeks. There is no TREND_CLASS_END any more, and --include-partial-end
+// is obsolete -- classification and display now see the identical bucket list.
//
// Inputs (replace before pasting; bootstrap-report.ps1 prints all values):
// = curEnd - 60d (first calendar day of the 60-day window)
-// = curEnd (today), EXCLUSIVE. Data is pulled up to but not
-// including 00:00 UTC today, so the final Sun-Sat bucket holds
-// Sunday..yesterday of the current week (the partial bar).
+// = curEnd (today), EXCLUSIVE, and the bin_at anchor. Data is
+// pulled up to but not including 00:00 UTC today.
//
// Output: feed to assets/scripts/bucket-trends.js with
-// --start= --end= --include-partial-end
-// where TREND_CLASS_END = startofweek(today) (bootstrap prints it as the
-// "Trend delta cutoff" value).
+// --start= --end=
materialized_view('ErrorStatsMetrics')
| where EventInfo_Time >= datetime() and EventInfo_Time < datetime()
| where isnotempty(error_code) and error_code != 'success'
| summarize errs = sum(countOverall),
devs = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time), error_code
+ by week = bin_at(EventInfo_Time, 7d, datetime()), error_code
| order by error_code asc, week asc
diff --git a/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-types.kql b/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-types.kql
index 7f37fd56..fd1e5132 100644
--- a/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-types.kql
+++ b/.github/skills/oncall-weekly-telemetry-report/assets/queries/60d-trend-types.kql
@@ -1,18 +1,20 @@
// 60-day per-error-type trend (with MergeUiRequiredExceptions to collapse variants).
//
-// LITERAL last 60 days ending today; Sun-Sat weekly bucketing kept (Kusto
-// startofweek() is Sunday-aligned). The current in-progress week is charted as the
-// final partial bar and is excluded from delta classification by bucket-trends.js,
-// not filtered here. See 60d-trend-codes.kql for full semantics.
+// LITERAL last 60 days ending today, bucketed into ROLLING 7-day windows anchored
+// at via bin_at() -- NOT Sun-Sat calendar weeks. The final bucket is
+// exactly the report's rolling WoW window, so classifier WoW == displayed WoW.
+// Every bucket is complete; there is no partial end bar and no TREND_CLASS_END.
+// See 60d-trend-codes.kql for the full rationale (it documents the false-negative
+// class this alignment fixes). Do not "restore" startofweek() here.
//
// Inputs (bootstrap-report.ps1 prints all values):
// = curEnd - 60d
-// = curEnd (today), EXCLUSIVE
+// = curEnd (today), EXCLUSIVE, and the bin_at anchor
materialized_view('ErrorStatsMetrics')
| extend unified_error_type = MergeUiRequiredExceptions(error_type)
| where EventInfo_Time >= datetime() and EventInfo_Time < datetime()
| where isnotempty(unified_error_type)
| summarize errs = sum(countOverall),
devs = dcount_hll(hll_merge(countDevicesHll))
- by week = startofweek(EventInfo_Time), unified_error_type
+ by week = bin_at(EventInfo_Time, 7d, datetime()), unified_error_type
| order by unified_error_type asc, week asc
diff --git a/.github/skills/oncall-weekly-telemetry-report/assets/queries/README.md b/.github/skills/oncall-weekly-telemetry-report/assets/queries/README.md
index f3cdbdd7..41c2e177 100644
--- a/.github/skills/oncall-weekly-telemetry-report/assets/queries/README.md
+++ b/.github/skills/oncall-weekly-telemetry-report/assets/queries/README.md
@@ -15,17 +15,16 @@ rationale.
| `` | ` - 7d` (e.g. `2026-07-02`). Inclusive lower bound of the current window. |
| `` | ` - 14d` (e.g. `2026-06-25`). Inclusive lower bound of the prior 7-day baseline window. The baseline window is `[PREV_START, CUR_START)`. |
| `` | First calendar day of the 60-day trend chart window: `CUR_END - 60d` (literal 60 days ending today). |
-| `` | Exclusive upper bound of the 60-day trend chart window: `CUR_END` (today). The final Sun-Sat bucket is the current in-progress (partial) week — charted, but excluded from delta classification. |
-| `` | Delta-classification cutoff = `startofweek(CUR_END)` (the Sunday that opens the current in-progress week). Passed to `bucket-trends.js` as `--end` (with `--include-partial-end`). On a `2026-07-09` (Thu) run, that's `2026-07-05`. Bootstrap prints it as "Trend delta cutoff". |
-| `` | First Sunday of the WoW-table sparkline series: `startofweek(CUR_END) - 56d`. **Sunday-aligned; 8 complete weeks.** |
-| `` | Sunday that OPENS the current in-progress week, exclusive: `startofweek(CUR_END)`. The `| where week < datetime()` filter keeps the WoW-row sparklines on 8 complete weeks. |
+| `` | Exclusive upper bound of the 60-day trend chart window: `CUR_END` (today), and the `bin_at(..., 7d, datetime())` anchor. The newest bucket is `[CUR_END - 7d, CUR_END)` and is complete. |
+| `` | First label of the WoW-table sparkline series: `CUR_END - 56d`. |
+| `` | Exclusive upper bound and `bin_at` anchor for sparklines: `CUR_END`. The sparkline window is 8 complete rolling weeks. |
| `` | Comma-separated KQL string list, e.g. `'invalid_resource', 'null_pointer_error'` |
| `` | Same shape but for `unified_error_type`. |
| `` | A single column name, replaced per dimension run. |
**The primary/WoW queries emit two rows per key (bucket = `prevStart` or `curStart`).** The JS helpers (`agg.js`, `summarize-attribution.js`) sort the bucket label lexicographically and treat the smaller value as "prev" and the larger as "cur" — so any pair of sortable datetimes works.
-**The 60-day trend queries emit Sun-Sat weekly buckets over the literal last 60 days ending today** (`[CUR_END - 60d, CUR_END)`). `startofweek()` is Sunday-aligned in Kusto. Do **not** filter the partial in-progress week at the source — the chart wants it as the final bar. Exclude it from the delta math via `bucket-trends.js --end= --include-partial-end` (`` = `startofweek(CUR_END)`); otherwise a partial "last" week reads as a fake −99% improvement on every code. The `wow-table-sparkline-series.kql` file is the exception: it keeps 8 complete weeks via `| where week < datetime()` so no WoW row ends on a partial dip.
+**The 60-day trend queries emit rolling 7-day buckets anchored at `` over the literal last 60 days ending today** (`[CUR_END - 60d, CUR_END)`). Use `bin_at(