Skip to content

feat(host-store): Postgres operational store — migrate JSON/in-process state off the filesystem - #36

Merged
jmlago merged 14 commits into
mainfrom
host-store
Jun 29, 2026
Merged

feat(host-store): Postgres operational store — migrate JSON/in-process state off the filesystem#36
jmlago merged 14 commits into
mainfrom
host-store

Conversation

@jmlago

@jmlago jmlago commented Jun 28, 2026

Copy link
Copy Markdown
Member

What

A single transactional Postgres store (host_store.py, psycopg3) for the
host's operational state — operator knob overrides, the provider overlay,
dashboard-issued consumer keys, and the call/usage ledger — migrated off the
scattered JSON/JSONL files + in-process dicts.

Why Postgres and not SQLite: the deployment runs the router and the ingress as
SEPARATE containers (router read-only on secrets, ingress read-write) sharing
state. A SQLite file can't bridge that asymmetry (mount RO/RW, WAL-reader-needs-RW,
multi-process fragility) — it was a real deploy-time blocker. A network DB both
containers reach over TCP solves it at the root, and is the end goal anyway.
Prod = RDS; dev = a compose postgres service (DATABASE_URL).

History note: the early commits on this branch built a SQLite version table by
table; the deployment topology (two containers) forced the jump to Postgres. The
commits tell that evolution; the end state is Postgres.

Landed (suite green against real Postgres throughout)

  • Backend: psycopg3 + ConnectionPool, schema created idempotently under a
    pg_advisory_lock (race-safe across both containers), each write its own
    transaction (atomic commit/rollback per with pool.connection() — the
    orphan-transaction class is impossible by construction).
  • Tables, fully migrated off JSON: settings_overrides (overrides.json),
    provider_overlays (providers.local.json), consumer_keys
    (issued-consumer-keys.json), and the calls fact ledger (alongside
    usage-history; GET /x/calls). One-shot idempotent legacy-JSON backfill at
    startup (router + ingress, guard-on-empty + advisory lock) so existing
    deployments migrate with no data loss.
  • Durable-write contract: set_* return a success bool; callers surface a
    persistence failure (a silently-failed key revoke would be a security hole) —
    config save, provider add, and the three key endpoints (update/revoke/rotate)
    now report 500 on failure.
  • Review fixes folded in (doctrine review + CodeRabbit): retention env
    validated; the ledger writer is a BOUNDED queue + dict(row) snapshot off the
    request latency path; get_consumer_keys fails CLOSED on an unreadable row;
    _ID_RE 2-40 off-by-one.
  • Infra: compose postgres:16 service + DATABASE_URL to both containers;
    psycopg[binary,pool] pinned; shell.nix updated.

Verify

Full suite 406 passed, 2 skipped, 0 failed against a real Postgres 16 under
nix-shell (tests run against the DB, no mocks; pure-logic tests need no DB). The
e2e (behave, features/) run against the live compose stack — the path that
exercises the two-container topology end to end.

Proposal for independent adjudication — not to self-merge.

Still ahead (separate PRs)

buyer_status ← market/status JSON, peer_offers ← market.json (+ the antseed
sidecar), and deriving route_* / retiring _stats + usage-history readers as
queries over calls — now unblocked because both containers reach the shared DB.

Summary by CodeRabbit

  • New Features

    • Added a new operator-facing view of recent call activity, including total request counts and capped history.
    • Provider and configuration changes now persist across restarts using shared app storage.
  • Bug Fixes

    • Improved reliability of dashboard updates by surfacing save failures instead of silently succeeding.
    • Added automatic migration of older saved data so existing setups continue to work after upgrade.
    • Configuration and provider changes now reload more consistently during startup and after updates.

First cut of a single transactional store for the host's operational state, which
is today scattered across JSON/JSONL files and in-process dicts. stdlib sqlite3
(no new dependency, no compose service), WAL mode, self-contained on the data
plane's volume.

`calls` is the FACT TABLE: one raw row per LLM call. It is the source of truth
from which per-route and per-session views (reliability/latency/ttft/cost/session
totals/cache-hot) will be DERIVED by query — not by host-side folding (combining
and scoring is a policy's job; the host stores raw).

This cut is additive and low-risk:
- host_store.py: open/schema/WAL, fail-soft insert_call, recent_calls/count,
  time-bounded retention (DELETE WHERE ts < cutoff — the discipline that fixed
  the usage-history OOMKilled crashloop).
- DUAL-WRITE: auth_proxy._append_usage_history also records the call into the
  ledger, alongside and independent of usage-history.jsonl. Replaces no reader
  yet, so the ledger fills and can be verified first. Fail-soft: never breaks a
  request.
- GET /x/calls: operator view of the ledger (in-act + verification surface).

Next: derive route_stats/sessions as LIVE queries over `calls`, then migrate the
usage-history and _stats readers, then drop the duplicate writes. Full table plan
(peer_offers, consumer_keys, provider_overlays, settings_overrides, buyer_status,
login_history; codex_accounts stays a file; secrets excluded) in the design doc.

Full suite 394 -> 400 passed (+6 new unit tests), 2 skipped, 0 failed; behaviour
preserved (additive).
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a Postgres-backed host store for calls and operator state, migrates settings/provider/consumer-key persistence to it, adds startup backfill, exposes recent calls through an API, and updates runtime wiring and tests to use the new store.

Changes

Host store operational state and call ledger

Layer / File(s) Summary
host_store: schema, queue, persistence
host_store.py
Defines the Postgres schema, connection pool, ledger writes and pruning, query helpers, durable operator-state tables, legacy migration, and test reset utilities.
settings, provider overlay, and auth proxy persistence
auth_proxy.py, provider_overlay.py, settings.py, serve.py
Moves settings overrides, provider overlays, and issued consumer keys onto host-store persistence, adds startup backfill, and makes dashboard writes fail when persistence fails.
compose, dependencies, and calls endpoint
compose.yml, requirements.txt, shell.nix, shim.py
Adds Postgres service wiring, updates container environment and health checks, adds the psycopg dependency, extends the dev shell, and exposes recent call history through GET /x/calls.
DB-backed test isolation and rewired persistence tests
tests/conftest.py, tests/test_auth_proxy_dashboard_full.py, tests/test_host_store.py, tests/test_provider_overlay.py, tests/test_settings.py, tests/test_sources.py
Replaces file-based fixtures with host-store-backed test isolation and updates dashboard, overlay, settings, and source tests to read and write through the new store.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I hopped from files to Postgres bright,
With calls in a ledger and keys tucked tight.
Overlays, settings, all found their new den,
The burrow reloaded and bloomed again.
🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: moving operational state from filesystem/JSON storage to a Postgres host store.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch host-store

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

jmlago added 3 commits June 28, 2026 19:22
Operator knob overrides now persist in the host store (settings_overrides table)
instead of overrides.json. Full migration — the JSON path is removed from the
runtime (read AND write go to the store); nothing in the app touches the file.

- host_store: settings_overrides(key, value JSON, updated_at) + get_overrides /
  set_overrides (whole-set replace, mirroring the old whole-file write; fail-soft).
- settings.py: reload() reads the store, validate_and_write() writes it; dropped
  OVERRIDES_PATH and all file I/O (and the now-unused json import).
- tests: test_settings + the sources override test now drive a tmp SQLite path
  instead of a tmp JSON file; the "bad value falls back to default" case now
  inserts a malformed row and asserts the default still wins.

Note: existing deployments' overrides.json values are not auto-imported (knobs are
ENV-defaulted, so defaults hold until re-set from the dashboard); a one-shot import
can be a separate migration script if needed.

