fix(config): delete the dead rate_limit.* settings and prune their rows - #819
Merged
Conversation
Closes #813. rate_limit.requests_per_minute and rate_limit.requests_per_hour were seeded into system_settings on every fresh database but read by no handler. Real rate limiting runs off server.disable_rate_limiting and server.ratelimit_public_rpm. left two settings an administrator can read and edit that silently do nothing — arguably a worse trap than the 404 it replaced, since a 404 at least fails loudly. Removes both declarations: the SettingDefs in setting_defs_misc.go and the exactClassifications entries in classification_registry.go. DefaultSystemSettings and SeedableOperationalDefs are projections of the registry, so both drop out of the seed list automatically; the pinned seededKeys golden list goes 9 -> 7. Removing the declarations alone is not sufficient. RDS and k3s both already hold the seeded rows, and a row whose key has no registry entry resolves to VisibilityInternal — so LIST would keep showing it while GET and DELETE 404, exactly the inconsistency #809 was about. Deleting the declarations without deleting the rows would fix the bug for new databases and re-create it for every existing one. So PruneRetiredSystemSettings (internal/dbschema/dead_setting_prune.go) deletes rows for keys the registry no longer declares, wired at the three sites that already run the other schema steps: cmd/server/main.go and auth/config_adapter.go (non-fatal) and cmd/dbtool/schema.go (surfaced, being the admin remediation path). Ordering is load-bearing: the prune must run BEFORE BackfillSystemSettingOrigin. expectedSeedValues derives from DefaultSystemSettings(), which projects the registry, so once these defs are gone a surviving NULL-origin rate_limit row matches no known default and the backfill would stamp it explicit moments before the prune deletes it — inflating the backfill's reported count and making upgrade logs misleading. TestPruneRetiredSystemSettings_RunsBeforeOriginBackfill pins the order. Deletion is unconditional rather than restricted to seeded-origin rows. The keys are leaving the product: an operator-set value for a setting nothing reads has no effect to preserve, and an explicit-origin row left behind reproduces the orphan case for precisely the installs most likely to notice. Keys and row counts are logged; values never are. Guardrails, both watched to fail: - TestRetiredRateLimitKeys_AreGone — re-adding either key as a SettingDef or as a classification entry fails by name. Planting the classification entry back produced "rate_limit.requests_per_minute was retired by #813 and must not have a classification entry". - TestPruneRetiredSystemSettings_LeavesLiveSettingsAlone — pins the blast radius. Replacing the IN-list with LIKE '%rate_limit%' fails on server.disable_rate_limiting, the live setting that actually drives rate limiting. Also retargets test fixtures and doc examples that used the retired keys as sample data (OpenAPI SystemSetting example and property description, regenerated into api/api.go; the api/models doc comment; dev-config.py's coverage list, which was also missing features.saml_enabled and session.timeout_minutes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FanMDLkRXPQEGY73Z4EsSL
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FanMDLkRXPQEGY73Z4EsSL
Verdict was APPROVED WITH NOTES, no blocking issues. Three of the four notes are fixed here; the fourth is filed as #818. N1 — chunk the IN-list (dead_setting_prune.go). Oracle caps an expression list at 1000 entries (ORA-01795), and retiredSettingKeys is documented as append-only, so the list is designed to grow monotonically. The sibling BackfillSystemSettingOrigin already routes keys through chunkStrings for exactly this reason. Inert at two entries, but it stops being inert silently and only on Oracle. Partial progress is now returned on error, since that becomes real once more than one chunk is in play. N2 — assert the absence of a deleted_at column by NAME, not by type (dead_setting_prune_test.go). The original test reasoned about GORM core, which diverts Delete to an UPDATE only for a field typed gorm.DeletedAt. gorm-oracle's replaced callback is looser: it diverts on stmt.Schema.LookUpField("deleted_at") != nil, i.e. any field mapping to that column whatever its type. TMI's tombstoned models use a plain *time.Time (api/models/models.go:159,212,252,323), so that shape appearing on SystemSetting is realistic — and it would leave SQLite and Postgres hard-deleting, the test green, and Oracle alone silently not deleting, reproducing #809 on the one platform this change exists to fix. Watched to fail: adding `DeletedAt *time.Time` to SystemSetting trips the new assertion while the rest of the test still passes on SQLite — which is the whole point of adding it. N4 — retarget the four remaining rate_limit.requests_per_minute examples in api-schema/tmi-openapi-3.1-experimental.json. The commit before this one retargeted the equivalents in tmi-openapi.json but missed the experimental spec. N3 (no Oracle-tagged integration coverage for the prune) is deferred to #818, as the reviewer suggested. The bind path was verified by reading GORM v1.31.2 and gorm-oracle v1.1.3 source, and the statement shape is already precedented in production code running on ADB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FanMDLkRXPQEGY73Z4EsSL
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FanMDLkRXPQEGY73Z4EsSL
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.
Closes #813. Follow-up to #812, per the decision recorded on #809 on 2026-08-23.
What
rate_limit.requests_per_minuteandrate_limit.requests_per_hourwere seededinto
system_settingson every fresh database but read by no handler. Real ratelimiting runs off
server.disable_rate_limitingandserver.ratelimit_public_rpm.#812 classified them, which cleared the live GET/DELETE 404 that #809 reported,
but left two settings an administrator can read and edit that silently do
nothing — arguably a worse trap than the 404 it replaced, since a 404 at least
fails loudly. This removes them.
Both declarations go: the
SettingDefs insetting_defs_misc.goand theexactClassificationsentries inclassification_registry.go.DefaultSystemSettingsandSeedableOperationalDefsare projections of theregistry, so both drop out of the seed list automatically; the pinned
seededKeysgolden list goes 9 → 7.Why the row prune is not optional
Removing the declarations alone would fix the bug for new databases and
re-create it for every existing one. RDS and k3s both already hold the seeded
rows, and a row whose key has no registry entry resolves to
VisibilityInternal— so LIST keeps showing it while GET and DELETE 404, whichis exactly the inconsistency #809 was about.
PruneRetiredSystemSettings(internal/dbschema/dead_setting_prune.go) deletesrows for keys the registry no longer declares, wired at the three sites that
already run the other schema steps:
cmd/server/main.goandauth/config_adapter.go(non-fatal) andcmd/dbtool/schema.go(surfaced, beingthe admin remediation path). All three are already inside the cross-replica
migration advisory lock by placement.
retiredSettingKeysis documented as append-only: a deployment can upgradeacross several releases at once and still be carrying a row retired two releases
back.
Two things worth reviewer attention
1. Ordering is load-bearing. The prune must run before
BackfillSystemSettingOrigin. That function'sexpectedSeedValues()derivesfrom
models.DefaultSystemSettings(), which projects the registry — so oncethese defs are gone, a surviving NULL-origin retired row matches no known
default and the backfill stamps it
explicitmoments before the prune deletesit. Harmless in outcome, but it inflates the backfill's reported row count and
makes upgrade logs misleading.
TestPruneRetiredSystemSettings_RunsBeforeOriginBackfillpins the order.2. Deletion is unconditional, including explicit-origin rows. The keys are
leaving the product: an operator-set value for a setting nothing reads has no
effect to preserve, and an explicit-origin row left behind reproduces the orphan
case above for precisely the installs most likely to notice it. Keys and row
counts are logged; values never are. Happy to switch to preserve-and-warn if
reviewers prefer.
Guardrails, both watched to fail
Per the rule #812 established — a guardrail nobody has watched fail is not
evidence.
TestRetiredRateLimitKeys_AreGone— re-adding either key as aSettingDefor as a classification entry fails by name. Planting theclassification entry back produced: "rate_limit.requests_per_minute was
retired by chore(config): delete the dead rate_limit.* settings and prune their existing rows #813 and must not have a classification entry".
TestPruneRetiredSystemSettings_LeavesLiveSettingsAlone— pins the blastradius. Replacing the IN-list with
LIKE '%rate_limit%'fails onserver.disable_rate_limiting, the live setting that actually drives ratelimiting.
A trap the tests would otherwise have hidden
Deleteis a hard DELETE only becausemodels.SystemSettinghas nogorm.DeletedAt. If one were added later,Deletesilently becomes an UPDATE,the row survives as soft-deleted, and it still resolves to
VisibilityInternal— reproducing #809's exact shape. Every other test here would keep passing,
because GORM's
Countfilters soft-deleted rows just likeFinddoes.TestPruneRetiredSystemSettings_HardDeletesusesUnscoped()so that day failsloudly.
Also
Retargets test fixtures and doc examples that used the retired keys as sample
data: the OpenAPI
SystemSettingexample and property description (regeneratedinto
api/api.gowith oapi-codegen v2.7.1 — verified only the 2 expectednon-base64 lines changed), the
api/modelsdoc comment, anddev-config.py'scoverage list, which was additionally missing
features.saml_enabledandsession.timeout_minutes.config-reference.mdis unchanged — these keys were never in it, having noConfigstruct field.Gates
make lint— 0 issuesmake build-server— cleanmake test-unit— 2750 passed, 0 failedmake test-integration— 85 passed, 0 failed (matches the feat(config): make one registry the authoritative declaration of every setting #812 baseline).Note the first run reported "23 passed" but the harness correctly refused to
call it a pass, because the OAuth stub was down and workflow tests skipped;
restarted and re-ran for the real result.
oracle-db-admin— APPROVED WITH NOTES, no blocking issuesOracle review outcome
Verdict: APPROVED WITH NOTES. The reviewer traced the bind path through
GORM v1.31.2 (
Expr.Build→Statement.AddVarslice arm emitting(:1,:2))and gorm-oracle v1.1.3 (
BindVarTopositional binds, no-RETURNINGexecuteDeletewith accurateRowsAffected), and confirmed thePrepareStmt no-op hazard is DDL-only (Oracle does DDL work at parse; DML works
at execute), that there is no
''/NULL exposure, that ordering is correct atall three sites, and that all three inherit the cross-replica migration
advisory lock.
Three of the four notes are fixed in this PR:
(ORA-01795) and
retiredSettingKeysis documented append-only, so it isdesigned to grow. Now routed through
chunkStringslike the sibling backfill,and partial progress is returned on error since that becomes real with more
than one chunk.
deleted_atassertion is now by NAME, not by type. My originaltest reasoned about GORM core, which diverts
Deleteto anUPDATEonly fora field typed
gorm.DeletedAt. gorm-oracle is looser: it diverts onstmt.Schema.LookUpField("deleted_at") != nil, any field mapping to thatcolumn whatever its type. TMI's tombstoned models use a plain
*time.Time(
api/models/models.go:159,212,252,323), so that shape landing onSystemSettingis realistic — and it would leave SQLite and Postgreshard-deleting, the test green, and Oracle alone silently not deleting,
reproducing fix(api): rate_limit.* settings are listed but 404 on GET/DELETE — unclassified keys seeded straight into the database #809 on the one platform this change exists to fix. Watched to
fail: adding
DeletedAt *time.Timetrips the new assertion while the rest ofthe test still passes on SQLite, which is precisely why it was needed.
api-schema/tmi-openapi-3.1-experimental.json, which the first commit missed.N3 (no Oracle-tagged integration coverage for the prune) is deferred to
#818 per the reviewer's suggestion — the bind path was verified from driver
source and the statement shape is already precedented in production code running
on ADB.
Verified against a real Postgres database
The dev-environment teardown snapshot is a clean before/after.
.previs anexport from a database seeded by pre-#813 code; the current file is an export
from one managed by this branch:
The whole
rate_limit:block is gone and every adjacentserver.*rate-limiting key survived — the blast radius confirmed on Postgres rather than
only on the SQLite-backed unit tests. Export went 62 -> 60 settings.