Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,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.
Expand Down
13 changes: 10 additions & 3 deletions services/api/app/routes/evidence.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions services/api/tests/test_evidence_source_updates.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading