powerbi-report-gotchas · diff
git:20260905.bee7d6a to git:20260905.fc19c3b
17 added, 0 removed. Audit A to A.
---
name: powerbi-report-gotchas
description: Hard-won Power BI PBIR authoring and Desktop-verification gotchas - validation-invisible rendering bugs, conditional-formatting and data-colour encodings, azureMap/scatter/matrix recipes, field-parameter failures, and the Desktop refresh/bridge mechanics. Use before authoring or debugging any PBIR visual, and whenever a report validates clean but renders wrong. Source-tool agnostic (Tableau, Qlik, Cognos to Power BI).
---
# Power BI report gotchas
Every entry below cost a real debugging cycle on a real migration. They are grouped by *when they bite
you*, not by visual type.
**The one rule that generates most of this file:** `powerbi-report-author validate` is **necessary, not
sufficient**. It passes structurally-valid-but-wrong encodings, and it **silently skips all JSON-schema
checks** when it cannot fetch the visualContainer schema (`PBIR_SCHEMA_UNREACHABLE` — it still prints
"0 errors"). It checks reference **shape**, not reference **target**; a well-formed `(entity, property)`
pair, filter payload, or hierarchy can still point at the wrong thing or render at the wrong grain.
Only a live Desktop render catches the class of bug in §1.
**Treat `PBIR_SCHEMA_UNREACHABLE` as "schema validation did NOT run."** The declared `2.11.0` schema
404s; `2.9.0` is the newest published. Confirm instead with a Desktop open-test (a schema violation
raises an error dialog on open) or an offline `ajv` harness against the real 2.9.0-family schemas.
> **Scope note:** the mechanisms here are pure Power BI, so this folder ports to a Qlik or Cognos
> migration unchanged. A handful of entries name the source-tool idiom that led to the discovery
> (Tableau shelves, `MAKELINE`); read those as examples, not as prerequisites. Cross-references to
> `.github/pbi.kb/` and `.github/agents/` are paths in the host migration repo.
> **Engine-version provenance — read before acting on any entry that names the deterministic engine.**
> Audited 2026-09-03 (issue #486) against upstream `Yarbrdab000/tableau-fabric-skills` issue state,
> on canonical plugin **2.353.0**, then re-verified 2026-09-03 after the plugin was upgraded to **2.356.0** (upstream `main`). Entries whose defect was fixed
> upstream now carry a ⚠️ retraction or version-qualification **in place**, with the original
> measurement kept visible. A targeted roll-forward check on 2026-09-05 advanced the canonical
> engine to **2.368.0**, re-ran the upstream behavior pins, and rechecked the #178 emitter source; it
> was not a fresh audit of every entry below. Two things that the original audit could **not** settle,
> so do not read silence as
> currency: (a) claims naming an engine **source line** (`twb_to_pbir.py:2366`, `:14280`,
> `_constant_mark_color_objects`) were pinned at 2.146.0–2.339.0 and the line numbers have certainly
> moved — treat them as *where to look*, never as a citation; (b) an engine claim with **no upstream
> issue** was left standing, because absence of an issue is not evidence of a fix. Neither was
> re-measured — the audit was deliberately documentation-only, since re-measuring means running the
> engine. Anything about **Power BI Desktop**, PBIR schemas or `powerbi-report-author` is unaffected
> by engine drift and was out of scope.
## 1. Validation-invisible rendering bugs
These pass `validate` but render wrong. Only a live Desktop screenshot catches them.
+ - **A text-returning measure or column bound to a numeric visual role (Y, Y2, MinValue, MaxValue,
+ TargetValue, scatter X/Size, azureMap Size, treemap Values) silently renders the visual empty.**
+ Power BI cannot plot non-numeric values on a continuous or numeric axis. `powerbi-report-author
+ validate` returns 0 errors because it validates only the structural presence and syntax of the
+ field reference, not data types. Statically checked by `scripts/check_field_bindings.py` when
+ TMDL declares `dataType: String`; untyped measures report `CANNOT ASSESS` offline rather than
+ guessing.
- **Conditional/Cases `Else` is IGNORED by Desktop for table `fontColor`** — the top/else band renders
**black**. Fix: append an explicit always-true final `Case` (e.g. `driver < 1e12` → the else colour)
instead of relying on `Else`.
- **azureMap `Location` role + explicit Lat/Long = a Desktop error** ("Remove Location… or set aggregate
to Average"). Fix: keep `Location`, set Lat/Long to **Average** aggregation (lossless when the grain
is one coordinate per point).
- **`field.Aggregation.Function` enum: Sum=0, Avg=1, Count=2, Min=3, Max=4.** 🟢 render-verified — a
5-projection `cardVisual` over a 730-row column (values 75.75–92.78) returned `0 → 61.82K` (sum),
`1 → 84.69` (avg), `2 → 533`, `3 → 75.75` (min), `4 → 92.78` (max). Note `2` is a **DISTINCT** count
(533 distinct weights out of 730 rows), not the row count — use `CountNonNull` if you want 730.
A wrong value is not a field reference, so it passes validation but **silently aggregates wrong**.
*(This corrects an earlier version of this file which claimed Max=2, Min=3, Count=4; the
`powerbi-report-authoring` references had it right.)*
- **Projection-level `format` overrides ARE honoured by Desktop.** 🟢 render-verified (Desktop
2.157.627.0, table-calcs migration): `proj.format = "MMMM yyyy"` on a `pivotTable` Rows projection
rendered `January 2015` (from a `Short Date` model format), and `proj.format = "#,0"` on a Values
projection rendered `6,928` instead of `6,928.00`. This is the **report-layer** way to restyle a
column you do not own — prefer it over asking the model owner to change `formatString`.
*(This corrects an earlier version of this file which listed it as "unconfirmed offline".)*
**`expansionStates`** remains unconfirmed and is a **no-op on initial render** (matrix still shows
collapsed); don't burn cycles chasing it, document a collapsed default or use a flat `tableEx` when
the grain is one row per leaf.
- **`labels.labelPosition = 'InsideCenter'` is silently IGNORED on a `lineChart`.** 🟢 render-verified
(Desktop 2.157.627.0). `formatting describe-object lineChart labels` lists it in the enum and
`validate` returns 0 errors, but the render is byte-identical to `Auto` **and** to `Above` — Desktop
always draws a line chart's data label *above* its marker. Deceptive rather than obviously broken: on
a bump chart (ranks 1..N in every column) an offset label still lands neatly inside *a* marker, just
the **wrong one**, so every number reads one row off. Diagnose by rendering the labels **black** — a
stray label with no marker under it appears at the edge of the plot. Fix with the lineChart-only
numerics **`labels.maximumOffset` / `labels.minimumOffset`**; measured sweep at `markerSize = 14`:
`0` ≈ half a row low, `-10` slightly high, **`-20` dead-centre**, `-30` slightly low, `-40` a full row
below. The offset is a pixel quantity cancelling a pixel quantity, so it is independent of plot height
(survives resize) but **coupled to `markerSize`** — re-sweep if the marker size changes.
Vocabulary is **type-dependent**: `labelOverflow` is columnChart-only, `maximumOffset`/`minimumOffset`
are lineChart-only, `labelDensity`/`show`/`color`/`labelPosition` exist on both — so a script that
flips a visual's type must also swap the label property set (see §3).
- **A `lineChart` with a multi-level `Category` silently renders only the TOP level.** 🟢 render-verified
(cold run S18): binding ordered `Date[Year]` + `Date[Month]` categories turned **48 monthly marks
into 4 yearly points** on an axis still scaled for 48, while `validate` returned 0 errors / 0 warnings
and the same two-level binding was correct on a `columnChart`. Fix: bind **one continuous column** at
the desired grain (for example month start) with `objects.categoryAxis[0].properties.axisType = 'Scalar'`.
Cross-layer trap: a semantic-model wave that "improves" a report from one date column to a Year→Month
hierarchy is harmless on bars and silently destructive on line charts, so the report owner must
re-render every line chart after that model-side rebinding.
- **A measure used as a visual-level filter at a FINER grain than it evaluates silently zeroes the
visual.** A scatter carrying a `Region Filter` measure at Sub-Category grain has
`SELECTEDVALUE('…'[Region])` blank, so the filter is false for every point → empty visual. When the
underlying measures already bake in the restriction, drop the redundant visual filter.
- **Slicers/maps showing "Column … cannot be found or may not be used"** almost always mean a
field-parameter table's columns didn't materialize — a **semantic-model** bug (`sourceColumn` needs
brackets, `[Value1]`). Suspect this first for FP-bound visuals; it is not a report-layer fix.
- **`slicer` + `data.mode = 'Single'` on a NUMERIC column silently ignores its
`objects.general[0].properties.filter` default.** 🟢 render-verified. Desktop draws a bare text input
showing the column's **minimum**, not the pre-selected value — so two what-if slicers intended to load
at `10` and `25.0` loaded at `1` and `0.0` while `validate` reported 0 errors. This breaks the
"every slicer has a default" rule (§8) *without any diagnostic*. Fix: use `mode = 'Dropdown'`; the
**identical** `general.filter` payload then renders the right value, proving the filter encoding was
never the problem. Treat `'Single'` as unsafe for what-if/numeric parameter controls.
- **A slicer's pre-selection lives in `objects.general[0].properties.filter`, not top-level
`filterConfig`.** 🟢 render-verified (cold run S19): a `filterConfig`-only Categorical `In [10L]`
rendered **All**; the byte-identical payload moved to `general.filter` rendered **10**. On a slicer,
`filterConfig` restricts which items are offered and pre-selects nothing, so engine-emitted defaults
there are inert. ⚠️ **The ENGINE half of this is fixed upstream — do not go hunting for it.**
`Yarbrdab000/tableau-fabric-skills#130` shipped in **2.149.0**, which is below the canonical
engine (2.356.0 at 2026-09-04 — resolve yours with `python scripts/engine_source.py`): the emitter has
written the open-on selection into `general.filter` since 2.51.0, and the two residual
`filterConfig`-only slicers were both **boolean** columns the pre-selection gate declined; the
corpus now emits **zero**. The PBIR rule above is unchanged and still yours to apply when you
hand-author a slicer — what is retracted is only the expectation of finding engine-emitted
`filterConfig` defaults to clean up. The filter `name` must be unique report-wide — a duplicate is a `validate`
**warning** (`PBIR_FILTER_NAME_DUPLICATE_GLOBAL`, verified in CLI 0.1.4 `dist/index.js`), so an
`errorCount`-only gate misses it — and an `Int64` literal needs the `L` suffix (`10L`).
- **A textbox that mixes a large title run and a small descriptor run in ONE paragraph wraps and
clips.** 🟢 render-verified. Desktop wraps the second run onto a new line, cuts it off at the box
bottom, and draws a stray vertical overflow mark at the right edge — `validate` says 0 errors. Fix:
emit **two paragraphs** (heading run, then descriptor run) and size the box for both lines; a
single-run wrap is not deterministic against the box width.
- **`scatterChart` projecting the SAME column into both `Category` and `Series` with an identical
`nativeQueryRef` renders "Error fetching data for this visual"** (LogisticsLive migration,
`powerbi-report-author` CLI 0.1.4). 🟢 render-verified — a 4-category-point scatter (`Category` =
`Discount Band`, `Series` = the same `Discount Band` column for the legend) passed `validate` with 0
errors/warnings but Desktop showed the error glyph with no further detail. Root cause: both
projections shared `nativeQueryRef: "Discount Band"` even though their `queryRef`s were correctly
disambiguated (`Superstore Orders.Discount Band` vs `...Discount Band1`) — per
`references/expressions.md` "if duplicated across roles, append a number", this applies to
**`nativeQueryRef` too, not just `queryRef`**. Fix: give the second projection a distinct
`nativeQueryRef` (e.g. `"Discount Band1"`); re-validate and reload — the error disappears and the
scatter renders normally. This is a second, independent instance of a validation-invisible bug class
(§1's core warning) — always reload+screenshot a scatter/chart that legends by the same field it
categorizes by, even after a clean `validate`.
- **Adding a `Rows` (small-multiples) field SILENTLY DROPS a measure-driven `sortDefinition`; the
category axis reverts to alphabetical.** 🟢 render-verified by controlled before/after
(`book_5-2-LOD`, `powerbi-report-author` CLI 0.1.4). A `clusteredBarChart` sorted Sub-Category DESC
by `'_Measures'[Sales across Regions]` rendered correctly (Phones, Chairs, Storage…). Moving
`Region` from a second `Category` level into `Rows` — **changing nothing else** — re-rendered it as
Accessories, Appliances, Art… `validate` still returned 0 errors, and the `sortDefinition` node is
still physically present and still correct in `visual.json`. So neither a schema check nor a
"did my sort survive?" JSON assertion can detect this; only a screenshot can.
**It also holds when the sort targets an ON-AXIS measure — retargeting is not a fix.** 🟢
render-verified, same report, three runs (Desktop 2.157.627.0, 2026-08-08): (1) sort → off-axis
`_Measures[Sales across Regions]` + `Rows` → alphabetical; (2) sort → the on-axis
`Sum(Orders[Sales])` that the bars are drawn from + `Rows` → **still alphabetical**; (3) that same
sort node with `Rows` REMOVED → perfect DESC (Phones, Chairs, Storage…). Runs 2 vs 3 are a clean
isolation — byte-identical `sortDefinition`, identical Category/Y/Tooltips, only the `Rows` well
differs — so the panes are the cause, not the sort target. Worth knowing because "the sort field
isn't on an axis" is the obvious first hypothesis and it is *wrong*: don't spend a cycle on it.
⚠️ **Do NOT cite Learn for this — the docs do not say it.** An earlier revision of this entry
claimed corroboration from Learn's small-multiples *Considerations and limitations*; that list
(re-fetched 2026-08-08) covers no-data items, scroll-to-load, Analyze/Summarize, rectangular
select, axis zoom, concatenate labels, total labels, zoom sliders, trend lines and forecasting —
**sorting is not on it**. The companion page *Interact with small multiples in Power BI* reads the
other way: "you can sort multiple aspects of a visual at once. Sort by the category, and also by
the axis in each multiple." Desktop does not do that. So this limitation is **real but
undocumented**, which is exactly why it can only be found by rendering — cite the measurement.
**Consequence for migrations:** when a source tool expresses *both* "this discrete pill forms
panes" *and* "sort this axis by a measure" (a Tableau `<cols>` pane pill + `<computed-sort>` is
exactly this pair), the **report layer** cannot carry the sort — a `sortDefinition` is ignored once
the pane field is in the small-multiples well, whether it names an off-axis or an on-axis field
(measured above). **This is NOT an accepted limitation — it has a proven model-side fix.** A
model-level **Sort by Column** (`sortByColumn`) on the category, keyed to a numeric rank column,
DOES survive small multiples: measured in `powerbi-semantic-model-gotchas` §4 ("a model-level sort
SURVIVES small multiples where a visual-level sort does not"), it reordered all 17 categories
correctly across all 4 panes on the byte-identical `clusteredBarChart` where the report-layer sort
was ignored. So **route the sort to the model owner as the real fix, not a fallback** — a rank
column plus `sortByColumn` is the mechanism that works, and it is the model's job, not the
report's. Keeping the report-layer `sortDefinition` targeted at a projected field is still a
harmless, render-neutral adjunct (belt-and-braces), but do not log an accepted limitation and stop:
the sort is recoverable, and we have proven it.
- **A measure's `SourceRef.Entity` must be the measure's HOME table, not the table it aggregates.**
🟢 verified at query level. `Sales (w/o Category)` = `CALCULATE(SUM('Orders'[Sales]),
REMOVEFILTERS('Orders'[Category]))` reads `Orders`, but is *defined* on a `_Measures` table — so
`{"Measure":{"Expression":{"SourceRef":{"Entity":"Orders"}},"Property":"Sales (w/o Category)"}}` is
wrong, and `"_Measures"` is right (`queryRef` follows: `_Measures.Sales (w/o Category)`). `validate`
returns **0 errors / 0 warnings** on the broken form (shape, not target — the header rule), and the
failure is `'Orders'[Sales (w/o Category)]` → *"Column … in table 'Orders' cannot be found or may not
be used"* only at query time. **A measure reference has TWO sites that must agree**: the projection
*and* the `filterConfig` `From` clause (`{"Name":"f","Entity":…}`); the previous pass fixed neither
and shipped. Two traps worth naming: (a) a **dedicated measures table is the common convention**, so
on any model with one, the fact-table entity is wrong for *every* measure — if your codegen has a
single `ENTITY` constant, columns and measures must not share it; (b) **verifying the measure's
semantics in DAX is not verifying the reference** — the previous pass ran `'_Measures'[…]` (correct)
while the PBIR encoded `'Orders'[…]` (wrong), and the green DAX result was read as proof. Prove the
**exact entity/property path the PBIR encodes**, and keep a negative control: the wrong path must
*error*.
- **On a visual that cannot render (an unlicensed azureMap, a tooltip-only page), this class of bug is
undetectable by screenshot** — a wrong measure reference and an environmental blank look identical.
Fall back to executing the encoded path against the live model.
### A literal must carry the COLUMN's type — and the same mistake fails two opposite ways
⚠️ **Historical measurement:** this still reproduced in canonical engine 2.356.0 on 2026-09-04.
`_data_point_colors` emitted every `scopeId` member through `_semantic_string_literal`, even though
the column datatype was already in scope, so a boolean member became the *string* `'true'`.
🟢 **Fixed in the canonical engine we now run (2.368.0), rechecked in source on 2026-09-05.**
[`Yarbrdab000/tableau-fabric-skills#178`](https://github.com/Yarbrdab000/tableau-fabric-skills/issues/178)
is **CLOSED COMPLETED (2026-09-03 20:48:36Z)**; closing commit `39276a08` reproduced the defect at
**2.358.0** and shipped the datatype-keyed `_scope_member_literal` path in **2.359.0**. Canonical
2.368.0 calls that helper from `_data_point_colors`: boolean columns emit bare `true`/`false`, while
genuine string members named `"true"`/`"false"` stay strings. Resolve the installed version with
`python scripts/engine_source.py` before trusting this either way: at ≤ 2.358.0 the defect is live,
at ≥ 2.359.0 it is not.
⚠️ **The previous wording said "#178 is OPEN … so this STILL REPRODUCES" — that inference was invalid
even while its conclusion happened to be right**, and it was wrong about the state within a day. An
issue's state is not a measurement in either direction (issue #486). Do not confuse this with
`Yarbrdab000/tableau-fabric-skills#130` (§1's slicer bullet), which fixed the same string-quoted
boolean in the *slicer pre-selection* path only; #178 separately fixed the **data-colour** slot.
🟢 render-verified (Desktop 2.157.828.0). PBIR encodes a literal's type in the string itself:
`'x'` = string · `10L` = Int64 · `100D` = Double · bare `true` = boolean. Comparing a **boolean**
column against the **string** `"'true'"` is a type mismatch that `validate` cannot see —
`result: succeeded, errorCount: 0, warningCount: 0` — and whose symptom depends entirely on **which
slot** the literal sits in:
| slot | outcome | how you find it |
|---|---|---|
| `objects.dataPoint[].selector.data[].scopeId.Comparison.Right` (**data colour**) | the **entire colour encoding is silently dropped**; every series takes the default colour | ❌ only by eye, against the source |
| `filterConfig` → `Where[].Condition.Comparison.Right` (**filter**) | the visual is **replaced by an error card** — *"Something's wrong with one or more filters"* + a **Fix this** button | ✅ immediately obvious |
**The silent one is the dangerous one.** A `stackedAreaChart` split by a boolean rendered entirely in
one colour: correct data, correct axes, correct shape — and a chart that no longer says what the
source said. Changing only the literal to bare `true` made the second series appear.
**Blast radius of the loud one is ONE visual, not the page.** In the same frame as the error card, the
azureMap, both area charts and both slicers rendered normally. So N bad filters cost you N visuals —
useful for triage, and it means "the page is blank" is *not* this bug.
**How to test a suspected literal bug — and the trap that wasted a cycle.** Use a **3-state** run on
the **real artifact**: `baseline` (no encoding) · `broken` · `correct`, changing *only* the literal.
The `correct` state is not optional — it is what proves your hand-written JSON is otherwise valid and
that the reload took effect; without it "broken looks wrong" is unfalsifiable. Measured control, a
`multiRowCard` filtered to profitable orders: Sales $2,326,534 → $1,841,231, Profit $292,297 →
$432,805, Ratio 12.6% → 23.5% — Sales down, Profit *up*, exactly as the semantics demand.
⚠️ **Do not invent your own analogue.** A fabricated *numeric* case (`"1.0D"` → `"'1.0'"`) rendered
**pixel-identical to baseline** and would have been written up as "benign"; only the engine's real
boolean artifact showed the defect. Reproduce on the artifact that actually carries the bug.
Grep your own output before believing it is absent — and classify by **container**, since the two rows
above are the same regex but different severities:
`"Value":\s*"'(true|false)'"`.
⚠️ **Classify the hits; do not expect a fixed count.** An earlier draft cited "4 hits across 2,074
`visual.json`" as if it were a stable control. It is not reproducible: the hits live in *untracked*
engine bundles, so the denominator moves with whatever scratch is on the machine — a re-run measured
**8 across 3,122**, with **0 in tracked files**. What holds, and what is worth acting on, is the
**container classification**: every hit found so far sits in the data-colour slot, none in a filter.
## 2. Data colours and conditional formatting
See the `powerbi-report-authoring` skill → `references/conditional-formatting.md`; for table-specific
idioms see `.github/pbi.kb/visuals/table-cond-format.md`.
- **Discrete/banded data colours use `dataPoint.fill.solid.color.expr.Conditional.Cases[]`, NOT
`fillRule.cases`** (`fillRule` is gradient/`linearGradient` only). Each case = a `Comparison`
(`Left = SelectRef.ExpressionName` of a projection's `queryRef`, cascading first-match) plus a
top-level `Else`, with `selector.data = [{dataViewWildcard:{matchingOption:0}}]`.
- **Scatter/chart per-point colour must reference a PROJECTED field.** A `dataPoint.fill` expr over an
*unprojected* measure (e.g. a text KPI measure in no field well) silently falls back to one solid
colour — carry the numeric driver on an axis or in Tooltips so it is in the visual query.
- **`scopeId` per-point colouring is the confirmed-good mode for many series** (verified with 81
per-company line `dataPoint.fill` entries — all render, no disappearing-line issue).
- **String-valued "colour helper" measures** (a source field returning a glyph/indicator string)
**cannot drive PBIR data-colour rules** → static colours; a recurring colour-encoding fidelity loss.
Record it rather than faking it.
+ - **`dataPoint.fill` pointing directly to a measure that returns literal hex-colour strings renders
+ as flat default theme colour with 0 validation errors.** ⚠️ **Distinct mechanism from the
+ string-valued colour helper above:** that entry is about a helper measure attempting to drive
+ discrete rule comparisons; this defect is a direct measure expression
+ (`fill.solid.color.expr.Measure`) bound directly into `solid.color.expr` expecting Power BI to
+ interpret the string as a colour hex code. Desktop silently ignores direct string measure bindings
+ in `solid.color.expr` and renders default theme colors without raising any validation error. Fix:
+ use discrete rules (`Conditional.Cases` with comparisons) or proper Field Value conditional
+ formatting. Statically checked by `scripts/check_field_bindings.py` (when `dataType: String` is declared
+ in TMDL).
## 3. PBIR mechanics
- **Handing a bundle over: keep `definition.pbir`'s `byPath` resolving.** 🟢 Verified 2026-08-11. The
deliverable is a **copy** of `<bundle>/pbip/` (the engine's `<bundle>/reports/` stays pristine as the
attribution baseline; there is no `out/` level — a bundle is
`<bundle>/{pbip,reports,semantic_models,handover,data}`). Two cases, and only one is a plain copy:
- **Model per workbook — plain copy, no rewrite.** `<bundle>/pbip/<wb>/` already holds `<Name>.Report/`
and `<Name>.SemanticModel/` as **siblings**, with `"path": "../<Name>.SemanticModel"`, and the
delivery folder has the identical shape — so copy the **contents** of `<bundle>/pbip/<wb>/`, not the
folder. (That folder is named for the *workbook*; the model inside is named for the *datasource*,
so copying the folder itself nests them wrongly.)
- **Shared/published datasource — the reference MUST be rewritten.** The model lands once in a
shared `datasources/<ds-slug>/fabric/` while each report goes to `workbooks/<slug>/fabric/`. They
are no longer siblings, so `definition.pbir` becomes
`"../../../../datasources/<ds-slug>/fabric/<Name>.SemanticModel"` — four levels up from inside
`<Name>.Report/`. **Verify it resolves on disk after writing it:** a broken `byPath` opens as a
report with *no model*, which reads like a binding defect and sends you debugging the wrong layer.
- **Never ship `<bundle>/reports/` itself** — it is a reference-only baseline, not portable. Its
`definition.pbir` does not resolve where it sits: `reports/` holds only `*.Report` folders, so the
`byPath` next to it has no model to point at (2026-08-11 it read `"../../pbip/<wb>/<Name>.SemanticModel"`;
on a 2026-08-13 bundle it reads `"../<Name>.SemanticModel"` — ⚠️ engine-version dependent, and
unresolvable either way).
- **Compare engine truth to working copy with git, not PowerShell `diff`.** The two sides differ in
shape, so compare the matching pair with:
```bash
git diff --no-index --stat <bundle>/reports/<WB>.Report <bundle>/pbip/<WB>/<WB>.Report
```
On Windows, bare `diff` is a PowerShell alias for `Compare-Object` and can compare only the two
path strings. `git diff` exits 1 both when trees differ and when a path is wrong, so require a real
stat line, not just the exit code.
- ⚠️ **A "shipped" deliverable can be structurally present and functionally EMPTY — check content, not
existence.** Reported 2026-08-19 from a 46-asset estate, found by direct verification rather than by
trusting a prior "done" status. A report folder that a sign-off had already passed contained only
Desktop-local settings: no real pages, no visuals, no model — the copy step was silently skipped or
partially interrupted, and every folder that was *supposed* to exist did exist. This is a different
failure from the `byPath` case above: there the reference is broken and Desktop tells you; here the
reference is fine and there is simply nothing behind it.
- Assert the shipped `<Name>.Report/definition/pages/` enumerates real pages **with visuals**, and
the shipped `<Name>.SemanticModel/definition/tables/` holds real tables — not merely that the
folders exist. A folder count is not a content check.
- **Trace the specific files you verified through to the shipped path.** Same estate, separate case:
a semantic model shipped with an unresolved stub still in it because the *fixed* working copy was
never the copy that got promoted. Verifying a fix in `<bundle>/pbip/` proves nothing about
`migrations/**/fabric/` — one direction, three locations, and the last hop is the one nobody
re-checks. Never assume a fix in one location propagated to another; re-run the check against the
deliverable itself.
- Corollary: a `✅ shipped` from an earlier session is a **claim**, not evidence. Re-verify it against
the artifact before building on it.
- **Visual-level filter = a top-level `filterConfig` key in `visual.json` (sibling to `visual`, NOT
nested under it)**, `type:"Categorical"`, `Version:2` `In`-condition. Nesting it under `visual` is
silently ignored. **Do not use this as a slicer default** — slicer pre-selection is
`objects.general[0].properties.filter` (see §1).
- **Stacked bar = `barChart` visualType (not `clusteredBarChart`); the first Y projection stacks from
0.** Per-series colours via `dataPoint[]` with `selector.metadata = <queryRef>` (queryRef, not
nativeQueryRef).
- **`displayName` on a projection is the header-rename mechanism** — Desktop auto-labels non-default
aggregations "Average of X"; `nativeQueryRef` does not control the header.
- **When two re-runnable fix scripts must commute across a VISUAL-TYPE change, the type-specific
property set must live in exactly one module.** A script that flips `columnChart` → `lineChart` and a
separate script that writes label properties cannot be order-independent if each hard-codes its own
vocabulary — run type-flip-last and you keep the old type's properties (`labelOverflow` on a
lineChart), run it first and you lose the new type's. Fix: expose `apply_<layer>(doc, visual_type)`
**and** `strip_foreign_<layer>_props(doc, visual_type)` from one module; whichever script runs last
re-applies the correct set for the final type and strips the other's leftovers, so both orders land
byte-identical. Prove it by hashing the whole `definition/` tree across several shuffled orders, not
by reasoning about it.
- ✅ **The good news: a leftover foreign property is a HARD `validate` error, not a silent render
bug** — this is the rare formatting mistake the CLI does catch. 🟢 Measured against
`powerbi-report-author` on engine-built column/line charts: injecting `dataPoint.fillTransparency`
into a `lineChart` exits **1** with `PBIR_FORMATTING_PROP_UNKNOWN` — *Unknown property
"fillTransparency" in formatting object "dataPoint" for lineChart* — as does
`dataPoint.transparency` on a `columnChart` and `labels.labelOverflow` on a `lineChart`; each
type with its own property exits 0.
- **So a `columnChart` → `lineChart` flip is never just `visualType`.** The minimum swap:
`dataPoint.fillTransparency` → `dataPoint.transparency`; drop `labels.labelOverflow` (columnChart
only) and use `labels.maximumOffset` / `minimumOffset` instead (lineChart only, see §1); and if a
flat mark colour was set, add **`lineStyles.strokeColor`** beside `dataPoint.defaultColor`.
🟢 The *validation* half is measured: all three of `defaultColor` alone, `strokeColor` alone, and
both together return **exit 0** on a lineChart — so `validate` catches the leftover from the old
type but is structurally blind to the **omission** of the new type's property.
⚠️ The *rendering* half is **inferred, not verified** — no Desktop render was possible. The claim
that `dataPoint` alone leaves the stroke on the theme colour ("which on a line chart is the whole
visual") is the deterministic engine's own, in `_constant_mark_color_objects`
(`twb_to_pbir.py`), where it is described as verified against an adjudicated corpus rebuild.
Second-hand. Treat "add both" as cheap insurance rather than a measured necessity, and upgrade
this line on the first render that shows it either way.
- **Normalise JSON key order when you write PBIR back.** Two scripts that *insert* different formatting
cards into `visual.objects` produce byte-different-but-semantically-identical files depending on run
order, which makes a genuine order-independence proof impossible to distinguish from a real defect.
Sort keys on write once, in the shared helper.
- **Reference-line `value` needs a type-suffixed numeric literal** (`{Literal:{Value:"100D"}}`); a bare
`"100"` parses to 0 and pins the line to the axis baseline with **no validation error**.
- **Theme: custom `visualStyles` are strictly validated per-visual-object and `fillPoint` is not valid
for scatterChart/filledMap** — keep custom themes minimal (set visual-specific formatting in each
`visual.json`); the theme file's internal `name` must exactly equal the `report.json` `customTheme`
reference **including `.json`**. Single-line caption/legend textboxes need ≥3.4 grid rows or they trip
`PBIR_TEXTBOX_HEIGHT_BELOW_FLOOR`.
- **`reportVersionAtImport` belongs inside every `themeCollection` entry, never at the top level of
`report.json`.** 🟢 CLI-verified against `powerbi-report-author` 0.1.4. Removing it from
`themeCollection.baseTheme` raises a schema error (`/themeCollection/baseTheme must have required
property 'reportVersionAtImport'`); removing it from `customTheme` raises both
`PBIR_THEME_VERSION_AT_IMPORT_MISSING` and the schema error; adding it at report root raises
`PBIR_SCHEMA_VALIDATION_ERROR` for an additional property. Ground-truth committed shape:
`examples/shipping-kpis/fabric/ShippingKPIs.Report/definition/report.json` has top-level keys
`$schema`, `themeCollection`, `resourcePackages`, `settings`, and both theme entries carry
`name`, `type`, `reportVersionAtImport`.
- **What-if % slicer format: `0.0"%"` (quoted) when the stored value is pre-scaled (e.g. 22.8); `0.0%`
(unquoted) only when it is a true 0–1 fraction** — mixing them mis-scales the display by 100×.
- **Check `formatString` against the field's actual numeric scale, not its semantic meaning.** A source
field can already be stored pre-scaled (`12.83` meaning "12.83%", not `0.1283`). Power BI's `0.00%`
multiplies by 100 for display, so an already-scaled value renders **100× inflated** (`1283%`). Sample
a raw value via DAX before choosing `0.00%` (true fraction) vs `0.00"%"` (literal suffix).
- **`validate` does NOT check that `definition.pbir`'s model reference resolves.** 🟢 verified: a
`.Report` whose `datasetReference.byPath.path` named a `.SemanticModel` folder that **exists nowhere
in the bundle** returned `errorCount: 0` — shape, not target again, so a report that **cannot
possibly open** validates clean. Check the path yourself (resolve it relative to the `.Report` folder
and confirm a `definition/` inside). Two migration-specific traps make this likely rather than exotic:
(a) an engine that emits the report **twice** (a `reports/` deliverable plus a packaged `pbip/`) gives
each copy a *different* relative path, and only the one beside the model is right — the copies being
byte-identical everywhere else hides it; (b) the model folder is often named after the **data
source**, not the workbook, so a workbook-named guess dangles. Deleting a redundant copy is usually
the wrong fix if the engine's own manifests declare it as `output_folder` — repoint it instead, and
prefer a relative cross-tree `byPath` (`../../pbip/<name>/<Model>.SemanticModel`), which does resolve.
- **A missing REQUIRED role in engine output means the measure was STUBBED — bind the stub, do NOT
delete the visual.** ⚠️ **RETRACTED as a diagnosis, and FIXED upstream — read this before acting on
the paragraph below.** Filed as `Yarbrdab000/tableau-fabric-skills#143`, **fixed in 2.166.0** (we
run 2.353.0), and the maintainer refuted the stated cause at the source *and asked us to correct it
downstream*: probing `_crosscheck_report_refs` with this exact model shape, **an emitted stub binds
normally** — `_model_object_names` matches `measure 'X' = BLANK()` fine. A projection reaches the
drop branch precisely when the model emitted **nothing** to bind it to, so "bind the stub" could
never have applied; binding would mean inventing a measure. His hypothesis for why the stub was
invisible is a **datasource-first split** (the measure lives in the standalone
`.SemanticModel` while the cross-check ran against the workbook's model parts) — a wiring defect,
not stub handling. What shipped is the *opposite* remedy: any **required** role emptied by a drop
now empties the whole visual, so at 2.353.0 you should expect a placeholder rather than a partial
`queryState`. **Still true and still worth the grep:** a `= BLANK()` stub in the model is the
evidence the source had a chart there, so grep for it before concluding a field is missing. The
original measurement is kept below unedited.
🟢 Measured 2026-08-18 on a cold run of engine 2.151.0 (issue #220).
`validate` failed with `PBIR_ROLE_REQUIRED_MISSING` — *Required role "Y" missing or has no
projections for visualType "clusteredColumnChart"* — on the engine's own **pristine** output. Cause:
that visual's only measure was a Tableau `FIXED` LOD which fell back to an inert stub
(`unresolved_reference: cross-table terms`), and the engine dropped the projection rather than
binding it; the two sibling visuals whose measure *translated* bound `Y` normally. ⚠️ The repair is
**not** to delete the visual — the stub already exists in the model as
`measure 'Regional Revenue (FIXED)' = BLANK()`, so re-adding it to `Y` makes the report valid and
openable while keeping the gap visible (a blank column) and the preserved `TableauFormula`
annotation pointing at what to restore. Deleting instead throws away the only in-report evidence
that the source had a chart there. So before concluding a field is missing, grep the model for a
`= BLANK()` measure matching the worksheet's shelf.
### Declaring a generated PBIR edit, or sign-off blocks
Every file under a `*.Report` folder is hash-baselined by the engine run, and the orchestrator's
pre-sign-off `check_migration_progress.py --bundle <b> --tamper` exits **1** on any that changed
without a matching declaration. `scripts/declare_generated_edit.py` is the **only** thing that writes
one: it runs your `_build/` script for you and records the before/after hashes as one append-only
`_build/generated-edit-declarations/*.json` file.
```bash
python scripts/declare_generated_edit.py --bundle <b> \
--target pbip/<WB>/<WB>.Report/definition/pages/<p>/visuals/<id>/visual.json \
--script <b>/_build/fix_axis_title.py \
-- --only pbip/<WB>/<WB>.Report/definition/pages/<p>/visuals/<id>/visual.json
# DECLARE: RECORDED pbip/.../visual.json -> <b>/_build/generated-edit-declarations/<timestamp...>.json
```
Measured — each of these leaves the gate RED while looking like it worked:
- **One `--target` per run.** A second run of an idempotent script prints `DECLARE: NO_CHANGE` and
records nothing, so a whole-tree emitter leaves every file but one UNDECLARED. Give the script an
`--only <bundle-relative path>` scope argument, pass it after `--`, and run the wrapper per target.
- **Never hand-edit first.** The wrapper hashes the target *before* running your script and the gate
only accepts a declaration whose baseline is the engine's hash, so a retro-declaration is never
accepted. Restore the target by re-running the engine — `reports/` is a **different file**, not a
copy of `pbip/` (see the `byPath` entry above).
- **Declare last.** Touching the target again afterwards invalidates its declaration.
Self-check before reporting done: `--tamper` must exit 0 (`DECLARED_DRIFT` passes, `DRIFT` does not).
## 4. Crosstabs and tables — a recurring fragility class
Two distinct failure modes, both seen in production:
1. **`tableEx` for a dimension+measure grid can render column headers with zero data rows**, even
though the underlying DAX is correct. Prefer `pivotTable` for any dimension-in-rows +
measure-in-values grid.
2. **A matrix that pivots a dimension into `Columns`** and reads a single shared, mixed-type text
column via one measure can have `SUMMARIZECOLUMNS` **silently drop specific (row, column)
combinations** — even with 100% clean data (verified via direct `CALCULATETABLE`/`COUNTROWS`), and
regardless of grouping column, measure formula, or relationship cross-filter direction. Root cause
not understood at the DAX-engine level. **Robust fix: avoid the Columns-pivot pattern entirely.**
Use one measure per branch (each with its own internal `CALCULATE(…, dimension = "X")`) projected as
separate flat `Values` entries (no `Columns` bucket).
**Whenever you are about to pivot a dimension into columns, consider the flat-table alternative
first** — it is safer, and usually a *more faithful* translation, because the source worksheet is
typically a flat table under the hood rather than a true cross-tab.
- **A table-style worksheet's exact field projection must be checked against the real source
worksheet, never inferred from a plausible guess.** This bug class recurred on the *same* visual
twice in one session. **Red flag:** a table/matrix `Values` well with exactly one active field and
other candidate fields present-but-inactive — that exact pattern showed up as a broken migration
artifact twice. Verify against the source worksheet's rendered columns before accepting it.
## 5. Maps — Azure Maps is the only non-deprecated option
`map`/`filledMap` are legacy Bing → a `PBIR_VISUAL_TYPE_DEPRECATED` warning **plus** a once-per-session
Desktop "Bing maps are going away" nag modal that the bridge screenshot does **not** surface.
**Bing `map` colour saturation is INERT — a §1-class validation-invisible bug.** 🔴 render-verified
NEGATIVE (2026-08-08, Desktop MSIX 2.157.627.0). `formatting describe-object map dataPoint` *does*
list `fillRule` with `displayName: "Color saturation"` (`type: unknown`), so a
`linearGradient3` over a projected measure looks correct and `validate` returns **0 errors** — but
Desktop renders **one flat hue**. Proof used: of 604 bubbles, 138 had negative values and the minimum
(−13,838) belonged to the *second-largest* bubble on the map, which an orange minimum stop must render
unmistakably orange; a pixel scan found **zero** orange pixels, and the max-value and min-value bubbles
rendered *identically pale*. **Do not read bubble lightness as a colour encoding** — on a bubble map
apparent lightness tracks **radius** (stroke-to-fill ratio), so small bubbles look dark and large ones
look pale whether or not any gradient is bound. Binding the measure to a `Gradient`
("Color saturation") **data role** instead does not rescue it: `catalog describe map` exposes only
`Category`/`Series`/`Y`/`X`/`Size`/`Tooltips`, `validate` raises `PBIR_ROLE_UNKNOWN`, and Desktop then
fails to produce canvas-capture metadata at all (`clip` height `0`) until it is removed. **Consequence:
a source `color = <measure>` encoding is not reproducible on Bing `map`** — it needs `azureMap` (whose
`bubbleLayer.fillColor` *does* honour the same FillRule), which in turn needs the Azure Maps tenant
entitlement. Treat that as a tenant/human action and log the gap rather than shipping a map that
silently drops one whole encoding channel.
**Measure-driven choropleth — the sanctioned azureMap pattern, ground-truth encoding:**
- `query.queryState.Category` = the location **key column** (e.g. `State`) as a `Column` projection.
That alone data-binds the reference layer (Azure Maps matches the key to a property in the boundary
file). The colouring measure does **not** go in a data well.
- `objects.referenceLayer` is a **2-entry array**:
- `[0]` (no selector): `datasourceType` = `'url'`, `referenceLayerUrl` = a hosted boundary GeoJSON
URL — fully declarative, no file upload, nothing in `RegisteredResources`. US states:
`https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json`
(its `name` property = full state name). Add `unmappedObjectVisibility: false` to hide states
filtered out of the data.
- `[1]` (selector `{data:[{dataViewWildcard:{matchingOption:1}}]}`): `polygonFillColor` = a
`FillRule`/`linearGradient3` bound to the measure — the **exact same FillRule shape as
`dataPoint.fill`** (`{Input:{Measure:…}, FillRule:{linearGradient3:{min, mid{value:0D}, max,
nullColoringStrategy 'asZero'}}}`).
- Bring a boundary file for the geography *before* building; if none exists or the join key is
uncertain, that is a research-then-ask moment, not a guess.
**Fixed-view small multiples:** for a small (≤~400px) region-highlighter multiple, set `mapControls`:
`defaultStyle 'road'`, `autoZoom` **false** (otherwise it fits to Alaska/Hawaii/PR in the boundary file
and shrinks the lower-48 to a dot), plus a fixed `zoom` + `centerLatitude/Longitude`. Zoom scales with
viewport (512px vector tiles): continental US fills a **384px**-wide map at **`zoom ≈ 2.0`** (a
700–940px map uses ≈2.9). `blank` style + `autoZoom` rendered empty/tiny — avoid.
**Match the source worksheet's basemap before choosing `defaultStyle`.** 🔴 render-verified (cold run
S20): applying the old "prefer `blank_accessible`" advice to 9 map visuals produced **9 blank-basemap
maps** — marks floating on white — because those source worksheets did draw real basemaps. For Tableau
specifically, read the worksheet basemap first; use `grayscale_light`, `night`, `satellite` or another
real style when the source has one, and reserve `blank_accessible` (with `showStylePicker: false`,
`showNavigationControls: false`, light `polygonStrokeColor`) for a source that genuinely draws none.
⚠️ The original `blank_accessible` advice is the deterministic engine maintainer's
(`Yarbrdab000/tableau-fabric-skills#106`, 2026-08-10, Desktop 2.157.627.0) and **not independently
reproduced here**; note it is a different value from plain `'blank'`, which rendered empty/tiny above.
**⚠️ `shapeMap` renders NOTHING — a blank rectangle** (same source, same session, a US-state
choropleth shaded by `Sum(Profit)`; `powerbi-report-author validate` returns 0 errors for it). That
makes the *modern* branch worse than the deprecated one: `filledMap` at least draws, and merely warns.
**Not independently reproduced here** — but it explains why a pristine engine run emits `shapeMap` for
several worksheets that nobody ever saw rendered, because our tier converts them to `azureMap` first.
Treat `shapeMap` as unusable rather than merely superseded.
⚠️ **The engine half is version-stale.** `shapeMap`/`filledMap` emission was the 2.113.0 behaviour;
`Yarbrdab000/tableau-fabric-skills#112` and **#128 (fixed 2.148.0)** moved location-only maps onto
`azureMap`, and 2.126.0 onwards was measured emitting `azureMap` throughout (`AGENTS.md`, engine
source section). Anything at or above 2.148.0 — the canonical engine was 2.356.0 at 2026-09-04 —
should emit `azureMap`, not `shapeMap`. The **Desktop** finding — that
`shapeMap` renders a blank rectangle and validates clean — is unaffected and still holds; what is
retracted is the expectation that a pristine run hands you `shapeMap` to convert.
**Route/line maps:** azureMap draws true 2-point routes via `PathID` + `PointOrder` + `pathLayer` (a
fidelity win over the dual-axis workaround) — but it needs **one data row per endpoint**. If the fact
stores origin+destination lat/long as columns on a single row, the arc cannot render, and that reshape
is a **semantic-model decision**: coordinate with the model owner up front for any `MAKELINE`/
`MAKEPOINT`-style route map, or fall back to endpoint bubbles with a documented note.
**Undocumented azureMap properties** — none appear in the CLI `catalog describe` output, so they are
recorded here rather than rediscovered. ⚠️ **Confidence: structural only.** They are accepted by
`validate` (0/0) and survive a Desktop reload, but they were authored in an environment where azureMap
cannot draw (see the licensing note above), so *no one has seen them take visual effect*. Treat the
property **names** as verified and their **rendered behaviour** as unconfirmed; upgrade to 🟢 on the
first signed-in render.
- **`bubbleLayer.sizeByValue`** (boolean) — the switch that makes the `Size` well actually drive bubble
radius. Expected behaviour without it: `Size` is projected but every bubble draws at the fixed
`bubbleRadius`, silently dropping the magnitude encoding. Pair with `minBubbleRadius` / `maxRadius`
to bound the ramp.
- **`mapControls.defaultStyle`** accepts more than `'road'`: **`'satellite'`** and **`'night'`** are both
accepted, and are the faithful targets for a Mapbox-satellite or dark-basemap source worksheet.
(`'blank'` is also accepted but rendered empty/tiny with `autoZoom` — see above.) Literal form is a
quoted string: `{"Literal":{"Value":"'satellite'"}}`.
- **`visualTooltip`** — binds a **canvas tooltip page** to a visual, and is how a Tableau "viz in
tooltip" worksheet is reproduced. It is a **`visualContainerObjects`** entry, *not* a
`visual.objects` one. Exact shape (three properties):
`show` = `true`, `type` = `'Canvas'`, `section` = **the target page's `name`/folder id**
(e.g. `'page-tooltip-over-time'`) — the page **id, not its `displayName`**, which is the easy
mistake. The tooltip *page itself* is render-verified; the *hover binding* is not, because the map
that would trigger it cannot draw here.
## 6. Scatter
**`scatterChart` X and Y must BOTH be MEASURES, never a grouping column.** Binding `Y` (or `X`) to a
dimension renders "Remove Values to display x- and y-axis pairs" — validation-clean, Desktop-only.
A "dimension-on-rows dot strip" → scatter with `Category` = the dimension (Details, one dot each),
`X` = value measure, `Y` = a **constant baseline measure** (`measure 'Dot Baseline' = 0`, hidden),
`Size` = value measure, colour via a `FillRule` gradient on a signed diff measure; hide the constant
`valueAxis` (`show:false` + `showAxisTitle:false`).
## 7. Desktop verification mechanics
- **A screenshot can be PARTIALLY drawn, and that is more dangerous than a blank one.** 🟢
Render-verified 2026-08-09, bridge CLI 0.1.2. Visuals that fetch remote resources — above all
`azureMap`, which chains model query → basemap tiles → remote reference-layer GeoJSON → marks —
draw **progressively**. Capture too early and you get a plausible, finished-looking map that is
silently missing marks. Measured on one page (604 city pies), same report, same warm Desktop:
**411** distinct canvas colours captured immediately after navigating (pies only in the
western/central US) vs **41,185** once settled (pies nationwide). Both look like real maps; only
the second is. This defeats mark-counting and any "does it render" check.
- **`screenshot-all --settle <ms>` does NOT fix it.** The flag exists but delays only before the
**first** capture. Measured: `--settle 5000` over 10 pages cost **38 s**, not the ~76 s a
per-page delay would cost. Use it to cover the post-`reload` cold start — nothing more.
- **`sleep(n)` then `screenshot` does NOT fix it either**, and is the trap most likely to catch
you: the sleep elapses while sitting on the *previous* page, then the screenshot verb navigates
and captures almost immediately, so the page you actually want gets **no settle at all**.
- **What works: recapture until ONE digest stays unchanged for a dwell — not merely until two
frames match.** Two consecutive equal frames only prove the render paused; the rule the
implementation actually enforces is that the digest is unchanged across the whole
`--stable-seconds` window, and that **time spent inside a blocking screenshot call does not
count toward the dwell** (an early version counted it, so slow captures banked "stable" time
they had not earned). Even then it is a *heuristic*, not a readiness signal — bridge CLI 0.1.2
exposes none — so a partial plateau longer than the dwell still passes: raise it for cold or
high-risk maps. It beats a fixed timeout in both directions, because one long sleep is
simultaneously too slow for warm pages and too short for cold ones (measured: 108 s vs 209 s
for 10 pages). Repo-local implementation:
`python scripts/capture_powerbi_pages.py <report.Report> <out-dir> --pid <pid>` — that script
lives in the host repo, not in this bundle, so outside it implement the rule above directly.
- Corollary: **after any `reload`, treat the first captures as suspect** — the earliest pages in a
batch are the ones that race.
- **The `powerbi-desktop` bridge has NO refresh verb.** Verbs as of bridge CLI 0.1.2: `status`,
`manifest`, `open`, `reload`, `screenshot`, `screenshot-all` (underlying methods
`application.state.get` / `report.snapshot.capture` / `file.reload`). PBIP stores no data cache, so a
freshly-opened import report renders **empty** ("tables have incomplete or no data"). **A clean
screenshot with empty visuals is an unrefreshed-model artifact, not a binding defect.**
- **A blank or sparse screenshot is not evidence of a blank or sparse page** — see the
partial-render entry above; re-capture with a stability dwell before trusting any image.
- **First check whether you even need to refresh.** A model handed over already refreshed AND saved
persists to `<Name>.SemanticModel/.pbi/cache.abf` and survives reopening. Run
`python scripts/refresh_pbip_model.py --pid <pid> --verify-only` — `DATA_OK` means you are done. On
`NO_DATA`, run the same script **without** `--verify-only` rather than hand-rolling the XMLA dance;
it also saves, so the next open keeps the data. The cache is discarded whenever `definition/*.tmdl`
becomes newer than it, so **any model edit means re-running it**. Full mechanism and the pid-binding
rule: the `pbip-model-refresh` skill.
- **Manual XMLA fallback (proven).** Refresh via TOM/XMLA against the child `msmdsrv` port: load
`Microsoft.AnalysisServices.AdomdClient` (copy the DLL out of WindowsApps first; direct load = Access
Denied), find the port via `Get-NetTCPConnection`, resolve the catalog GUID via
`$SYSTEM.DBSCHEMA_CATALOGS`, and `ExecuteNonQuery` a TMSL
`{"refresh":{"type":"full","objects":[{"database":"<guid>"}]}}`. **Refresh report-bound tables only**
— a full refresh can hang 6+ min on a large orphaned table. Never kill `SaveChanges` mid-flight. The
refreshed data **survives `reload`**, so the steady-state loop is
regenerate → validate → reload → screenshot.
- **External XMLA refresh does NOT clear Desktop's "calculated columns need refresh" banner** — a UI
dirty-flag only; the data underneath is correct.
- **Store/MSIX Desktop** needs `$env:PBI_DESKTOP_PATH` set to the WindowsApps `PBIDesktop.exe` on each
fresh PowerShell process. `reload` can deadlock (`BRIDGE_ERROR "Another operation is already in
progress"`, `-32511`) while idle — recover by killing **your own** Desktop PID and relaunching.
- **PBIR files and an open Desktop session can race.** Desktop autosaves periodically, so a direct file
edit to `definition/` while Desktop has the report open can be silently clobbered by the next
autosave, or vice versa. Close/reload Desktop around direct PBIR edits, or use the bridge's `reload`.
- **In a parallel batch the single Desktop bridge is a hard serialization point** — only one build can
hold it. Do **not** force-open into an instance owned by another build with unsaved changes, and
never screen-scrape as a substitute (focus-steal + privacy risk). When contended, base sign-off on
structural validation **plus** an independent field-reference cross-check against the model TMDL.
`PBIR_SCHEMA_UNREACHABLE` offline is benign but means JSON-schema validation was skipped — back it
with the field cross-check.
## 8. Reading the source spec
These are about translating a migration spec faithfully; they generalise to any source tool that has
shelves, tooltips and manual sorts.
- **A `.twb`/`.twbx` DOES contain Tableau-rendered reference images — check before declaring "no
reference exists".** Every worksheet embeds a base64 PNG in a `<thumbnail>` element (192×192, one per
worksheet). Three separate agents on one migration asserted no reference was obtainable because there
was no Tableau Server/Public URL; the ground truth was inside the file the whole time. They are small,
so use them for **shape, mark type, layering, axis direction, label presence, header formatting and
actual numbers** — not font/pixel claims. This is decisive evidence: on one migration the thumbnails
overturned a confidently-reasoned static mapping (see next entry).
- **Tableau's `<mark class='Automatic'/>` resolves to LINE when a date field sits on Columns — even a
discrete date part.** Reading `Automatic` + a discrete `:ok` date pill as "bars" is a natural but
wrong inference, and it produced a `columnChart` with the rank measure on `Y` and 17 categories on
`Series` — which **stacks**, summing 17 ranks into a meaningless ~153 bar, for a source that was a
classic bump chart of 17 crossing lines. The engine reported it `tier: rebuilt, status: rebuilt` with
no warning, because stacking is structurally valid. **Never resolve `Automatic` from the XML alone
when a date is on Columns — check the thumbnail.**
⚠️ **FIXED UPSTREAM at engine 2.351.0 — and CONFIRMED BY MEASUREMENT at 2.356.0, so this defect NO
LONGER REPRODUCES.** `Yarbrdab000/tableau-fabric-skills#184` was **closed COMPLETED 2026-09-02**:
`_has_continuous_date` is replaced in the Automatic branch by `_has_date_dimension`, which gates on
**date-ness** (the doc citation below was the whole fix) and accepts three families — `*-Trunc`, the
discrete `_DATE_PARTS`/`_DATE_EXACT_DERIVATIONS` including `MDY`, and a raw date column with **no
derivation at all**. 🟢 **Measured here on canonical 2.356.0** over the committed A–H fixture set —
not inferred from the issue being closed:
```
A Automatic + discrete date PART (Year) columnChart -> lineChart FIXED
E Automatic + discrete EXACT date (MDY) columnChart -> lineChart FIXED
F Automatic + date PART over a datetime columnChart -> lineChart FIXED
G Automatic + date-valued CALCULATED field columnChart -> lineChart FIXED
D explicit Bar mark columnChart (UNCHANGED) carve-out held
H non-date string dimension columnChart (UNCHANGED) control held
```
`tests/test_issue_424_chart_type_pin.py::test_variant_a_emits_a_line_and_keeps_the_reproduction_binding`
now asserts the corrected type, and the engine's own `viz_fidelity` agrees (`visual_type: "line"`,
`status: "rebuilt"`). **D and H holding is the load-bearing part**: they are the two fixtures that
kill an over-broad remedy, so their survival is the evidence that #184's fix is the *correct* one
rather than the mark-agnostic or any-discrete remedy warned about below. So on a >= 2.351.0 bundle,
a `columnChart` under an `Automatic` mark with a date on Columns is **no longer the expected
output** — if you still see one, that is a NEW finding worth reporting, not this one. Everything
below is retained as the reproduction and the reasoning, not as current engine behaviour.
⚠️ **This WAS a KNOWN ENGINE DEFECT with a committed reproduction — recognise it, don't re-derive it.**
Filed upstream as `Yarbrdab000/tableau-fabric-skills#184`; ours is #424. Two things were previously
missing from this entry and both cost a cycle: *why* Tableau does it, and *where* in the engine it
goes wrong.
- **The Tableau rule is documentation, not folklore.** *[Change the Type of Marks in the
View](https://help.tableau.com/current/pro/desktop/en-us/viewparts_marks_marktypes.htm)* (fetched
2026-09-01) states it twice and neither statement mentions continuity: *"The Line mark type is
selected when there is **a date field** and a measure as the inner fields"*, and, under **Bar**,
*"If the dimension is a date dimension, the **Line** mark is used instead."* ⚠️ Popular blog
summaries say the opposite ("blue pill → bar, green pill → line") and a web search will hand you
that first — cite the product doc.
- **The engine gates on CONTINUITY instead of date-ness.** `twb_to_pbir.py:2366`
`_has_continuous_date` is true only for a `*-Trunc` derivation, and its own docstring states the
belief the docs contradict — *"a discrete date PART … is NOT continuous. Under an Automatic mark
Tableau renders a continuous date + a measure as a LINE (a discrete date -> bars)"*. Consumed at
`twb_to_pbir.py:2505-2508`, and the predicate is `endswith("-Trunc")` — so by construction nothing
in `_DATE_PARTS` (`:394`) or `_DATE_EXACT_DERIVATIONS` (`:410`) satisfies it. 🟢 Measured on engine
2.339.0: `Year`, `MonthYear` **and `MDY`** (the literal date value at day grain — unambiguously a
date dimension) all emit `columnChart`; `Month-Trunc` and an explicit `Line` mark emit `lineChart`.
⚠️ The Tableau half is doc-derived, not render-verified — no Tableau install was used.
- **Reproduction:** `fixtures/upstream-repros/issue-424-automatic-mark-discrete-date/` — an A/B/C
triple identical but for one thing each — pinned by `tests/test_issue_424_chart_type_pin.py`.
- ⚠️ **A shared visual id across two workbooks is NOT evidence of an engine inconsistency**, and
#424 was reported on exactly that inference. A PBIR visual name is
`_sanitize(f"v-{page_name}-{i}-{ws['name']}")` (`twb_to_pbir.py:14280`), and `_sanitize` (`:748`)
is a 16-char prefix plus an 8-char md5 of that whole string — so the id is a deterministic
function of **(dashboard name, zone index, worksheet name) only** and encodes nothing about mark
type, encodings or data. Sibling dashboards copy-pasted inside one estate collide by
construction. 🟢 Measured: the three fixtures above all emit `v-page-Detail8fe20137fae` while two
of them disagree on `visualType`.
- **What still needs the source, and cannot be inferred:** *which* of the two inputs a given
workbook has. An explicit `<mark class='Bar'/>` is a faithful `columnChart` (Tableau stacks bars
by default too, so the meaningless total exists in the source as well) — a real defect only if
the mark is `Automatic` or `Line`. Read the worksheet's `<mark class=…>` and the date pill's
`derivation` before "fixing" anything; a `.twb`/`.twbx` thumbnail settles it (see the entry
above).
- ⚠️ **The over-broad "fix" is a real hazard, not a hypothetical — do not flip every date chart to
a line.** 🟢 Measured by injecting five candidate remedies into 2.339.0 over eight one-variable
fixtures: a **mark-agnostic** remedy (every date-on-Columns chart becomes a line) breaks the
explicit-`Bar` case that is currently correct, and an **any-discrete** remedy also breaks a plain
string dimension. Each wrong remedy was separated by exactly one fixture — `Year`-only by a
discrete *exact date*, a `datatype=='date'` remedy by a **`datetime`** column, a
base-columns-only remedy by a date-valued **calculated field**, mark-agnostic by the `Bar` mark,
any-discrete by the string dimension — and a three-fixture set covering only the obvious cases
**passed all five**. If you repair one of these by hand, keep an explicit-`Bar` chart and a
non-date bar chart in front of you as controls.
- **`columnChart` + `Series` STACKS. Never use it for a ranking/index measure.** Stacking is only
meaningful for additive quantities; ranks, indices, percentages and averages are not additive, so a
stacked encoding of them is always wrong and never raises a validation error.
- **Nested shelf grouping** — a `(a / b)` shelf notation is a layout/hierarchy nesting, **not** a
calculation. Translate to a multi-field axis or a legend + axis combination, matching the nesting
order.
- **Customized tooltips** often embed dynamic field references. Recreate as a Power BI tooltip page or
the visual's default tooltip fields, whichever preserves intent with less build effort; note any
reduced fidelity.
- **Manual sort orders** — implement via a "Sort by column" helper column in the semantic model
(coordinate with the model owner if one does not exist) rather than a one-off visual-level sort that
will not survive a refresh.
- **Never infer a field's purpose from its raw internal name** — always use the resolved
`field_id`/`caption`. Internal names go permanently stale after a Ctrl-drag duplication (a zone's
`param` resolving to a field internally named `[Y-Axis (copy 2)]` whose real caption is "Map KPI" —
nothing to do with any Y-axis control). If a zone's `field_id` is `null`, the parser is telling you
the reference did not resolve: **flag it, do not guess from the XML name text.**
- **A "Measure Names / Measure Values" virtual pivot has no direct Power BI equivalent** and must not
be recreated literally. Bind each field in `pivoted_field_ids` directly to the visual. If it is
empty, the parser could not resolve the underlying fields — flag it, don't guess.
- **Don't silently drop unresolved shelf references** (`UNRESOLVED:…` ids in
`limitations_encountered`) — surface them as "this visual may be missing a field" rather than
building an incomplete visual without comment.
- **Set a sensible default on every filter-driving slicer, via `objects.general[0].properties.filter`,
never a top-level `filterConfig`, which pre-selects nothing (§1).** Without an *effective* default
every bound visual renders an aggregate-across-all-rows value on first load (in one workbook: an
aggregate across 906 cities) — which reads as "broken" even though the DAX and binding are correct.
Pick a default matching the reference screenshot, and confirm visually.
## 9. Keeping the visual mapping current (research per idiom, not per instance)
To keep visual choices up to date without re-researching 30 visuals on every dashboard, research **per
distinct Tableau idiom**, cache the result, and reuse it:
1. **Collect the distinct idioms** in this workbook (mark type × key encoding: e.g. "filled map / region
choropleth", "dual-axis line+bar", "part-to-whole", "KPI with trend"). Dedupe — 30 visuals are
usually 5-8 idioms.
2. **For each idiom without current cached guidance, spawn a focused research subtask** that answers:
*what is the best Power BI visual for this Tableau idiom today, and how does Microsoft Learn say to
build/configure it well?* The subtask must return a recommended visual + concrete configuration
notes + **Microsoft Learn citation(s) with the access date**, cross-checked against
`catalog describe`. **Maps are the priority** — Azure Maps guidance (reference layers, data-bound
layers, bubble vs choropleth) changes and is easy to get subtly wrong.
3. **Cache it into the cookbook**: add/refresh a `## MS Learn best practice (as of <date>)` section in
the idiom's `visuals/<type>.md` with the recommendation + citation. Downstream, every instance of
that idiom reuses the cached decision; the dated citation makes staleness visible on the next run.
4. Only then encode, following the (B) precedence above.
This is what makes the cookbook self-refreshing against Microsoft Learn rather than a frozen snapshot.
## 10. Reading the report-side handover queue
```bash
python scripts/read_handover.py <bundle> --workbook <name> --viz
python scripts/read_handover.py <bundle> --workbook <name> --viz --severity blocking
python scripts/read_handover.py <bundle> --list # estate-wide triage, both queues
```
A handover slice is large (347 KB for a 60-stub workbook) and a file-read tool refuses it outright,
so reading it by hand means parsing it programmatically and then filtering. The reader does that
once and ranks the result.
### What `--viz` puts first, and why it matters
**Emptied visuals** (`pbip_ref_drops[].emptied`) - visuals whose *every* field binding was dropped.
They render blank on a report that validates clean, and nothing else ranks them: in the worked
example there were **15**, sitting unremarked beside a 170-item `remediation_worklist`. This is the
single highest-value thing the reader surfaces, and it is why `--severity` **never hides them** - a
blank visual outranks any severity band the worklist assigns.
⚠️ **"nothing else ranks them" became FALSE in engine 2.355.0, and was verified here on canonical
2.356.0 on 2026-09-03.** `Yarbrdab000/tableau-fabric-skills#189` sets
`pbip_ref_drops[].severity` **structurally at the drop site** (`emptied: true` →
`severity: "blocking"`), plus a `blocking` worklist rule in the `emptied_visual` category. A
**partial** drop deliberately stays `high`, not `blocking`. For engine >= 2.355.0, prefer the
engine's own `severity`; the reader still ranks them and remains correct on older bundles. This
entry was not separately re-measured during the targeted 2.368.0 roll-forward.
⚠️ Two caveats worth keeping: upstream's own corpus emits **zero** `pbip_ref_drops` entries, so this
shipped on fixture coverage against *our* estate measurements and the maintainer explicitly asked how
many of our 23 now carry `blocking` — **that number is still unmeasured here**; and `emptied` is set
by two paths (losing every role, and losing a *required* role), both of which render blank.
Then `remediation_worklist` grouped by category, with each distinct `remediation` text printed
**once** rather than repeated per item, then `viz_fidelity` tier counts.
⚠️ **A previous version of this section claimed these payloads were "unreachable at any workbook
size" because they sit ~93% into the file.** The offsets are real (`remediation_worklist` at byte
156,764, `viz_fidelity` at 315,571 of a 347 KB slice); the conclusion drawn from them was **wrong and
has been retracted** - see `powerbi-semantic-model-gotchas` §8 for the falsifying experiment and the
lesson. A deep byte offset is evidence of a byte offset, not of unreachability.
### Still true: a `viz_fidelity` reason can be a deferral you must NOT reverse
Reading the queue is necessary, not sufficient. Measured entry, quoted in full because the
abbreviated form reads as a simple gap:
> *"table-calc filter on 'Last' (LAST) is not reproduced: it runs after aggregation and HIDES marks,
> which Power BI cannot express as a filter ... 6 other table calc(s) share this view and would be
> silently re-scoped if it were re-added as an ordinary filter."*
Re-adding that as an ordinary filter would silently change six other visuals' numbers. The validator
classifies each row as **fixable / accepted-limitation / false-claim**; repair only what it routes to
you, and route a false claim back rather than fixing it quietly.