management/store — persistence for agent-network entities
Risk level: Medium — six brand-new tables behind AutoMigrate, one upsert-counter table that runs on the request hot path, and one column carrying an encrypted secret. Backward-compat impact: Additive (six new tables created by AutoMigrate; the
Storeinterface gains 23 methods, but no existing column/index is touched).
Module boundary
This module is the persistence layer for the Agent Network feature. Everything the management server stores about LLM proxying — providers, policies, guardrails, the per-account settings row, a usage-counter table written on every proxied LLM request, and the account-budget rules — flows through the methods added to store.Store. The module owns six tables, six entity types from management/server/agentnetwork/types, and a single hot-path upsert (IncrementAgentNetworkConsumption) consumed by the proxy fleet.
Out of scope here: the catalog of provider definitions (compiled-in, no DB), the synthesizer/manager built on top of these CRUDs (covered in 21-management-agentnetwork.md), and the HTTP handlers that translate API requests into Save/Delete calls.
Files
| Path | Role |
|---|---|
management/server/store/sql_store_agentnetwork.go |
gorm implementations of all 23 store methods |
management/server/store/sql_store_agentnetwork_budgetrule_test.go |
round-trip + account-scoping coverage against a real sqlite store |
management/server/store/sql_store.go |
one import, six entities appended to the AutoMigrate slice (sql_store.go:40, sql_store.go:141-142) |
management/server/store/store.go |
23 methods added to the Store interface (store.go:328-354) |
management/server/store/store_mock_agentnetwork.go |
mockgen output for the new interface surface |
Tables added / migrations
All six tables are created by db.AutoMigrate invoked from NewSqlStore at sql_store.go:133-143. There is no hand-rolled SQL migration script — the schema is whatever GORM derives from the struct tags.
agent_network_providers—Provider.TableName()at provider.go:76. PKid, index onaccount_id, named indexidx_agent_network_provideronprovider_id. Carries an at-rest-encryptedapi_keyand ed25519session_private_key(provider.go:35,56).extra_valuesandmodelsare JSON blobs (serializer:json).agent_network_policies—Policy.TableName()at policy.go:70. PKid, index onaccount_id. JSON columns:source_groups,destination_provider_ids,guardrail_ids,limits.agent_network_guardrails—Guardrail.TableName()at guardrail.go:41. PKid, index onaccount_id. JSONchecks.agent_network_settings—Settings.TableName()at settings.go:33. PKaccount_id(one row per account), named indexidx_agent_network_settings_cluster_subdomainonsubdomainonly — the index name implies a composite, but only one column is tagged.agent_network_consumption—Consumption.TableName()at consumption.go:46. Composite PK across(account_id, dim_kind, dim_id, window_seconds, window_start_utc)— the same tuple the upsert keys on.agent_network_budget_rules—AccountBudgetRule.TableName()at budgetrule.go:35. PKid, index onaccount_id. JSONtarget_groups,target_users,limits.
CRUD surface added
Provider, Policy, Guardrail, BudgetRule follow the same pattern: Get<Kind>ByID, GetAccount<Kind> (list), Save<Kind> (upsert), Delete<Kind>, with account-scoping enforced by the existing accountAndIDQueryCondition / accountIDCondition constants (sql_store.go:59-62). Provider additionally exposes GetAllAgentNetworkProviders (cross-account, used by the synthesizer). Settings exposes Get/GetByCluster/Save (no delete — one row per account, created on first save). Consumption exposes the upsert Increment, a point Get, and a cross-window List.
Architecture & flow
flowchart LR
handlers["HTTP handlers<br/>(management/server/agentnetwork)"] -->|Save/Delete| iface["Store interface<br/>store.go:328-354"]
manager["agentnetwork.Manager"] -->|Get*| iface
synth["synthesizer<br/>(global)"] -->|GetAllAgentNetworkProviders| iface
proxy["proxy fleet<br/>(hot path)"] -->|IncrementAgentNetworkConsumption| iface
iface --> sql["SqlStore methods<br/>sql_store_agentnetwork.go"]
iface -.gomock.-> mock["MockStore<br/>store_mock_agentnetwork.go"]
sql --> gorm["gorm.DB"]
gorm --> tables[("6 tables<br/>agent_network_*")]
sql --> enc["crypt.FieldEncrypt<br/>(provider only)"]
Reads decrypt provider secrets in-place; writes do provider.Copy().EncryptSensitiveData(...) before db.Save so the caller's in-memory object keeps the plaintext api_key (sql_store_agentnetwork.go:88-102). Every list/get takes a LockingStrength and applies clause.Locking{Strength: ...} when non-None — matching the rest of the store. The upsert path uses clause.OnConflict with gorm.Expr server-side increments so concurrent proxy nodes converge without read-modify-write races (sql_store_agentnetwork.go:321-335).
Invariants enforced at the store layer
- Account scoping. Every entity-by-ID method keys on
account_id = ? and id = ?; no cross-tenant leak path through the API is reachable as long as callers always pass the auth'daccountID(sql_store_agentnetwork.go:70,141,201,429). - NotFound mapping.
gorm.ErrRecordNotFoundis translated to typedstatus.NewAgentNetwork*NotFoundError;Delete*returns NotFound whenRowsAffected == 0(sql_store_agentnetwork.go:111-113,171-173,231-233,461-463). - Provider secret encryption at rest.
SaveAgentNetworkProvideralways encrypts before persist;Get*always decrypts after read. The plaintextapi_keynever reaches the DB through this layer (sql_store_agentnetwork.go:31,54,80,90). - Consumption monotonicity. The upsert only ever issues
col = col + ?for the three counter columns — no decrement path exists (sql_store_agentnetwork.go:330-332). - Window alignment is the caller's responsibility. The store stamps
WindowStartUTCas-passed; alignment to epoch happens intypes.WindowStartat consumption.go:51-58. - Settings has no Delete. Intentional — one row per account, created on first save; the row sticks around for the account lifetime.
Things to scrutinize
Correctness
SaveAgentNetworkProvidersaves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintextapi_keyand anyCreatedAt/UpdatedAtgorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch.IncrementAgentNetworkConsumption'sCreateprovides initial counter values (TokensInput: tokensIn, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres, mysql) silently rejects theOnConflictclause — GORM emits engine-specific SQL butON DUPLICATE KEY UPDATE(mysql) vsON CONFLICT (...)(sqlite/postgres) need their unique constraint to match the composite PK onagent_network_consumption; it does, by construction.IncrementAgentNetworkConsumptionwritesupdated_at: time.Now().UTC()literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-sidenow(). Acceptable for an audit field.GetAgentNetworkConsumptionreturns a zero-valued non-nil row onErrRecordNotFound(sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check.
Concurrency / transactions
- Hot-path
IncrementAgentNetworkConsumptionruns outside any explicit transaction; concurrency safety relies entirely on the DB serialising theON CONFLICTupsert against the composite PK. This is correct for postgres and mysql; for sqlite it serialises behind the single writer. SaveAgentNetworkSettingsis a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging.Save*Providerusesdb.Saveon a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generatedxid(vanishingly unlikely) would surface as a PK violation.
Migration safety
- All six tables ride
AutoMigrate(sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Threeboolcolumns onagent_network_settings(EnableLogCollection,EnablePromptCollection,RedactPii) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres/mysql produce the same default. - The named index
idx_agent_network_settings_cluster_subdomainon settings.go:15 is declared on onlysubdomain. Either the cluster column also needsgorm:"index:idx_agent_network_settings_cluster_subdomain"to make it composite, or the name is misleading. - The named index
idx_agent_network_provideronProvider.ProviderID(provider.go:30) is not unique and not scoped to account — two providers in the same account with the sameprovider_idare permitted at the DB layer; uniqueness, if any, must live above the store.
Backward compatibility
- Net additive. No removed methods, no renamed columns, no schema change to existing tables. Existing deployments running a prior binary continue to work; the first boot of the new binary creates the six tables.
- The
Storeinterface grows by 23 methods (store.go:330-354); any non-mock external implementer ofstore.Storewill fail to compile. The repo only hasSqlStore+MockStore, both updated.
Performance (indexes, N+1)
- All by-account list queries hit the
idx_account_idper-table index. No N+1: list methods return the full slice in one query. GetAgentNetworkSettingsByCluster(sql_store_agentnetwork.go:263-277) does a tablescan oncluster— no index. Tolerable for the bootstrap label generator (one-shot at provisioning) but worth noting if the call moves onto a hot path.ListAgentNetworkConsumptionreturns every row ever recorded for the account (sql_store_agentnetwork.go:382-400) — unbounded growth, noLIMIT, no time filter. With one row per (dim, window) per request burst, this table grows fastest of the six; a retention job + a paginated list method are obvious follow-ups.
Test coverage
| Test file | Locks down |
|---|---|
sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_RoundTrip |
full save → reload of AccountBudgetRule including the JSON-serialised PolicyLimits, target slices, double-delete returns NotFound (lines 18-59) |
sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_ScopedByAccount |
cross-account isolation for budget rules (lines 63-78) |
sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip |
collection toggles default off, survive save/reload at the set values (lines 83-112) |
Gap: there is no store-level test for providers (encryption round-trip), policies, guardrails, or IncrementAgentNetworkConsumption (concurrent upsert, window-key uniqueness). The consumption upsert is the most performance-sensitive method in this module and the only one without a real-sqlite test.
Known limitations / explicit non-goals
- No retention / GC for
agent_network_consumption. - No
DeleteforSettings(one row per account, cleared with the account). - No DB-engine-specific tuning — the same struct tags drive sqlite, mysql, postgres.
- Provider
extra_valuesandmodelsare JSON blobs; querying inside them is not supported by design. GetAgentNetworkConsumption"not-found = zero row" contract is convenient but unconventional.
Cross-references
- Upstream: shared/api, management/agentnetwork
- End-to-end flow: ../01-end-to-end-flows.md
- Top-level: ../00-overview.md