Skip to content

canonical(enums): CausalSeverity is a fourth severity fork, and 219 severity literals sit outside the ratchet's matcher #14988

Open

Description

@mrveiss

Found while doing the #14956 literal sweep. Two findings, both out of that PR's scope,
both measured on Dev_new_gui after the sweep landed.

1. CausalSeverity is a fourth severity fork

services/causal_inference_engine.py declares its own severity enum:

class CausalSeverity(str, Enum):
    CRITICAL = "critical"
    DEGRADED = "degraded"
    WARNING = "warning"

After #14956 every one of its three values is a member of the canonical
autobot_shared.status_enums.Severity, so the member sets no longer diverge — it is a
straight alias away from being collapsed the way AlertSeverity and ErrorSeverity were
in #14957.

Why it was not collapsed there. CausalSeverity is (str, Enum); canonical Severity
is a plain Enum. Aliasing it changes the serialisation behaviour of every holder of a
member, and that is exactly the decision #13597 scope item 3 parked:

Severity, TaskStatus, Priority, LLMProvider, OperationOutcome, HealthStatus,
AgentStatus are plain Enum while AgentLifecycleStatus and ConnectionStatus are
(str, Enum). Two serialisation behaviours in one module.

So this is blocked on that rule being stated, not on the member sets.

What is already known about the blast radius — every use is either an identity
comparison or a .value read; nothing observed depends on the str subclass:

  • _assess_severity / _generate_recommendations compare members by identity.
  • CausalAnalysisReport.to_dict() serialises self.severity.value.
  • The report dataclass is reached through to_dict(), not returned raw.
  • CausalSeverity(EXAMPLE_1_POOL_EXHAUSTION["severity"]) is a value lookup.

That needs confirming against the diagnostics endpoint before anyone acts on it — the point
here is that the audit is half-done, not that the change is safe.

2. 219 severity literals in two forms the ratchet cannot see

repo_tests/enum_union_guard_test.py ratchets one shape only — the "severity": "<literal>"
dict entry. Two other shapes carry the same defect and are unguarded. Measured under
autobot-backend/ and autobot_shared/:

