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.
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: []. Returningnullor 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
failedtofail_count, changingmeasuredfrom 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
Zat 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_assetarray too large to ship in one response. Keeptotalsandclause_indexin the base document and pageper_assetbehind a cursor (?after=<asset_id>&limit=200), sorted byasset_idso pages are stable across requests. - Enum drift. A new
severityvalue 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.
Related
- Scheduled QC report generation — parent reference: the scoring pipeline and the JSON/Parquet/CSV output fork this contract belongs to.
- Parquet telemetry schema design — the columnar warehouse projection of the same violation telemetry.
- CI/CD gating for caption builds — the gate that reads the
statusfield this document exposes. - Generating daily QC reports with Python — the aggregation core that produces the per-asset verdicts rendered here.
Part of: Automated QC Validation & Reporting — the deterministic caption QC and reporting reference.