Full suite 400 passed, 2 skipped, 0 failed; behaviour preserved.
Operator-added providers now persist in the host store (provider_overlays table)
instead of providers.local.json. Full migration — the JSON path is removed from
the runtime (load AND save go to the store); keys are still never stored here
(they live in .env.secrets via the auth_env indirection).

- host_store: provider_overlays(provider_id, entry JSON, added_at) +
  get_provider_overlays / set_provider_overlays (whole-set replace, fail-soft).
- provider_overlay.py: load_overlay()/save_overlay() go through the store;
  dropped overlay_path/DEFAULT_OVERLAY_PATH/PROVIDERS_OVERLAY_PATH and all file
  I/O (and the now-unused json/os/Path imports). validate_entry/apply_to_host
  unchanged. Callers (auth_proxy) already pass no path.
- tests: the overlay roundtrip + the dashboard-add + merge tests drive a tmp
  SQLite path instead of a JSON file.

Full suite 400 passed, 2 skipped, 0 failed; behaviour preserved.
Dashboard-issued consumer records now persist in the host store (consumer_keys
table) instead of issued-consumer-keys.json. Full migration — the JSON path is
removed from the runtime.

- host_store: consumer_keys(consumer, record JSON, updated_at) + get_consumer_keys
  -> (records, ok) and set_consumer_keys. `ok` is False only on a real store error,
  preserving the old load-failed safety (don't clobber good data; fail closed on
  unreadable metadata) — an empty table is ok=True.
- auth_proxy: _load_issued_keys / _write_issued_consumer_records go through the
  store; dropped DASHBOARD_ISSUED_KEYS_PATH and the file I/O.
- tests: the dashboard consumer-key tests drive a tmp SQLite store via helpers
  (_set_issued/_issued_data/_issued_text) instead of reading/writing the JSON;
  the malformed-metadata fail-closed test now sets bad records + simulates a store
  load failure (get_consumer_keys -> ok=False).

Full suite 400 passed, 2 skipped, 0 failed; behaviour preserved.
@jmlago jmlago changed the title feat(host-store): SQLite call ledger (foundation, dual-write) feat(host-store): SQLite operational store — migrate JSON state off the filesystem Jun 28, 2026
jmlago added 4 commits June 28, 2026 20:09
The settings/provider-overlay/consumer-key migrations remove the JSON readers, so
without this an existing deployment would boot with empty tables and lose its
operator config — for consumer_keys that means every dashboard-issued key stops
working until re-issued (an outage). This seeds the store from the legacy JSON
once, automatically, on the next deploy.

- host_store.migrate_legacy_json(): for each migrated table, _seed_if_empty()
  loads the legacy JSON (settings overrides / providers.local.json /
  issued-consumer-keys.json from their PVC paths) and seeds via the existing
  set_*. Guarded on table-EMPTY (not file-exists), so it never clobbers data
  written after migration and is a no-op on every later boot. Fail-soft: an
  absent or corrupt file leaves the table empty and logs (a corrupt legacy file
  migrates to an empty table, not a failure).
- serve.py: run it at startup before anything reads the store, then settings.reload()
  (whose import-time read saw the empty store).
- tests: seeds-when-empty, no-op-when-non-empty (no clobber), no-op-when-absent,
  fail-soft-on-corrupt.

Once /x/calls and the dashboard confirm the data post-deploy, the legacy JSON
files can be deleted; then migrate_legacy_json + its env paths can be removed.
Supersedes the "not auto-imported" notes on the earlier table commits.

Full suite 404 passed (+4 backfill tests), 2 skipped, 0 failed.
…enames

The settings/provider-overlay/consumer-key state lives in the SQLite store now,
not the JSON files. Refresh the docstrings and the dashboard add-provider help
text that still named providers.local.json. Cosmetic; no behaviour change.
…nnection

A set_*/insert write that failed AFTER its DELETE (e.g. an IO error mid-INSERT)
was caught and logged but left a PENDING transaction on the process-wide
connection: the DELETE stayed open and uncommitted. The next committer (an
insert_call on the very next request) would then flush that orphan DELETE,
silently wiping the table — including consumer_keys (credentials). Found by an
independent doctrine review of #36.

Wrap every write in `with c:` so the connection context-manager commits on
success and ROLLS BACK on any exception, inside the existing fail-soft try. A
half-done write can no longer leave a pending DELETE for a later commit to flush.

Test (guard verified by the rejection): a set_consumer_keys that fails mid-write
must leave the prior credentials intact AND survive the next insert_call commit.
Confirmed it FAILS on the pre-fix code (table wiped) and passes after.
insert_call ran synchronously under a process-wide lock with a SQLite commit on
the request's completion path — serializing every call's completion on the store
mutex and blocking the event loop on the write. Best-effort telemetry must not
sit on the latency path. Found by an independent doctrine review of #36.

Add a single-worker background writer; the hot path calls insert_call_async (a
fire-and-forget submit) instead of insert_call. The worker shares the connection
under the same lock + `with c:`, so it stays transaction-safe with set_*/reads.
insert_call stays synchronous for tests and the worker itself.

Used `concurrent.futures` rather than the review's asyncio.to_thread because the
call site (_record_request -> _append_usage_history) is synchronous and cannot
await. Noted the remaining pre-existing debt in a comment: the usage-history file
append is still synchronous blocking IO, to offload when its reader migrates onto
the ledger.

Full suite 405 passed, 2 skipped, 0 failed.

@jmlago jmlago left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ b719301 — follow-up to the prior doctrine review

The three new commits map 1:1 to the prior review's two blocking findings plus
the cosmetic cleanup: b719301 (offload) ↔ Tier 2 #1; 25769a9 (rollback) ↔
Tier 2 #2; 054cc30 (docs) ↔ the body's pending comment-text cleanup. Verified
against the tree, not the commit messages.

Verdict: APPROVE WITH COMMENTS

Both blocking Tier 2 findings are resolved and verified:

Tier 2 #1 — blocking IO + serialization on the async path → RESOLVED.
_append_usage_history now calls insert_call_async, which submits insert_call
to a single-worker ThreadPoolExecutor. The SQLite write and the process-wide
_lock move off the event loop; one worker preserves FIFO order and serializes
safely with set_*/reads under the same lock (check_same_thread=False already
present). The comment honestly records that the usage-history file append below
is still pre-existing synchronous blocking IO — the right scoping.

Tier 2 #2 — orphaned transaction poisons the shared connection → RESOLVED.
Every set_*, insert_call and _prune_locked now wraps its mutation in
with c: (commit on success, rollback on exception). Covered by a precise
new regression test, test_failed_write_rolls_back_and_does_not_poison_next_commit:
it forces an OperationalError mid-executemany after the DELETE and asserts
(a) the credentials stay intact and (b) the next insert_call commit does not
flush a phantom DELETE. Exactly the reported failure mode.

Comments (non-blocking)

  • [Axis 7 — unbounded writer queue] New, introduced by the offload. The
    ThreadPoolExecutor(max_workers=1) has an unbounded work queue. If insert
    rate ever outran the single worker's drain (e.g. the _lock held by a long
    set_*/prune during a burst), the Future queue would grow without bound — the
    same failure class the store exists to prevent (the OOMKilled lesson), in a
    different organ. In practice unreachable: local SQLite drain (µs–ms) outpaces
    LLM-call completion (~seconds/call) by orders of magnitude, so queue depth stays
    ~0. Flagged only because a maxsize with a drop-oldest policy is cheap insurance
    consistent with the "bound everything" discipline. Not blocking.
  • [ontology] Still standing: the ledger route_key (provider|family|served)
    diverges from the canonical route_reliability.route_key (provider|family|peer).
    Reconcile and enrich peer_id before deriving route_* from calls.
    Acknowledged in the description; tracked roadmap precondition, unchanged here.
  • reset() (test hook) does not touch _writer; harmless because tests use the
    synchronous insert_call, never _async. Minor.
  • Atomicity (Axis 5): the prior advisory to split the consumer_keys migration
    into its own revertable PR still stands as advisory, not blocking.

Verification

  • tests/test_host_store.py11 passed (10 → 11, +the rollback regression)
    under nix-shell + timeout @ b719301.
  • Layer (Axis 2): host_store still imports stdlib only (+concurrent.futures)
    → leaf, no cycle. Dependency (Axis 4): zero new deps (stdlib).

Path to merge

Mergeable. The only actionable item is the unbounded-queue comment, and it is
optional. Before merge: re-run the full suite locally (CI is notify-only, so the
"400 passed" figure is author testimony until reproduced) and, to keep it fresh,
regenerate the review object to record host_store in the ontology/layers/prior-art
and the new debt (still-synchronous file append, unbounded writer queue) in §8.

@jmlago
jmlago marked this pull request as ready for review June 28, 2026 19:44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
auth_proxy.py (1)

316-323: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not report key mutations as saved when persistence failed.

host_store.set_consumer_keys() catches all write exceptions and returns None, so rotations, revocations, or consumer settings can return success without durable metadata. A failed revoke can reappear after restart. Make the setter raise/return a status and abort the dashboard operation on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth_proxy.py` around lines 316 - 323, The persistence path in
_write_issued_consumer_records currently assumes host_store.set_consumer_keys
succeeds even though it swallows write failures, so mutations can be reported as
saved when they are not durable. Update host_store.set_consumer_keys to either
raise on persistence errors or return an explicit success status, then have
_write_issued_consumer_records and the dashboard mutation flow check that result
and abort/report failure for rotations, revocations, or consumer setting updates
when persistence does not complete.
🧹 Nitpick comments (1)
tests/test_provider_overlay.py (1)

111-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore malformed-overlay coverage on the SQLite path.

This roundtrip test no longer checks the old “bad persisted data loads as empty overlay” contract. Since po.load_overlay() now relies on host_store.get_provider_overlays() for that fail-soft behavior, please add the DB-backed equivalent here so the migration keeps that safety net covered.

Possible follow-up test
 def test_overlay_load_save_roundtrip(db):
     assert po.load_overlay() == {"providers": {}}
     overlay = {"providers": {"groq": {"base_url": "https://x", "auth_env": "G_KEY",
                                       "served_models": [{"family": "f"}]}}}
     po.save_overlay(overlay)
     assert po.load_overlay() == overlay
     po.save_overlay({"providers": {}})        # save empty -> overlay cleared
     assert po.load_overlay() == {"providers": {}}
+
+
+def test_overlay_load_ignores_malformed_rows(db):
+    with host_store._lock:
+        c = host_store._connect()
+        c.execute(
+            "INSERT INTO provider_overlays(provider_id, entry, added_at) VALUES (?,?,?)",
+            ("groq", "not json{", 0),
+        )
+        c.commit()
+    assert po.load_overlay() == {"providers": {}}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_provider_overlay.py` around lines 111 - 118, The roundtrip test in
test_overlay_load_save_roundtrip no longer covers the fail-soft behavior for
malformed persisted data on the SQLite path. Add a DB-backed assertion around
po.load_overlay() that seeds an invalid provider overlay through
host_store.get_provider_overlays() (or the underlying SQLite-backed store) and
verifies it still returns {"providers": {}}. Keep the existing save/load
roundtrip, and make sure the new coverage targets the load_overlay path that now
depends on host_store.get_provider_overlays().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@auth_proxy.py`:
- Around line 213-215: Preserve fail-closed behavior in the consumer key loading
path: `host_store.get_consumer_keys()` currently skips JSON-decode failures but
still reports success, which leaves `_issued_keys_load_failed` false and can let
`_consumer_meta()` fall back to default metadata. Update `get_consumer_keys()`
so any unreadable row makes `ok` false, or return an inactive tombstone for that
consumer, and keep the existing `_issued_keys_load_failed = not ok` handling in
`auth_proxy.py` so corrupted metadata cannot be silently ignored.

In `@host_store.py`:
- Around line 236-250: The durable write helpers are swallowing persistence
failures instead of surfacing them to callers. Update `set_overrides()` and the
related operator-state writers `set_provider_overlays()` and
`set_consumer_keys()` in `host_store.py` so they either re-raise on failure or
return an explicit success indicator, and then make the `auth_proxy.py` callers
fail the request when the write does not persist. Preserve the atomic
transaction behavior in these helpers, but do not leave the error only in
`_log.warning`; callers must be able to detect the failure.
- Line 41: _VALIDATE_`ROUTER_DB_RETENTION_DAYS`_ before assigning
`_RETENTION_DAYS` in `host_store.py` so module import cannot fail on non-integer
values, and reject or clamp negative values so the retention cutoff used by the
ledger pruning logic around the cutoff calculation does not end up in the
future. Update the `_RETENTION_DAYS` initialization and the related
retention/cutoff path to use a safe default or explicit validation with a clear
fallback.
- Around line 173-181: The async writer in insert_call_async should not rely on
an unbounded ThreadPoolExecutor backlog, and it must avoid writing a mutable row
object that callers can change after submission. Update the host-store writer
path around insert_call_async and _writer so it snapshots each row with
dict(row) before enqueueing, and add a bounded queue/semaphore around submit to
limit pending inserts when SQLite is slow.

In `@provider_overlay.py`:
- Around line 41-44: The save_overlay path is hiding persistence failures
because host_store.set_provider_overlays() does not surface errors back to
callers. Update save_overlay() to use a store contract that returns success or
raises on write failure, and make dashboard_add_provider() handle that result so
provider creation is not reported as successful when the overlay write fails.
Keep the fix focused on save_overlay() and the
host_store.set_provider_overlays() call site so failures propagate clearly.
- Line 31: The provider ID regex in _ID_RE currently allows 41 characters
because the first character is matched separately from the quantified range.
Update the pattern so the total length matches the intended 2-40 character
validation, and keep the regex and any related validation message in sync in
provider_overlay.py.

In `@settings.py`:
- Around line 188-190: The settings update path in the host override flow is
swallowing persistence failures and still returning success. Update the code
around host_store.set_overrides() and reload() so the setter exposes write
failure explicitly (by raising or returning a status) and handle that failure
here by returning an error response instead of new, []. Keep the fix localized
to the override save/reload logic so the caller can distinguish a successful
write from a DB write that failed and left state unchanged.

In `@tests/test_auth_proxy_dashboard_full.py`:
- Around line 26-30: _reset_db setup in _use_db() only clears the cached
host_store connection before the test, so later tests can keep using the old tmp
SQLite handle after ROUTER_DB_PATH is restored. Convert _use_db into a yield
fixture or add a teardown cleanup that calls host_store.reset() again after the
test, ensuring the host_store cache is cleared both before and after each use.

In `@tests/test_settings.py`:
- Around line 20-27: The `store` fixture only resets `host_store`, but
`settings.reload()` leaves module-global overrides in memory, so teardown can
leak state into later tests. Update the `store` fixture teardown to clear the
`settings` cache/state as well as calling `host_store.reset()`, using the
existing `settings.reload()`/`settings.get()` flow and the `host_store` and
`settings` symbols to ensure each test ends with default empty settings.

In `@tests/test_sources.py`:
- Around line 669-673: The test is leaving the process-wide host_store SQLite
connection open, so later tests can keep using the cached tmp database after
ROUTER_DB_PATH is unset. Wrap the host_store usage in a try/finally and ensure
the shared connection is closed or reset in the cleanup path after
host_store.reset() and settings.reload(). Use the host_store and settings
symbols in this test to place the isolation fix where the connection is opened.

---

Outside diff comments:
In `@auth_proxy.py`:
- Around line 316-323: The persistence path in _write_issued_consumer_records
currently assumes host_store.set_consumer_keys succeeds even though it swallows
write failures, so mutations can be reported as saved when they are not durable.
Update host_store.set_consumer_keys to either raise on persistence errors or
return an explicit success status, then have _write_issued_consumer_records and
the dashboard mutation flow check that result and abort/report failure for
rotations, revocations, or consumer setting updates when persistence does not
complete.

---

Nitpick comments:
In `@tests/test_provider_overlay.py`:
- Around line 111-118: The roundtrip test in test_overlay_load_save_roundtrip no
longer covers the fail-soft behavior for malformed persisted data on the SQLite
path. Add a DB-backed assertion around po.load_overlay() that seeds an invalid
provider overlay through host_store.get_provider_overlays() (or the underlying
SQLite-backed store) and verifies it still returns {"providers": {}}. Keep the
existing save/load roundtrip, and make sure the new coverage targets the
load_overlay path that now depends on host_store.get_provider_overlays().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5885fabc-bb65-4e95-805c-4070408b77a5

📥 Commits

Reviewing files that changed from the base of the PR and between 3c0718f and b719301.

📒 Files selected for processing (11)
  • auth_proxy.py
  • host_store.py
  • provider_overlay.py
  • serve.py
  • settings.py
  • shim.py
  • tests/test_auth_proxy_dashboard_full.py
  • tests/test_host_store.py
  • tests/test_provider_overlay.py
  • tests/test_settings.py
  • tests/test_sources.py

Comment thread auth_proxy.py
Comment on lines +213 to +215
records, ok = host_store.get_consumer_keys()
_issued_keys_load_failed = not ok
return records

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Preserve fail-closed behavior for unreadable key metadata.

host_store.get_consumer_keys() skips rows whose JSON cannot be decoded while still returning ok=True. That keeps _issued_keys_load_failed false, so a corrupted metadata row can disappear and _consumer_meta() falls back to default metadata for a statically known caller. Treat any row decode failure as ok=False, or return an inactive tombstone for that consumer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth_proxy.py` around lines 213 - 215, Preserve fail-closed behavior in the
consumer key loading path: `host_store.get_consumer_keys()` currently skips
JSON-decode failures but still reports success, which leaves
`_issued_keys_load_failed` false and can let `_consumer_meta()` fall back to
default metadata. Update `get_consumer_keys()` so any unreadable row makes `ok`
false, or return an inactive tombstone for that consumer, and keep the existing
`_issued_keys_load_failed = not ok` handling in `auth_proxy.py` so corrupted
metadata cannot be silently ignored.

Comment thread host_store.py Outdated
Comment thread host_store.py Outdated
Comment thread host_store.py Outdated
Comment thread provider_overlay.py Outdated
Comment thread provider_overlay.py Outdated
Comment thread settings.py Outdated
Comment thread tests/test_auth_proxy_dashboard_full.py
Comment thread tests/test_settings.py
Comment on lines +20 to +27
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setenv("ROUTER_DB_PATH", str(tmp_path / "host-store.db"))
host_store.reset()
settings.reload()
yield
host_store.reset()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clear the settings cache in teardown as well.

host_store.reset() only closes the DB connection. The module-global overrides loaded by settings.reload() remain live, so a test that leaves an override set can affect later tests that read settings.get() without reloading. Teardown should restore empty/default settings, not just close SQLite.

One way to make teardown fully isolate state
 `@pytest.fixture`
 def store(tmp_path, monkeypatch):
     monkeypatch.setenv("ROUTER_DB_PATH", str(tmp_path / "host-store.db"))
     host_store.reset()
     settings.reload()
     yield
+    host_store.set_overrides({})
+    settings.reload()
     host_store.reset()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setenv("ROUTER_DB_PATH", str(tmp_path / "host-store.db"))
host_store.reset()
settings.reload()
yield
host_store.reset()
`@pytest.fixture`
def store(tmp_path, monkeypatch):
monkeypatch.setenv("ROUTER_DB_PATH", str(tmp_path / "host-store.db"))
host_store.reset()
settings.reload()
yield
host_store.set_overrides({})
settings.reload()
host_store.reset()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_settings.py` around lines 20 - 27, The `store` fixture only resets
`host_store`, but `settings.reload()` leaves module-global overrides in memory,
so teardown can leak state into later tests. Update the `store` fixture teardown
to clear the `settings` cache/state as well as calling `host_store.reset()`,
using the existing `settings.reload()`/`settings.get()` flow and the
`host_store` and `settings` symbols to ensure each test ends with default empty
settings.

Comment thread tests/test_sources.py Outdated
jmlago added 5 commits June 28, 2026 21:20
The deployment runs router + ingress as SEPARATE containers (router RO on secrets,
ingress RW) sharing state. A SQLite file can't bridge that asymmetry (mount RO/RW,
WAL-reader-needs-RW) — and the end goal is Postgres anyway (RDS in prod). So jump
straight to Postgres: a network DB both containers reach over TCP, no mount
gymnastics, multi-pod ready.

- host_store: psycopg3 + ConnectionPool, DATABASE_URL. Schema created idempotently
  under a pg advisory lock (race-safe across the two containers). Each write is one
  transaction (auto commit/rollback per `with pool.connection()` block) — the
  orphan-transaction poison bug is gone by construction.
- Folds in the review fixes: set_overrides/set_provider_overlays/set_consumer_keys
  now RETURN bool (a swallowed durable-write failure must not report success — a
  silent failed key revoke is a security hole); ROUTER_DB_RETENTION_DAYS validated;
  the async ledger writer is a BOUNDED queue + dict(row) snapshot; get_consumer_keys
  fails CLOSED on any undecodable row.
- shell.nix: add psycopg + psycopg-pool.

Verified against a real Postgres 16: schema init, insert_call/recent_calls/count,
set_*/get_* (bool contract), overlay + consumer-key roundtrips — all green.

WIP on the branch: tests still use the SQLite-era ROUTER_DB_PATH+reset fixtures and
will be converted to DATABASE_URL + a TRUNCATE-per-test fixture run against the
compose Postgres (which also unlocks the behave e2e). compose.yml postgres service,
requirements (psycopg), set_* caller updates, _ID_RE off-by-one, and moving
migrate_legacy_json to auth-proxy startup follow. The suite does not run until that
sweep lands.
- compose.yml: a `postgres:16` service (healthchecked) on the internal network;
  DATABASE_URL injected into both router and ingress (both depend_on it healthy).
  Default points at the compose service; prod overrides DATABASE_URL with the RDS
  endpoint. This is what lets the two containers share state over the network
  (no shared-file / mount-asymmetry).
- requirements.txt: psycopg[binary,pool]==3.2.12 (matches shell.nix).

compose config validates.
The store is Postgres, so the tests that touch it run against a real Postgres
instead of a tmp SQLite file. This also exercises the two-container reality the
single-process SQLite tests never did — the deployment split that was invisible to
them.

- tests/conftest.py: default DATABASE_URL to a local throwaway Postgres (:55432;
  CI/compose overrides it) + a `host_store_clean` fixture that TRUNCATEs before each
  store-using test (isolation against the shared DB) and skips if Postgres is down.
- the per-file fixtures (store/db/_use_db) drop ROUTER_DB_PATH+reset and truncate
  instead; the sqlite-specific poison test is replaced by a Postgres atomicity +
  bool-contract test (a failed set_* returns False and rolls back, data intact —
  the orphan-transaction class is structurally impossible with a pool-per-write).
- the malformed-row / list-roundtrip tests use the pool + %s placeholders.

Full suite 406 passed, 2 skipped, 0 failed against Postgres 16 under nix-shell.
Pure-logic tests still run without a DB.
The setters now return a success bool; make the callers act on it so a failed
persistence is reported, not pretended:
- settings.validate_and_write: a failed set_overrides -> error response, not "saved".
- provider_overlay.save_overlay returns bool; dashboard_add_provider -> 500 if the
  overlay didn't persist (otherwise the key is written but the provider is lost on
  restart — an orphaned key).
- _write_issued_consumer_records returns bool; the three dashboard key endpoints
  (settings update, REVOKE, create/rotate) -> 500 on failure. A swallowed failed
  revoke would report success while the key keeps working after restart.

Plus:
- provider_overlay._ID_RE: {1,40} -> {1,39} so the regex matches the documented
  2-40 char limit (it allowed 41).
- migrate_legacy_json also runs at the ingress (auth-proxy) startup, not only in
  serve.py: idempotent (advisory lock + guard-on-empty), so the backfill is safe
  whether the router or the ingress reaches the shared DB first (k8s starts both
  containers in parallel; the SQLite-era "migration ran in the wrong container" can
  no longer leave the owner's store empty).

Full suite 406 passed, 2 skipped, 0 failed against Postgres 16.
@jmlago jmlago changed the title feat(host-store): SQLite operational store — migrate JSON state off the filesystem feat(host-store): Postgres operational store — migrate JSON/in-process state off the filesystem Jun 28, 2026
The e2e (behave, features/) drives the live compose stack at BASE_URL; with these
in the nix shell it runs under nix-shell like the unit suite (the machine has no
system python for a venv).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
host_store.py (1)

379-379: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Avoid dynamic table SQL in the seed helper.

Current callers pass constants, but this f-string still trips S608/OpenGrep and makes future misuse easy. Use a fixed SQL map instead.

Proposed fix
+_SEED_COUNT_SQL = {
+    "settings_overrides": "SELECT count(*) FROM settings_overrides",
+    "provider_overlays": "SELECT count(*) FROM provider_overlays",
+    "consumer_keys": "SELECT count(*) FROM consumer_keys",
+}
+
 def _seed_if_empty(table: str, legacy_path: str, to_rows) -> None:
@@
-            n = conn.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
+            count_sql = _SEED_COUNT_SQL.get(table)
+            if count_sql is None:
+                raise ValueError(f"unsupported seed table {table!r}")
+            n = conn.execute(count_sql).fetchone()[0]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@host_store.py` at line 379, The seed helper is building SQL with a dynamic
table name in the count query, which triggers S608/OpenGrep and leaves room for
unsafe future use. Update the seeding logic in host_store.py to avoid f-string
SQL in this path by using a fixed SQL mapping for the known tables, and have the
helper look up the appropriate constant query before calling conn.execute in the
seed/count code.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@auth_proxy.py`:
- Around line 316-326: The handlers that use _issued_consumer_records() must
fail closed when _issued_keys_load_failed is true instead of continuing into
state changes. Add an early guard in dashboard_update_consumer,
dashboard_revoke_key, and dashboard_create_key immediately after loading the
records so they return 500 before any env mutation, in-memory hash updates, or
calls that would persist a replacement set. Keep the check near the
_issued_consumer_records() flow so it is applied before any record mutation
logic.

In `@compose.yml`:
- Line 29: The Postgres password is hardcoded in the default DATABASE_URL/DSN
values, so make it configurable while preserving the dev default. Update the
compose/env defaults and any matching DSN definitions at the other referenced
locations so they all read from the same password variable or fallback value
consistently. Use the existing DATABASE_URL-related entries in the compose
configuration to keep the service env and defaults in sync.

In `@host_store.py`:
- Around line 386-388: The legacy backfill path in _seed_if_empty() is logging
success even when persistence fails because to_rows() can return False. Update
the seeding flow for set_overrides(), set_provider_overlays(), and
set_consumer_keys() so the return value from the setter is checked before
emitting the “seeded” log, and only log success when the destination was
actually persisted; otherwise treat it as a failed seed and avoid the success
message.
- Around line 428-432: The test helper truncate_all_for_tests currently
truncates tables without waiting for queued ledger writes, so stale async
inserts can repopulate the store afterward. Update truncate_all_for_tests to
drain or flush any pending ledger/background write queue before running the
TRUNCATE on calls, settings_overrides, provider_overlays, and consumer_keys,
using the relevant pool/ledger helper methods already available in host_store.py
to ensure the store is truly clean for the next test.
- Around line 123-125: The provisional ConnectionPool created in the host store
initialization path can leak if _init_schema(p) throws before _pool is assigned.
Update the initialization logic around _init_schema and _pool in host_store.py
to ensure the newly opened pool is always closed on schema init failure, using a
try/finally or equivalent cleanup path, while keeping _pool set only after
successful initialization.

In `@tests/conftest.py`:
- Around line 34-38: The blanket exception handling in host_store setup is
masking real regressions by turning all failures from host_store.reset() and
host_store.truncate_all_for_tests() into pytest.skip. Narrow the exception
handling in this conftest fixture so only genuine Postgres
connectivity/bootstrap errors are skipped, and let unexpected exceptions from
host_store.truncate_all_for_tests() propagate and fail the test instead.

---

Nitpick comments:
In `@host_store.py`:
- Line 379: The seed helper is building SQL with a dynamic table name in the
count query, which triggers S608/OpenGrep and leaves room for unsafe future use.
Update the seeding logic in host_store.py to avoid f-string SQL in this path by
using a fixed SQL mapping for the known tables, and have the helper look up the
appropriate constant query before calling conn.execute in the seed/count code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8843c9a6-3902-47da-82d1-23e2eae6ae65

📥 Commits

Reviewing files that changed from the base of the PR and between b719301 and 06c3bdd.

📒 Files selected for processing (15)
  • auth_proxy.py
  • compose.yml
  • host_store.py
  • provider_overlay.py
  • requirements.txt
  • serve.py
  • settings.py
  • shell.nix
  • shim.py
  • tests/conftest.py
  • tests/test_auth_proxy_dashboard_full.py
  • tests/test_host_store.py
  • tests/test_provider_overlay.py
  • tests/test_settings.py
  • tests/test_sources.py
✅ Files skipped from review due to trivial changes (1)
  • requirements.txt
🚧 Files skipped from review as they are similar to previous changes (7)
  • tests/test_settings.py
  • serve.py
  • provider_overlay.py
  • shim.py
  • settings.py
  • tests/test_auth_proxy_dashboard_full.py
  • tests/test_host_store.py

Comment thread auth_proxy.py
Comment on lines +316 to +326
def _write_issued_consumer_records(records: dict[str, dict[str, Any]]) -> bool:
"""Persist the consumer records; returns True on success, False on a
persistence failure — a swallowed failure would let a key rotation/revocation
be reported as saved while still working after a restart (a security hole)."""
compact = {}
for consumer, record in sorted(records.items()):
normalized = _normalize_consumer_record(consumer, record)
if normalized["status"] == "active" and not normalized["allowed_routes"] and normalized["rate_per_min"] is None and normalized["burst"] is None and not normalized["keys"]:
continue
compact[consumer] = normalized
_write_json_file(Path(DASHBOARD_ISSUED_KEYS_PATH), compact)
return host_store.set_consumer_keys(compact)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed before mutating key state when the store load failed.

These handlers build records from _issued_consumer_records(), but if _issued_keys_load_failed is true, continuing can turn an unreadable/failed load into a full-table replacement; revoke/create can also mutate env/in-memory hashes before returning 500. Abort immediately after loading records.

Proposed fix pattern
     records = _issued_consumer_records()
+    if _issued_keys_load_failed:
+        return JSONResponse(status_code=503, content={"ok": False, "error": "consumer key store unavailable"})
     meta = records.get(consumer, _normalize_consumer_record(consumer, {}))

Apply the guard in dashboard_update_consumer, dashboard_revoke_key, and dashboard_create_key before any record/env mutation.

Also applies to: 2005-2006, 2056-2057, 2512-2513

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth_proxy.py` around lines 316 - 326, The handlers that use
_issued_consumer_records() must fail closed when _issued_keys_load_failed is
true instead of continuing into state changes. Add an early guard in
dashboard_update_consumer, dashboard_revoke_key, and dashboard_create_key
immediately after loading the records so they return 500 before any env
mutation, in-memory hash updates, or calls that would persist a replacement set.
Keep the check near the _issued_consumer_records() flow so it is applied before
any record mutation logic.

