fix(config): converge env/DB precedence onto one rule with an explicit-vs-seeded marker (#794) - #806
Merged
Merged
Conversation
…t-vs-seeded marker (#794) Precedence between the config layer (YAML + env) and the system_settings table was decided per key by whichever accessor a caller happened to use. Five keys followed four different rules, and the gap caused a user-visible login outage on api.tmi.dev on 2026-08-20. The two rules were deliberate and opposite, and each was right about half the problem: - GetString (#415): an explicit config value wins; a bare struct default yields to the database. - GetDatabaseString (#767): the database always wins; config is a first-run fallback only. #767 chose database-only because reading through GetString inverted it — env silently shadowed a row an admin had just edited. But it paid for that with the outage: RDS carried a registry-seeded default of http://localhost:8080/oauth2/callback, that row outranked a correctly-set TMI_OAUTH_CALLBACK_URL, and every provider rejected the resulting redirect_uri. Nothing warned. The missing concept was on the database side. #415 already distinguished an operator-supplied config value from a struct default (MigratableSetting .Explicit); rows had no equivalent, so a seeded row and an admin-set row were indistinguishable. This adds system_settings.origin and collapses both accessors into GetResolvedString: config explicit? db explicit? winner no no database (both defaults; prefer hot-reloadable) no yes database (#415 preserved) yes no config (fixes the outage) yes yes database (#419/#767 runtime editability preserved) Bootstrap keys are exempt and keyed on the classification registry, so the guard holds even when no config provider is wired. Only "explicit" reads as explicit. NULL, "seeded", an unexpected value and an empty string all read as seeded, so every way an origin can be lost — Oracle binding '' as NULL, a writer that forgets the stamp, a stale pre-upgrade Redis entry with no origin key — degrades toward config, the layer an operator can see and control. BackfillSystemSettingOrigin stamps existing rows explicit only where they show operator intent (modified_by set, or the value has moved off the registry default), so deliberate rows keep their authority; for at-rest-encrypted values the value comparison is meaningless and modified_by alone decides. A CHECK constraint enforces the domain on both engines. Also here: - warnIfConfigDatabaseDiverges logs at startup when an explicit config value and a database row disagree, naming both and the winner. This would have caught both 2026-08-20 regressions in seconds. It reads every row in one List rather than one query per key, which on a cold Oracle ADB was seconds of added startup. - auth.everyone_is_a_reviewer gains a runtime reader at all. Its only consumer read the config struct directly, so the database row was visible in the admin API, editable, and inert. Memoized for 60s because it sits on every authenticated request, not just /oauth2/*. - config-reference.md documents per-key precedence, including that auth.oauth.providers.* merges per-ID rather than following the scalar rule. Secret redaction in the new warning goes through a new MigratableSetting .IsSecret(). Class.Secret is false for the provider subtrees — a blanket true there would mis-mask non-secret sub-keys like .client_id — so those carry secrecy only on the per-setting Secret flag. Checking Class.Secret alone would have written real OAuth client secrets and SAML private keys in plaintext to logs/tmi.log on every boot during a secret rotation. Integration suite goes from 83 passed / 2 failed to 85 passed / 0 failed: TestIdentityLink and TestIdentityLink_SecondConfirmRejected were failing on main for exactly this bug — a seeded row beating the harness's explicit TMI_OAUTH_CALLBACK_URL, sending the callback to the wrong port. TMI_OAUTH_CALLBACK_URL is deliberately kept: it remains the fallback for the row-deletion case, and the converged rule makes it non-shadowing. oracle-db-admin: APPROVED WITH NOTES over three review rounds. Blocking findings fixed: a plain Update silently rewrote modified_at on every backfilled row; encryption-mismatched rows originally defaulted to explicit, which reproduced the outage class on encrypted installs. Follow-ups filed: #803 (expose origin via the admin API), #804 (Oracle-side coverage — all new tests run on SQLite, which cannot reproduce the ''-is-NULL mechanics), #805 (ReEncryptAll stamps modified_by on every row, weakening it as an intent signal). Refs #419, #767, #415. Fixes #794. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018YadGYzmxyMtJbxanvtzRJ
Markers on new and changed entities carried the all-zeros placeholder sha, which sem reports as invalid-sha. Re-anchored against the commit that introduced them now that the files exist in history — sem blame cannot resolve a path that is not yet in HEAD, so this necessarily follows the code commit. Descriptions are unchanged; only the anchors move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018YadGYzmxyMtJbxanvtzRJ
) CodeQL flagged go/clear-text-logging (1 high) on the new startup warning: Password, LLMAPIKey, TextEmbeddingAPIKey and RerankAPIKey reach a logging call. Every one of those flows is redacted today — database.redis.password is bootstrapClass so the category filter drops it before the redaction branch, and the timmy.*_api_key keys are operationalSecretMonolith() so IsSecret() catches them. As reported, it is a false positive. Fixing it anyway, because CodeQL is objecting to the shape rather than to those specific keys, and the shape is genuinely fragile: printing values for anything not Secret-classified makes log safety depend on every key being classified correctly, forever. That dependency had already failed once in this same PR — the auth.oauth.providers. / auth.saml.providers. / content_oauth.providers. subtrees carry Class.Secret=false (a blanket true would mis-mask non-secret sub-keys like .client_id), so a check keyed on classification wrote real OAuth client secrets in plaintext until the security review caught it. The warning now reports only the key and which layer wins. That is what an operator needs — which setting to inspect, and which side is in force — and the values remain one GET /admin/settings/{key} and one printenv away, where reading them is a deliberate act rather than a side effect of booting. MigratableSetting.IsSecret() stays: warnIfPlaintextSecretsAtRest still needs it, and it remains the right single source for "is this value sensitive". Refs #794. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018YadGYzmxyMtJbxanvtzRJ
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.
Fixes #794.
The problem
Precedence between the config layer (YAML + env) and the
system_settingstable was decided per key by whichever accessor a caller happened to use. Five keys followed four different rules, and the gap caused a user-visible login outage on api.tmi.dev on 2026-08-20.The two rules were deliberate and opposite, and each was right about half the problem:
GetStringGetDatabaseString#767 chose database-only for a real reason — reading through
GetStringinverted it, so env silently shadowed a row an admin had just edited. But it paid with the outage: RDS carried a registry-seeded default ofhttp://localhost:8080/oauth2/callback, that row outranked a correctly-setTMI_OAUTH_CALLBACK_URL, and every provider rejected the resultingredirect_uri. Nothing warned.The fix
The missing concept was on the database side. #415 already distinguished an operator-supplied config value from a struct default (
MigratableSetting.Explicit); rows had no equivalent, so a seeded row and an admin-set row were indistinguishable. This addssystem_settings.originand collapses both accessors intoGetResolvedString:Bootstrap keys are exempt, keyed on the classification registry so the guard holds even with no config provider wired.
The polarity is the safety property
Only a literal
"explicit"reads as explicit. NULL,"seeded", an unexpected value and an empty string all read as seeded, so every way an origin can be lost — Oracle binding''as NULL, a writer that forgets the stamp, a stale pre-upgrade Redis entry with nooriginkey — degrades toward config, the layer an operator can actually see and control.BackfillSystemSettingOriginstamps existing rows explicit only where they show operator intent (modified_byset, or the value has moved off the registry default), so deliberate rows keep their authority. For at-rest-encrypted values the comparison is meaningless (ciphertext never equals a plaintext default) andmodified_byalone decides. ACHECKconstraint enforces the domain on both engines.Also here
warnIfConfigDatabaseDivergeslogs at startup when an explicit config value and a DB row disagree, naming both and the winner. This would have caught both 2026-08-20 regressions in seconds. Reads every row in oneListrather than one query per key — as serial round-trips that was seconds of added startup on a cold Oracle ADB.auth.everyone_is_a_reviewergains a runtime reader at all. Its only consumer read the config struct directly, so the DB row was visible in the admin API, editable, and completely inert. Memoized for 60s because it sits on every authenticated request, not just/oauth2/*.config-reference.mddocuments per-key precedence, including thatauth.oauth.providers.*merges per-ID rather than following the scalar rule.TMI_OAUTH_CALLBACK_URLis deliberately kept — it remains the fallback for the row-deletion case, and the converged rule makes it non-shadowing.Verification
make lint0 issues ·make build-serverclean ·make test-unit2678 passed / 0 failedmake test-integration85 passed / 0 failed — up from 83/2.TestIdentityLinkandTestIdentityLink_SecondConfirmRejectedwere failing onmainfor exactly this bug: a seeded row beating the harness's explicitTMI_OAUTH_CALLBACK_URL, sending the callback to the wrong port. Confirmed against a cleanmainworktree, which fails both withauthorization_url = http://localhost:8080.oracle-db-admin: APPROVED WITH NOTES across three review rounds. Blocking findings fixed: a plainUpdatesilently rewrotemodified_aton every backfilled row (nowUpdateColumn); encryption-mismatched rows originally defaulted to explicit, which reproduced the outage class on encrypted installs.Class.Secret, which is false for theauth.oauth.providers./auth.saml.providers./content_oauth.providers.subtrees — a blanket true there would mis-mask non-secret sub-keys like.client_id, so those carry secrecy only on the per-settingSecretflag. CheckingClass.Secretalone would have written real OAuth client secrets and SAML private keys in plaintext tologs/tmi.logon every boot during a rotation. Now goes through a newMigratableSetting.IsSecret()so the two flags cannot be consulted inconsistently again, with a regression test that loads a real YAML config and asserts neither secret reaches any log line.Deployment notes
tmi-dbtool --import-configbefore the new server binary has booted against a given database — the server's migration adds theorigincolumn, and dbtool ahead of it would fail on the unknown column.--import-configmarks imported rows explicit, so after a fix(dev): make dev-cluster configuration survive a reset — provider Secret + DB settings snapshot/restore #792 snapshot/restore the database outranks env for those keys. That is the intended reading of "an operator deliberately put this value here", and the new startup warning makes it visible rather than silent.Follow-ups filed
originthrough the admin settings API (left out to avoid an OpenAPI regen and theapi/api.gorebase conflicts it causes)''-is-NULL mechanics the feature depends onReEncryptAllstampsmodified_byon every row, weakening it as an intent signal for the backfill on installs that have rotated their encryption keyRefs #419, #767, #415.
🤖 Generated with Claude Code
https://claude.ai/code/session_018YadGYzmxyMtJbxanvtzRJ