fix(config): three settings an operator can set that did nothing - #398
Merged
Conversation
Each of these parsed an environment variable at boot and then had no
reader on any path an operator could observe.
1. Agent load-balancing weights (AGENT_LB_*). config.LoadBalancingConfig
parsed seven variables that nothing outside config.go read, and the
selector ranked agents purely on CurrentJobs/MaxConcurrentJobs. The
heartbeat path made it worse: it called UpdateMetrics, which never
touches LoadScore or MetricsUpdatedAt, so the load_score column never
reflected anything. Weights now flow config -> AgentService (persisted
score, recomputed each heartbeat) and config -> AgentSelector (job
placement). The disk/network throughput ceilings became weight fields
instead of file-scope constants, and the heartbeat payload accepts the
disk/network metrics those two weights need. Resource metrics older
than 5 minutes are ignored so one stale sample from a wedged agent
cannot bias scheduling forever.
2. Admin-audit-log retention. cmd/server/workers.go hardcoded
DryRun: true with no plumbing, so admin_audit_logs grew forever and
the 365-day policy could not be enforced by any deployment. Now
configurable via ADMIN_AUDIT_RETENTION_{ENABLED,DRY_RUN,DAYS,
INTERVAL,BATCH_SIZE}. DryRun still defaults to true: deleting audit
history on upgrade would be a compliance incident, and the controller
already reports what it would delete. That report is now WARN-level
with the variable to flip. Config validation refuses to boot with a
retention window under 30 days while deletion is enabled, because a
zero or negative window moves the cutoff to now-or-later and empties
the table.
3. AI_RATE_LIMIT_RPM and the auto-triage defaults. No limiter existed
anywhere, so a setting that reads as a spend cap enforced nothing. A
token-bucket limiter (golang.org/x/time/rate, already a direct
dependency) now sits in front of Provider.Complete, keyed per
credential so platform-mode tenants share the platform key's budget
and each BYOK tenant gets its own. Over-budget calls wait, then fail
with ErrRateLimited rather than reaching the provider. Separately,
AI_AUTO_TRIAGE_DEFAULT_{ENABLED,SEVERITIES} and AI_AUTO_TRIAGE_DELAY
now seed tenants that have never configured auto-triage; a tenant's
explicit choice, including an explicit off, still wins.
Behaviour is unchanged for a deployment that sets nothing: the shipped
weights match the previous constants, retention stays a dry run, the
rate limit default of 60 rpm is far above real triage volume, and
AI_AUTO_TRIAGE_DEFAULT_ENABLED defaults to false.
added 2 commits
August 2, 2026 18:22
CodeQL go/weak-sensitive-data-hashing flagged the SHA-256 over the API key that keyed the limiter registry. The alert is a heuristic — the digest was a map key, never stored or compared for authentication — but hashing a credential to partition rate limits was the wrong shape regardless. Scope now derives from AI mode plus tenant ID: platform-mode tenants share one budget because they share the platform key's bill, and each BYOK tenant gets its own. No credential is involved, the mapping is readable in a log, and billing identity is the more accurate unit anyway. CreateProvider keeps its signature and delegates to the new CreateProviderForTenant.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three settings an operator could set where nothing happened. Each parsed an environment variable at boot and then had no reader on any path the operator could observe.
All three premises held; details and evidence below.
1. Agent load-balancing weights were inert
config.LoadBalancingConfigparsed sevenAGENT_LB_*variables that nothing outsideconfig.goread.AgentSelector.selectLeastLoadedranked agents onCurrentJobs/MaxConcurrentJobsalone.The heartbeat side was worse than reported.
AgentService.UpdateHeartbeatcalledAgent.UpdateMetrics, which sets CPU/memory/ActiveJobs and never touchesLoadScoreorMetricsUpdatedAt.UpdateExtendedMetrics— the only method that computes a score — had zero call sites. So theload_scorecolumn never reflected anything, anddocs/architecture/agent-heartbeat-optimization.md("Load score // Computed every heartbeat") described something that did not happen. The disk/network weights had no inputs at all:HeartbeatRequestcarried no disk or network fields, soAGENT_LB_DISK_IO_WEIGHTandAGENT_LB_NETWORK_WEIGHTcould only ever multiply zero.Fixed:
LoadBalancingWeightsgains the two normalization ceilings, which were file-scopeconsts inside the scoring function. An operator on NVMe or 10 GbE had no way to say what "100% busy" meant on their hardware.config.LoadBalancingConfig.Weights()is the single seam; bothAgentService(persisted score) andAgentSelector(job placement) take it at boot.load_scorewith the configured weights and stampsmetrics_updated_at.HeartbeatRequestaccepts optionaldisk_read_mbps/disk_write_mbps/network_rx_mbps/network_tx_mbps. Backwards compatible — agents that omit them leave those terms at zero, exactly as today.ComputeLoadScoreWithWeightsrather than reading the stored column, so a weight change takes effect immediately instead of waiting for every agent to heartbeat.Two safety valves: resource metrics older than 5 minutes are ignored and the agent is ranked on queue depth alone (a wedged agent keeps its last CPU reading forever, and one bad sample would otherwise bias scheduling permanently), and an all-zero weight set is rejected in favour of the defaults (it would score every agent at 0 and make selection arbitrary).
Existing selector behaviour is preserved: unlimited-capacity agents still short-circuit, fully-loaded agents are still never selected, ties still keep the first candidate.
2. Admin audit retention was permanently a dry run
cmd/server/workers.gohardcodedDryRun: truewith no config plumbing, soadmin_audit_logsgrew forever and the 365-day policy could not be enforced by any deployment. (PriorityAuditRetentionController, right above it, deletes for real at 180 days — the inconsistency is what made this look accidental.)Now configurable:
ADMIN_AUDIT_RETENTION_ENABLED/_DRY_RUN/_DAYS/_INTERVAL/_BATCH_SIZE.Default chosen:
DRY_RUN=true, retention 365 days, controller enabled.The defensible reading is that the operator decision here is whether to delete, not whether to have a policy. Flipping the default to real deletion would mean an upgrade silently destroys up to a year of platform-admin audit history — irreversible, and audit retention is exactly the thing SOC 2 / PCI DSS / HIPAA auditors ask about. Nobody asked for that on a version bump. Keeping the controller enabled in dry-run is what makes the growth visible rather than silent: it already counts what it would delete, and that report is now WARN-level and names the variable to flip. So the shipped behaviour is byte-identical to today, and the difference is that an operator now has a switch.
Guardrail on the irreversible path: config validation refuses to boot when deletion is enabled with a window under 30 days.
RetentionDays: 0or a negative value puts the cutoff at now-or-later and empties the table;NewAuditRetentionControllermapped only== 0to the default, so a negative slipped through. Both are now caught, and the controller's own fallback widened to<= 0for defence in depth. Dry-run configs skip the check — nothing is deleted, so a bad value cannot hurt.3.
AI_RATE_LIMIT_RPMenforced nothingNo limiter existed in
internal/app/aitriage/orinternal/infra/llm/. A setting that reads as a spend cap was decoration.The limiter lives at the LLM call boundary —
internal/infra/llm/ratelimit.go, a decorator overProviderapplied byFactoryto every provider it hands out. That is the one seam every triage call passes through (AITriageService.runTriage->CreateProvider->provider.Complete). Usesgolang.org/x/time/rate, already a direct dependency ingo.mod(internal/infra/http/middleware/ratelimit.go); no new dependency.One limiter per budget scope, shared process-wide via a registry on the Factory (a single Factory is built in
services.go). Per-instance limiters would be trivially bypassed since providers are created per triage job.Scope is derived from AI mode plus tenant ID, never from the credential. Platform-mode tenants share the platform key's budget because they share the bill; each BYOK tenant gets its own because it pays its own.
CreateProviderkeeps its signature and delegates to a newCreateProviderForTenant.The first revision keyed the scope on a SHA-256 prefix of the API key. CodeQL's
go/weak-sensitive-data-hashingflagged it, and while the alert is a heuristic — the digest was a map key, never stored or compared for authentication — hashing a credential to partition rate limits was the wrong shape regardless. Billing identity is the more accurate unit, the mapping is readable in a log, and no credential is involved. Fixed in the second commit.Over-budget calls wait for a token and fail with
ErrRateLimitedif none arrives within 30s or before the caller's deadline, whichever is sooner. They never reach the provider, which is the point of a cost control.categorizeErroralready mapsErrRateLimitedto a user-facing "AI service is temporarily busy" message, so no new error surface.Burst is the full minute's allowance, so a batch of triage jobs starting together is not serialised to one call every
60/rpmseconds; sustained throughput is still capped.AI_RATE_LIMIT_RPM=0disables the cap.Read
internal/app/aitriage/budget.gofirst as instructed. It is a per-tenant monthly token budget on a different axis (spend volume, persisted, RFC-008), gated off in Phase 1, and its warn/block setters are the documented deferral. A per-process request-rate cap does not belong inside it, so the limiter is separate and the two compose: budget pre-check, then rate limit, then call.Also in scope, from the same item:
AI_AUTO_TRIAGE_DEFAULT_ENABLED,AI_AUTO_TRIAGE_DEFAULT_SEVERITIESandAI_AUTO_TRIAGE_DELAYwere equally inert.extractAISettingsstarted from a zeroAISettings, andShouldAutoTriage/EnqueueAutoTriagereadTypedSettings()(a JSON round-trip, so absent keys land on Go zero values, not ontenant.DefaultSettings()). Either way an operator who enabled auto-triage platform-wide saw nothing. Those three now seed tenants that have never set the corresponding key; both call sites go through the raw settings map so "absent" stays distinguishable from "explicitly false", and a tenant's explicit choice — including an explicit off — always wins. A tenant's severity list replaces the platform default rather than being unioned with it, which would have silently widened its scope.Behaviour with no configuration change
Unchanged. Shipped weights equal the previous constants, retention stays a dry run,
AI_RATE_LIMIT_RPM=60is far above real triage volume,AI_AUTO_TRIAGE_DEFAULT_ENABLED=false.Verification
GOWORK=off go build ./...,go vet ./...andgo test ./...all exit 0; 92 packages pass.make lint-new BASE_REF=origin/developis clean.Every fix has a test proven red against the unfixed code by reverting only the production change:
selectLeastLoadedscoringTestAgentSelLoadBalancing_CPUBreaksJobLoadTieTestAgentSelLoadBalancing_WeightsChangeSelectionTestAgentSelLoadBalancing_ZeroWeightsRejectedComputeLoadScoreWithWeightsTestComputeLoadScore_HonorsThroughputCeilingsadminAuditRetentionConfig-> hardcoded literalTestAdminAuditRetention_DryRunIsConfigurableDRY_RUN=falsedid not reach the controller; days/interval/batch all ignoredComplete+ factory wiringTestRateLimitedProvider_BlocksCallOverBudgetTestRateLimitedProvider_SharedBudgetAcrossProvidersTestRateLimitedProvider_HonorsCallerContextTestFactory_AppliesRateLimit*llm.ClaudeProvider, not rate limitedextractAISettingsTestAITriage_AutoTriageDefaults_ApplyToUnconfiguredTenantAI_AUTO_TRIAGE_DEFAULT_ENABLED=truedid not reach an unconfigured tenantPlus non-regression tests for the parts that must not change: tenant opt-out beats the platform default, tenant severity list replaces rather than merges, stale metrics fall back to queue depth,
AI_RATE_LIMIT_RPM=0means no cap,budgetScopepartitions platform vs BYOK correctly, dry-run configs still validate, and the shipped default is dry-run at 365 days.Found, not fixed
AgentHeartbeatData.CurrentJobsis written toAgent.ActiveJobs, while scheduling readsAgent.CurrentJobs(maintained byClaimJob/ReleaseJob). The names invite a mix-up. Pre-existing; preserved exactly rather than fixed here.selectTenantAgentreturnsSelectAgentResult{Agent: nil, Message: "Tenant agent assigned"}when every candidate is at capacity.FindAvailableWithCapacityshould make that unreachable, but the message is a lie if it ever is. Pre-existing and pinned by an existing test, so left alone.AGENT_LB_DISK_IO_WEIGHT/AGENT_LB_NETWORK_WEIGHTbecome live the moment the agent reports them; that change belongs in the agent repo.