You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
tech-debt(backups): service_type is a bare string among eight sibling enums, and now needs two spellings of postgres #13578
The cost showed up immediately when a second engine was added. api/stateful.py now dispatches on the raw string and has to accept two spellings, because nothing constrains what a caller sends:
An unknown value is not rejected at the API boundary — it reaches _run_backup, creates a Backup row, and only then fails with Unsupported service type. A BackupStatus.FAILED row for a typo is indistinguishable from one for a real failure.
Work
class BackupServiceType(str, enum.Enum) in models/database.py, alongside the eight that already exist
BackupCreate.service_type typed as the enum, so FastAPI rejects an unknown value with 422 before a row is written
Dispatch keyed on enum members rather than string literals; the postgres/postgresql alias becomes one canonical member
Existing rows keep working — the column stays a string at rest, the enum is the boundary
Delivered in #14957 (branch issue-13845-enum-union, head 17b58390). Member sets, deliberate-fork findings and the per-fork union tables are in the PR body. Not merged — CI in flight.
CI: all 10 required contexts present and green, checked by name. 59 success / 2 skipped / 0 failures. All 12 python-suite shards green, including shard 10 — the one that went red on the pin tests.
Review: independent code-reviewer pass. Every fork's member set was reconstructed from the merge-base tree and diffed against the union rather than taken from the PR body.
Nothing was dropped.FORBIDDEN, DANGEROUS, OAUTH_REFRESH_TOKEN and ANY all have a real home.
FORBIDDEN and DANGEROUS were kept as separate members, and the reasoning is now in the source: they are distinct reasons reaching the same verdict — DANGEROUS means the command matched a destructive pattern (RISKY_COMMAND_PATTERNS), FORBIDDEN means the base command is on the executor's deny list (FORBIDDEN_COMMANDS). "dangerous" is also already serialized by two live endpoints. Folding either away would have broken a wire contract or erased a distinction the executor actually makes.
The union created a fail-open, and the PR closed it. With two blocking members, an equality check against one let the other producer's blocking verdict through. The enum gained .blocks, and review confirmed all three production comparison sites migrated with none missed. A HIGH_RISK_COMMAND_LEVELS set that previously drew only from CommandRiskLevel now picks up FORBIDDEN automatically.
map_risk_to_level was fail-open — it defaulted unmapped members to MEDIUM, downgrading a blocking verdict to mid-scale. Now total, with the fallback changed to CRITICAL, and a guard that reads the table via AST and asserts totality against the live enum so a future member without an entry fails loudly.
A third SecretType the issues never named was found in api/schemas_system.py — also missing OAUTH_REFRESH_TOKEN, so POST /secrets could not accept the kind the database row already stored. And the real OAuth producer was persisting "connector_oauth_token" as a bare string, commented as a SecretType label while belonging to no enum at all.
Four deliberate forks preserved with evidence, none collapsed: the #7258 legacy-wire RiskLevel, the standalone host-side SecretType, InjectionRisk (grades text, not commands — discovered by the new scan and documented rather than folded), and the three role vocabularies whose own issue states they are correctly separate.
The guard caught itself failing open. Mutation M9 re-declared a fork behind from enum import Enum as _E and the scan stayed green, because it matched the literal word Enum. Fixed by resolving the import to its local names. A two-hop re-export remains a known limit, now stated in the docstring and pinned by a test that instructs the next person to update it rather than delete it.
A regression this PR caused was found and fixed before merge:models/schemas.py gained an import that test_apply_secrets.py could not satisfy — that test replaces models.database with a MagicMock and hand-installs one real stand-in, because pydantic needs a genuine enum type. The result was a collection error, so all 9 tests in the file stopped running rather than one failing. The blast radius was then settled by collecting both trees (1697 each) rather than grepping, since collection is exactly where the failure manifests.
Remaining and filed: #14956 (169 severity literals — the honest measured count, not the issue's 119, with 43 already out of vocabulary; ratcheted so it can only fall), #14974 (ANY advertised on two API surfaces that reject it), #14955 (CRITICAL rendering as unclassified grey).
Activity
mrveiss commentedon Aug 24, 2026
Delivered in #14957 (branch
issue-13845-enum-union, head17b58390). Member sets, deliberate-fork findings and the per-fork union tables are in the PR body. Not merged — CI in flight.fix(test): give the models.database stub a real BackupServiceType so …
fix(enums): collapse four divergent enum forks to the union of their …
mrveiss commentedon Aug 24, 2026
Delivered and merged as
342eeb05e(PR #14957).python-suiteshards green, including shard 10 — the one that went red on the pin tests.code-reviewerpass. Every fork's member set was reconstructed from the merge-base tree and diffed against the union rather than taken from the PR body.Nothing was dropped.
FORBIDDEN,DANGEROUS,OAUTH_REFRESH_TOKENandANYall have a real home.FORBIDDENandDANGEROUSwere kept as separate members, and the reasoning is now in the source: they are distinct reasons reaching the same verdict —DANGEROUSmeans the command matched a destructive pattern (RISKY_COMMAND_PATTERNS),FORBIDDENmeans the base command is on the executor's deny list (FORBIDDEN_COMMANDS)."dangerous"is also already serialized by two live endpoints. Folding either away would have broken a wire contract or erased a distinction the executor actually makes.The union created a fail-open, and the PR closed it. With two blocking members, an equality check against one let the other producer's blocking verdict through. The enum gained
.blocks, and review confirmed all three production comparison sites migrated with none missed. AHIGH_RISK_COMMAND_LEVELSset that previously drew only fromCommandRiskLevelnow picks upFORBIDDENautomatically.map_risk_to_levelwas fail-open — it defaulted unmapped members toMEDIUM, downgrading a blocking verdict to mid-scale. Now total, with the fallback changed toCRITICAL, and a guard that reads the table via AST and asserts totality against the live enum so a future member without an entry fails loudly.A third
SecretTypethe issues never named was found inapi/schemas_system.py— also missingOAUTH_REFRESH_TOKEN, soPOST /secretscould not accept the kind the database row already stored. And the real OAuth producer was persisting"connector_oauth_token"as a bare string, commented as aSecretTypelabel while belonging to no enum at all.Four deliberate forks preserved with evidence, none collapsed: the #7258 legacy-wire
RiskLevel, the standalone host-sideSecretType,InjectionRisk(grades text, not commands — discovered by the new scan and documented rather than folded), and the three role vocabularies whose own issue states they are correctly separate.The guard caught itself failing open. Mutation M9 re-declared a fork behind
from enum import Enum as _Eand the scan stayed green, because it matched the literal wordEnum. Fixed by resolving the import to its local names. A two-hop re-export remains a known limit, now stated in the docstring and pinned by a test that instructs the next person to update it rather than delete it.A regression this PR caused was found and fixed before merge:
models/schemas.pygained an import thattest_apply_secrets.pycould not satisfy — that test replacesmodels.databasewith aMagicMockand hand-installs one real stand-in, because pydantic needs a genuine enum type. The result was a collection error, so all 9 tests in the file stopped running rather than one failing. The blast radius was then settled by collecting both trees (1697 each) rather than grepping, since collection is exactly where the failure manifests.Remaining and filed: #14956 (169 severity literals — the honest measured count, not the issue's 119, with 43 already out of vocabulary; ratcheted so it can only fall), #14974 (
ANYadvertised on two API surfaces that reject it), #14955 (CRITICALrendering as unclassified grey).