Skip to content

fix(observe): a failed observation is not evidence of absence (audit theme 3) - #141

Draft
passcod wants to merge 2 commits into
mainfrom
claude/pr-115-theme-3-observation-uncertainty
Draft

fix(observe): a failed observation is not evidence of absence (audit theme 3)#141
passcod wants to merge 2 commits into
mainfrom
claude/pr-115-theme-3-observation-uncertainty

Conversation

@passcod

@passcod passcod commented Aug 2, 2026

Copy link
Copy Markdown
Member

Closes cross-cutting theme 3 from the logic bug audit: absence of observation conflated with observation of absence.

Previously stacked on #140 (theme 4), which has now merged; this PR is based on main and its diff is theme 3 only.

The class

The code had one vocabulary for "state of the world" and none for "quality of the evidence", so missing or unclassifiable evidence was coerced into the most destructive definite answer. Two instances:

Failed queries defaulted to all-absent. observe_one_pod handled an observer.observe() error by returning a fully actuatable ObservedInstance with every flag false — byte-for-byte what "confirmed absent" looks like. actuate_one_pod then ran its Job terminal predicate (!container_exists && !is_running && previously_ran) with no check on the failure, stopped the Job, and recorded it in completed_jobs, from which job-terminal.defense guarantees it is killed again if it ever reappears. One transient hiccup in any of the three probes permanently destroyed an in-flight batch workload.

Unrecognised-but-present states mapped to "removed". parse_container_status's catch-all swallowed podman's real transitional states stopping, removing and initialized into Unknown, which Observer::observe mapped to ContainerMissing — in an arm that had just proven the container exists, because the inspect returned it. That persists as container_removed, which the oracle reads as the transition to Unscheduled and termination_success reads as terminal success. A postgres draining through a long stop_timeout_secs was recorded as removed, and barriers sequenced after the stop were satisfied while the old container still held its volumes and network.

The change

Uncertainty is resolved at the observer boundary and does not cross it disguised as fact.

PodObservation is Observed(..) | Failed { .. }, and actuate_one_pod takes an ObservedInstance — so "failure looks like absence" is unrepresentable and the compiler forces every caller to route Failed explicitly. Failed instances skip actuation entirely: no start, no stop, no job-terminal detection, no completed_jobs insertion. Their observe_failure still reaches the fault path, and because they emit no facts, the oracle keeps the last state it derived. The observation stays atomic per instance — reporting the probes that did succeed would resurrect the bug in a subtler form (container_exists: false because the inspect failed while the network probe succeeded).

Note absence stays inside Observed: a ContainerMissing from a successful query is a real observation and must keep driving teardown and job-terminal logic.

ContainerStatus gains Stopping, and parse_container_status models podman's documented state set with no silent catch-all — an unrecognised string logs an error naming it. Stopping and Unknown both map to a new in-tick-only ContainerPresentIndeterminate fact that sets container_exists and persists nothing, so a draining container is present (it still holds its volumes) and the oracle is not told it went away. Because ContainerStatus matches carry no _ arm, adding a variant breaks compilation at every consumer that must decide what it means — which is how this change found its own call sites.

Finally, the observe_failed fault is gated on 3 consecutive failures per instance, so a single blip logs an error without minting an operator-visible fault. The streak resets on the first success and lives in memory beside the reconciler's other per-tick state; losing it on restart is fine, because a restart re-observes anyway.

Findings closed

Finding Severity
H1 — transient observe failure kills a running Job permanently high
Observer reports a gracefully-stopping container as removed medium
parse_container_status catch-all maps stopping/removing/initialized to Unknown medium

No persistence change is needed: world_observations is an append-only log of definite transitions, and "unknown this tick" is correctly represented by writing nothing — which makes the oracle hold the last-known state for free. No new obs_kind, no migration.

Enforcement

  • Spec: new r[observe.failure-not-absence] in runtime.md — a failed observation yields no facts; no destructive actuation may be based on one; lifecycle derivation retains the last observed state; an attempt succeeds or fails in full; an observed-but-unrecognised state is evidence the thing exists; a single failure must not fault, a persistent one must. r[observe.deployment]'s state list is widened to name the indeterminate-present case.
  • Tests: stopping/removing are not absence, initialized is Created, an unmodelled state is present-but-indeterminate; ContainerPresentIndeterminate persists no observation while ContainerMissing still records container_removed.

Not in scope

Wrong answers from successful queries — H4's SubState-vs-Result mis-read is a different bug. The genuine ambiguity of absence for --rm Jobs, where "exited 0 and auto-removed" and "crashed and auto-removed" both surface as container_removed and only unit_failed disambiguates. And systemd's CollectMode=inactive-or-failed erasing evidence before the observer runs.

The stub System still cannot fail a probe, which is exactly why the existing tests passed while production died; fault-injection hooks on the stub ContainerRuntime/ProcessManager would let the reconcile-loop behaviour be tested end to end rather than at the unit level, and are worth adding next.

Two places turned "we could not determine the state" into "the thing is
gone", and everything downstream trusted the degraded answer.

A failed observe returned an ObservedInstance with every flag false, which is
byte-for-byte what confirmed-absent looks like. actuate_one_pod's Job
terminal predicate (!container_exists && !is_running && previously_ran) then
stopped the Job and recorded it in completed_jobs, from which
job-terminal.defense guarantees it is killed again if it reappears — one
transient podman or systemd hiccup permanently destroyed an in-flight batch
workload. PodObservation splits the two, so the compiler forces every caller
to route Failed explicitly; absence stays inside Observed, because a
ContainerMissing from a successful query is a real observation.

parse_container_status's catch-all swallowed podman's real transitional
states, and Unknown was mapped to ContainerMissing in an arm that had already
proven the container exists. A container draining through a long
stop_timeout_secs was recorded as removed, so barriers sequenced after the
stop released while it still held its volumes and network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
@github-code-quality

github-code-quality Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: TypeScript, Rust

TypeScript / code-coverage/vitest

The overall coverage in commit 8f40819 in the claude/pr-115-theme-... branch remains at 66%, unchanged from commit b383126 in the main branch.

Rust / code-coverage/rust

The overall coverage in commit 8f40819 in the claude/pr-115-theme-... branch remains at 59%, unchanged from commit b383126 in the main branch.

Show a code coverage summary of the most impacted files.
File main b383126 claude/pr-115-theme-... 8f40819 +/-
crates/core/src/oi/server.rs 60% 60% 0%
crates/core/src...ncile/faults.rs 5% 5% 0%
crates/core/src...em/reconcile.rs 25% 25% 0%
crates/core/src...ystem/podman.rs 0% 5% +5%
crates/core/src...system/types.rs 64% 77% +13%

Updated August 02, 2026 03:03 UTC

passcod commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

CI red on Vitest, and it isn't this PR: the failure is AppDetail.images.test.tsx > lists in-use and pinned images and clears all pins, which does a synchronous getByText("in use") immediately after an async findByText(...) resolves — so it asserts on a chip that may not have rendered yet. This branch changes no frontend code at all (git diff main...HEAD -- crates/web/ is empty; it's system/podman.rs, system/observer.rs, system/types.rs, system/reconcile/* and the spec), and the suite passes locally on this exact commit — 45 files, 300 tests, twice.

Leaving it rather than papering over it here: tightening that assertion is a frontend change that doesn't belong in an observation-semantics PR, and it would hide the same flake on every other branch. Worth its own fix — the assertion wants findByText like the line above it.

Will re-check when CI re-runs.


Generated by Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Base automatically changed from claude/pr-115-theme-4-fault-lifecycle to main August 2, 2026 02:56
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.

2 participants