From 9cbdb989ac6a3e2da1a7d321d038037d9bb5739e Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 27 Jun 2026 10:39:15 -0700 Subject: [PATCH] Surface reconciliation in the dashboard and keep the view read-only Two gaps in the baseline-reconciliation feature: - The dashboard claimed to summarise acknowledged drift, but renderIntegrity() never rendered it. Add a `recon` chip to the integrity summary and a Reconciliation state-card (accepted/ok/stale counts plus stale items). - A read-only GET /api/integrity could write the store: list() flushed a TTL-lapsed ok->stale transition to disk, violating the read-only dashboard invariant. Add list(persist=False) and use it from the web summary path so staleness is evaluated in memory without touching the file. Covered by a new test asserting integrity_report() never rewrites the store. Co-Authored-By: Claude Opus 4.8 --- enodia_sentinel/reconcile.py | 9 ++++++--- enodia_sentinel/static/dashboard.html | 25 ++++++++++++++++++++++++- enodia_sentinel/web.py | 9 +++++---- tests/test_reconcile_integration.py | 14 ++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/enodia_sentinel/reconcile.py b/enodia_sentinel/reconcile.py index 7848cdd..c25f4ce 100644 --- a/enodia_sentinel/reconcile.py +++ b/enodia_sentinel/reconcile.py @@ -244,9 +244,11 @@ class ReconcileStore: # -- queries ----------------------------------------------------------- def list(self, stale_only: bool = False, live_fps: dict | None = None, - now: datetime | None = None) -> list[Record]: + now: datetime | None = None, persist: bool = True) -> list[Record]: """All records, re-evaluating staleness against TTL and any supplied - live state. Persists newly detected transitions (the lazy write-back). + live state. Persists newly detected transitions (the lazy write-back) + unless ``persist=False`` — the read-only dashboard path needs the + recomputed status without touching disk. TTL expiry is always authoritative. A content-bound (fim/pkgfile) ack is only re-checked when ``live_fps`` actually carries that kind's data; with @@ -257,7 +259,8 @@ class ReconcileStore: live_fps = live_fps or {} for rec in self._records.values(): self._refresh_listed(rec, live_fps, now) - self.flush_stale() + if persist: + self.flush_stale() out = [r for r in self._records.values() if not stale_only or r.status == "stale"] return sorted(out, key=lambda r: (r.kind, r.target)) diff --git a/enodia_sentinel/static/dashboard.html b/enodia_sentinel/static/dashboard.html index 02c0ccd..b6bcacc 100644 --- a/enodia_sentinel/static/dashboard.html +++ b/enodia_sentinel/static/dashboard.html @@ -593,7 +593,8 @@ function renderIntegrity(){ "watchdog "+(checks.watchdog||"unknown"), "fim "+(checks.fim_baseline||"unknown"), "pkgdb "+(checks.pkgdb_anchor||"unknown"), - "siglevel "+(checks.pacman_siglevel||"unknown") + "siglevel "+(checks.pacman_siglevel||"unknown"), + "recon "+(checks.reconciliation||"unknown") ])); summary.append(el("div","fine","Generated "+new Date((report.generated_at||0)*1000).toLocaleString())); box.append(summary); @@ -653,6 +654,28 @@ function renderIntegrity(){ footCard.append(paths); grid.append(footCard); + const recon = report.reconciliation || {total:0, ok:0, stale:0, stale_items:[]}; + const reconCard = el("article","state-card "+(recon.stale ? "review" : "ok")); + reconCard.append(el("h3",null,"Reconciliation")); + addStateLine(reconCard,"accepted",recon.total); + addStateLine(reconCard,"ok",recon.ok); + addStateLine(reconCard,"stale",recon.stale); + if((recon.stale_items||[]).length){ + const stale = el("div","path-list"); + recon.stale_items.forEach(it=>{ + const row = el("div","path-row missing"); + row.append(el("b",null,"stale")); + row.append(el("span",null,it.kind+" "+it.target+" — "+(it.reason||""))); + stale.append(row); + }); + reconCard.append(stale); + } else if(recon.total){ + reconCard.append(el("div","fine","All acknowledgements still match live state.")); + } else { + reconCard.append(el("div","fine","No acknowledged drift.")); + } + grid.append(reconCard); + box.append(grid); } function renderRules(){ diff --git a/enodia_sentinel/web.py b/enodia_sentinel/web.py index d7e51bd..fbac22e 100644 --- a/enodia_sentinel/web.py +++ b/enodia_sentinel/web.py @@ -421,11 +421,12 @@ def integrity_report(cfg: Config, status: dict | None = None, def _reconciliation_summary(cfg: Config) -> dict: - """Acknowledged-drift counts for the dashboard. Read-only: this evaluates - TTL expiry and persisted status, but does not run live FIM/package scans - from the web request path (consistent with the rest of integrity_report).""" + """Acknowledged-drift counts for the dashboard. Read-only: it evaluates TTL + expiry and persisted status in memory but never writes the store + (``persist=False``), and does not run live FIM/package scans from the web + request path (consistent with the rest of integrity_report).""" from . import reconcile - records = reconcile.ReconcileStore.load(cfg).list() + records = reconcile.ReconcileStore.load(cfg).list(persist=False) stale = [r for r in records if r.status == "stale"] return { "total": len(records), diff --git a/tests/test_reconcile_integration.py b/tests/test_reconcile_integration.py index a957547..204b47a 100644 --- a/tests/test_reconcile_integration.py +++ b/tests/test_reconcile_integration.py @@ -132,6 +132,20 @@ class WebReconciliationBlockTest(unittest.TestCase): self.assertEqual(recon["stale"], 1) self.assertEqual(report["checks"]["reconciliation"], "review") + def test_integrity_report_never_writes_store(self): + # The dashboard is read-only: rendering the integrity view must not + # persist a TTL-lapsed ack, even though it now reports it as stale. + past = reconcile._utcnow() - __import__("datetime").timedelta(days=1) + store = reconcile.ReconcileStore.load(self.cfg) + store.accept("listener", "2/b", {"key": "2/b"}, "expired", "luna", + expires_at=past) # status persisted as "ok" + reconcile.ReconcileStore.invalidate_cache() + path = reconcile.ReconcileStore.store_path(self.cfg) + before = path.read_bytes() + web.integrity_report(self.cfg) + self.assertEqual(path.read_bytes(), before, + "integrity_report wrote to the reconciliation store") + if __name__ == "__main__": unittest.main()