Repository navigation
fix(enums): collapse four divergent enum forks to the union of their members (#13845, #13846, #13597, #13578) - #14957
Merged
Merged
Conversation
This was referenced Aug 24, 2026
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
mrveiss
added a commit
that referenced
this pull request
Aug 24, 2026
…the three rungs it was missing (#14956, #13597) (#14990) * fix(enums): converge severity literals on the canonical enum and add the three rungs it was missing (#14956, #13597) The enum-fork half of #13597 landed in #14957; this is the literal sweep. Measured with the ratchet's own matcher: 169 bare "severity" dict literals across 43 files under the two Python roots. 145 converted to canonical Severity reads; the 24 that remain are values crossing an external boundary and are now an explicit, staleness-checked allowlist rather than a bare count. Three literals were not synonyms of an existing rung, so the canonical enum gained them rather than folding them away: warning (a Prometheus label the monitor publishes), degraded (serialised into the causal-analysis response) and error (the grade the capability audit counts findings at). Folding any of the three would have renamed a scraped label or an API response value. Adding rungs would have added three always-zero buckets to the bug-prediction risk distribution, so Severity.score_ladder() pins the grading subset and the endpoints iterate that instead of the enum. All three auto-tools sanitizers stored the member NAME ("HIGH") in a field carrying the value ("high"), so anything comparing against Severity.HIGH.value matched none of them. Fixed, with the icon/order table they each had a private copy of hosted once in tool_base. * chore(types): regenerate generated API types from OpenAPI schema(s) Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml. * Auto-fix: code formatting Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml. * fix(enums): keep the sweep out of docstring prose and regenerate the frontend types (#14956) Two CI failures on the first head, both from the sweep. code-quality: the regex conversion rewrote a literal inside an endpoint docstring, where a fenced json block documents the response body. That turned documented JSON into something no reader can copy, and left the added import unused. A docstring is prose, so it is exempt from the ratchet rather than a target for it: the literal is restored, the import dropped, and the file joins the deliberate-literal set alongside the other documentation examples. The corrected count is 144 of 169, not 145 - the docstring was never a legitimate conversion. The ceiling moves 24 -> 25 to match the re-measured population, and the allowlist is renamed to say what it actually holds: two qualifying reasons, an external boundary or documentation prose. Added test_no_enum_read_was_written_inside_a_string, which walks every module mentioning the enum by AST and fails if an enum read appears inside a string constant. That is the exact defect CI caught, so it is now pinned rather than remembered. check-drift: Severity gaining three rungs widened the generated TypeScript union, and the checked-in generated file was stale. Regenerated with the repo generator, not hand-edited. No consumer of the generated union switches on it exhaustively: the only typed consumer is AutomationWorkflowStep.risk_level, read as a two-value predicate and a class binding, and every switch over a severity takes a plain string with a default branch. The frontend carries two hand-written forks of this type that have already drifted, filed separately. * docs(enums): record the prose-editing trap in the guard's own trap list (#14956) The file enumerates the traps it is built against; the sweep's docstring regression added a fourth and the list did not say so. A trap list that is missing the most recent trap is the kind of stale comment that teaches the next reader the wrong lesson. * chore(types): regenerate generated API types from OpenAPI schema(s) Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
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 #13845, #13846, #13597, #13578
Thinking Path
Four issues, one shape: an enum forked into two or more definitions, and in every case
the divergent tail was the functionality. So the method was member sets, not names — a
same-name sweep is precisely what missed these, since each pair used different names for
one concept.
1. Member sets first. Enumerated every side of every fork as a set before touching
anything. That immediately turned up two forks the issues did not name:
SecretTypeinapi/schemas_system.py— the request/response vocabulary,also missing
OAUTH_REFRESH_TOKEN. SoPOST /secretscould not even accept the one kindthe database row could already store. canonical(enums): SecretType and SecretRequirement duplicate the secret taxonomy — agents cannot express an OAuth requirement #13846 described the gap at the agent layer; it was
live at the API layer too.
"connector_oauth_token"— a bare string persisted byknowledge/connectors/credential_store.py,commented "SecretType label" while being absent from every
SecretTypethat existed.The real OAuth producer was already bypassing the taxonomy.
2. Deliberate forks checked before collapsing. Read the docstrings first. Three found,
none collapsed:
models/command_execution.py::RiskLevelsecurity/prompt_injection_detector.py::InjectionRiskLOWonly there,DANGEROUS/FORBIDDENonly here)autobot-infrastructure/shared/scripts/secrets_manager.py::SecretTypeChatSecretScopeis duplicated there (#11759)WEBHOOK_URL/CUSTOMare infra-only3. Wire and persistence surface checked per fork.
"dangerous"is emitted byPOST /terminal/commandand by the terminal WebSocket;"postgresql"was a live dispatchkey and so is already in stored rows;
secrets.typestoresSecretTypevalues; the SLMservice_typecolumn is a plain string at rest. Every one of those spellings still parsesand, where it was emitted, is still emitted.
4. Then union. One canonical name per concept, plain concept names, no compat aliases
left behind — every call site migrated.
What Changed
#13845 —
CommandRisk∪CommandRiskLevelCanonical
CommandRisknow lives inautobot_shared/status_enums.py, the documented enumhome. Both fork sites were deleted and all 14 importers migrated.
CommandRisk(executor)CommandRiskLevel(wire schema)SAFECommandRisk.SAFEsafeMODERATECommandRisk.MODERATEmoderateHIGHCommandRisk.HIGHhighCRITICALCommandRisk.CRITICALcriticalFORBIDDENCommandRisk.FORBIDDENforbiddenDANGEROUSCommandRisk.DANGEROUSdangerousNothing dropped.
FORBIDDENandDANGEROUSare kept as separate members with separatevalues rather than one folded into the other, because
"dangerous"is already on the wireand they are genuinely different reasons: DANGEROUS = matched a destructive pattern,
FORBIDDEN = base command is deny-listed. They reach the same verdict, so the enum grew a
.blocksproperty and every blocking decision now asks it. Comparing against one memberalone would have let the other producer's blocking verdict through — a fail-open the union
created and
.blockscloses.Two hand-written rank tables (
_RISK_ORDERin the executor,_RISK_LEVELSin the approvalmanager) listed five members each and would both have rated
DANGEROUSas off-scale. Bothare gone; ordering is now
CommandRisk.rank, derived from declaration order.services/agent_terminal/utils.py::map_risk_to_leveldefaulted an unmapped member toRiskLevel.MEDIUM— a blocking verdict silently downgraded to mid-scale. It now has anexplicit entry per member, a fail-closed
CRITICALdefault, and a guard asserting totality.#13846 —
SecretType∪SecretRequirement∪ the schema forkmodels.secretschemas_systemSecretRequirementSSH_KEYSecretType.SSH_KEYPASSWORDSecretType.PASSWORDAPI_KEYSecretType.API_KEYTOKENSecretType.TOKENCERTIFICATESecretType.CERTIFICATEDATABASE_URLSecretType.DATABASE_URLOAUTH_REFRESH_TOKENSecretType.OAUTH_REFRESH_TOKENINFRASTRUCTURE_HOSTSecretType.INFRASTRUCTURE_HOSTOTHERSecretType.OTHERANYSecretType.ANYconnector_oauth_tokenSecretType.CONNECTOR_OAUTH_TOKENNothing dropped, and one kind recovered from a bare string. The
# nosec B105/# nosemgrepsuppressions survive on the canonical definition.ANYis preserved as a real member but is not a storable kind:SecretType.concrete()excludes it (derived by exclusion, so a kind added later is included without a second edit),
GET /secrets/typesservesconcrete(), andSecretCreateRequestrejects it with a namedvalidator.
SecretType.expand()resolvesANYto the whole taxonomy — without which arequirement of
{ANY}would be looked up as the literal type"any", which no stored secretcarries, so the broadest possible requirement fetched nothing at all.
AgentSecretMapping.required_types/optional_typeswereSet[str]of bare strings whileSecretRequirementsat unused beside them. They are nowSet[SecretType], so an agentmapping can name
OAUTH_REFRESH_TOKENdirectly instead of falling back to the blanket grant.#13597 — severity
The enum half (
AlertSeverity/ErrorSeverity→ canonicalSeverity) had already landedon base; this PR asserts it stayed landed, by AST, so re-declaring either as its own enum
fails even though the name would still exist.
The second half is the literal sweep, and the honest number is 169, not 119 — measured
on this tree with the matcher the ratchet uses, across 43 files. It grew by 50 between filing
and being worked. 43 of the 169 are already outside the canonical vocabulary
(
warning×25,HIGH×9,CRITICAL×3,MEDIUM×3,error,degraded,Extreme).Converting 169 call sites alongside three enum collapses is not a safely reviewable change,
so this PR ships the ratchet — pinned at exactly 169, to be lowered as literals convert,
never raised — and files the remainder with the full distribution as #14956. A guard that
stops regrowth is worth more than a partial sweep: the count moved 119 → 169 precisely
because nothing stopped it.
Closes #13597is claimed because the enum consolidation theissue is titled for is complete and the literal half is now tracked, bounded and guarded.
#13578 —
service_typeBackupServiceType(str, enum.Enum)added tomodels/database.pybeside the eight siblings.BackupCreate,ReplicationCreateandDataVerifyRequestare typed with it, so an unknownengine is a 422 before a
Backuprow is written — no moreFAILEDrow for a typo that isindistinguishable from a real failure. Dispatch is keyed on members.
redisBackupServiceType.REDISpostgresBackupServiceType.POSTGRESpostgresqlBackupServiceType.POSTGRESvia_missing_postgresqlstill parses — it was a live key and is therefore already in stored rows — butit collapses to one member at the boundary, so nothing downstream branches twice. The column
stays a string at rest, so existing rows are untouched. Response schemas keep
service_type: str, so the response wire shape is unchanged.Every persistence and comparison site uses
.valueexplicitly (#14944):str()of a(str, Enum)member is"BackupServiceType.REDIS", and these values reach SQL parameters.Verification
Guard:
repo_tests/enum_union_guard_test.py, 40 tests, all passing. Run against ascratch copy of the tree outside the repo; no mutation was ever committed or pushed.
Mutation pairs — each removal and each rename must go red, because green after a rename
is what going blind looks like:
CommandRisk.FORBIDDENDANGEROUS→DESTRUCTIVE, value keptCommandRisk.CRITICAL...is_present_by_name[CRITICAL]SAFE→NONE, value kept...is_present_by_name[SAFE]SecretType.OAUTH_REFRESH_TOKENSecretType.ANY→WILDCARD{"severity": "high"}BackupServiceType.POSTGRESCommandRiskLevelwithfrom enum import Enum as _ESecretRequirementwith a plainEnumbaseM9 caught a fail-open in my own guard. The fork scan matched the literal word
Enuminthe base list, so a base imported under an alias made a re-declared
CommandRiskLevelinvisible and the scan reported the fork collapsed. Fixed by resolving the
enumimport toits local names, and pinned by
test_the_scan_sees_an_enum_whose_base_was_imported_under_an_alias, which drives syntheticsource rather than depending on the tree containing an example. After the fix, M9 is RED and
the clean tree is 40/40.
Anti-false-pass measures, since an empty result reads as a clean one:
test_the_enumeration_reaches_the_repofloors the tracked-Python walk at 3000.test_the_enum_scan_reaches_the_repofloors the enum scan at 150.test_the_severity_literal_matcher_still_matchesproves the regex still matches, and stilldoes not match a non-literal.
test_every_known_distinct_entry_still_names_a_real_enumfails if an allowlist entry isstranded by a rename — an entry naming nothing exempts nothing, silently.
Enum semantics verified in a scratch copy outside the repo: rank/blocks/strictest, both wire
spellings parsing,
concrete()/expand(),_missing_resolvingpostgresql/POSTGRES/" Redis "and rejectingmysql/""/"any", and pydantic honouring_missing_whilestill rejecting an unknown engine. The
str()trap was confirmed live:str(SecretType.API_KEY)is'SecretType.API_KEY', not'api_key'— hence.valueatevery boundary.
api_endpoint_migrations_test.pyasserted on source text (self.assertIn("risk_level = CommandRiskLevel.SAFE", source)), so it could not tell "the classification still works" from"the identifier was renamed" and went red on a no-behaviour-change union. Rewritten to drive
the endpoint and assert on what it returns.
Not verified locally, left to CI: the full Python suite, and any runtime import of backend
modules — this repo's rule is that CI verifies correctness and the host verifies runtime.
Model Used
Claude Opus 5 (1M context)
Discovered and filed, not fixed here
out of vocabulary.
useCommandApproval.ts::getRiskClasskeysMODERATEandDANGEROUS, whichno producer emits; the actual producer emits
MEDIUMandCRITICAL, so a CRITICALcommand renders in the same neutral grey as an unclassified one. Pre-dates this change and
survives it unchanged; fixing it needs the producer set confirmed and a duplicate map in
ApprovalRequestCard.vuereconciled, which is a different blast radius.AgentSecretsIntegration.get_secrets_for_agenthas no caller outside its own module —the agent secret-injection path is built but unwired. Noted here rather than filed blind;
it is the seam feat(secrets): agents have no write path to the vault — add store_secret_for_agent with provenance #13711 is due to wire.
Generated types, and one consequence worth a reviewer's eye
The OpenAPI schema changes here, so both
api.tsfiles drift. They were not hand-edited:the repo's own
autofix-typesjob regenerated and committed them(
e19554fa chore(types): regenerate generated API types from OpenAPI schema(s)), which is thebuiltin path rather than a side-channel.
CommandRiskLevelwas never inapi.ts— it wasnever a pydantic field type — so nothing drifts there.
That regeneration surfaced a real consequence of preserving
ANYas an enum member:SecretTypetypes bothSecretCreateRequest.typeandSecretModel.type, so the public typenow advertises
"any"on a request and a response that always reject it. It is not afunctional bug — the
_reject_the_wildcardvalidator returns 422,GET /secrets/typesservesconcrete(), and nothing can therefore be stored or returned with that value — but thedeclared type is wider than the accepted type.
Filed as #14974 with three options and a recommendation, rather than resolved unilaterally:
narrowing it correctly requires verifying a real schema dump rather than predicting one, and
the alternative (moving the wildcard out of the enum) contradicts #13846's explicit acceptance
criterion that
ANYbe preserved. Flagging it here so the reviewer sees it in the diff ratherthan discovering it in
api.ts.Review fix — a confirmed regression, and the sweep that followed it
Review found one blocking regression, and it was mine.
autobot-slm-backend/models/schemas.pynow imports
BackupServiceTypefrommodels.database, butautobot-slm-backend/tests/api/test_apply_secrets.pyreplacesmodels.databasewith a bareMagicMock()and hand-installs one real stand-in (NodeStatus) precisely because pydanticneeds a genuine enum type to build a schema. There was no equivalent for
BackupServiceType,so it resolved to a
MagicMockattribute and pydantic raisedPydanticSchemaGenerationError— a collection error, so all 9 tests in the file stoppedrunning rather than one failing.
Fixed by adding a real
_BackupServiceTypestand-in beside_NodeStatus, with a commentsaying why it must be a real enum.
I did not stop at the file the log named. Grep could not answer "is anything else stubbed the
same way", so I settled it by collecting instead:
autobot-slm-backend/tests/origin/Dev_new_gui, same invocationIdentical counts, so parity is restored and nothing else has the same shape. The 9 tests in
test_apply_secrets.pywere then run directly: 9 passed.The local run also reports a
multipartsys.modules leak fromautobot-slm-backend/tests/api/conftest.py. That is not mine: the base tree produces abyte-identical leak report, including the same 7 known leakers. It is an artifact of running
the whole SLM tests directory in one invocation locally, which
pytest.inidocuments. Ichecked rather than assumed, after a truncated
grep | headfirst made the base look clean —the same empty-result-reads-as-clean trap this PR's guard is built against, this time in my
own tooling.
Two smaller review items, in the same commit (
579eeca9, whose message names only theblocking fix):
repo_tests/enum_union_guard_test.py— the fork scan resolves a directfrom enum import ..., so a two-hop re-export is invisible. Narrower than M9 andevasion-only, but real. Now stated in
_local_enum_names's docstring and pinned bytest_a_two_hop_re_export_is_a_known_blind_spot, which asserts the current limit and tellsthe next person to update it rather than delete it if the scan learns to follow re-exports.
A limit nobody wrote down is the one mistaken for coverage.
autobot-slm-backend/services/replication.py— dropped a redundantBackupServiceType(service_type).valuere-wrap on an already-typed member.Guard after these edits: 41 passed (40 + the new boundary test).
CI went red on
python-suite shard 10/12— root-caused and fixedTwo failures, both mine, both in
autobot-backend/api/secrets_test.py:These are pin tests over the secret-kind vocabulary, and they went red for exactly the right
reason: the vocabulary changed.
test_secret_type_values_pinnedimportedSecretTypefromapi.schemas_system— the 8-member fork — and now sees the 11-member union;test_get_secret_types_response_pinnedpins the/secrets/typespayload, which now servesconcrete()(10) instead of 8.Updated rather than deleted, and made sharper in the process. The enum and the endpoint are
now deliberately different populations, so the test says so instead of asserting they are
equal — asserting equality would have hidden the one thing most worth pinning:
EXPECTED_TYPES— the 10 concrete kinds the endpoint serves, gainingoauth_refresh_tokenand
connector_oauth_token.test_secret_type_values_pinned— pins all 11 enum values and thatconcrete()is the10, and that
anyis in neither served nor concrete.test_the_oauth_kinds_are_expressible— new, asserting canonical(enums): SecretType and SecretRequirement duplicate the secret taxonomy — agents cannot express an OAuth requirement #13846's actual functional gap isclosed rather than inferring it from a list length.
Verified against the reproduction:
secrets_test.py10 passed. Shard 10 reported2 failed, 2467 passed; the other 9 finished shards were green, so these two were the wholeof the red.
A third hand-written risk-rank table, found by sweeping rather than by CI
Grepping the risk vocabulary while fixing the above turned up
services/approval_memory.py::RISK_LEVELS— a third literal rank table (after the two insecure_command_executor.pyandcommand_approval_manager.py), listing the same five membersand therefore blind to
DANGEROUS. Now derived:{risk.value: risk.rank for risk in CommandRisk}.Only relative order matters there (both sides of the comparison read the same table) and the
order is unchanged; the integers are never persisted. Also replaced a hardcoded
"risk": "forbidden"insecure_command_executor.pywithCommandRisk.FORBIDDEN.value.Branch was BEHIND, and the comparison that revealed it was wrong first
While checking whether the change dropped any tests, a backend collection showed base at
23783 and this branch at 23714 — 69 fewer. The removals were all in RBAC and auth-parity
files this PR never touches, which is what made it worth chasing rather than rounding off.
Cause: base had moved two commits since this branch was cut, one of them
#14936 "establish the canonical role vocabulary" — which reparametrizes exactly those
role/permission tests. I had been diffing my branch against a newer base than it was built
on, so the delta was someone else's work, not a regression here.
Branch updated by merging
origin/Dev_new_guiin (clean, no conflicts; merged rather thanrebased because CI has pushed commits to this branch and a force-push would discard them).
Two files were touched by both sides —
autobot-backend/security_layer.pyandautobot-frontend/src/types/generated/api.ts— and both retain both sides' changes. Branch isnow 0 commits behind base.