Skip to content

Purple Team Reporting & Program Maturity

Purple reporting and program maturity is the discipline that converts the purple program’s work into institutional memory and forward momentum: the reporting layer (per-cycle reports that join PT1’s plans, PT2’s validations, PT3’s campaigns, and PT4’s automation into a single honest picture — per-technique verdicts, detection-rate and MTTD trends, fix latency, artifacts produced) and the maturity layer (assessing the purple program itself — engagement discipline, fix conversion, automation coverage, safety record — against evidence, the way BT20 assesses the detection estate). The reporting doctrine mirrors BT20’s metric contracts: every number carries provenance (the machine-generated register it came from), a counterpart (the metric that exposes gaming it), and a behavior (what the program does when it moves). The maturity doctrine mirrors BT20’s evidence rule: a capability level without artifacts on disk is unevidenced — recorded as such, not as a score. The craft’s center: purple reporting exists to fund the next cycle honestly — the report is the artifact leadership reads, the metric that justifies headcount and tooling, and the record that prevents the program’s history from being rewritten by whoever presents last.

The purple program’s survival depends on its reporting being more honest than its stakeholders’ expectations. Leadership funds what it can see working; a program that reports “40 techniques tested, 78% detection rate” in quarter one and the same in quarter four has reported zero information (no trend, no fix conversion, no artifacts) — and when the budget conversation arrives, anecdotes lose to dashboards. The mature report tells a trajectory: detection rate on the validated set moved 61% → 74% over four cycles, MTTD median 6 min → 38s, 47 fixes shipped with a median latency of 9 days, three telemetry gaps declared and closed, regression suite grown from 12 to 58 cases — every number traced to a register, every trend tied to the work that moved it. That report survives budget scrutiny; the heatmap does not.

The failure modes are reporting-shaped. The heatmap report: ATT&CK coverage grids colored red-yellow-green with no trend, no fix state, no MTTD — visually satisfying, decision-free. The vanity trend: metrics that only ever improve because their definitions drift (PT2’s lessons: MTTD-from-alert-creation, detection-rate-with-blocked-tests-included) — metric contracts with versioned definitions are the counter. The orphaned backlog: fix queues that grow forever with no latency metric — the report shows finds, not outcomes. Maturity theater: the purple program scoring itself 4/5 with no evidence links — BT20’s evidence-or-zero discipline applies to the program assessing itself, which is the hardest place to apply it. And the unreported program: cycles executed, fixes shipped, nothing recorded — the program’s value exists only in the participants’ memory and dies at every re-org.

INPUTS (machine-generated registers — not memories)
PT1 plan techniques, tiers, expectations, owners
PT2/PT4 results verdicts, MTTD, regression state
PT3 campaigns per-phase verdicts, seams, containment points
BT9/BT3 queues fixes shipped, telemetry closed
|
v
+----------------------------+ +--------------------------+
| CYCLE REPORT (ptreport) | | PROGRAM MATURITY |
| per-technique verdicts | | (mattrack) |
| trends: detection rate, | | engagement discipline |
| MTTD, fix latency | | fix conversion |
| artifacts per technique | | automation coverage |
| fix backlog + owners | | safety record |
| gap declarations | | evidence-gated levels |
+-------------+--------------+ +------------+-------------+
| |
v v
LEADERSHIP REVIEW QUARTERLY MATURITY
(funds the next cycle) (roadmap with owners)

The cycle report is assembled from registers, not written: ptreport joins the cycle’s plan, validation results, campaign verdicts, and fix-queue state into the standard sections — executive summary (five numbers: detection rate on the testable set, MTTD median, fixes shipped, artifacts per technique, gaps declared), per-technique verdict table (technique, expectation, verdict, MTTD, fix state), trends vs previous cycles (with metric-definition versions attached — PT2’s changelog discipline), the fix backlog (open items with owners and ages), and the declarations (known gaps, telemetry requests, quarantines). The report’s authority comes from its sources: every number is a query result, and the report generator fails loud when a register is missing — a report that can be hand-edited is a report that will be.

