From a1aeaef5278c8bdd5e4709606ebee9d22ee6380e Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:30:14 +0200 Subject: [PATCH 1/2] fix: the audit list is bounded; metrics count in SQL --- CHANGELOG.md | 3 ++ services/api/app/routes/audit.py | 29 ++++++++++--------- services/api/tests/test_audit_list_bounded.py | 16 ++++++++++ 3 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 services/api/tests/test_audit_list_bounded.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 91336d4..1765f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ a decision with visible consequences. ## Unreleased +- **The audit list is bounded.** `GET /audit` returned every row ever written; it now returns the + newest `limit` rows (default 500, at most 2000). `/audit/metrics` counts with GROUP BY instead of + loading every row of the window. - **Merging entities keeps lineage and the graph whole.** The startup dedup and the manual merge (`POST /entity/merge`) now share one routine: lineage links to the removed entity move to the kept one, an edge between the two (a would-be self-loop) or one the kept entity already has diff --git a/services/api/app/routes/audit.py b/services/api/app/routes/audit.py index 76560a5..057c179 100644 --- a/services/api/app/routes/audit.py +++ b/services/api/app/routes/audit.py @@ -3,19 +3,25 @@ from app.core.db import SessionLocal from app.models.audit import MemoryAudit from fastapi import APIRouter -from sqlalchemy import select +from sqlalchemy import func, select router = APIRouter(prefix="/audit", tags=["audit"]) READ_ACTIONS = {"runtime_context", "runtime_ask", "read"} WRITE_ACTIONS = {"write", "upgrade", "edit", "delete", "transcript_write", "transcript_delete"} +DEFAULT_LIMIT = 500 +MAX_LIMIT = 2000 + + @router.get("") -def list_audit(): +def list_audit(limit: int = DEFAULT_LIMIT): + """The newest audit rows, at most `limit` (1..2000): the table grows with every read.""" db = SessionLocal() try: rows = db.execute( - select(MemoryAudit).order_by(MemoryAudit.created_at.desc()) + select(MemoryAudit).order_by(MemoryAudit.created_at.desc(), MemoryAudit.id.desc()) + .limit(max(1, min(limit, MAX_LIMIT))) ).scalars().all() return [ @@ -38,19 +44,16 @@ def audit_metrics(hours: int = 24): cutoff = datetime.now(UTC) - timedelta(hours=bounded_hours) db = SessionLocal() try: - rows = db.execute( - select(MemoryAudit.action, MemoryAudit.created_at).where(MemoryAudit.created_at >= cutoff) + counts = db.execute( + select(MemoryAudit.action, func.count()).where(MemoryAudit.created_at >= cutoff) + .group_by(MemoryAudit.action) ).all() by_action: dict[str, int] = {} - reads = 0 - writes = 0 - for action, _created_at in rows: + for action, count in counts: key = str(action or "unknown") - by_action[key] = by_action.get(key, 0) + 1 - if key in READ_ACTIONS: - reads += 1 - if key in WRITE_ACTIONS: - writes += 1 + by_action[key] = by_action.get(key, 0) + count + reads = sum(count for key, count in by_action.items() if key in READ_ACTIONS) + writes = sum(count for key, count in by_action.items() if key in WRITE_ACTIONS) return { "window_hours": bounded_hours, "reads": reads, diff --git a/services/api/tests/test_audit_list_bounded.py b/services/api/tests/test_audit_list_bounded.py new file mode 100644 index 0000000..e29d3fc --- /dev/null +++ b/services/api/tests/test_audit_list_bounded.py @@ -0,0 +1,16 @@ +"""The audit list is bounded: it returns the newest rows, at most `limit`.""" + +from app.models.audit import MemoryAudit +from memory_stack import stack as stack + + +def test_the_audit_list_returns_at_most_the_limit_newest_first(stack): + with stack.sessions() as db: + db.add_all([MemoryAudit(id=f"a{n:03d}", action="read", payload_json="{}") for n in range(30)]) + db.commit() + + assert len(stack.get("/audit?limit=10").json()) == 10 + assert len(stack.get("/audit?limit=0").json()) == 1 + assert len(stack.get("/audit").json()) == 30 + metrics = stack.get("/audit/metrics").json() + assert metrics["by_action"]["read"] == 30 and metrics["reads"] == 30 From f1ded5a64dd7a3b7a2c5e9d4cc7dc45efe02587f Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:25:28 +0200 Subject: [PATCH 2/2] changelog: place entry apart from other open PRs --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8bde3f..3a9ca4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,6 @@ a decision with visible consequences. ## Unreleased -- **The audit list is bounded.** `GET /audit` returned every row ever written; it now returns the - newest `limit` rows (default 500, at most 2000). `/audit/metrics` counts with GROUP BY instead of - loading every row of the window. - - **A lockout stops wrong keys, never the right one.** After five wrong keys from one address, every key from that address was refused for 300 s, so one misconfigured caller behind a proxy or the host network locked out the rest, and failures never expired. Now the key is checked @@ -21,6 +17,10 @@ a decision with visible consequences. call refused), evidence processing stored an empty `local_ai_proposal` analysis, so the evidence counted as analysed and was never analysed again. Now nothing is stored. +- **The audit list is bounded.** `GET /audit` returned every row ever written; it now returns the + newest `limit` rows (default 500, at most 2000). `/audit/metrics` counts with GROUP BY instead of + loading every row of the window. + - **The conflict check reads whole words and keeps the owner's words in recall.** It counted "not" inside "knot" or "cannot " and matched long words as substrings over the first eight only. Now it compares whole words ("not", "never", "no longer", "stopped", "cannot", "-n't"