feat(host-store): Postgres operational store — migrate JSON/in-process state off the filesystem - #36
Conversation
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).
📝 WalkthroughWalkthroughIntroduces 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. ChangesHost store operational state and call ledger
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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.
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
left a comment
There was a problem hiding this comment.
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_lockheld 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 amaxsizewith 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 canonicalroute_reliability.route_key(provider|family|peer).
Reconcile and enrichpeer_idbefore derivingroute_*fromcalls.
Acknowledged in the description; tracked roadmap precondition, unchanged here. reset()(test hook) does not touch_writer; harmless because tests use the
synchronousinsert_call, never_async. Minor.- Atomicity (Axis 5): the prior advisory to split the
consumer_keysmigration
into its own revertable PR still stands as advisory, not blocking.
Verification
tests/test_host_store.py→ 11 passed (10 → 11, +the rollback regression)
undernix-shell+timeout@b719301.- Layer (Axis 2):
host_storestill 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.
There was a problem hiding this comment.
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 winDo not report key mutations as saved when persistence failed.
host_store.set_consumer_keys()catches all write exceptions and returnsNone, 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 winRestore 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 onhost_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
📒 Files selected for processing (11)
auth_proxy.pyhost_store.pyprovider_overlay.pyserve.pysettings.pyshim.pytests/test_auth_proxy_dashboard_full.pytests/test_host_store.pytests/test_provider_overlay.pytests/test_settings.pytests/test_sources.py
| records, ok = host_store.get_consumer_keys() | ||
| _issued_keys_load_failed = not ok | ||
| return records |
There was a problem hiding this comment.
🔒 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.
| @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() | ||
|
|
There was a problem hiding this comment.
🩺 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.
| @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.
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.
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).
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (1)
host_store.py (1)
379-379: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winAvoid 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
📒 Files selected for processing (15)
auth_proxy.pycompose.ymlhost_store.pyprovider_overlay.pyrequirements.txtserve.pysettings.pyshell.nixshim.pytests/conftest.pytests/test_auth_proxy_dashboard_full.pytests/test_host_store.pytests/test_provider_overlay.pytests/test_settings.pytests/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
| 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) |
There was a problem hiding this comment.
🔒 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.
| 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} |
There was a problem hiding this comment.
🔒 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
| p = ConnectionPool(_dsn(), min_size=1, max_size=8, open=True) | ||
| _init_schema(p) | ||
| _pool = p |
There was a problem hiding this comment.
🩺 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.pyRepository: 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)
PYRepository: 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.
| data = json.loads(p.read_text()) | ||
| to_rows(data) | ||
| _log.info("host_store: seeded %s from %s", table, legacy_path) |
There was a problem hiding this comment.
🗄️ 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.
| 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") |
There was a problem hiding this comment.
🩺 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.
| 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.
| try: | ||
| host_store.reset() | ||
| host_store.truncate_all_for_tests() | ||
| except Exception as exc: # noqa: BLE001 | ||
| pytest.skip(f"host store Postgres unavailable: {exc}") |
There was a problem hiding this comment.
🎯 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.
Remaining work to make the migration completeThis PR landed the Postgres store + migrated the operator-config tables 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)
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
Cross-cutting cleanup (after the above)
Design notes (the law, the per-provider price map, the table sketch) live in the |
… 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.
What
A single transactional Postgres store (
host_store.py, psycopg3) for thehost'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) sharingstate. 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
postgresservice (DATABASE_URL).Landed (suite green against real Postgres throughout)
ConnectionPool, schema created idempotently under apg_advisory_lock(race-safe across both containers), each write its owntransaction (atomic commit/rollback per
with pool.connection()— theorphan-transaction class is impossible by construction).
settings_overrides(overrides.json),provider_overlays(providers.local.json),consumer_keys(issued-consumer-keys.json), and the
callsfact ledger (alongsideusage-history;
GET /x/calls). One-shot idempotent legacy-JSON backfill atstartup (router + ingress, guard-on-empty + advisory lock) so existing
deployments migrate with no data loss.
set_*return a success bool; callers surface apersistence 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.
validated; the ledger writer is a BOUNDED queue +
dict(row)snapshot off therequest latency path;
get_consumer_keysfails CLOSED on an unreadable row;_ID_RE2-40 off-by-one.postgres:16service +DATABASE_URLto both containers;psycopg[binary,pool]pinned;shell.nixupdated.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). Thee2e (
behave,features/) run against the live compose stack — the path thatexercises 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 antseedsidecar), and deriving
route_*/ retiring_stats+ usage-history readers asqueries over
calls— now unblocked because both containers reach the shared DB.Summary by CodeRabbit
New Features
Bug Fixes