Comment thread compose.yml
ANTSEED_CONTROL_URL: http://antseed:8379
ANTSEED_CONTROL_TOKEN: ${ANTSEED_CONTROL_TOKEN:-}
# Operational store (router reads operator config from it). Prod = RDS.
DATABASE_URL: ${DATABASE_URL:-postgresql://hoststore:hoststore@postgres:5432/hoststore}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Make the dev Postgres password configurable.

The password is baked into both the service env and default DSNs. Keep the dev default if needed, but allow overriding it consistently.

Proposed fix
-      DATABASE_URL: ${DATABASE_URL:-postgresql://hoststore:hoststore@postgres:5432/hoststore}
+      DATABASE_URL: ${DATABASE_URL:-postgresql://hoststore:${POSTGRES_PASSWORD:-hoststore}`@postgres`:5432/hoststore}
@@
-      DATABASE_URL: ${DATABASE_URL:-postgresql://hoststore:hoststore@postgres:5432/hoststore}
+      DATABASE_URL: ${DATABASE_URL:-postgresql://hoststore:${POSTGRES_PASSWORD:-hoststore}`@postgres`:5432/hoststore}
@@
-      POSTGRES_USER: hoststore
-      POSTGRES_PASSWORD: hoststore
-      POSTGRES_DB: hoststore
+      POSTGRES_USER: ${POSTGRES_USER:-hoststore}
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hoststore}
+      POSTGRES_DB: ${POSTGRES_DB:-hoststore}

Also applies to: 85-85, 183-185

🧰 Tools
🪛 Checkov (3.3.1)

[medium] 29-30: Basic Auth Credentials

(CKV_SECRET_4)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@compose.yml` at line 29, The Postgres password is hardcoded in the default
DATABASE_URL/DSN values, so make it configurable while preserving the dev
default. Update the compose/env defaults and any matching DSN definitions at the
other referenced locations so they all read from the same password variable or
fallback value consistently. Use the existing DATABASE_URL-related entries in
the compose configuration to keep the service env and defaults in sync.

Source: Linters/SAST tools

Comment thread host_store.py
Comment on lines +123 to +125
p = ConnectionPool(_dsn(), min_size=1, max_size=8, open=True)
_init_schema(p)
_pool = p

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and inspect the relevant section with line numbers.
git ls-files | rg '^host_store\.py$|/host_store\.py$'
sed -n '1,220p' host_store.py | cat -n

# Find related pool/schema initialization and shutdown paths.
rg -n "_init_schema|ConnectionPool|close\(|_pool\s*=" host_store.py

Repository: genlayerlabs/unhardcoded

Length of output: 10955


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the shutdown path around the pool cleanup and any other pool lifecycle handling.
sed -n '390,450p' host_store.py | cat -n

# Quick read-only probe for psycopg_pool docs installed in the environment, if available.
python3 - <<'PY'
import inspect
try:
    from psycopg_pool import ConnectionPool
    print("ConnectionPool found")
    print("init signature:", inspect.signature(ConnectionPool))
    print("close signature:", inspect.signature(ConnectionPool.close))
except Exception as e:
    print("Unable to introspect psycopg_pool:", e)
PY

Repository: genlayerlabs/unhardcoded

Length of output: 2353


Close the provisional pool if schema initialization fails. host_store.py:123-125 If _init_schema(p) raises, _pool stays None and the newly opened ConnectionPool is never closed, so repeated retries can leak pool resources.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@host_store.py` around lines 123 - 125, The provisional ConnectionPool created
in the host store initialization path can leak if _init_schema(p) throws before
_pool is assigned. Update the initialization logic around _init_schema and _pool
in host_store.py to ensure the newly opened pool is always closed on schema init
failure, using a try/finally or equivalent cleanup path, while keeping _pool set
only after successful initialization.

Comment thread host_store.py
Comment on lines +386 to +388
data = json.loads(p.read_text())
to_rows(data)
_log.info("host_store: seeded %s from %s", table, legacy_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Check legacy backfill setter failures before logging success.

set_overrides(), set_provider_overlays(), and set_consumer_keys() return False on persistence failure, but _seed_if_empty() ignores that and logs seeded even when the destination stayed empty.

Proposed fix
-            to_rows(data)
+            if not to_rows(data):
+                raise RuntimeError(f"{table} setter returned false")
             _log.info("host_store: seeded %s from %s", table, legacy_path)

Also applies to: 399-413

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@host_store.py` around lines 386 - 388, The legacy backfill path in
_seed_if_empty() is logging success even when persistence fails because
to_rows() can return False. Update the seeding flow for set_overrides(),
set_provider_overlays(), and set_consumer_keys() so the return value from the
setter is checked before emitting the “seeded” log, and only log success when
the destination was actually persisted; otherwise treat it as a failed seed and
avoid the success message.

Comment thread host_store.py
Comment on lines +428 to +432
def truncate_all_for_tests() -> None:
"""Test helper: wipe every table for isolation against a shared Postgres."""
with _get_pool().connection() as conn:
conn.execute("TRUNCATE calls, settings_overrides, provider_overlays,"
" consumer_keys")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Drain pending ledger writes before truncating test tables.

Queued async inserts from a prior test can land after TRUNCATE, contaminating the next test’s supposedly clean store.

Proposed fix
 def truncate_all_for_tests() -> None:
     """Test helper: wipe every table for isolation against a shared Postgres."""
+    _write_q.join()
     with _get_pool().connection() as conn:
         conn.execute("TRUNCATE calls, settings_overrides, provider_overlays,"
                      " consumer_keys")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def truncate_all_for_tests() -> None:
"""Test helper: wipe every table for isolation against a shared Postgres."""
with _get_pool().connection() as conn:
conn.execute("TRUNCATE calls, settings_overrides, provider_overlays,"
" consumer_keys")
def truncate_all_for_tests() -> None:
"""Test helper: wipe every table for isolation against a shared Postgres."""
_write_q.join()
with _get_pool().connection() as conn:
conn.execute("TRUNCATE calls, settings_overrides, provider_overlays,"
" consumer_keys")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@host_store.py` around lines 428 - 432, The test helper truncate_all_for_tests
currently truncates tables without waiting for queued ledger writes, so stale
async inserts can repopulate the store afterward. Update truncate_all_for_tests
to drain or flush any pending ledger/background write queue before running the
TRUNCATE on calls, settings_overrides, provider_overlays, and consumer_keys,
using the relevant pool/ledger helper methods already available in host_store.py
to ensure the store is truly clean for the next test.

Comment thread tests/conftest.py
Comment on lines +34 to +38
try:
host_store.reset()
host_store.truncate_all_for_tests()
except Exception as exc: # noqa: BLE001
pytest.skip(f"host store Postgres unavailable: {exc}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't turn host-store regressions into skipped tests.

Lines 34-38 catch every exception, so schema or migration bugs from host_store.truncate_all_for_tests() get reported as “Postgres unavailable” and the suite can go green. Only skip on actual connectivity/bootstrap failures; let all other exceptions fail the test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conftest.py` around lines 34 - 38, The blanket exception handling in
host_store setup is masking real regressions by turning all failures from
host_store.reset() and host_store.truncate_all_for_tests() into pytest.skip.
Narrow the exception handling in this conftest fixture so only genuine Postgres
connectivity/bootstrap errors are skipped, and let unexpected exceptions from
host_store.truncate_all_for_tests() propagate and fail the test instead.

@jmlago

jmlago commented Jun 28, 2026

Copy link
Copy Markdown
Member Author

Remaining work to make the migration complete

This PR landed the Postgres store + migrated the operator-config tables
(settings_overrides, provider_overlays, consumer_keys) and the calls
ledger, with the legacy-JSON backfill — validated against a real Postgres and the
live two-container compose stack (auth + revoke + ledger end-to-end, 28+ behave
scenarios green).

What's left for zero JSON / zero in-process operational state, in two buckets:

Bucket 1 — the remaining JSON files (clean kills; coherent with this PR's scope)

  • peer_offersmarket.json (antseed marketplace).
    • The Node sidecar (antseed/merge-market.js) writes market.json today; it
      must write Postgres instead (add a pg client to Dockerfile.antseed; the
      15-min rolling window becomes INSERT ... ts + WHERE last_seen > now()-interval
      instead of the hand-rolled union).
    • sources/antseed.py._load_market reads market.json → read peer_offers.
    • Suggested table: peer_offers(peer_id, provider_id, service, price_in, price_cache_read, price_out, reputation, max_concurrency, first_seen, last_seen, fetched_at).
  • buyer_statusstatus-{pid}.json (antseed buyer pin/escrow status).
    • Same owner (sidecar writes it from buyer status --json); sources/antseed.py
      reads it (_pinned_peer). Small table buyer_status(pid, status_json, fetched_at)
      or per-field.

Bucket 2 — analytics + ranking state (a different form — recommend its own PR/PRs)

This is the big, higher-risk piece (hot-path ranking + the dashboard analytics
rewrite). Now unblocked because both containers reach the shared Postgres (the
router can finally persist its own state; the §8 per-pod debt goes away).

  • Enrich calls with peer_id (+ cache-token breakdown, ttft, error_type)
    prerequisite for the two below. The usage row doesn't carry peer_id today,
    so per-peer marketplace route identity is lost; thread it through _record_request.
  • route_* folds → Postgres (reliability / latency / economics / cache /
    session-meter / tool-capability). They fold at the call outcome in
    llm_router_host (the router). Either persist the fold state to a
    route_state table or derive from calls by query. Touches the offer-stamp /
    ranking hot path → validate carefully (and keep folding/scoring in the policy,
    not host-side — store raw, expose raw).
  • _stats + usage-history.jsonl → queries over calls. The dashboard
    analytics (_stats by_caller/provider/route/key…) and the timeframe stats read
    usage-history. Rewrite those readers to aggregate calls, then drop the
    duplicate usage-history write
    (_append_usage_history currently dual-writes) and
    delete the file path. Largest mechanical piece.

Cross-cutting cleanup (after the above)

  • Delete the legacy JSON files post-migration (the backfill is guard-on-empty)
    and then remove migrate_legacy_json + its env paths.
  • codex_accounts stays files (credentials) and .env.secrets stays (secrets) —
    not part of "no JSON"; only encrypted-at-rest would move them.
  • Decide retention/indexing on calls as volume grows; consider a materialized
    route_stats/sessions if the live queries get hot.

Design notes (the law, the per-provider price map, the table sketch) live in the
working design doc the author kept; the principle throughout: store raw, derive by
query; folding/scoring is the policy's job, not the host's.

@jmlago
jmlago merged commit 86d0d89 into main Jun 29, 2026
1 check passed
jmlago added a commit that referenced this pull request Jun 29, 2026
… the filesystem (#38)

* feat(host-store): peer_offers — antseed market book off the filesystem

Move the antseed marketplace book from market.json (a file on a shared
volume, unioned by hand in merge-market.js) into the Postgres host store —
the next slice of the JSON/in-process migration after #36.

Form delta:
- Definition: a new `peer_offers` table holds one RAW row per (peer, service)
  — the seller's announced prices/cap/reputation as columns, not interpreted.
  The antseed sidecar is the sole writer (it runs `antseed network browse`);
  sources/antseed._load_market is the sole reader. The 15-min sliding window
  that merge-market.js unioned by hand is now a read-time filter on
  observed_at (WHERE observed_at >= now - window); the sidecar prunes rows
  past the window.
- Invariants: store raw, derive by query — no scoring host-side; the negative
  / cached>input / reputation gates stay in offers_sync. Fail-soft: a DB error
  degrades to "no antseed candidates" exactly as a missing dump did.
  Behaviour preserved: offers_sync / market_book unchanged.
- Irreversible: peer_offers is new DB state; market.json is retired.

Changes:
- host_store.py: peer_offers schema (PK (peer_id, service) + observed_at
  index) and a window-filtered peer_offers() reader; truncate hook updated.
- antseed/write-market.js: replaces merge-market.js — flattens the browse
  dump to (peer, service) rows, UPSERTs into peer_offers (type-cleaning at the
  write, mirroring the old Python coercion), prunes past the window.
- sources/antseed.py: _load_market reads host_store.peer_offers(); the file /
  staleness / flatten code and the now-dead coercion helpers are removed.
- Dockerfile.antseed: pin pg@8.16.3 + NODE_PATH so the writer can require it.
- compose.yml: DATABASE_URL + postgres dependency for the antseed service (it
  already shares the llm-router-internal network with postgres).
- tests: seed peer_offers (shared conftest helper) instead of market.json; new
  host_store peer_offers round-trip + window tests.

Sovereignty (Axis 4): pg is the boring standard Postgres client, pinned, and
lives only in the sidecar; no new Python dependency (psycopg is from #36).

Verification: full suite 409 passed, 2 skipped, 0 failed against the compose
Postgres; the real Node writer -> Postgres -> Python reader round-trip,
non-dump validation and window prune checked; the full stack boots healthy and
/x/market surfaces a seeded antseed peer end to end.

* feat(host-store): buyer_status — antseed buyer status off the filesystem

Twin of the peer_offers move: the antseed buyer's status (session pin +
escrow + wallet) goes from status-<id>.json on the shared volume to the
Postgres host store. With both off the filesystem, sources/antseed.py no
longer touches disk and the antseed-market volume is removed entirely.

Form delta:
- Definition: a new `buyer_status` table holds one row per buyer pid — the
  raw buyer-reported fields (pinned_peer_id, deposits_available/_reserved,
  wallet_address, connection_state) as columns. The antseed sidecar writes it
  (write-status.js on the poll loop + control.js after a wallet op);
  sources/antseed reads it (_pinned_peer + balances).
- Invariants: store raw — deposits stay the strings the buyer reports and are
  coerced on read, exactly as the JSON status was. Fail-soft: a missing row /
  store error degrades to "no pin, no balance" as a missing status file did.
  Behaviour preserved: _pinned_peer / balances unchanged but for the source.
- Irreversible: buyer_status is new DB state; status-<id>.json is retired and
  the antseed-market volume (+ both mounts) is dropped.

Changes:
- host_store.py: buyer_status schema + a buyer_status(pid) reader; truncate
  hook updated.
- antseed/store.js: shared buyer_status row shape + UPSERT, used by both
  writers so they can't drift.
- antseed/write-status.js: replaces the inline node -e + atomic_write; reads
  `buyer status --json`, UPSERTs buyer_status, validates (non-status -> no
  write).
- antseed/control.js: refreshStatus UPSERTs buyer_status via a pg pool instead
  of writing the file; still returns the fresh status for the HTTP response.
- antseed/entrypoint.sh: write_status calls write-status.js; the now-dead
  atomic_write helper is removed; comments updated.
- sources/antseed.py: _pinned_peer + balances read host_store.buyer_status;
  the file / json / Path / market_dir machinery is removed (no disk access).
- Dockerfile.antseed: COPY store.js + write-status.js.
- compose.yml: drop the antseed-market volume and its router/antseed mounts.
- tests: seed buyer_status (shared conftest helper) instead of status files;
  new host_store buyer_status round-trip/absent test.

Verification: full suite 410 passed, 2 skipped, 0 failed against the compose
Postgres; the real write-status.js -> Postgres -> Python reader round-trip and
non-status validation checked; all four sidecar JS files pass node --check;
the full stack boots healthy and creates buyer_status on boot.

* feat(host-store): calls carries served_by + tokens_cached (engine #23 bump)

#3 of the operational-store migration: enrich the `calls` fact table with the
two raw per-call facts the #4 route/analytics views will derive from — the
executed route identity and the cache-token breakdown. Prerequisite for keying
per-route stats off the ledger.

- Submodule bump core 97d0333 -> 537e204 (unhardcoded-engine #23): the engine's
  `chosen` now carries `served_by` — the marketplace peer that served the call,
  or the provider itself for a direct route (never nil). Host suite green on it.
- host_store.py: `calls` gains `served_by TEXT` + `tokens_cached BIGINT`, applied
  to existing tables via idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
  (CREATE TABLE IF NOT EXISTS never alters an existing table — the store gains its
  first in-place migration). insert_call maps both. route_key is left unchanged:
  deriving a peer-granular route key from served_by is #4's job; this commit only
  captures the raw fact.
- shim.py: `_build_x_router` surfaces `served_by` from `chosen` (tokens_cached was
  already there).
- auth_proxy.py: the ingress threads served_by + tokens_cached off x_router into
  the recorded call (both stream and unary paths) -> insert_call.

ttft was intentionally NOT added: nothing measures it yet, so the column would be
idle (Axis 3). error_type was already a column.

Verification: full suite 411 passed, 2 skipped, 0 failed against the compose
Postgres; the ALTER migration applies in place on boot; a live chat records
served_by + tokens_cached in `calls` end to end against engine #23.

* test(host-store): guard the peer_offers/buyer_status cross-language column contract

peer_offers and buyer_status are CREATEd by the Python host store but WRITTEN by
the Node antseed sidecar (write-market.js, antseed/store.js) and seeded by Python
test mimics (conftest). Three places must agree on the column set and nothing at
runtime makes them: the readers are fail-soft, so a renamed/added/dropped column
degrades antseed to "no candidates" silently -- and the unit suite can't see it,
because it seeds via the Python mimic, not the real Node writer (green proves the
reader works, not that Node and Python agree).

Add a static contract test that parses the column list out of all three sources
and asserts it matches per table. Pure text parsing: no DB, no node runtime, runs
in the ordinary unit suite; red on any drift (verified by injecting a rename).
The live behave e2e stays the only thing exercising the real Node writer; this
guards the part that drifts.

* fix(antseed): guard a non-hex ANTSEED_IDENTITY_HEX in the entrypoint

Prod runs the sidecar as the image now (not the inline node command), so the
entrypoint must keep the inline's safety: a CHANGE_ME / unset-secret placeholder
is not a valid identity and the CLI would reject it. Unset it when it isn't a
64-hex string so the buyer falls back to a generated key on the data volume
(matching the previous inline behaviour); the prod secret is a real hot-wallet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant