Skip to content

Commit 9b13a6a

Browse files
committed
Merge 0.5.11 — FathomDB as a capability, not a sink (internal milestone)
Engine migration to fathomdb 0.8.21 with a legacy-file guard, provenance-keyed storage (source_id = authenticated client ID) with engine-owned projections, bounded readers, engine-served log search (#11), per-client erasure (CLI + admin API), TUI ballast (#24, #27, #28), and the two standing owner decisions. Internal milestone: no version bump, no v* tag, no PyPI publish.
2 parents 8a5e4d8 + 66f89ab commit 9b13a6a

32 files changed

Lines changed: 2379 additions & 348 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,4 @@ agents/.aural/
7272
# Benchmark corpora — third-party datasets, not redistributed by Airlock.
7373
# See dev/corpora/README.md for provenance and licenses.
7474
data/
75+
.coverage

CHANGELOG.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Internal 0.5.11 milestone (not published, 2026-08-07)
11+
12+
The FathomDB 0.8 milestone: the engine migration and its legacy-file guard,
13+
provenance-keyed storage with engine-owned projections, bounded readers,
14+
engine-served log search (#11), per-client erasure, and TUI ballast
15+
(#24, #27, #28). Everything below ships publicly with the next published
16+
release; the breaking FathomDB change applies only to `db`-extra users.
17+
18+
### Added
19+
20+
- **TUI: failover audit trail (#24).** The model detail pane shows the recent
21+
failovers involving that model (timestamp, original → target, reason) from
22+
an incremental JSONL tail, and the Overview status line counts failovers in
23+
the last 5 minutes — what *actually happened*, alongside the configured
24+
chain the models table already shows.
25+
- **TUI: provider-wide rate-limit escalation (#27).** `impacted_clients` (a
26+
live-only value the separate-process TUI cannot compute from its replica)
27+
is now part of the admin provider snapshot; the providers table badges the
28+
Impacted cell with `⚠ESC` at the escalation threshold, the provider detail
29+
pane names the impacted clients and whether escalation is ongoing, and a
30+
`provider_escalation` alert rule fires on the event itself.
31+
- **TUI: Gemini response-mode distribution (#28).** Provider and client detail
32+
panes show mode percentages instead of raw counts, with a skew flag when a
33+
single mode exceeds 80% of a meaningful sample (≥10 recent responses).
34+
35+
- **Per-client erasure (CLI + admin API).** `airlock admin erase-client
36+
<client-id> --confirm <client-id>` and `POST
37+
/airlock/admin/clients/{client_id}/erase` remove every FathomDB row whose
38+
provenance is that authenticated client id. Loopback-only (like
39+
`force_quarantine`), audited with the full `EraseReport` in the
40+
`admin_action` record, idempotent, and honest about failure: a partial
41+
erasure answers HTTP 409 with the obligation outstanding and is never
42+
reported as done. **Scope:** this erases the search/analysis store only —
43+
JSONL logs are untouched and governed separately by `AIRLOCK_MAX_LOG_DAYS`;
44+
a user-facing deletion obligation requires both.
45+
- **Log search served by the engine (#11).** `search_request_logs` exposes
46+
FathomDB's search over the FTS projections (error text, messages, response
47+
text), and the advisor gains a `search_logs` tool on the same seam. The
48+
result labels itself honestly: `hybrid` only when dense retrieval could
49+
actually contribute; `lexical_only` with the reason otherwise (the normal
50+
path here — no embedder is configured); the advisor's JSONL fallback is
51+
labelled `substring`. A degraded result is never presented as hybrid, and a
52+
hybrid result carries `soft_fallback` when a branch could not contribute.
53+
54+
### ⚠️ Breaking
55+
56+
- **FathomDB migrated to 0.8.x (`fathomdb>=0.8.21,<0.9`).** The 0.3.x store is
57+
abandoned with no migration path: Airlock now refuses to open a 0.3.x database
58+
file (naming the file and the reason) rather than letting 0.8.x silently adopt
59+
it, and the default database filename moved from `airlock.db` to
60+
`airlock-fathom.db` so an existing state directory can never be adopted by
61+
accident. Old files are left in place; their records remain in the JSONL logs.
62+
63+
### Changed
64+
65+
- The opt-in remote analyzer executor defaults to `claude-sonnet-5`
66+
(was `claude-sonnet-4-5`); `AIRLOCK_ANALYZER_REMOTE_MODEL` still overrides.
67+
Owner decision — this is a paid path.
68+
- The Fathom request logger writes 0.8.x dict batches with a mandatory
69+
`source_id` on every row: the **authenticated client ID** (`key:<last8>`),
70+
stamped by the guardian at pre-call from the validated bearer key and always
71+
overwriting any client-supplied value — never the forgeable
72+
`X-Airlock-Client` header. Unauthenticated traffic collapses to the
73+
`no_client` sentinel, so no write path can produce an unerasable row.
74+
`source_id` is the axis per-client erasure will target.
75+
- RequestLog projections (filterable/rankable fields, FTS over error text,
76+
messages, and response text) are declared to the engine via
77+
`configure_projections`; Airlock no longer maintains any derived index.
78+
No vector projection is declared — no embedder is configured, by design.
79+
- `api/queries.py` reads through `fathomdb.read` with an explicit `ReadView`
80+
(active rows only) and the typed `fathomdb.errors` hierarchy — the
81+
`AttributeError`-based capability sniffing and silent empty-list fallbacks
82+
are gone.
83+
- **No datastore read is unbounded anymore.** `get_request_logs` /
84+
`get_billing_metrics` default to the shared 50k `DATASTORE_QUERY_LIMIT`
85+
(the old default was `limit=1000000` — a limit in name only) and report
86+
truncation instead of dropping it: billing metrics carry
87+
`truncated`/`limit_hit`, the TUI Overview marks a partial cost sum with
88+
`(partial)`, and the advisor's `get_recent_errors` reports the datastore
89+
window the same way it reports the JSONL one.
90+
91+
### Removed
92+
93+
- `_ensure_vector_stub_table` (a 0.3.1 write-path workaround; 0.8.x owns its
94+
indexes).
95+
- `search_logs` and its Python-side substring fallback, superseded by the
96+
engine's hybrid search (#11).
97+
1098
## [0.5.10] — 2026-08-05
1199

12100
The first published release since 0.5.8. It carries **two milestones**: the

airlock/admin/erase.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Per-client erasure from the FathomDB store (0.5.11 B-2).
2+
3+
``erase_source(client_id)`` removes every row under a provenance together with
4+
its full-text and secondary-index shadows, and finishes the erasure at rest.
5+
It is idempotent, so an interrupted obligation can be retried.
6+
7+
**Scoping — this is not full erasure.** It erases a client from the **search
8+
and analysis store**. The same records exist in JSONL, which ``erase_source``
9+
does not touch; JSONL retention is governed separately by
10+
``AIRLOCK_MAX_LOG_DAYS``. A user-facing deletion obligation requires both, and
11+
the JSONL half is explicitly out of scope for 0.5.11.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import datetime
17+
from typing import Any
18+
19+
20+
class EraseIncomplete(Exception):
21+
"""An erasure half-completed: the obligation is OUTSTANDING.
22+
23+
Never reported as success — the honest response is "incomplete, retry".
24+
Retrying is safe; that is what ``erase_source``'s idempotence buys.
25+
Carries the ``admin_action`` audit record for the failed attempt.
26+
"""
27+
28+
def __init__(self, record: dict[str, Any]):
29+
super().__init__(record.get("error") or "erasure incomplete")
30+
self.record = record
31+
32+
33+
def _iso_now() -> str:
34+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
35+
36+
37+
def _report_dict(report: Any) -> dict[str, Any]:
38+
"""Serialize an ``EraseReport`` — the receipt, not a bare "ok".
39+
40+
An erasure audit trail that records only that the call was made cannot
41+
answer what was actually removed.
42+
"""
43+
return {
44+
"source_ref": report.source_ref,
45+
"nodes_excised": report.nodes_excised,
46+
"edges_excised": report.edges_excised,
47+
"projections_invalidated": report.projections_invalidated,
48+
}
49+
50+
51+
def erase_client(client_id: str, actor: str, *, confirm: Any) -> dict[str, Any]:
52+
"""Erase every FathomDB row whose provenance is ``client_id``.
53+
54+
``confirm`` must repeat the client id — erasure is irreversible and must
55+
not be a single mistyped word away.
56+
57+
Returns an ``admin_action`` record carrying the ``EraseReport``.
58+
59+
Raises
60+
------
61+
ValueError
62+
Confirmation mismatch, or the datastore is not enabled here.
63+
EraseIncomplete
64+
The erasure half-completed; the record says so and retry is safe.
65+
"""
66+
if not client_id:
67+
raise ValueError("client_id is required")
68+
if confirm != client_id:
69+
raise ValueError(
70+
"confirmation mismatch: pass the client id again as 'confirm' "
71+
"to run this irreversible operation"
72+
)
73+
74+
import airlock.datastore as datastore
75+
76+
engine = datastore.get_engine()
77+
if engine is None:
78+
raise ValueError(
79+
"FathomDB is not enabled on this proxy (AIRLOCK_ENABLE_FATHOMDB "
80+
"unset or the db extra is not installed); nothing to erase here"
81+
)
82+
83+
from fathomdb.errors import ErasureIncompleteError
84+
85+
base: dict[str, Any] = {
86+
"record_type": "admin_action",
87+
"timestamp": _iso_now(),
88+
"op": "erase_client",
89+
"actor": actor,
90+
"client_id": client_id,
91+
# The scoping constraint, carried into the audit trail itself.
92+
"scope_note": "fathomdb store only; JSONL retention is governed by AIRLOCK_MAX_LOG_DAYS",
93+
}
94+
try:
95+
report = engine.erase_source(client_id)
96+
except ErasureIncompleteError as exc:
97+
raise EraseIncomplete(
98+
{
99+
**base,
100+
"outcome": "incomplete",
101+
"error": str(exc),
102+
"retry_safe": True,
103+
}
104+
) from exc
105+
return {**base, "outcome": "complete", "erase_report": _report_dict(report)}

airlock/admin/http.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing import Any
1515

1616
import airlock.fast.state as _state
17+
from airlock.admin.erase import EraseIncomplete, erase_client
1718
from airlock.admin.policy import LOOPBACK_HOSTS, Principal, admin_enabled, decide
1819
from airlock.callbacks.enterprise_logger import write_admin_action_record
1920
from airlock.fast.settings import get_settings
@@ -40,6 +41,9 @@ def _view_providers() -> dict:
4041
"cooldown_remaining": round(ps.cooldown_remaining(), 1) if ps else 0.0,
4142
"half_open": ps._half_open_probe if ps else False,
4243
"last_reason": ps.last_reason if ps else "",
44+
# Live-only state (rate_limit_events with CC-6 floors) — the
45+
# separate-process TUI cannot compute this from its replica (#27).
46+
"impacted_clients": sorted(ps.impacted_clients()) if ps else [],
4347
"remaining_tokens": rl.remaining_tokens if rl else None,
4448
"limit_tokens": rl.limit_tokens if rl else None,
4549
"remaining_requests": rl.remaining_requests if rl else None,
@@ -146,6 +150,13 @@ def _match_route(method: str, path: str):
146150
False,
147151
lambda p, b, a: _state.store.clear_client_backoff(client, actor=a),
148152
)
153+
if method == "POST" and len(seg) == 3 and seg[0] == "clients" and seg[2] == "erase":
154+
client = seg[1]
155+
return (
156+
"admin:erase_client",
157+
True, # loopback-only (operator) — destructive, like force_quarantine
158+
lambda p, b, a: erase_client(client, a, confirm=b.get("confirm")),
159+
)
149160
if (
150161
method == "POST"
151162
and len(seg) == 3
@@ -192,6 +203,11 @@ def handle_admin_request(
192203
write_admin_action_record(result)
193204
except ValueError as exc:
194205
return 400, {"error": str(exc)}, {}
206+
except EraseIncomplete as exc:
207+
# A partial erasure is never reported as complete. Audit the attempt,
208+
# answer 409 with the obligation outstanding; retrying is safe.
209+
write_admin_action_record(exc.record)
210+
return 409, dict(exc.record), {}
195211
except Exception: # noqa: BLE001 — the perimeter must never raise (CC-10)
196212
return 500, {"error": "internal error"}, {}
197213
return 200, result, {}

airlock/advisor/tools.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@
2323
from pathlib import Path
2424
from typing import Any, Callable
2525

26-
from airlock.api.queries import get_request_logs
26+
from airlock.api.queries import (
27+
DATASTORE_QUERY_LIMIT,
28+
get_request_logs,
29+
node_properties,
30+
search_request_logs,
31+
)
2732
from airlock.fast.state import StateStore
2833
from airlock.log_query import LogPage, LogQuery, query_logs
2934

30-
#: Upper bound on rows pulled from the datastore in one advisor query.
31-
DATASTORE_QUERY_LIMIT = 50_000
32-
3335
logger = logging.getLogger("airlock.advisor.tools")
3436

3537

@@ -145,10 +147,11 @@ def get_recent_errors(log_dir: str, days: int = 2) -> dict:
145147

146148
truncated: dict[str, Any] = {"truncated": False, "limit_hit": None}
147149
if engine is not None:
148-
# Was limit=1000000 — a limit in name only, and every row was
149-
# materialized before filtering.
150+
# Bounded read (was limit=1000000 — a limit in name only). The failure
151+
# filter stays Python-side: the engine's json-path predicate allowlist
152+
# is fixed and does not cover $.success.
150153
nodes = get_request_logs(engine, limit=DATASTORE_QUERY_LIMIT)
151-
records = [n.properties if hasattr(n, "properties") else n for n in nodes]
154+
records = [node_properties(n) for n in nodes]
152155
failures = [
153156
r
154157
for r in records
@@ -200,6 +203,48 @@ def get_recent_errors(log_dir: str, days: int = 2) -> dict:
200203
}
201204

202205

206+
# ---------------------------------------------------------------------------
207+
# Tool 2b: search_logs
208+
# ---------------------------------------------------------------------------
209+
210+
211+
def search_logs(log_dir: str, query: str, limit: int = 20, days: int = 7) -> dict:
212+
"""Search request logs (#11): engine search when available, JSONL scan otherwise.
213+
214+
The engine path reports its ``mode`` honestly — ``hybrid`` only when dense
215+
retrieval actually contributed alongside lexical; ``lexical_only`` with the
216+
reason otherwise. The JSONL fallback is a bounded substring scan and says
217+
so: ``substring`` is not search-engine ranking and must not look like it.
218+
"""
219+
engine = None
220+
try:
221+
import airlock.datastore
222+
223+
engine = airlock.datastore.get_engine()
224+
except Exception:
225+
engine = None
226+
227+
if engine is not None:
228+
out = search_request_logs(engine, query, limit=limit)
229+
out["backend"] = "fathomdb"
230+
return out
231+
232+
needle = query.lower()
233+
234+
def _matches(record: dict[str, Any]) -> bool:
235+
return needle in json.dumps(record, default=str).lower()
236+
237+
page = _load_page(log_dir, days=days, predicate=_matches)
238+
return {
239+
"backend": "jsonl",
240+
"mode": "substring",
241+
"degraded_reason": "datastore disabled; bounded JSONL substring scan",
242+
"soft_fallback": None,
243+
"results": page.records[-limit:],
244+
"window": _truncation(page),
245+
}
246+
247+
203248
# ---------------------------------------------------------------------------
204249
# Tool 3: get_analysis_report
205250
# ---------------------------------------------------------------------------
@@ -467,6 +512,30 @@ def get_knobs(log_dir: str) -> dict:
467512
"description": "Get recent error records grouped by model, client, and error type",
468513
},
469514
),
515+
"search_logs": (
516+
search_logs,
517+
{
518+
"type": "object",
519+
"properties": {
520+
"query": {
521+
"type": "string",
522+
"description": "Search text (matched against error text, messages, and response text)",
523+
},
524+
"limit": {
525+
"type": "integer",
526+
"description": "Maximum results to return",
527+
"default": 20,
528+
},
529+
"days": {
530+
"type": "integer",
531+
"description": "JSONL fallback window in days (ignored on the datastore path)",
532+
"default": 7,
533+
},
534+
},
535+
"required": ["query"],
536+
"description": "Search request logs. Result carries mode: hybrid, lexical_only (with reason), or substring (JSONL fallback)",
537+
},
538+
),
470539
"get_analysis_report": (
471540
get_analysis_report,
472541
{

0 commit comments

Comments
 (0)