Skip to content

fault_manager: a planned-stop switch, so a weekend is not a wave of faults - #664

Draft
bburda wants to merge 10 commits into
mainfrom
feat/planned-stop-switch
Draft

fault_manager: a planned-stop switch, so a weekend is not a wave of faults#664
bburda wants to merge 10 commits into
mainfrom
feat/planned-stop-switch

Conversation

@bburda

@bburda bburda commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

A plant stops on purpose: a weekend, a changeover, a maintenance slot. Every controller the fault manager watches goes quiet at once, and there was no way to tell it that this was expected. With a debounce set and healing on, every one of those faults confirms and heals, every transition is an audit row, and the first Monday starts with a fault list nobody can read.

A planned stop is a switch on the fault manager, and the operator's interface owns the calendar. ~/set_planned_stop {active, reason, declared_by} and ~/get_planned_stop are ROS services, so they are already SOVD operations on the fault manager entity: POST /apps/fault_manager/operations/set_planned_stop/executions, behind the operations routes' existing role. No new route, no new DTO, no change to any released message or service type, no gateway source file touched; the served OpenAPI document is byte-identical before and after.

Marked, not suppressed. While the switch is on, a fault whose cycle starts (a new code, a reactivation from CLEARED, or a re-fail from HEALED) is recorded, debounced, captured and audited exactly as any other fault, and is muted the way a correlated symptom is muted: out of the default fault list, counted in muted_count, visible with include_muted, its confirmation and updates withheld from the event topic (a clear or heal is published as it is for any muted fault). The bridges report one FAILED per transition, so dropping such a fault would lose it for as long as the alarm stands on the controller; marking keeps it.

Ownership is a persisted fact. The stop owns the cycles that started inside it, as a flag on the fault row in the store; the correlation engine derives its mute map from that ownership plus any rule that overlays it, and when the rule's mute ends the stop's mute is back. A restart inside a stop keeps every owned fault muted. Switching off writes the declaration, announces one confirmation per CONFIRMED owned fault, then clears the flags, and a start-up that finds flags behind a withdrawn declaration finishes that release before it serves anything. Every switch transition is an audit row with the reason and the declarer (with audit_log.enabled), and get_planned_stop serves the last declaration after the withdrawal.


Issue


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

One observable change on a fault manager with no correlation rules: the engine and its cleanup timer now always exist, so muted_count and cluster_count are emitted as 0 and the timer costs about 190 ns per tick at the 5 s default, measured.


Testing

  • Gateway end to end (real gateway, fault manager and a reporting node over the real graph): the switch through the operations route, faults raised during the stop out of the default list and in include_muted, nothing on the stream for them, a fault confirmed before the stop untouched by it, one confirmation per released fault after switch-off with a settle window proving no second frame.
  • Fault manager over its services with SQLite: the exact event set seen during a stop, a fault that clears during the stop, a re-fail from HEALED, a rule-muted symptom and a cluster-muted code composed with the stop, the scoped acknowledge, the auto-confirm timer under the stop, restart inside a stop, an interrupted release finished at the next start, a new stop declared right after such a start, a store that refuses the write, and 200 faults released at once.
  • Unit: the engine's two mute sources and their hand-over, the storage flag and its migration on a database from before the column existed, the audit markers, the release ordering with a store that records call order.
  • Mutations run and recorded: unmute-at-switch-off removed, announcement removed, persistence removed, cycle-start gate forced, PASSED gate removed, re-mute on rule expiry removed, flags cleared before the announcement; each reddens the test that pins it.
  • Suites: fault manager unit and integration, msgs unit, the fault, operations, health, OpenAPI contract and error-coverage feature tests, lint; clang-tidy zero findings in changed hunks; Sphinx with no new warnings.

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

SetPlannedStop declares or withdraws a planned stop and answers with the
state the switch was in before the call; GetPlannedStop reads back the
declaration, its reason, its declarer and when it started.

Refs #656
…opping them

The correlation engine gains a second mute source. While a planned stop is
declared, a fault whose cycle starts is reported, debounced, confirmed,
captured and audited exactly as it would be otherwise, and is registered as
muted with rule_id 'planned_stop' and the pseudo root cause 'PLANNED_STOP':
absent from the default fault list, counted in muted_count, and never
announced on the event stream. A rule that mutes the same code takes the
mute over, and withdrawing the stop then leaves it alone.

Withdrawing releases every fault the stop alone was holding down and
publishes one EVENT_CONFIRMED for each of those that is CONFIRMED, because
that confirmation was never announced. Both transitions are recorded in the
audit log with their reason and declarer, whatever audit_log.transitions
says; a request for the state the switch is already in changes nothing and
records nothing.

The declaration is stored, so a stop declared before a restart is still in
force after it. The correlation engine is now always constructed, with no
rules when none are configured, because the switch needs no configuration.

Refs #656
Engine unit cases pin the two mute sources composing: a rule takes a mute
over from the switch and keeps it at switch-off, and a fault cleared inside
the stop leaves both maps. Storage cases pin the declaration surviving a
reopen on SQLite and living for the process on the memory backend, and the
audit case pins a transition with no fault code chaining and staying
tamper-evident.

Three launch tests drive a live manager: the main one covers marking,
counting, capture, the audit rows, idempotent requests, an empty and a
kilobyte reason, 200 faults released by one switch-off, a fault mid-debounce
when the stop ends, and a rule-muted symptom the switch must not release;
one kills the manager and lets launch bring it back; one drives a
confirmation through the auto-confirm timer with a control fault proving the
timer fires at all.

The gateway feature test is the acceptance: it turns the stop on and off
through POST /apps/fault_manager/operations/set_planned_stop/executions,
watches GET /faults and the SSE fault stream, and counts confirmations per
fault code either side of the switch-off.

Refs #656
Covers what marking means and what it does not change, how the switch and a
correlation rule compose, which two configuration choices decide whether the
declaration survives a restart and whether transitions are audited, and how
an operator reaches the switch over SOVD as an operation on the fault
manager entity - including that hybrid and manifest discovery need the
entity declared before that address exists.

Refs #656
Reporters are level-triggered: FaultReporter and the diagnostic bridge send
FAILED for as long as the condition holds, not once per transition. Muting on
any report therefore took a fault that was confirmed and announced BEFORE the
stop off the fault list within a second of the declaration, and announced it a
second time at the switch-off. The mark is now gated on the cycle boundary the
report path already computes: a new fault, or one raised again after a clear.
A repeat report of a fault the stop already marked changes nothing.

One mute now has one owner. The hand-over to a rule happens where the rule
registers its mute, not where any path reports should_mute: the auto-cluster
path sets that flag without ever writing the mute map, so the old hand-over
left the stop's entry owned by nobody - muted for good, released by nothing.
In the other direction the stop no longer overwrites an entry a rule holds.
An acknowledgement that skips the correlation cascade, which is every scoped
per-entity clear, now also releases the stop's mute.

The mark survives a restart: with a declaration still in force the manager
re-registers it for every stored fault that is not CLEARED and whose cycle
started at or after the declaration, so the switch-off after a reboot still
releases and announces them.

A store that refuses the write ends the request with success=false instead of
taking the process down, and the declaration is written before anything is
muted, unmuted, announced or audited. GetPlannedStop keeps serving the
declaration after the withdrawal, stamped with ended_at, so the reason stays
readable once the plant is back up. The audit rows carry the same __audit__
fault code the log's own lifecycle markers use.

Refs #656
…anged

The silence assertion now reads the exact set of event types published per
fault code, and the scenario re-reports a marked fault so that an update
leaking past the mute is a case the test can actually see - counting
confirmations alone could not. The exactly-once assertions wait out a settle
window after the counts are right, because a duplicate published a moment
later satisfies a poll and is never seen.

New cases: a fault already up when the stop begins stays visible and is
announced once, over the services and over the gateway; a fault raised again
after a clear is marked; a scoped acknowledgement releases the mute; a
cluster rule does not orphan it; the declaration is readable after the
withdrawal; a store that refuses the write is reported rather than fatal; the
audit rows carry the installation sentinel.

Docs on every surface that described the muting: what a cycle is, that
publication equals a rule-muted symptom exactly (CLEARED included), that the
audit rows exist only when the audit log is on, that the declaration outlives
the stop, and that the correlation cleanup timer now runs on every manager.

Refs #656
It repeated the claim that a marked fault is never published as a FaultEvent,
and listed a GetPlannedStop response that no longer matches the service.

Refs #656
The stop marked faults by writing a mute entry and remembering the codes in a
set. That set was the only record, so it could not survive a restart intact, a
cluster rule could strand an entry it never wrote, and a rule releasing a fault
mid-stop dropped it out of the stop for good - its confirmation announced by
nobody, which is the failure marking exists to prevent.

Ownership is now a flag on the fault row: the stop owns a CYCLE that starts
while it is declared, for the whole cycle. The mute is derived from that. A
rule's mute overlays an owned fault instead of taking it, and when the overlay
ends - root cause acknowledged, window closed, cluster expired - the engine
re-asserts the stop's mute. Ownership ends when the fault is acknowledged or
the stop is withdrawn, and a restart reads the flags back rather than comparing
timestamps, which a clock step would break and which cannot tell a rule's mute
from the stop's.

A cycle now also starts when a healed fault fails again: the manager publishes
EVENT_CLEARED at the heal, so the confirmation that follows is fresh news.
occurrence_count keeps its own, narrower definition.

Muting gates the PASSED path too. should_mute was computed for FAILED reports
only, so a PASSED that left a muted fault CONFIRMED announced an update for a
fault the list was hiding; and it was computed from what the report matched
rather than from the mute map, so a repeat report of a fault whose rule had
stopped matching did the same.

The withdrawal writes the declaration, announces, and clears the flags last, so
a process that dies in between leaves faults owned by an ended declaration -
which the next startup recognises, announces once a consumer is listening, and
clears. Clearing first would have turned a crash into permanent silence.

The storage-injection seam is a protected constructor rather than a public
runtime swap: the backend is in place before snapshot and rosbag capture borrow
a raw pointer to it.

Refs #656
The ordering that decides what a crash mid-release costs was only reasoned
about: the interrupted-release test starts from the state such a crash leaves
and cannot see how the state came about. A storage double now records the
switch-off's own calls, so clearing the ownership flags before the
announcement fails a test instead of passing one.

The cleanup timer's cost is measured rather than asserted: ~190 ns per call on
a rules-free engine, ~2.3 us of CPU per minute at the 5 s default.

Docs on every surface follow the ownership model - what a cycle is, that a
rule's mute overlays ownership rather than taking it and hands the fault back
when it ends, that the flags and the declaration survive a restart while a
rule's mute does not, and that an interrupted switch-off is finished at
startup. Two stale sentences went with them: the audit header still described
an empty fault code, and SetPlannedStop still said the reason is served only
while the stop is on.

Refs #656
…tructor

Deferring it to a wall timer left a window: the services exist by then, so an
operator could declare a new stop and new cycles could become owned by it,
and the deferred work would announce a fault the new stop was holding and
clear every ownership flag - the new stop's included. The recovery now runs
synchronously in the constructor, before this node has a service or a timer
that could run, and drops exactly the flags it captured; the storage gains a
scoped release for that.

The price is an announcement that may reach no subscriber, because nothing has
matched a publisher that is milliseconds old and the events topic is volatile.
That is the same accepted loss as any other publication made at startup, and
the released faults are in the default fault list either way - which is what
the launch test asserts, alongside the manager's own log line.

Refs #656
@bburda bburda self-assigned this Sep 8, 2026
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.

Declare a planned stop, so a weekend is not a wave of faults

1 participant