From db01fda4b46197e160740438c9a1e94947fbbeca Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:30:10 +0200 Subject: [PATCH 1/2] fix: source edits keep the ingest key; a disabled source takes no evidence --- CHANGELOG.md | 4 ++ services/api/app/routes/evidence.py | 13 ++++-- .../api/tests/test_evidence_source_updates.py | 44 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 services/api/tests/test_evidence_source_updates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 91336d4..47356a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ a decision with visible consequences. ## Unreleased +- **Editing an evidence source keeps its listener working.** New secrets on `POST /evidence/sources` + replaced the whole secret map, dropping the ingest key from `rotate-ingest-key`, so the listener + was cut off; the ingest key now stays. `POST /evidence` refuses a disabled source (409), and a + source's "last ingested" is the arrival time, not the event's own time. - **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/evidence.py b/services/api/app/routes/evidence.py index 8042eae..b92669d 100644 --- a/services/api/app/routes/evidence.py +++ b/services/api/app/routes/evidence.py @@ -1,6 +1,6 @@ import json import secrets -from datetime import datetime +from datetime import UTC, datetime from app.core.agent import get_agent_id from app.core.db import SessionLocal @@ -89,7 +89,11 @@ def upsert_source(payload: EvidenceSourceUpsertRequest): row.description = payload.description.strip() row.config_json = json.dumps(payload.config) if payload.secrets: - row.secret_json = next_secret_json + # New secrets replace the old ones, but never the listener's ingest key, which + # only rotate-ingest-key sets; dropping it would cut the listener off. + current = json.loads(row.secret_json or "{}") + kept = {"ingest_key": current["ingest_key"]} if current.get("ingest_key") else {} + row.secret_json = json.dumps({**kept, **payload.secrets}) row.enabled = payload.enabled db.commit() db.refresh(row) @@ -139,6 +143,8 @@ def create_evidence(payload: EvidenceObjectCreateRequest, agent_id: str = Depend source = db.execute(select(EvidenceSource).where(EvidenceSource.source_key == source_key)).scalar_one_or_none() if source is None: raise HTTPException(404, f"Evidence source '{source_key}' not found") + if not source.enabled: + raise HTTPException(409, f"Evidence source '{source_key}' is disabled") occurred_at = parse_timestamp(payload.occurred_at, "occurred_at") row = EvidenceObject( agent_id=agent_id, @@ -154,7 +160,8 @@ def create_evidence(payload: EvidenceObjectCreateRequest, agent_id: str = Depend occurred_at=occurred_at or datetime.utcnow(), ) db.add(row) - source.last_ingested_at = row.occurred_at + # When it arrived, not when the event happened (which may be long ago or ahead). + source.last_ingested_at = datetime.now(UTC) db.commit() db.refresh(row) return _evidence_to_dict(row) diff --git a/services/api/tests/test_evidence_source_updates.py b/services/api/tests/test_evidence_source_updates.py new file mode 100644 index 0000000..ee4058d --- /dev/null +++ b/services/api/tests/test_evidence_source_updates.py @@ -0,0 +1,44 @@ +"""Editing an evidence source keeps its listener working, and a disabled source takes nothing.""" + +import json + +from app.models.evidence_source import EvidenceSource +from memory_stack import stack as stack +from sqlalchemy import select + +SOURCE = {"source_key": "notes", "source_type": "manual", "label": "Notes"} + + +def _post(stack, path, body): + return stack.http.post(path, headers=stack.admin_headers(), json=body) + + +def _secrets(stack): + with stack.sessions() as db: + row = db.execute(select(EvidenceSource).where(EvidenceSource.source_key == "notes")).scalar_one() + return json.loads(row.secret_json) + + +def test_new_secrets_keep_the_listeners_ingest_key(stack): + _post(stack, "/evidence/sources", SOURCE) + key = _post(stack, "/evidence/sources/notes/rotate-ingest-key", {}).json()["ingest_key"] + + _post(stack, "/evidence/sources", {**SOURCE, "secrets": {"api_key": "abc"}}) + + assert _secrets(stack) == {"ingest_key": key, "api_key": "abc"} + + +def test_a_disabled_source_takes_no_evidence_and_last_ingested_is_arrival_time(stack): + _post(stack, "/evidence/sources", SOURCE) + old = _post(stack, "/evidence", {"source_key": "notes", "title": "Old note", "summary": "from 2001", + "occurred_at": "2001-01-01T00:00:00Z"}) + assert old.status_code == 200, old.text + with stack.sessions() as db: + source = db.execute(select(EvidenceSource).where(EvidenceSource.source_key == "notes")).scalar_one() + assert source.last_ingested_at.year >= 2026 + + _post(stack, "/evidence/sources", {**SOURCE, "enabled": False}) + refused = _post(stack, "/evidence", {"source_key": "notes", "title": "Late", "summary": "after disable"}) + + assert refused.status_code == 409 + assert [row["title"] for row in stack.get("/evidence").json()["results"]] == ["Old note"] From 9f785f549c73f0c6b5957a069b6db40e2fa21c34 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:25:13 +0200 Subject: [PATCH 2/2] changelog: place entry apart from other open PRs --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95024f1..e0ef9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,6 @@ a decision with visible consequences. ## Unreleased -- **Editing an evidence source keeps its listener working.** New secrets on `POST /evidence/sources` - replaced the whole secret map, dropping the ingest key from `rotate-ingest-key`, so the listener - was cut off; the ingest key now stays. `POST /evidence` refuses a disabled source (409), and a - source's "last ingested" is the arrival time, not the event's own time. - - **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 @@ -18,6 +13,11 @@ a decision with visible consequences. while the address is locked, and failures older than 15 minutes no longer count. Applies to the admin key, read keys, listener keys and the current-key confirmations. +- **Editing an evidence source keeps its listener working.** New secrets on `POST /evidence/sources` + replaced the whole secret map, dropping the ingest key from `rotate-ingest-key`, so the listener + was cut off; the ingest key now stays. `POST /evidence` refuses a disabled source (409), and a + source's "last ingested" is the arrival time, not the event's own time. + - **No model answer, no AI analysis.** When no model answered (Pi down, local model off, hosted 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.