Skip to content

fix(config): delete the dead rate_limit.* settings and prune their rows - #819

Merged
ericfitz merged 5 commits into
mainfrom
dev/1.9.2/delete-dead-rate-limit-settings
Aug 23, 2026
Merged

fix(config): delete the dead rate_limit.* settings and prune their rows#819
ericfitz merged 5 commits into
mainfrom
dev/1.9.2/delete-dead-rate-limit-settings

Conversation

@ericfitz

Copy link
Copy Markdown
Owner

Closes #813. Follow-up to #812, per the decision recorded on #809 on 2026-08-23.

What

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.

#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 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.

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, which
is exactly the inconsistency #809 was about.

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). All three are already inside the cross-replica
migration advisory lock by placement.

retiredSettingKeys is documented as append-only: a deployment can upgrade
across 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's expectedSeedValues() derives
from models.DefaultSystemSettings(), which projects the registry — so once
these defs are gone, a surviving NULL-origin retired row matches no known
default and the backfill stamps it explicit moments before the prune deletes
it. Harmless in outcome, but it inflates the backfill's reported row count and
makes upgrade logs misleading.
TestPruneRetiredSystemSettings_RunsBeforeOriginBackfill pins 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 a
    SettingDef or as a classification entry fails by name. Planting the
    classification 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 blast
    radius. Replacing the IN-list with LIKE '%rate_limit%' fails on
    server.disable_rate_limiting, the live setting that actually drives rate
    limiting.

A trap the tests would otherwise have hidden

Delete is a hard DELETE only because models.SystemSetting has no
gorm.DeletedAt. If one were added later, Delete silently 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 Count filters soft-deleted rows just like Find does.
TestPruneRetiredSystemSettings_HardDeletes uses Unscoped() so that day fails
loudly.

Also

Retargets test fixtures and doc examples that used the retired keys as sample
data: the OpenAPI SystemSetting example and property description (regenerated
into api/api.go with oapi-codegen v2.7.1 — verified only the 2 expected
non-base64 lines changed), the api/models doc comment, and dev-config.py's
coverage list, which was additionally missing features.saml_enabled and
session.timeout_minutes.

config-reference.md is unchanged — these keys were never in it, having no
Config struct field.

Gates

  • make lint — 0 issues
  • make build-server — clean
  • make test-unit2750 passed, 0 failed
  • make test-integration85 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.
  • SEM markers refreshed
  • oracle-db-adminAPPROVED WITH NOTES, no blocking issues

Oracle review outcome

Verdict: APPROVED WITH NOTES. The reviewer traced the bind path through
GORM v1.31.2 (Expr.BuildStatement.AddVar slice arm emitting (:1,:2))
and gorm-oracle v1.1.3 (BindVarTo positional binds, no-RETURNING
executeDelete with accurate RowsAffected), and confirmed the
PrepareStmt 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 at
all three sites, and that all three inherit the cross-replica migration
advisory lock.

Three of the four notes are fixed in this PR:

  • N1 — chunked the IN-list. Oracle caps an expression list at 1000
    (ORA-01795) and retiredSettingKeys is documented append-only, so it is
    designed to grow. Now routed through chunkStrings like the sibling backfill,
    and partial progress is returned on error since that becomes real with more
    than one chunk.
  • N2 — the deleted_at assertion is now by NAME, not by type. My original
    test reasoned about GORM core, which diverts Delete to an UPDATE only for
    a field typed gorm.DeletedAt. gorm-oracle is looser: it diverts on
    stmt.Schema.LookUpField("deleted_at") != nil, 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 landing on
    SystemSetting is realistic — and it would leave SQLite and Postgres
    hard-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.Time trips the new assertion while the rest of
    the test still passes on SQLite, which is precisely why it was needed.
  • N4 — retargeted four leftover examples in
    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. .prev is an
export from a database seeded by pre-#813 code; the current file is an export
from one managed by this branch:

-rate_limit:
-    requests_per_hour: 1000
-    requests_per_minute: 100
 server:
     disable_rate_limiting: false
     ratelimit_public_rpm: 0
     require_if_match: false

The whole rate_limit: block is gone and every adjacent server.*
rate-limiting key survived — the blast radius confirmed on Postgres rather than
only on the SQLite-backed unit tests. Export went 62 -> 60 settings.

ericfitz and others added 5 commits August 23, 2026 01:15
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
@ericfitz
ericfitz merged commit 72dd09a into main Aug 23, 2026
15 checks passed
@ericfitz
ericfitz deleted the dev/1.9.2/delete-dead-rate-limit-settings branch August 23, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(config): delete the dead rate_limit.* settings and prune their existing rows

1 participant