shape matcher hits files
keyword / assignment severity\s*=\s*["'][A-Za-z_]+["'] 97
comparison severity["']?\]?\s*==\s*["'][A-Za-z_]+["'] 122
total 219 55

20 of the 219 are outside the canonical vocabulary, and unlike the ones #14956 resolved
these are not case errors — they are words the enum has no rung for at all:

value count
none 10
moderate 7
missing 2
forbidden 1

Each needs the same per-value decision #14956 applied: a genuine synonym gets mapped, a
genuinely distinct rung gets added to the canonical enum, and nothing gets folded if that
would change a wire format, an API response, or a stored value. moderate in particular
already exists as CommandRisk.MODERATE, and forbidden as CommandRisk.FORBIDDEN, so at
least some of these are a different enum's vocabulary appearing in a field named
severity — which is a mis-typed field, not a missing rung.

Work

Refs #14956, #13597

Activity

  1. mrveiss commented on Sep 2, 2026

    @mrveiss
    OwnerAuthor

    Measured this whole issue against Dev_new_gui at 2363ad3aa9 before touching anything.
    Two of the four work items were already done on base; the counts in the third are wrong.
    PR #15508 guards what had landed and ratchets what had not.

    Item 1 — CausalSeverity is a fourth fork: STALE

    It is already collapsed. services/causal_inference_engine.py:36:

    from autobot_shared.status_enums import Severity as CausalSeverity
    

    No class CausalSeverity exists anywhere in the tree. Nothing asserted it, though, so a
    re-declaration of the name would have been invisible — #15508 pins the alias by AST binding.

    Item 1's blocker — the plain Enum vs (str, Enum) rule: STALE

    The rule is stated in the status_enums module docstring, and each (str, Enum) carries its
    own reason (AgentLifecycleStatus SQLAlchemy coercion, ConnectionStatus pre-existing
    comparisons, SecretType persisted value). #13597 scope item 3 is done.

    Item 2 — verify the diagnostics endpoint serialises via to_dict(): CONFIRMED

    api/diagnostics.py:87 — return FailureAnalysisResponse(data=report.to_dict()), and
    causal_inference_engine.py:102 emits "severity": self.severity.value. So dropping the
    str base was inert. Both halves of that path are now pinned by #15508, because the safety
    argument only holds while the .value read is there.

    Item 4 — the 219 literals: MEASURED 194 across 53 files, not 219 across 55

    Same matchers as this issue quotes, same two roots:

    shape claimed measured files
    keyword / assignment 97 73 29
    comparison 122 121 40
    total 219 194 53 (union)

    The two shapes are disjoint — 73 + 121 = 194 deduplicated by span — so each can be ratcheted
    independently. #15508 pins keyword 68-73, comparison 116-121, file spread 48-53.

    The out-of-vocabulary distribution in this issue is exactly right: 20 total,
    none 10 / moderate 7 / missing 2 / forbidden 1.

    Item 3 — decide each of none / moderate / missing / forbidden: ANSWERED

    All four are the same defect, and it is the one this issue suspected: a different enum's
    vocabulary sitting in a field named severity. None of them is a missing rung, and none
    should be added to Severity.

    • forbidden — security/command_patterns.py:78 annotates the field str with the three
      words in a trailing comment, and :493 branches on it. That ladder is CommandRisk, which
      already has FORBIDDEN. A command permission is not an outcome grade.
    • none / moderate — autobot_shared/delta_engine.py:120 classifies a metric delta as
      none / moderate / critical. none means no finding at all; no severity ladder can
      express absence.
    • missing — autobot_shared/env_drift_detector.py:55 records a drift kind, listed
      alongside type_mismatch and unknown. It says what drifted, not how bad it is.

    This matters because the cheapest way to drive the out-of-vocabulary count to zero is to widen
    Severity, which makes the guard read clean while making the canonical enum mean less — the
    opposite of #13597. #15508 fails if any of the four is added, and the Severity docstring now
    names all three foreign vocabularies so the next reader does not have to re-derive this.

    The remaining work is per-field typing in those three modules, which is a behaviour change in
    each and belongs in its own PR.

    What is left on this issue

    • Type the four fields to the enum they actually belong to (CommandRisk in
      command_patterns, a delta classification in delta_engine, a drift kind in
      env_drift_detector)
    • Drain the 194 literals; the ratchet in test(enums): ratchet the two severity-literal shapes the #13597 guard cannot see (#14988, #13597) #15508 stops them growing but does not convert
      them. 40 of the 53 files are comparison-shaped, where the compared value may arrive from
      outside the process — each needs the producer checked, which is why it was not bundled.

    Items 1 and 2 can be ticked now.

  2. modified the milestones: Backlog, v0.12.0 on Sep 12, 2026
  3. mrveiss commented on Sep 20, 2026

    @mrveiss
    OwnerAuthor

    Re-verification against current origin/main (2026-09-20). Tracked branch lane-enums-canonical is already gone from origin.

    AC1 done; AC2/AC3/AC4 not:

    • The plain-Enum vs (str, Enum) rule is now stated in autobot_shared/status_enums.py's module docstring (the canonical(enums): AlertSeverity and ErrorSeverity duplicate the shared Severity enum, and 119 severity literals bypass enums entirely #13597 scope item), explaining when each applies (boundary serialization needs (str, Enum); identity-comparison contexts stay plain Enum). CausalSeverity no longer exists in services/causal_inference_engine.py at all — collapsed under Severity as the issue proposed, not just documented.
    • Diagnostics-endpoint verification (that the causal report serializes via to_dict() before the alias landed) — no evidence found either way; not something I could confirm without tracing the live route, and the collapse already happened, so this is retroactive at this point regardless.
    • The 4 out-of-vocabulary values (none, moderate, missing, forbidden) — no decision found recorded anywhere (DELIBERATE_SEVERITY_LITERALS in the guard doesn't mention any of them, and I found no other tracking).
    • The 219 literals in the two unguarded shapes, and extending the ratchet to cover them — not done. repo_tests/enum_union_guard_test.py's _SEVERITY_LITERAL regex still only matches the single {"severity": "value"} dict-entry shape (SEVERITY_LITERAL_CEILING=25, a much smaller, unrelated number from canonical(enums): 169 bare severity literals remain, 43 of them outside the canonical vocabulary #14956's original ratchet). No severity = "value" or severity == "value" pattern exists anywhere in the file — the two new shapes the issue measured at 219 hits across 55 files remain completely unguarded and, as far as I can tell, unconverted.

    1 of 4 done (and done well — a real collapse, not just documentation). The 219-literal sweep is the substantial remaining work. Leaving open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions