Skip to content

fix(enums): collapse four divergent enum forks to the union of their members (#13845, #13846, #13597, #13578) - #14957

Merged
mrveiss merged 13 commits into
Dev_new_guifrom
issue-13845-enum-union
Aug 24, 2026
Merged

mrveiss merged 13 commits into
Dev_new_guifrom
issue-13845-enum-union

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

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:

2. Deliberate forks checked before collapsing. Read the docstrings first. Three found,
none collapsed:

enum why it stays evidence
models/command_execution.py::RiskLevel uppercase wire format already serialized by legacy Redis/SQLite records, 8+ producers its own docstring, #7258 — named as out of scope by #13845
security/prompt_injection_detector.py::InjectionRisk grades text for injection patterns, not a command for execution policy; scales differ at both ends (LOW only there, DANGEROUS/FORBIDDEN only here) found by the new fork scan; docstring added recording the distinction, and it is allowlisted in the guard with a test that the allowlist entry still names a real enum
autobot-infrastructure/shared/scripts/secrets_manager.py::SecretType runs standalone on infrastructure hosts and never imports application packages — the same reason ChatSecretScope is duplicated there (#11759) canonical-pointer comment added; WEBHOOK_URL / CUSTOM are infra-only

3. Wire and persistence surface checked per fork. "dangerous" is emitted by
POST /terminal/command and by the terminal WebSocket; "postgresql" was a live dispatch
key and so is already in stored rows; secrets.type stores SecretType values; the SLM
service_type column is a plain string at rest. Every one of those spellings still parses
and, 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 ∪ CommandRiskLevel

Canonical CommandRisk now lives in autobot_shared/status_enums.py, the documented enum
home. Both fork sites were deleted and all 14 importers migrated.

member CommandRisk (executor) CommandRiskLevel (wire schema) canonical member value
SAFE yes yes CommandRisk.SAFE safe
MODERATE yes yes CommandRisk.MODERATE moderate
HIGH yes yes CommandRisk.HIGH high
CRITICAL yes — CommandRisk.CRITICAL critical
FORBIDDEN yes — CommandRisk.FORBIDDEN forbidden
DANGEROUS — yes CommandRisk.DANGEROUS dangerous

Nothing dropped. FORBIDDEN and DANGEROUS are kept as separate members with separate
values
rather than one folded into the other, because "dangerous" is already on the wire
and 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
.blocks property and every blocking decision now asks it. Comparing against one member
alone would have let the other producer's blocking verdict through — a fail-open the union
created and .blocks closes.

Two hand-written rank tables (_RISK_ORDER in the executor, _RISK_LEVELS in the approval
manager) listed five members each and would both have rated DANGEROUS as off-scale. Both
are gone; ordering is now CommandRisk.rank, derived from declaration order.

services/agent_terminal/utils.py::map_risk_to_level defaulted an unmapped member to
RiskLevel.MEDIUM — a blocking verdict silently downgraded to mid-scale. It now has an
explicit entry per member, a fail-closed CRITICAL default, and a guard asserting totality.

#13846 — SecretType ∪ SecretRequirement ∪ the schema fork

member models.secret schemas_system SecretRequirement canonical member
SSH_KEY yes yes yes SecretType.SSH_KEY
PASSWORD yes yes yes SecretType.PASSWORD
API_KEY yes yes yes SecretType.API_KEY
TOKEN yes yes yes SecretType.TOKEN
CERTIFICATE yes yes yes SecretType.CERTIFICATE
DATABASE_URL yes yes yes SecretType.DATABASE_URL
OAUTH_REFRESH_TOKEN yes — — SecretType.OAUTH_REFRESH_TOKEN
INFRASTRUCTURE_HOST yes yes — SecretType.INFRASTRUCTURE_HOST
OTHER yes yes — SecretType.OTHER
ANY — — yes SecretType.ANY
connector_oauth_token (bare string, no enum) — — SecretType.CONNECTOR_OAUTH_TOKEN

Nothing dropped, and one kind recovered from a bare string. The # nosec B105 /
# nosemgrep suppressions survive on the canonical definition.

ANY is 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/types serves concrete(), and SecretCreateRequest rejects it with a named
validator. SecretType.expand() resolves ANY to the whole taxonomy — without which a
requirement of {ANY} would be looked up as the literal type "any", which no stored secret
carries, so the broadest possible requirement fetched nothing at all.

AgentSecretMapping.required_types / optional_types were Set[str] of bare strings while
SecretRequirement sat unused beside them. They are now Set[SecretType], so an agent
mapping can name OAUTH_REFRESH_TOKEN directly instead of falling back to the blanket grant.

#13597 — severity

The enum half (AlertSeverity / ErrorSeverity → canonical Severity) had already landed
on 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 #13597 is claimed because the enum consolidation the
issue is titled for is complete and the literal half is now tracked, bounded and guarded.

#13578 — service_type

BackupServiceType(str, enum.Enum) added to models/database.py beside the eight siblings.
BackupCreate, ReplicationCreate and DataVerifyRequest are typed with it, so an unknown
engine is a 422 before a Backup row is written — no more FAILED row for a typo that is
indistinguishable from a real failure. Dispatch is keyed on members.

spelling before canonical member
redis dispatch key BackupServiceType.REDIS
postgres dispatch key BackupServiceType.POSTGRES
postgresql second dispatch key for the same engine BackupServiceType.POSTGRES via _missing_

postgresql still parses — it was a live key and is therefore already in stored rows — but
it 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 .value explicitly (#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 a
scratch 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:

# mutation result
M1 drop CommandRisk.FORBIDDEN RED (import fails closed)
M2 rename DANGEROUS → DESTRUCTIVE, value kept RED (import fails closed)
M3 drop CommandRisk.CRITICAL RED — 3 tests, incl. ...is_present_by_name[CRITICAL]
M4 rename SAFE → NONE, value kept RED — 4 tests, incl. ...is_present_by_name[SAFE]
M5 drop SecretType.OAUTH_REFRESH_TOKEN RED — 3 tests, incl. the OAuth-expressibility test
M6 rename SecretType.ANY → WILDCARD RED — 5 tests
M7 add one bare {"severity": "high"} RED — ratchet ceiling
M8 drop BackupServiceType.POSTGRES RED
M9 re-declare CommandRiskLevel with from enum import Enum as _E GREEN — fail-open, see below
M10 re-declare SecretRequirement with a plain Enum base RED

M9 caught a fail-open in my own guard. The fork scan matched the literal word Enum in
the base list, so a base imported under an alias made a re-declared CommandRiskLevel
invisible and the scan reported the fork collapsed. Fixed by resolving the enum import to
its local names, and pinned by
test_the_scan_sees_an_enum_whose_base_was_imported_under_an_alias, which drives synthetic
source 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_repo floors the tracked-Python walk at 3000.
  • test_the_enum_scan_reaches_the_repo floors the enum scan at 150.
  • test_the_severity_literal_matcher_still_matches proves the regex still matches, and still
    does not match a non-literal.
  • test_every_known_distinct_entry_still_names_a_real_enum fails if an allowlist entry is
    stranded by a rename — an entry naming nothing exempts nothing, silently.
  • Every mutation asserted its own target existed before mutating.

Enum semantics verified in a scratch copy outside the repo: rank/blocks/strictest, both wire
spellings parsing, concrete()/expand(), _missing_ resolving postgresql/POSTGRES/
" Redis " and rejecting mysql/""/"any", and pydantic honouring _missing_ while
still rejecting an unknown engine. The str() trap was confirmed live:
str(SecretType.API_KEY) is 'SecretType.API_KEY', not 'api_key' — hence .value at
every boundary.

api_endpoint_migrations_test.py asserted 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


Generated types, and one consequence worth a reviewer's eye

The OpenAPI schema changes here, so both api.ts files drift. They were not hand-edited:
the repo's own autofix-types job regenerated and committed them
(e19554fa chore(types): regenerate generated API types from OpenAPI schema(s)), which is the
builtin path rather than a side-channel. CommandRiskLevel was never in api.ts — it was
never a pydantic field type — so nothing drifts there.

That regeneration surfaced a real consequence of preserving ANY as an enum member:
SecretType types both SecretCreateRequest.type and SecretModel.type, so the public type
now advertises "any" on a request and a response that always reject it. It is not a
functional bug — the _reject_the_wildcard validator returns 422, GET /secrets/types serves
concrete(), and nothing can therefore be stored or returned with that value — but the
declared 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 ANY be preserved. Flagging it here so the reviewer sees it in the diff rather
than 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.py
now imports BackupServiceType from models.database, but
autobot-slm-backend/tests/api/test_apply_secrets.py replaces models.database with a bare
MagicMock() and hand-installs one real stand-in (NodeStatus) precisely because pydantic
needs a genuine enum type to build a schema. There was no equivalent for BackupServiceType,
so it resolved to a MagicMock attribute and pydantic raised
PydanticSchemaGenerationError — a collection error, so all 9 tests in the file stopped
running rather than one failing.

Fixed by adding a real _BackupServiceType stand-in beside _NodeStatus, with a comment
saying 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:

tree result
this branch, autobot-slm-backend/tests/ 1697 collected, 0 collection errors
origin/Dev_new_gui, same invocation 1697 collected

Identical counts, so parity is restored and nothing else has the same shape. The 9 tests in
test_apply_secrets.py were then run directly: 9 passed.

The local run also reports a multipart sys.modules leak from
autobot-slm-backend/tests/api/conftest.py. That is not mine: the base tree produces a
byte-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.ini documents. I
checked rather than assumed, after a truncated grep | head first 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 the
blocking fix):

  • repo_tests/enum_union_guard_test.py — the fork scan resolves a direct
    from enum import ..., so a two-hop re-export is invisible. Narrower than M9 and
    evasion-only, but real. Now stated in _local_enum_names's docstring and pinned by
    test_a_two_hop_re_export_is_a_known_blind_spot, which asserts the current limit and tells
    the 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 redundant
    BackupServiceType(service_type).value re-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 fixed

Two failures, both mine, both in autobot-backend/api/secrets_test.py:

FAILED TestChatSecretScopeEnum::test_secret_type_values_pinned
FAILED test_get_secret_types_response_pinned

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_pinned imported SecretType from
api.schemas_system — the 8-member fork — and now sees the 11-member union;
test_get_secret_types_response_pinned pins the /secrets/types payload, which now serves
concrete() (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:

Verified against the reproduction: secrets_test.py 10 passed. Shard 10 reported
2 failed, 2467 passed; the other 9 finished shards were green, so these two were the whole
of 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 in
secure_command_executor.py and command_approval_manager.py), listing the same five members
and 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" in secure_command_executor.py with CommandRisk.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_gui in (clean, no conflicts; merged rather than
rebased 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.py and
autobot-frontend/src/types/generated/api.ts — and both retain both sides' changes. Branch is
now 0 commits behind base.

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

mrveiss and others added 3 commits August 24, 2026 15:44
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

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

mrveiss and others added 3 commits August 24, 2026 16:39
Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
@mrveiss
mrveiss merged commit 342eeb0 into Dev_new_gui Aug 24, 2026
75 checks passed
@mrveiss
mrveiss deleted the issue-13845-enum-union branch August 24, 2026 14:31
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>
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.

1 participant