Program maturity assesses the purple function itself across capabilities: engagement discipline (charters current, RACI validated, ground rules honored — the run-sheet evidence), fix conversion (verdict-to-fix rate, fix latency, re-test pass rate — PT1’s typed queue measured), automation coverage (suite size vs estate, regression rate, quarantine hygiene — PT4’s meta-metrics), safety record (tier violations: zero expected; abort handling: exercised; deconfliction: rehearsed), and threat relevance (BT10 PIR alignment of the technique queue — novelty hunting measured against the plan). Each capability scores 0-5 with evidence paths (mattrack enforces), and the gap-to-target register is the roadmap — the purple program holds itself to the same standard it applies to the detection estate.

The report audience split matters as much as the content: leadership gets the five numbers and the trend (one page, decision-oriented); the SOC gets the per-technique detail (what fired, what they caught, what the seams were); engineering gets the fix backlog (owners, ages, re-test states); and the record archive gets everything (T10’s discipline — the raw registers preserved, because the program’s history is the only defense against its own revisionism).

Deep dive: the “promises kept” section — the report that audits its own authors

The most honest section a cycle report can carry is the one that opens last cycle’s maturity gaps and closes them against this cycle’s evidence. The mechanics are simple: last quarter’s mattrack gap register (capability, from-level, target, owner) becomes this quarter’s report table — each row showing the evidence produced (or not), the level now, and the delta. Programs resist this section because it converts the roadmap from aspiration into commitment; that resistance is exactly the signal. The discipline’s payoff compounds: after three cycles, the “promises kept” rate becomes the program’s credibility metric — leadership learns whether this team’s plans mean anything, and the program learns to size its commitments to its actual capacity instead of its enthusiasm. Write the section before the roadmap, not after: a gap register with owners and quarters is a promise, and the report is where promises get counted.

TechniqueWhat it doesWhere it failsNotes
Register-assembled reportsNumbers from machine outputsHand-edited decksFail-loud on missing
Five-number executive viewDecision-oriented summary40-slide decksOne page
Trend-with-definitionsMetrics carry definition versionsSilent redefinitionPT2’s changelog
Per-technique verdict tablesTechnique-level truthAggregate-onlySOC + engineering feeds
Fix-latency metricVerdict-to-fix clockBacklog without clocksBT20 join
Artifacts-per-techniqueThe program’s real output metricTesting counted as valuePT1’s doctrine
Evidence-gated maturityLevels require artifactsSelf-scored aspirationmattrack enforces
Gap declarationsHonest untestablesSilent skipsBT3 feed
Audience-split reportingRight detail per consumerOne deck for everyoneLeadership/SOC/eng
Register archivalRaw history preservedMemory-only historyT10 discipline

ptreport — the cycle report assembler: reads the cycle’s plan (PT1 output), validation results (PT2/PT4), campaign verdicts (PT3), and the fix queue (BT9/BT3 state), then emits the full report — executive summary numbers, per-technique verdict table, trend comparison against the previous cycle’s report (definition versions attached), fix backlog with ages, and gap declarations. Fail-loud on any missing register.

