Compliance Dashboard JSON Output Format

A compliance dashboard breaks the morning a report renames total_fail to failed or drops a key a chart depended on — the panel goes blank during an FCC audit and no one notices until a regulator asks why. The defence is a versioned JSON contract the report and the dashboard both agree on: a single document carrying schema_version, an ISO-8601 generated_at and window, severity totals for the top-line rollup, a per_asset array for drilldown, and a clause_index that maps every violation back to the regulation it enforces. Changes are additive only, keys are stable and sorted, and the version bumps by policy — so an old dashboard reading a new report ignores fields it does not know rather than crashing. This page defines that document with dataclasses and json.dumps, the stable projection the scheduled QC reporting job hands to its front end.

The versioned document builder

# dashboard_contract.py — versioned, stable JSON the compliance dashboard consumes
from __future__ import annotations

import datetime as dt
import json
from dataclasses import dataclass, field
from enum import Enum

SCHEMA_VERSION = "2.1.0"  # MAJOR.MINOR.PATCH — MINOR is additive-only; MAJOR may remove/rename


class Severity(str, Enum):
    INFO = "info"
    WARNING = "warning"
    ERROR = "error"


@dataclass(frozen=True)
class Violation:
    cue_tc: str          # SMPTE ST 12-1 HH:MM:SS:FF
    rule: str            # e.g. "reading_rate"
    clause: str          # e.g. "FCC 47 CFR § 79.1"
    severity: Severity
    measured: float
    threshold: float


@dataclass
class AssetReport:
    asset_id: str
    status: str                                   # "pass" | "fail"
    cue_count: int
    violations: list[Violation] = field(default_factory=list)

    def to_dict(self) -> dict:
        # Per-asset drilldown: stable, explicit keys — never dump __dict__ blindly.
        return {
            "asset_id": self.asset_id,
            "status": self.status,
            "cue_count": self.cue_count,
            "violation_count": len(self.violations),
            "violations": [
                {
                    "cue_tc": v.cue_tc,
                    "rule": v.rule,
                    "clause": v.clause,
                    "severity": v.severity.value,      # serialize the enum's value, not its repr
                    "measured": round(v.measured, 2),
                    "threshold": v.threshold,
                }
                for v in self.violations
            ],
        }


def _iso(ts: dt.datetime) -> str:
    """UTC, second-precision ISO-8601 with a trailing Z the dashboard can parse unambiguously."""
    return ts.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def build_document(
    assets: list[AssetReport],
    window_start: dt.datetime,
    window_end: dt.datetime,
    generated_at: dt.datetime,
) -> dict:
    """Assemble the full contract: rollup totals + per-asset drilldown + clause index."""
    totals = {s.value: 0 for s in Severity}          # every severity present even at zero
    clause_index: dict[str, dict] = {}
    for a in assets:
        for v in a.violations:
            totals[v.severity.value] += 1
            entry = clause_index.setdefault(
                v.clause, {"clause": v.clause, "rules": set(), "count": 0}
            )
            entry["rules"].add(v.rule)
            entry["count"] += 1
    return {
        "schema_version": SCHEMA_VERSION,
        "generated_at": _iso(generated_at),
        "window": {"start": _iso(window_start), "end": _iso(window_end)},   # half-open [start, end)
        "totals": {
            "assets": len(assets),
            "passed": sum(a.status == "pass" for a in assets),
            "failed": sum(a.status == "fail" for a in assets),
            "by_severity": totals,
        },
        "per_asset": [a.to_dict() for a in assets],
        # Sorted list, not a set — JSON has no set type, and sorting keeps bytes stable.
        "clause_index": [
            {"clause": c["clause"], "rules": sorted(c["rules"]), "count": c["count"]}
            for c in sorted(clause_index.values(), key=lambda c: c["clause"])
        ],
    }


def render(document: dict) -> str:
    """Byte-stable JSON: sorted keys + UTF-8 so identical input yields identical output."""
    return json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False)


if __name__ == "__main__":
    now = dt.datetime.now(dt.timezone.utc)
    assets = [
        AssetReport("a1b2", "fail", 812, [
            Violation("01:00:12:04", "reading_rate", "FCC 47 CFR § 79.1", Severity.ERROR, 24.3, 20.0),
        ]),
        AssetReport("c3d4", "pass", 640),
    ]
    doc = build_document(assets, now - dt.timedelta(days=1), now, now)
    print(render(doc))

Code walkthrough

SCHEMA_VERSION is the load-bearing field of the whole document, so it is the first key a consumer reads. The contract follows a MAJOR.MINOR.PATCH discipline: a MINOR bump adds a field and never removes or renames one, so a dashboard pinned to 2.0 keeps working against a 2.1 report by ignoring the new key; a MAJOR bump is the only version allowed to remove, rename, or retype a field, and it signals every consumer to update before reading. Encoding the policy in the version string is what lets producer and consumer evolve on independent schedules without a flag day.

Severity is a str-backed Enum, which serializes cleanly through v.severity.value and, just as importantly, is a closed set. A dashboard can build its severity columns from three known values rather than discovering a stray "critical" at runtime. The totals["by_severity"] map is seeded with every enum member at zero before counting, so a window with no warning rows still emits "warning": 0 — the dashboard’s warning tile renders a zero instead of throwing on a missing key, the single most common cause of a blank panel.

The document separates rollup from drilldown deliberately. totals is the cheap top-line a dashboard reads to paint its summary tiles — asset counts and a severity histogram — without walking the per-asset detail. per_asset is the expensive drilldown a user expands one asset at a time. Keeping them as sibling keys means the dashboard’s landing view deserializes and reads totals alone, deferring the heavy per_asset array until a row is opened. Each AssetReport.to_dict writes explicit keys rather than dumping __dict__, so a field added to the dataclass never leaks into the wire format by accident — the contract changes only when you change the serializer.

clause_index is the audit affordance. It inverts the per-asset view into a per-regulation summary — every distinct clause, the rules that tripped it, and a count — so an auditor asking “how many § 79.1 breaches across the whole window?” reads one array instead of scanning every asset. It is built from a set of rules for de-duplication but serialized as a sorted list, because JSON has no set type and sorting is what keeps the output byte-stable. That byte-stability, enforced by render’s sort_keys=True, is the same property the daily QC report relies on for WORM storage: identical telemetry always serializes to identical bytes, so the document can be hashed and compared.

A JSON Schema note: pin this contract with a $schema document that sets "additionalProperties": true at the top level so a MINOR field addition validates against the prior schema, and mark only schema_version, generated_at, window, totals and per_asset as required. Validate every emitted document against the schema in CI — the same gate that runs caption QC in GitHub Actions — so a contract regression fails the build before a dashboard ever sees it.

Contract reference

Field Type Contract rule
schema_version string (semver) required; MINOR additive, MAJOR breaking
generated_at ISO-8601 UTC Z required; second precision
window.start / window.end ISO-8601 UTC Z required; half-open [start, end)
totals.assets / passed / failed int required rollup counts
totals.by_severity object keys = full Severity enum, zero-filled
severity enum info / warning / error closed set; new value ⇒ MAJOR bump
per_asset[] array drilldown; explicit keys only
per_asset[].measured float rounded to 2 dp
clause_index[] array sorted by clause; rules de-duplicated
Version policy additive-only within MAJOR remove/rename ⇒ MAJOR

The clause strings and threshold values in the contract are owned upstream — their authoritative source is the scheduled QC reporting threshold table and, for the reading-rate figures, enforcing character-rate limits in QC; the § 79.1 clause text lives at 47 CFR § 79.1. The dashboard never re-derives these — it renders what the contract carries.

Versioned JSON contract: rollup, drilldown, and clause index Scored asset reports feed a builder emitting one versioned JSON document. A schema_version and ISO-8601 window header sits above a totals rollup (with a zero-filled by-severity map) read by the dashboard summary tiles, a per_asset drilldown array expanded one row at a time, and a clause_index sorted by clause for the audit view. Additive-only versioning lets an older dashboard ignore unknown fields. Versioned JSON contract — rollup, drilldown, clause index Additive-only fields; an older dashboard ignores what it does not know. Scored assets AssetReport[] + violations build_document schema_version 2.1.0 JSON document · header: schema_version · generated_at · window[start,end) totals — rollup assets · passed · failed · by_severity{info,warning,error} zero-filled → summary tiles read this alone per_asset[] — drilldown asset_id · status · cue_count · violations[cue_tc,rule,clause,severity] → expanded one row at a time clause_index[] — audit sorted by clause · rules[] de-duplicated · count per regulation → "how many § 79.1 breaches?" in one array

Edge cases & known gotchas

  • Empty window. A window with no assets must still emit a full document — totals.assets = 0, every severity zero, per_asset: [], clause_index: []. Returning null or omitting keys forces the dashboard into null-guards on every field; an all-zero document renders correctly with no special-casing.
  • Additive-only changes. Within a MAJOR version, only add fields, and add them as optional. Renaming failed to fail_count, changing measured from a number to a string, or removing a key are all MAJOR events — ship them behind a version bump and support the old shape until every consumer migrates.
  • ISO-8601 timestamps. Emit UTC with an explicit Z at second precision; a naive local timestamp or a bare offset is ambiguous across the timezone boundary and silently mis-sorts a window. Parse defensively on the dashboard side and reject anything without a zone.
  • Large-payload pagination. A window of thousands of failing assets produces a per_asset array too large to ship in one response. Keep totals and clause_index in the base document and page per_asset behind a cursor (?after=<asset_id>&limit=200), sorted by asset_id so pages are stable across requests.
  • Enum drift. A new severity value is a breaking change even though it only adds data — a dashboard that switch-cases on three severities mishandles a fourth. Treat any change to the severity set as a MAJOR bump, not a MINOR one.

Integration hook

This JSON contract is the human-facing branch of the scheduled QC reporting fork: the same scored telemetry that lands in the warehouse via the Parquet telemetry schema is projected here into the versioned document a dashboard renders. It consumes the per-asset verdicts produced by generating daily QC reports with Python and exposes the same pass/fail status the build gate reads, so the dashboard, the warehouse, and CI/CD gating for caption builds all derive from one scored frame without any of them re-deriving a verdict.

Part of: Automated QC Validation & Reporting — the deterministic caption QC and reporting reference.