# Schema 3.6.0

scoreReasons becomes a map of contributions

Version coverage: /docs/reference/versions/


{{< callout context="caution" title="Schema and wire-format change" icon="alert-triangle" >}}
`scoreReasons` becomes a real protobuf `map<string, double>`. Producers and consumers should move to
the 3.6.0 generated schemas together.
{{< /callout >}}

## Changed — scoreReasons is a map of contributions

`ClientSample.scoreReasons`, `PeerConnectionSample.scoreReasons`, `InboundTrackSample.scoreReasons`
and `OutboundTrackSample.scoreReasons` changed from an optional **array of strings** to an optional
**map of doubles** — `Record<string, number>` in the generated TypeScript (Avro:
`["null", {"type": "map", "values": "double"}]`), mapping each reason to how much it contributed to
the calculated score.

```jsonc
// 3.4.0 – 3.5.0
"scoreReasons": ["high-rtt", "high-packetloss"]

// 3.6.0
"scoreReasons": { "transport-delay-degraded": 2.5, "transport-loss-sustained": 2.5 }
```

A list of labels says *which* findings applied; a map says **which one actually cost the score**.
That is the difference between a dashboard that lists five reasons for a 4.8 and one that shows the
single 0.2 that moved it.

## On the wire

The generator learned to emit **proto3 maps for primitive-valued Avro maps** — union-valued maps
such as `payload` keep travelling as JSON strings. A map field sorts with the repeated group, so
**neighbouring field numbers are unchanged**.

Both codecs treat the field exactly as they treated the string array: written whole whenever
present, never carried forward, and an empty map meaning the same as an absent one.

## Upgrading

A consumer that summed or counted reasons needs to decide what it now means:

```typescript
// how many reasons applied — unchanged in spirit
Object.keys(sample.scoreReasons ?? {}).length;

// what the score actually lost, which was not previously answerable
Object.values(sample.scoreReasons ?? {}).reduce((a, b) => a + b, 0);
```

{{< callout context="tip" title="A mixed fleet still reads" icon="rocket" >}}
`observer-js` folds a legacy `string[]` into the record shape with a magnitude of `0`, which keeps
any summing of contributions honest about the magnitude the old wire never carried. So an older
client's samples are readable alongside a newer one's without special-casing.
{{< /callout >}}

On the producing side, [`client-monitor-js`](/docs/client-monitor-js/scoring/) publishes exactly this
shape: `scoreReasons` is keyed by issue type wherever an issue is behind the charge, and by the name
of a continuous reading otherwise, and **a charge of `0` is dropped rather than written** — a reason
sitting at zero reads as a fault that was found and never resolved.

[← Back to version history](/docs/schema/versions/) · [3.5.0 →](../v3-5-0/)