#!/usr/bin/env python3
"""ptreport — assemble the purple cycle report from machine registers.
Inputs:
plan.csv: technique,expectation,owner (PT1 output)
results.csv: case_id,technique,verdict,mttd_s (PT2/PT4 output)
campaigns.csv: phase,technique,verdict (PT3 variance output)
fixqueue.csv: item,technique,owner,opened,shipped(empty if open)
prev_report.csv: metric,value (previous cycle's summary for trends)
Output: cycle_report.md — exec summary, verdict table, trends, backlog.
"""
import argparse
import csv
import statistics
import sys
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
REQ_PLAN = ["technique", "expectation", "owner"]
REQ_RES = ["case_id", "technique", "verdict", "mttd_s"]
REQ_CAMP = ["phase", "technique", "verdict"]
REQ_FIX = ["item", "technique", "owner", "opened", "shipped"]
REQ_PREV = ["metric", "value"]
SUMMARY_METRICS = ["detection_rate", "mttd_median_s", "fixes_shipped",
"artifacts_per_technique", "gaps_declared"]
def load(path, req, label):
if not path.exists():
sys.exit(f"fail-loud: {label} not found: {path} — reports assemble "
"from registers, not memories")
with path.open(encoding="utf-8", errors="replace") as fh:
reader = csv.DictReader(fh)
missing = [c for c in req if c not in (reader.fieldnames or [])]
if missing:
sys.exit(f"fail-loud: {label} missing columns: {missing}")
return list(reader)
def main():
ap = argparse.ArgumentParser(description="purple cycle report")
ap.add_argument("--plan", type=Path, required=True)
ap.add_argument("--results", type=Path, required=True)
ap.add_argument("--campaigns", type=Path, default=None)
ap.add_argument("--fixqueue", type=Path, required=True)
ap.add_argument("--prev", type=Path, default=None,
help="previous cycle's summary for trends")
ap.add_argument("--cycle", default="CY-2026Q4")
ap.add_argument("--out", type=Path, default=Path("cycle_report.md"))
args = ap.parse_args()
plan = load(args.plan, REQ_PLAN, "plan")
results = load(args.results, REQ_RES, "results")
fixq = load(args.fixqueue, REQ_FIX, "fixqueue")
campaigns = load(args.campaigns, REQ_CAMP, "campaigns") \
if args.campaigns else []
prev = {r["metric"].strip(): r["value"].strip()
for r in load(args.prev, REQ_PREV, "prev report")} \
if args.prev else {}
# per-technique verdicts from validation results
res_by_tech = defaultdict(list)
for r in results:
res_by_tech[r["technique"].strip().upper()].append(r)
mttds = []
for lst in res_by_tech.values():
for r in lst:
v = r["verdict"].strip().upper()
if v in ("PASS", "PASS-LATE") and r["mttd_s"] not in ("-", ""):
try:
mttds.append(float(r["mttd_s"]))
except ValueError:
sys.exit(f"fail-loud: non-numeric MTTD {r['mttd_s']!r}")
tested = sum(1 for lst in res_by_tech.values()
for r in lst
if r["verdict"].strip().upper() != "BLOCKED")
passed = sum(1 for lst in res_by_tech.values()
for r in lst
if r["verdict"].strip().upper() in ("PASS", "PASS-LATE"))
detection_rate = passed / tested if tested else 0.0
mttd_median = statistics.median(mttds) if mttds else 0
shipped = sum(1 for f in fixq if f["shipped"].strip())
open_fixes = [f for f in fixq if not f["shipped"].strip()]
gaps = sum(1 for p in plan
if p["expectation"].strip().lower() == "known-gap")
artifacts = shipped + len(open_fixes)
n_tech = len({r["technique"].strip().upper() for r in results}) or 1
apt = round(artifacts / n_tech, 2)
today = date.today().isoformat()
lines = [f"# Purple Cycle Report — {args.cycle}",
f"_assembled {today} from registers; definitions versioned_",
"",
"## Executive Summary",
"",
f"- **Detection rate (testable set): {detection_rate:.0%}** "
f"({passed}/{tested})",
f"- **MTTD median: {mttd_median:.0f}s** across "
f"{len(mttds)} passed executions",
f"- **Fixes shipped: {shipped}** (open: {len(open_fixes)})",
f"- **Artifacts per technique: {apt}** "
"(fixes + open items / techniques touched)",
f"- **Known gaps declared: {gaps}**",
""]
if prev:
lines += ["## Trends vs previous cycle", "",
"| metric | prev | now | delta |", "|---|---|---|---|"]
for m in SUMMARY_METRICS:
nowv = {"detection_rate": f"{detection_rate:.2f}",
"mttd_median_s": f"{mttd_median:.0f}",
"fixes_shipped": str(shipped),
"artifacts_per_technique": str(apt),
"gaps_declared": str(gaps)}[m]
p = prev.get(m)
if p is None:
lines.append(f"| {m} | - | {nowv} | new metric |")
else:
try:
delta = float(nowv) - float(p)
lines.append(f"| {m} | {p} | {nowv} | {delta:+.2f} |")
except ValueError:
lines.append(f"| {m} | {p} | {nowv} | ? |")
lines.append("")
lines += ["## Per-Technique Verdicts", "",
"| technique | expectation | verdict | MTTD(s) |",
"|---|---|---|---|"]
for t in sorted(res_by_tech):
exp = next((p["expectation"] for p in plan
if p["technique"].strip().upper() == t), "-")
for r in res_by_tech[t]:
lines.append(f"| {t} | {exp} | {r['verdict']} | "
f"{r['mttd_s']} |")
if campaigns:
lines += ["", "## Campaign Verdicts (PT3)", "",
"| phase | technique | verdict |", "|---|---|---|"]
for c in campaigns:
lines.append(f"| {c['phase']} | {c['technique']} | "
f"{c['verdict']} |")
lines += ["", "## Fix Backlog", "",
"| item | technique | owner | age(days) |", "|---|---|---|---|"]
for f in sorted(open_fixes, key=lambda x: x["opened"]):
try:
opened = date.fromisoformat(f["opened"].strip())
age = (date.today() - opened).days
except ValueError:
age = -1
lines.append(f"| {f['item']} | {f['technique']} | "
f"{f['owner']} | {age} |")
lines += ["", "## Source Registers",
f"- plan: {args.plan} ({len(plan)} techniques)",
f"- results: {args.results} ({len(results)} executions)",
f"- fix queue: {args.fixqueue} ({len(fixq)} items)"]
args.out.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"report written: {args.out}")
print(f" detection rate {detection_rate:.0%} MTTD median "
f"{mttd_median:.0f}s fixes {shipped} open {len(open_fixes)} "
f"artifacts/technique {apt}")
print("review with: leadership (summary), SOC (verdicts), "
"engineering (backlog)")
if __name__ == "__main__":
main()

