Skip to content

Commit eb34082

Browse files
rubinderclaude
andauthored
Daily platform report with day-over-day finding diffs (#8)
* feat: daily platform report with day-over-day finding diffs Closes #6. The platform was accumulating a narrative and discarding it. ops.monitor_results has carried run_at since Task 18 and nothing ever read it back. Worse, the one artifact that looked like a history was destroying one: incident slugs are deliberately stable so a recurring finding rewrites one file rather than spawning many, which means write_incident OVERWRITES and yesterday is gone. Nothing could answer "when did this start?" or "did yesterday's problem clear?" Two append-only Iceberg tables: ops.finding_log every finding on every run, keyed on sensors.finding_key ops.agent_runs one row per run, findings or not The second exists because a clean run writes no finding rows, so without it "ran and found nothing" and "never ran" are the same empty table -- and a report would render the second as a clean bill of health. Same empty-delta blind spot this codebase has now closed four times (quarantine_rate SELECT 0.0, range on an all-NULL column, freshness on no values, an enum watch on an empty column). src/ops/report.py assembles seven sections. build_report() takes a dataclass and returns a string: it is handed no engine, so it CANNOT compute a metric even by accident, which is what makes "the report recomputes nothing" a property rather than a comment. A test asserts the module never references monitors.evaluate, sensors.detect or validator.validate. A report that recomputes can disagree with the monitor that raised the alert, and the report is the one people believe. Absences are reported as absences: no run recorded says so instead of rendering clean; no monitor results is "an absence of evidence, not a pass"; no previous run says "nothing to diff against" instead of calling every standing finding new; a monitor with no prior value prints an em dash, never 0. An unknown finding kind routes to Errors rather than vanishing. drift-demo --day N applies one scheduled change instead of all four, so the agent can run between them and the log accumulates a real timeline. Bare drift-demo is unchanged. reports/ holds a four-day sequence from one continuous history: clean, +column, rename, then the rest plus a volume collapse -- 0, 1, 2, 8 findings. Fixed a latent bug found on the way in: incident_slug() hashed table|kind|column|change with no monitor name, so EVERY monitor_breach on a table hashed identically. With one breach firing that was invisible; with two the second incident file silently overwrote the first, and this feature's diff would have reported a finding as "cleared" while it was still breaching. Extracted sensors.finding_key() as the single definition of finding identity, shared by the incident filename and the log row -- they must agree or "first seen" describes something other than the file on disk. Incident filenames also stopped rendering "None" for breaches carrying no column. The committed incident slugs change accordingly; this is a one-time migration. Also fixed: the new tests reached graph.run()'s act node and wrote into the repo's real docs/incidents/. test_agent_graph and test_bronze already guard against this; test_report now uses the same autouse fixture. The suite no longer dirties the committed artifact set. Reviewed: 275 tests pass (250 before), ruff clean, four-day sequence re-run end to end at full scale and every committed report and incident regenerated from it. Day four runs one day past the end of the data on purpose, which is why three arrival SLAs breach; stated in the README rather than left to look accidental. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199ePw5w34FyfGAB41mkv3G * fix: review round 1 -- re-running the agent doubled findings; report render was unstable Two defects found by probing the shipped code rather than re-reading the diff. 1. `make agent` twice on one date -- an ordinary thing to do -- appended the same logical finding to ops.finding_log twice, and the report counted rows rather than findings. One open finding rendered as "2 still open". A diff whose counts are wrong is worse than no diff, because the counts are read as a measurement. `_latest_per_key` collapses to one row per finding_key per run; `make monitor` gets the same treatment. 2. The diff sections were rendered in set-iteration order, so regenerating a report reshuffled its lines with no content change. Reports are committed artifacts: an unstable render puts noise in every git diff, and a diff that is usually noise stops being read. Sorted by finding key, with a test that renders twice and compares. Also added a test that an unescaped pipe in detail text cannot split a Markdown table cell -- it was already handled, but nothing pinned it. Reviewed: 279 tests pass (275 before), ruff clean. reports/daily-2026-07-01.md re-rendered under the stable ordering; counts are unchanged (6 new, 0 cleared, 2 still open), only line order. Verified byte-stable across two regenerations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199ePw5w34FyfGAB41mkv3G --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5fbe51e commit eb34082

21 files changed

Lines changed: 1630 additions & 31 deletions

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all bronze silver gold forecast agent monitor arrival test lint clean timetravel drift-demo schema-history cross-version maintenance
1+
.PHONY: all bronze silver gold forecast agent monitor arrival report test lint clean timetravel drift-demo schema-history cross-version maintenance
22
UV := uv run
33

44
all: bronze silver gold forecast
@@ -10,6 +10,7 @@ forecast: ; $(UV) python -m src.forecast.report
1010
agent: ; $(UV) python -m src.agent.graph
1111
monitor: ; $(UV) python -m src.ops.runner
1212
arrival: ; $(UV) python -m src.ops.arrival
13+
report: ; $(UV) python -m src.ops.report
1314
timetravel: ; $(UV) python -m src.lakehouse.maintenance timetravel
1415
drift-demo: ; $(UV) python -m src.lakehouse.maintenance drift-demo
1516
schema-history: ; $(UV) python -m src.lakehouse.maintenance schema-history

README.md

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ A complete data platform, built end to end, small enough to read:
4747
- **17 data-quality monitors + 8 arrival checks** with persisted metric history
4848
and robust (median/MAD) baselines. 10 of the 17 are declared in YAML; the
4949
other 7 are column-type checks generated per contract field.
50+
- **A daily report that tells the story**`make report` assembles verdict,
51+
schema evolution, data quality, anomalies, errors and a **day-over-day diff**
52+
from `ops.*`. It reads; it never recomputes. A three-day sample sequence is in
53+
[`reports/`](reports/).
5054
- **The AI-SDLC record** — the spec, the plan, every task brief, every review
5155
diff, and a ledger of what was found and changed. See
5256
[`docs/ai-sdlc/`](docs/ai-sdlc/workflow.md).
@@ -170,12 +174,13 @@ uv run make lint # ruff
170174
|---|---|
171175
| `make all` | `bronze silver gold forecast` end to end, offline, deterministic |
172176
| `make monitor` | 17 data-quality checks; persists metrics to `ops.monitor_results` and routes alerts to `ops.alert_log` (**dry-run** unless `--execute`) |
177+
| `make report` | the daily platform report for `AS_OF_DATE`, assembled from `ops.*` and written to `reports/daily-<date>.md` |
173178
| `make arrival` | 8 checks across 3 arrival SLAs, on the NYSE trading calendar |
174179
| `make agent` | the LangGraph ops agent (**dry-run** unless `--execute`) |
175180
| `make schema-history` | every schema version with field ids |
176181
| `make cross-version` | one query spanning snapshots written under different schemas |
177182
| `make timetravel` | read at snapshot N−1 vs N |
178-
| `make drift-demo` | evolve the schema and inject a volume collapse |
183+
| `make drift-demo` | evolve the schema and inject a volume collapse (`--day N` applies one scheduled change at a time) |
179184
| `make maintenance` | expire old snapshots |
180185
| `make smoke` | `make all` at `N_TRANSACTIONS=5000` |
181186

@@ -436,15 +441,16 @@ live behaviour on the strength of its unit tests. It runs now.
436441
A LangGraph state machine (`src/agent/graph.py`):
437442

438443
```
439-
sense ──▶ monitor ──▶ classify ──┬──▶ act ──▶ END
440-
└──▶ END (no action)
444+
sense ──▶ monitor ──▶ classify ──▶ persist ──┬──▶ act ──▶ END
445+
└──▶ END (no action)
441446
```
442447

443448
| Node | What it does |
444449
|---|---|
445450
| `sense` | Reads Iceberg **metadata** — schema history, snapshot log, per-snapshot added-records, newest date. No data scan where metadata suffices. |
446451
| `monitor` | Runs the `src/ops` monitors and arrival SLAs; converts breaches into findings. |
447452
| `classify` | Severity per finding: `breaking` / `renaming` / `widening` / `additive` / `enum_drift` / `benign`. **Rules first**; the optional Claude call handles only unmatched cases. |
453+
| `persist` | Appends this run to `ops.finding_log` and `ops.agent_runs`. Runs on **every** path, including the clean one. |
448454
| `act` | Writes `docs/incidents/<slug>.md` and would file a GitHub issue. **Dry-run by default.** |
449455

450456
**Classification is rules-first because CI must never depend on a model call**
@@ -548,6 +554,104 @@ PyIceberg will not narrow a column type and faking that state would misrepresent
548554
what the storage layer does
549555
([decisions/0005](docs/ai-sdlc/decisions/0005-drift-scenario-coverage-split.md)).
550556

557+
## The daily report
558+
559+
Everything above measures. `make report` is the thing that *reads it back*.
560+
561+
The platform was already accumulating a narrative and then throwing it away.
562+
`ops.monitor_results` has carried `run_at` since Task 18 and nothing ever queried
563+
it. `docs/incidents/*.md` records the latest state of each open finding — and
564+
because the incident slug is deliberately stable so a recurring finding rewrites
565+
one file instead of spawning many, **every write destroyed the previous state**.
566+
Nothing in the repository could answer "when did this start?" or "did yesterday's
567+
problem go away?"
568+
569+
Two append-only tables fix that. `ops.finding_log` records every finding on every
570+
run, keyed on `sensors.finding_key`. `ops.agent_runs` records the run itself —
571+
because a clean run writes no finding rows, so without it "the agent ran and
572+
found nothing" and "the agent never ran" are the same empty table, and only one
573+
of those is good news.
574+
575+
```bash
576+
uv run make report # today
577+
AS_OF_DATE=2026-06-29 uv run make report # any recorded day
578+
```
579+
580+
### A four-day sequence
581+
582+
`drift-demo --day N` applies one scheduled schema change instead of all four, so
583+
the agent can run between them and the log accumulates a real timeline. The four
584+
files in [`reports/`](reports/) are this sequence, verbatim, from one continuous
585+
warehouse history:
586+
587+
```bash
588+
AS_OF_DATE=2026-06-28 make monitor agent report # clean: 0 findings
589+
uv run python -m src.lakehouse.maintenance drift-demo --day 1 # +merchantCategoryCode
590+
AS_OF_DATE=2026-06-29 make monitor agent report # 1 finding
591+
uv run python -m src.lakehouse.maintenance drift-demo --day 4 # checkNumber -> check_reference
592+
AS_OF_DATE=2026-06-30 make monitor agent report # 2 findings
593+
uv run python -m src.lakehouse.maintenance drift-demo # the rest + a volume collapse
594+
AS_OF_DATE=2026-07-01 make monitor agent report # 8 findings
595+
```
596+
597+
Day three's diff section — the sentence the platform previously could not
598+
produce at all:
599+
600+
```markdown
601+
Compared with `2026-06-29`: **1 new**, **0 cleared**, **1 still open**.
602+
603+
### New
604+
- `[renaming]` column 'checkNumber' was renamed to 'check_reference' (field id 20)
605+
606+
### Still open
607+
- `[additive]` column 'merchantCategoryCode' present in table but not declared
608+
in contract (open 1d (since 2026-06-29))
609+
```
610+
611+
Not "there is a finding", but *this one is new, this one has been open since
612+
Monday, and nothing was resolved.*
613+
614+
Day four is where every section fills in at once: two schema changes still open,
615+
a volume collapse, the `bronze_txn_row_count` monitor breaching against its now
616+
250,000-row baseline, and three arrival-SLA gaps.
617+
618+
**Why day four fires arrival SLAs, stated plainly:** the dataset ends
619+
`2026-06-30`, so running the fourth day at `2026-07-01` is deliberately one day
620+
past the end of the data. That is what makes `silver.transactions`,
621+
`silver.stock_prices` and `gold.forecast_training_set` report a missing period —
622+
the same mechanism the `AS_OF_DATE=2026-07-31 make arrival` example uses. It is a
623+
real breach of a real SLA against a logical clock, not a contrivance, but the
624+
sequence that produced it is stated with it.
625+
626+
### The report reads; it does not measure
627+
628+
`build_report()` takes a `ReportSnapshot` dataclass and returns a string. It is
629+
handed no engine, so it **cannot** compute a metric even by accident — which is
630+
what makes the claim testable rather than a comment. `load_snapshot()` is the
631+
only function in the module that touches a warehouse.
632+
633+
The reason is not tidiness. A report that recomputes a number can disagree with
634+
the monitor that raised the alert, and when those two disagree, the report is the
635+
one people believe.
636+
637+
### Absences are reported as absences
638+
639+
Three of them, each a place where a naive report would print something reassuring:
640+
641+
| Situation | What a naive report shows | What this one shows |
642+
|---|---|---|
643+
| No agent run for the date | empty findings table → looks clean | `Verdict — NO RUN RECORDED`, "this is **not** a clean bill of health" |
644+
| No monitor results | empty quality table → looks clean | "an absence of evidence, not a pass — run `make monitor`" |
645+
| No previous run to diff | every finding listed as "new" | "nothing to diff against… not because it is new" |
646+
647+
A monitor metric with no prior value prints ``, never `0`: unchanged and
648+
never-measured are different facts, and printing zero for the second is a small
649+
lie that reads as a measurement.
650+
651+
A finding kind the report has no section for is routed to Errors rather than
652+
dropped. A new check family that is measured, recorded, and invisible is the
653+
exact failure this file exists to prevent.
654+
551655
## Results
552656

553657
**Read the caveat at the top of this file first.** Prices are synthetic with a
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# 0007 — Finding history lives in Iceberg, not in the incident files
2+
3+
**Type:** design decision for [#6](https://github.com/rubinder/autonomous_data_platform/issues/6)
4+
**Alternative considered:** an append-only event log inside each `docs/incidents/*.md`
5+
**Shipped:** two append-only Iceberg tables, `ops.finding_log` and `ops.agent_runs`
6+
7+
## The problem
8+
9+
The platform was accumulating a narrative and discarding it.
10+
11+
`ops.monitor_results` has carried `run_at` (partitioned by month) since Task 18,
12+
and **nothing ever read it back**. `make monitor` wrote; no code queried.
13+
14+
Worse, the one artifact that looked like a history was actively destroying one.
15+
`actions.incident_slug()` is deliberately stable so a recurring finding rewrites
16+
a single file rather than spawning one per run — the right call for bounded tree
17+
churn, made deliberately by a human, and the reason `docs/incidents/` is
18+
committable at all. But it means `write_incident` **overwrites**. Yesterday's
19+
state is gone. Nothing in the repository could answer "when did this start?" or
20+
"did yesterday's problem clear?"
21+
22+
## Why the table, not the file
23+
24+
The rejected alternative was to append a dated event line inside each incident
25+
file, keeping everything readable in the repo with no warehouse.
26+
27+
Against it:
28+
29+
- **Diffing means parsing prose.** "What changed since the previous run" becomes
30+
a Markdown-scraping exercise over files whose format exists for humans. Any
31+
format change silently breaks the diff.
32+
- **It reintroduces unbounded growth** — the exact thing the stable slug was
33+
chosen to avoid. A finding open for six months is a file with 180 event lines,
34+
and every agent run dirties the git tree.
35+
- **Findings that never file an incident would have nowhere to live.** Only
36+
`ACTIONABLE` severities write incident files. `additive`, `widening` and
37+
`enum_drift` findings would be absent from a file-based history entirely —
38+
and those are precisely the ones whose *accumulation over time* is the story
39+
the report is supposed to tell.
40+
- The ops tables already exist, already carry `run_at`, and are already
41+
partitioned by month. The table answer is consistent with how this codebase
42+
already records operational facts.
43+
44+
The incident files keep their existing job: the current state of each open
45+
actionable finding, readable in the repo, one file per finding.
46+
47+
## Why `ops.agent_runs` is a second table and not a flag
48+
49+
A clean run produces no findings, so it writes no rows to `ops.finding_log`. A
50+
report reading only that table cannot distinguish **"the agent ran and found
51+
nothing"** from **"the agent never ran"** — and it would render the second as a
52+
clean bill of health.
53+
54+
That is the same empty-delta blind spot this codebase has now closed four times:
55+
`silver_txn_quarantine_rate: SELECT 0.0`, the `range` check passing on an
56+
all-NULL column, `freshness` on no values, and (in
57+
[0006](0006-renames-paired-by-field-id-and-a-vacuous-guard-test.md)) an enum
58+
watch on a column holding nothing. One row per run, findings or not, closes it
59+
structurally rather than by remembering to check.
60+
61+
## The report takes no engine
62+
63+
`build_report(snapshot: ReportSnapshot) -> str` is handed a dataclass, not a
64+
warehouse. It therefore **cannot** compute a metric even by accident, which is
65+
what makes "the report recomputes nothing" a property rather than a comment.
66+
`load_snapshot()` is the only function in the module that touches an engine, and
67+
a test asserts the module never references `monitors.evaluate`, `sensors.detect`
68+
or `validator.validate`.
69+
70+
The reason is not tidiness. A report that recomputes a number can disagree with
71+
the monitor that raised the alert — and when those two disagree, the report is
72+
the one people believe.
73+
74+
## A bug found on the way in
75+
76+
`incident_slug()` hashed `table | kind | column | change`, with no monitor name.
77+
Every `monitor_breach` on a given table therefore hashed **identically**:
78+
79+
```
80+
bronze.yodlee_transactions_raw|monitor_breach|None|
81+
```
82+
83+
With one monitor breaching, invisible. With two, the second incident file
84+
silently overwrote the first — and the day-over-day diff would have reported a
85+
finding as "cleared" while it was still breaching, which is the worst possible
86+
lie for this feature to tell.
87+
88+
Fixed by extracting `sensors.finding_key()` as the single definition of finding
89+
identity, used by both the incident filename and the `ops.finding_log` row. They
90+
have to agree, or "first seen" describes something different from the file on
91+
disk. The filename also stopped rendering `None` for breaches that carry no
92+
column.
93+
94+
## Review round: two defects the tests did not reach
95+
96+
**1. Running `make agent` twice in one day doubled the findings.**
97+
`ops.finding_log` is append-only, and re-running the agent on the same date is
98+
an entirely ordinary thing for a human to do. The report counted *rows*, not
99+
findings, so a second run turned one open finding into "2 still open". A
100+
day-over-day diff whose counts are wrong is worse than no diff at all, because
101+
the counts are read as a measurement. `_latest_per_key` now collapses to one row
102+
per `finding_key` per run; the same applies to `make monitor`.
103+
104+
**2. The report was not byte-stable across regenerations.**
105+
The diff sections were built from set differences and rendered in set-iteration
106+
order, so simply regenerating a report reshuffled its lines. These are committed
107+
artifacts: an unstable render produces a git diff on every regeneration that has
108+
nothing to do with what changed, and a diff that is usually noise is a diff
109+
nobody reads. Now sorted by finding key, with a test that renders twice and
110+
compares.
111+
112+
Both were found by probing the shipped code, not by re-reading the diff. Neither
113+
would have failed a test that only asked "does the report render?"
114+
115+
## Scope note
116+
117+
`drift-demo --day N` was added because the report needed something to narrate.
118+
`evolve_all()` applies four schema changes in one shot, which produces one before
119+
and one after and no story in between. Bare `drift-demo` is unchanged.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Monitor Breach — bronze.yodlee_transactions_raw
2+
3+
**Severity:** breaking
4+
5+
**Detected as of:** 2026-07-01
6+
7+
## What was observed
8+
9+
[bronze_txn_row_count] 5 is 0% of trailing median 250000
10+
11+
## Why this severity
12+
13+
Monitor 'bronze_txn_row_count' breached on bronze.yodlee_transactions_raw: [bronze_txn_row_count] 5 is 0% of trailing median 250000 A monitor breach is, by definition, already a judged verdict -- there is no additive reading of a check that has already failed.
14+
15+
## Evidence
16+
17+
```
18+
{'monitor': 'bronze_txn_row_count', 'kind': 'row_count', 'column': None, 'metric': 5.0, 'baseline': 250000.0, 'status': 'breach'}
19+
```

docs/incidents/bronze-yodlee_transactions_raw-checkNumber-7b8d24a4.md renamed to docs/incidents/bronze-yodlee_transactions_raw-checkNumber-13673376.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Severity:** renaming
44

5-
**Detected as of:** 2026-06-30
5+
**Detected as of:** 2026-07-01
66

77
## What was observed
88

docs/incidents/bronze-yodlee_transactions_raw-volume_anomaly-f0497e07.md renamed to docs/incidents/bronze-yodlee_transactions_raw-volume_anomaly-9fdcb62b.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Severity:** breaking
44

5-
**Detected as of:** 2026-06-30
5+
**Detected as of:** 2026-07-01
66

77
## What was observed
88

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Monitor Breach — gold.forecast_training_set
2+
3+
**Severity:** breaking
4+
5+
**Detected as of:** 2026-07-01
6+
7+
## What was observed
8+
9+
[gold.forecast_training_set_arrival_gap] 1 missing period(s): 2026-07-01
10+
11+
## Why this severity
12+
13+
Monitor 'gold.forecast_training_set_arrival_gap' breached on gold.forecast_training_set: [gold.forecast_training_set_arrival_gap] 1 missing period(s): 2026-07-01 A monitor breach is, by definition, already a judged verdict -- there is no additive reading of a check that has already failed.
14+
15+
## Evidence
16+
17+
```
18+
{'monitor': 'gold.forecast_training_set_arrival_gap', 'kind': 'arrival_gap', 'column': 'trade_date', 'metric': 1.0, 'baseline': None, 'status': 'breach'}
19+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Monitor Breach — silver.stock_prices
2+
3+
**Severity:** breaking
4+
5+
**Detected as of:** 2026-07-01
6+
7+
## What was observed
8+
9+
[silver.stock_prices_arrival_gap] 1 missing period(s): 2026-07-01
10+
11+
## Why this severity
12+
13+
Monitor 'silver.stock_prices_arrival_gap' breached on silver.stock_prices: [silver.stock_prices_arrival_gap] 1 missing period(s): 2026-07-01 A monitor breach is, by definition, already a judged verdict -- there is no additive reading of a check that has already failed.
14+
15+
## Evidence
16+
17+
```
18+
{'monitor': 'silver.stock_prices_arrival_gap', 'kind': 'arrival_gap', 'column': 'trade_date', 'metric': 1.0, 'baseline': None, 'status': 'breach'}
19+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Monitor Breach — silver.transactions
2+
3+
**Severity:** breaking
4+
5+
**Detected as of:** 2026-07-01
6+
7+
## What was observed
8+
9+
[silver.transactions_arrival_gap] 1 missing period(s): 2026-07-01
10+
11+
## Why this severity
12+
13+
Monitor 'silver.transactions_arrival_gap' breached on silver.transactions: [silver.transactions_arrival_gap] 1 missing period(s): 2026-07-01 A monitor breach is, by definition, already a judged verdict -- there is no additive reading of a check that has already failed.
14+
15+
## Evidence
16+
17+
```
18+
{'monitor': 'silver.transactions_arrival_gap', 'kind': 'arrival_gap', 'column': 'txn_date', 'metric': 1.0, 'baseline': None, 'status': 'breach'}
19+
```

0 commit comments

Comments
 (0)