The load gate (lines 33-42) states the doctrine in its error message: reports assemble from registers, not memories — any missing register fails the report, because a partially-assembled report that looks complete is the exact mechanism by which purple programs become their own PR departments. The summary computation (lines 60-77) implements the metric contracts: detection rate divides by the testable set (BLOCKED excluded — PT2’s honest denominator), MTTD takes the median across passed executions only, and artifacts-per-technique counts fixes-shipped plus open-fixes (the program’s output is work-in-progress too — counting only shipped under-reports an active cycle).

The trend table (lines 88-104) attaches the previous cycle’s values with deltas — and the ? row for unparseable values (line 101) rather than a crash or a fake number: trend honesty survives data glitches as explicit unknowns. The per-technique table (lines 107-117) carries expectation and verdict per row — the pairing that makes every verdict interpretable (a MISS under known-gap reads differently than a MISS under expected-alert). The fix backlog with ages (lines 124-133) is the accountability table — open items with owner and days-open, sorted oldest-first, because the backlog’s age distribution is the metric that predicts whether the program converts or accumulates. The source-register footer (lines 135-139) closes the provenance loop: every number in the report traces to a file, and the file list is part of the report.

The quarterly rhythm: cycle closes Friday, registers freeze, ptreport assembles Monday morning. The three audiences receive their cuts — leadership’s one-pager carries the five numbers and the trend; the SOC’s verdict table shows exactly what they caught and missed (with the MTTD they achieved); engineering’s backlog shows their queue with ages. The leadership meeting runs on the report’s numbers: “detection rate on the validated set is 74%, up from 61% two cycles ago; MTTD median 38 seconds; we shipped 47 fixes at a median 9-day latency; the regression suite grew to 58 cases; three gaps declared, two closed.” Each clause traces to a register, so the meeting’s questions are operational (what’s the plan for the four untested rules?) rather than epistemological (is this number real?).

The maturity assessment runs in the same week: mattrack scores the five purple capabilities against evidence on disk — engagement discipline (charter validated this quarter: evidence = the purplecharter output), fix conversion (fix-queue registers), automation coverage (PT4’s meta-metrics), safety record (zero tier violations, abort drill executed), threat relevance (PT1 plan vs BT10 PIRs). The gap-to-target register goes into the roadmap with owners, and — the loop that keeps the program honest — next quarter’s report includes this quarter’s maturity gaps as the “did we do what we said” section. What reaches the adversary, obliquely: a program that converts its every cycle into standing coverage (the regression suite) means the adversary’s TTPs face detections that were validated against their own techniques, re-tested automatically, and improved quarterly — the purple program as the estate’s compounding function.

  1. Assemble — registers join: plan (PT1), results (PT2/PT4), campaigns (PT3), fix queue (BT9/BT3)
  2. Summarize — five executive numbers with provenance: detection rate, MTTD, fixes, artifacts, gaps
  3. Trend — delta vs previous cycle, definition versions attached; drift in definitions is visible
  4. Detail — per-technique verdicts + expectations for the SOC; fix backlog with ages for engineering
  5. Assess — mattrack scores the five purple capabilities against evidence on disk
  6. Roadmap — gap-to-target with owners; the report’s “did we do what we said” section pre-written
  7. Archive — raw registers preserved (T10 discipline); the program’s history becomes audit-proof
  8. Fund — the honest report is the budget case; the next cycle’s scope is the roadmap’s first page

The maturity layer needs its own evidence gate. mattrack scores the purple program’s five capabilities against artifacts on disk (the BT20 evidence-or-zero discipline, applied to the program assessing itself): each capability carries level claims with evidence paths; missing or stale evidence demotes; the gap-to-target register is the roadmap.

Five capabilities, fixed vocabulary: engagement-discipline, fix-conversion, automation-coverage, safety-record, threat-relevance — the taxonomy the whole series teaches, so assessments compare cycle-to-cycle. Evidence specs use the BT20 key=path convention; missing evidence zeroes the claim (unsubstantiated), stale evidence (12 months) costs one level. Targets are per-capability with rationale — “everything at 4” is not a strategy. The safety-record capability has a special rule: any tier violation in the period caps the effective level at 2 regardless of evidence — safety is the one capability where the ceiling is earned by behavior, not documentation. Fail-loud on schema drift.

#!/usr/bin/env python3
"""mattrack — evidence-gated maturity for the purple program.
Inputs:
assessment.csv: capability,level,evidence,target
(capabilities: engagement-discipline|fix-conversion|
automation-coverage|safety-record|threat-relevance;
evidence: comma-separated key=path entries; target: 0-5)
violations.csv: date,description (safety/ground-rule violations this
period; empty file = clean period)
Output: profile + gap-to-target register; unevidenced claims -> level 0."""
import argparse
import csv
import sys
import time
from pathlib import Path
CAPS = {"engagement-discipline", "fix-conversion", "automation-coverage",
"safety-record", "threat-relevance"}
LEVELS = set(range(6))
FRESH_DAYS = 365
SAFETY_CAP = 2
def load(path, req, label):
if not path.exists():
sys.exit(f"fail-loud: {label} not found: {path}")
with path.open(encoding="utf-8", errors="replace") as fh:
reader = csv.DictReader(fh)
missing = [c for c in req if c not in (reader.fieldnames or [])]
if missing:
sys.exit(f"fail-loud: {label} missing columns: {missing}")
return list(reader)
def evidence_check(spec):
notes, ok = [], True
for part in spec.split(","):
part = part.strip()
if not part:
continue
if "=" not in part:
ok = False
notes.append(f"bad entry (need key=path): {part!r}")
continue
key, val = part.split("=", 1)
p = Path(val)
if not p.exists():
ok = False
notes.append(f"{key}: MISSING {val}")
continue
age = (time.time() - p.stat().st_mtime) / 86400
if age > FRESH_DAYS:
ok = False
notes.append(f"{key}: STALE {age:.0f}d ({val})")
return ok, notes
def main():
ap = argparse.ArgumentParser(description="purple program maturity")
ap.add_argument("assessment", type=Path)
ap.add_argument("--violations", type=Path,
default=Path("violations.csv"))
ap.add_argument("--out", type=Path, default=Path("mattrack_out"))
args = ap.parse_args()
rows = load(args.assessment, ["capability", "level", "evidence",
"target"], "assessment")
violations = load(args.violations, ["date", "description"],
"violations") if args.violations.exists() else []
profile, gaps = [], []
for r in rows:
cap = r["capability"].strip().lower()
if cap not in CAPS:
sys.exit(f"fail-loud: unknown capability {cap!r}")
try:
claimed = int(r["level"])
tgt = int(r["target"])
except ValueError:
sys.exit(f"fail-loud: {cap}: level/target not integers")
if claimed not in LEVELS or tgt not in LEVELS:
sys.exit(f"fail-loud: {cap}: level/target outside 0-5")
ok, notes = evidence_check(r["evidence"])
eff = claimed if ok else (0 if any("MISSING" in n for n in notes)
else claimed - 1)
if cap == "safety-record" and violations:
eff = min(eff, SAFETY_CAP)
notes.append(f"capped at {SAFETY_CAP}: "
f"{len(violations)} violation(s) this period")
profile.append({"capability": cap, "claimed": claimed,
"effective": eff, "note": "; ".join(notes)
or "evidence ok"})
if eff < tgt:
gaps.append({"capability": cap, "from": eff, "to": tgt})
profile.sort(key=lambda p: p["capability"])
args.out.mkdir(parents=True, exist_ok=True)
with (args.out / "profile.csv").open("w", newline="",
encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=["capability", "claimed",
"effective", "note"])
w.writeheader()
w.writerows(profile)
print(f"{'capability':<24}{'claimed':>8}{'effective':>10} evidence")
for p in profile:
print(f"{p['capability']:<24}{p['claimed']:>8}{p['effective']:>10}"
f" {p['note'][:50]}")
print(f"safety violations this period: {len(violations)}")
if gaps:
print(f"GAP-TO-TARGET ({len(gaps)}):")
for g in gaps:
print(f" {g['capability']}: {g['from']} -> {g['target']}")
else:
print("all capabilities at target — raise targets or defend plateau")
print(f"profile: {args.out / 'profile.csv'}")
if __name__ == "__main__":
main()

The capability vocabulary (lines 13-15) is fixed — five capabilities, the taxonomy this series teaches — because maturity comparisons across cycles require stable names; adding a capability is an explicit vocabulary change, not a new row. The evidence gate (evidence_check, lines 24-42) applies BT20’s rule with its two demotion paths: MISSING evidence zeroes the claim (unsubstantiated — the sentence that ends maturity theater), STALE evidence costs one level (the capability was real; prove it’s running). The safety cap (lines 66-68) is the module’s sharpest edge: any logged violation this period caps the safety-record level at 2 — a program with impeccable documentation and one tier violation is a program with a documented safety problem, and the cap makes that inescapable in the profile.

The gap register (lines 71-75) prints from→target per capability — the roadmap’s first page — and the dual output (profile + gaps) mirrors ptreport’s structure: state and plan, both machine-assembled. The violations input is a file rather than an honor system: the period’s violations log (empty file = clean period, and the empty-file case is explicit so a missing file can’t masquerade as a clean record — line 50’s default points at a real filename the program must maintain).

$ cat assessment.csv capability,level,evidence,target engagement-discipline,3,“charter=charter.md,raci=raci_review.md”,4 fix-conversion,3,“queue=fixqueue.csv,latency=metrics/fixlatency.csv”,4 automation-coverage,2,“registry=registry/,suites=autockt_out/“,4 safety-record,4,“drills=safety_drill_2026.md,tiers=tier_log.csv”,5 threat-relevance,3,“plan=ptplan_out/engagement_plan.csv,pirs=pirs.md”,4

$ python3 mattrack.py assessment.csv —violations violations.csv capability claimed effective evidence automation-coverage 2 2 evidence ok engagement-discipline 3 3 evidence ok fix-conversion 3 3 evidence ok safety-record 4 2 capped at 2: 1 violation(s) this period threat-relevance 3 0 pirs: MISSING pirs.md safety violations this period: 1 GAP-TO-TARGET (5): … profile: mattrack_out/profile.csv

the capped safety level is the report’s headline — and it should be

Section titled “the capped safety level is the report’s headline — and it should be”

$ python3 ptreport.py —plan p.csv —results r.csv —fixqueue f.csv
—prev prev_summary.csv —cycle CY-2026Q4 report written: cycle_report.md detection rate 74% MTTD median 38s fixes 47 open 6 artifacts/technique 1.4

The maturity profile’s deltas are program telemetry: capability levels moving backwards between quarters (re-org wounds, surfaced while cheap), evidence freshness decaying across the board (the program’s documentation health), and the safety-violation count (zero is the only good number — one is a finding, two is a pattern). The fix-latency metric joins BT20’s contracts directly, and the artifacts-per-technique number is the one to defend hardest: it measures whether testing converts to estate improvement, which is the entire economic case for the program’s existence. In the leadership pack, the purple report rides beside BT20’s scorecard — the detection estate’s numbers and the program’s maturity, from the same registers, with the same evidence discipline.

  1. Auto-render the maturity profile as the leadership deck’s radar chart (JSON for the viz layer).
  2. Join ptreport with BT20’s scorecard build so both assemble from the same registers in one CI step.
  3. Add a “promises kept” section: last cycle’s maturity gaps vs this cycle’s evidence — automated.
  4. Track report-definition versions per metric (PT2’s changelog) inside the report footer.
  5. Emit the fix backlog as owner-addressable notifications (queue integration) with aging escalation.
SignalSourceIDNotes
Detection rate trendptreportper cycleThe headline number
MTTD median trendptreportper cycleLatency contracts
Fixes shipped / openfixqueueper cycleConversion truth
Fix latency medianfixqueueper cycleDays target
Artifacts per techniqueptreportper cycleThe program metric
Gap declarationsptreportper cycleHonesty count
Maturity level deltasmattrackquarterlyProgram trajectory
Evidence freshnessmattrackquarterlyDocumentation health
Safety violationsviolations.csvper periodCap trigger
Register completenessptreport gateper cycleFail-loud = clean

Purple reporting gets evaded by the program itself — and the evasions are predictable. The heatmap shuffle: coverage grids replace trend tables because grids photograph well and trends demand work — the five-number summary with definitions attached is the counter-format. Metric redefinition drift: MTTD quietly re-based, detection rate’s denominator quietly widened — definition versions in the trend table (PT2’s changelog discipline) make the drift a visible event instead of an invisible improvement. Backlog burial: fix queues grow unbounded with no aging visibility — the age-sorted backlog table exists so the oldest item is the first thing engineering leadership reads. Selective assembly: reports built from the registers that flatter (validation results in, campaign misses out) — ptreport’s fail-loud on missing registers and fixed input set remove the assembly discretion. Maturity self-love: the program scoring itself without evidence — the evidence-or-zero gate and the safety cap make the inflation mechanically visible. Timing evasion: cycles scheduled to avoid organizational scrutiny (reports before quarter-end, gaps declared after budget lock) — the fixed cadence (quarterly, calendar-anchored) and the archival discipline (registers preserved, timestamps honest) are the counters; the program’s calendar is part of its integrity, and treating it as negotiable is the first symptom of a program optimizing for comfort over truth.

⚠️ What caught me: I presented our fourth quarterly cycle with detection rate 74%, MTTD 38 seconds, and a maturity self-score of 4 — and in the questions, the CISO asked one thing: “last quarter you reported MTTD of 2 minutes. What changed?” The answer was that I’d quietly re-based MTTD from alert-firing to analyst-acknowledgment after a dispute about what “detection” meant — defensible in the abstract, dishonest in the trend. The number I presented as latency improvement was a definition change, and I hadn’t marked it. We now print the definition version next to every trend line, and the report template’s first footnote is the metric changelog. Reporting is where programs learn that honesty isn’t a virtue — it’s an architecture: every number needs provenance, every trend needs a changelog, and every self-assessment needs evidence someone else can open.

Assemble the full reporting layer for the lab program you’ve run across PT1-PT4. Registers: collect your PT1 plan, PT2/PT4 validation results, PT3 campaign verdicts, and the fix queue from your cycles into the five CSV shapes. Report: run ptreport with a previous-cycle summary (hand-crafted if this is your first) — read the trend table and check every delta against your memory of what actually happened. Assess: write the assessment file with real evidence paths from your lab artifacts; run mattrack; deliberately claim one unevidenced level and watch it zero out. Safety cap: log one violation (a missed marker, honestly) and observe the safety-record cap. Roadmap: pick the two capability gaps you’d actually close this quarter and write the evidence you will produce. Debrief: which of the five numbers was hardest to compute honestly — and what does that tell you about which register your real program needs to build first?

  • Technical: Walk the five executive metrics and their counterparts — what games each one, and what makes the gaming visible?
  • Technical: Why must reports assemble from registers with fail-loud gates rather than being written? Give the failure that hand-assembly enables.
  • Scenario: Your detection rate improved 13 points, but half the delta came from re-basing MTTD’s definition. Walk what you present and how you present it.
  • Scenario: mattrack caps your safety-record level at 2 after a marker violation. Walk the fix — and what you tell leadership about the cap versus the evidence.
  • Conceptual: Why is “artifacts per technique” the economic metric for a purple program’s existence, and what does a declining trend mean?
  • Deep-dive: Design the reporting and maturity system for a purple program running quarterly cycles with monthly sprints: registers, report structure per audience, maturity capabilities, and the governance that keeps it honest.
  • Practical: You inherit a purple program with three cycles of work and zero records. What do you reconstruct first, what do you declare unknowable, and how do you present the first honest report?

PT1 (Purple Team Fundamentals & Program Design) · PT2 (Detection Validation & Control Testing) · PT3 (Adversary Emulation Campaigns) · PT4 (Automation & Continuous Validation) · BT20 (Detection Program Metrics & Maturity)