feat(faults): one fault identity and a shared file/clear discipline (audit theme 4) - #140
Conversation
Dedup, subject identity, and the decision of when a fault stops being true were re-implemented at every call site, and the sites had diverged into five idioms: ad-hoc already_filed scans keyed differently each time, in-memory prev-set diffs, full sweeps, over-broad clears, and missing halves. Migration v53 adds a subject column — the thing that is faulty, which used to hide in resource_name, instance_id, or a substring of the description — backfills it, and enforces at most one active fault per (app, kind, subject). file_once dedups on that key; sync_faults converges a scope to the set of conditions that currently hold, reading the database rather than an in-memory prior set. Fixes: a later volume's backup success no longer erases an earlier volume's failure (H8); ingress-conflict and site-service faults clear after a daemon restart; a stop_failed fault is no longer cleared by the stop_sent recorded before the stop was attempted; audit_lag dedups; adding a registry clears the disallowed_registry fault it resolves; and tailscale_unreachable, documented but never filed, is filed and converged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
Pull request overview
This PR implements the “fault lifecycle” discipline described in the audit theme 4 write-up by giving faults a first-class identity (app, kind, subject) and adding shared helpers to file-once and converge active faults against the persisted set (restart-safe), then ports several key call sites to the new approach.
Changes:
- Adds DB migration v53 introducing
faults.subjectand a partial unique index enforcing at most one active fault per(app, kind, subject), including backfill and duplicate cleanup. - Introduces
FaultKey,FaultMeta,file_once,FaultScope, andsync_faults, and updates multiple subsystems (reconciler, backups, audit, tailscale, registries) to use converge/narrow clears. - Updates spec and expands tests to lock in the new lifecycle invariants and restart-safe clearing behaviour.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/spec/runtime.md | Adds the new r[fault.lifecycle] spec invariant describing identity, uniqueness, and restart-safe clears. |
| crates/core/src/system/reconcile/faults.rs | Ports ingress-conflict + site-service fault handling to persisted-set convergence via sync_faults. |
| crates/core/src/system/reconcile.rs | Removes in-memory “prior set” fields that prevented clears after restart. |
| crates/core/src/runtime/tailscale.rs | Implements filing/clearing for tailscale_unreachable as a converged condition fault. |
| crates/core/src/runtime/faults/tests.rs | Updates existing tests for subject uniqueness and adds new lifecycle/convergence tests. |
| crates/core/src/runtime/faults.rs | Adds subject, introduces FaultKey/FaultScope, implements file_once + sync_faults, and updates core fault DB operations. |
| crates/core/src/runtime/db/tests.rs | Bumps expected schema version to 53. |
| crates/core/src/runtime/db/migrations/v53.sql | Adds subject, backfills it, clears pre-existing duplicates, and adds partial unique index for active faults. |
| crates/core/src/runtime/db.rs | Registers migration v53. |
| crates/core/src/runtime/audit.rs | Switches audit_lag to file_once to prevent unbounded duplication. |
| crates/core/src/oi/handler/registries/tests.rs | Adds an OI test ensuring /registries/add clears disallowed_registry when it resolves. |
| crates/core/src/oi/handler/registries.rs | Re-evaluates apps after adding a registry so condition faults clear promptly. |
| crates/core/src/oi/handler/backups.rs | Keys backup faults by volume and clears only the relevant volume’s faults on success. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let active: Vec<FaultRecord> = list_active_faults(db, scope.app_filter())? | ||
| .into_iter() | ||
| .filter(|f| scope.owns(f)) | ||
| .collect(); |
| if inserted == 0 { | ||
| let existing: String = db.conn.query_row( | ||
| "SELECT id FROM faults | ||
| WHERE app = ?1 AND kind = ?2 AND subject = ?3 AND cleared_at IS NULL", | ||
| rusqlite::params![app, kind, key.subject], | ||
| |row| row.get(0), | ||
| )?; | ||
| return Ok((existing, false)); | ||
| } |
Two review catches. file_keyed assumed a conflicting insert always leaves an active row to read back; the winner can be cleared in between, which frees the key, so it retries rather than erroring. And sync_faults read every active fault and filtered in memory — a global sweep such as ingress conflicts runs every tick, so the cost grew with the total fault count rather than with the kind's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
crates/core/src/runtime/db/migrations/v53.sql:20
- Migration v53 only backfills
subjectfor active faults (WHERE cleared_at IS NULL ...). Because the faults table is also used as a history log, this leaves cleared historical rows with an emptysubject, making the new identity information unavailable/ambiguous when inspecting past faults (and any future UI/reporting that relies onsubject). Backfilling for all rows is safe and keeps history consistent.
UPDATE faults
SET subject = COALESCE(instance_id, resource_name, '')
WHERE cleared_at IS NULL AND subject = '';
| fn clear_backup_faults_for_volume(db: &crate::runtime::db::Db, app: &AppName, vol_id: &str) { | ||
| for kind in ["backup_failed", "backup_source_unavailable"] { | ||
| let active: Vec<_> = faults::list_active_faults(db, Some(app)) | ||
| .unwrap_or_default() | ||
| .into_iter() |
…istory Review catches. The snapshot-creation failure path still filed backup_failed through file_fault with no resource_name, so its subject derived to empty and clear_backup_faults_for_volume — which matches on the volume — would never clear it. Third of three, now keyed like the others. The migration also backfilled subject only for active faults. This table is the fault history the operator interface reads, so cleared rows are backfilled too rather than left ambiguous in the way the column exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/core/src/oi/handler/backups.rs:633
clear_backup_faults_for_volumeonly clears rows wheresubject == vol_id, but pre-v53 backup faults were filed viafile_fault(..., None, None, None, ...), so they backfill tosubject == ""and will never be cleared by a later successful backup after upgrade. Also,unwrap_or_default()silently ignores DB errors and can leave faults uncleared with no log.
Consider (a) logging and returning on list_active_faults errors, and (b) clearing legacy empty-subject backup faults alongside the volume-keyed ones so upgrades don’t strand an old backup_failed/backup_source_unavailable fault indefinitely.
fn clear_backup_faults_for_volume(db: &crate::runtime::db::Db, app: &AppName, vol_id: &str) {
for kind in ["backup_failed", "backup_source_unavailable"] {
let active: Vec<_> = faults::list_active_faults(db, Some(app))
.unwrap_or_default()
.into_iter()
.filter(|f| f.kind == kind && f.subject == vol_id)
.collect();
The branch was cut from a stale local main ref at v0.5.0, so the fault migration was numbered v53 — which main has since used for the Canopy settings, and v54 for the autonomous-restart record. Renumber the fault subject migration to v55 and leave the shipped blocks untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Code Coverage OverviewLanguages: TypeScript, Rust TypeScript / code-coverage/vitestThe overall coverage in commit d0559fc in the Rust / code-coverage/rustThe overall coverage in commit d0559fc in the Show a code coverage summary of the most impacted files.
Updated |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/core/src/oi/handler/backups.rs:633
- Backup faults are now keyed by volume id (
subject == vol_id), but existing activebackup_failed/backup_source_unavailablerows from before the subject migration were filed with noinstance_id/resource_name, so they backfill to an empty subject. Those legacy active faults would never be cleared by a successful backup with the new per-volume clearing logic, leaving a stale condition fault until manual intervention.
let active: Vec<_> = faults::list_active_faults(db, Some(app))
.unwrap_or_default()
.into_iter()
.filter(|f| f.kind == kind && f.subject == vol_id)
.collect();
crates/core/src/runtime/faults.rs:131
- The comment says the legacy subject-derivation matches “migration v53”, but this PR introduces the backfill in
v55.sql. Keeping the migration number accurate avoids confusion when correlating behaviour to schema versions.
// Sites that have not yet been given an explicit subject derive one the
// same way migration v53 backfilled the existing rows, so a fault filed
// before the migration matches the key its site computes after it.
Closes cross-cutting theme 4 from the logic bug audit: fault lifecycle asymmetries. See the pattern analysis.
The class
The fault store was deliberately minimal —
file_faulta bare INSERT,clear_faulta flag flip — so dedup, subject identity, and when a fault stops being true were re-implemented at every call site. Five idioms had diverged:(kind, instance_id),(kind, resource_name),(kind, description), barekind— andaudit_lagskipping the scan entirely, so it duplicated without bound.reconcile_ingress_conflictsandreconcile_site_service_faultscleared onlyprior \ current, whereprioris aReconcilerfield that starts empty on every daemon start. The faults are in the database, so one filed before a restart could never clear.clear_faults_by_kind(app, "backup_failed"), wiping every other volume's fault.audit_laghad no clear path;tailscale_unreachablehad a threshold constant and a doc comment but no filing code at all.Five of the six findings are not wrong judgements but wrong bookkeeping, copy-pasted per site.
The change
Faults get a first-class identity —
(app, kind, subject), where subject is the thing that is faulty: a volume id, an image ref, ahost:port, an instance hex. It used to hide inresource_name, ininstance_id, or in a substring of the description, and where none of those fitted it was simply absent, which is why clearing had to fall back to app-plus-kind.instance_id/resource_name, clears pre-existing duplicates (keeping the newest of each group — the migration would otherwise fail to build its index over them), and adds a partial unique index enforcing at most one active fault per key.file_once(db, key, meta, description)— the dedup four sites hand-rolled. Enforced by the index rather than a read-then-write, so concurrent filers cannot both win.sync_faults(db, scope, current)— converge a scope to the set of conditions that hold right now: file what is missing, clear what is no longer present. Restart-safe by construction, because it compares against the persisted active set rather than an in-memory prior one.FaultScopebounds what a sweep may clear, so converging one kind cannot touch another.file_faultkeeps its signature and derives a subject the same way the migration backfills, so the ~20 sites not ported here get the uniqueness guarantee without a change.Findings closed
backup_failedingress_conflictand site-service faults never clear after a daemon restartsync_faultssweeps; the twoReconcilerfields are deletedstop_failedfiled and cleared in the same tickstop_sentis recorded before the stop is attempted, so the file and clear sets overlapped; they are now disjoint per instancedisallowed_registryfaults/registries/addre-evaluates, as/registries/removealready didaudit_lagfiled without dedup and never clearedfile_once, with the clear path named at the sitetailscale_unreachablefault never filedEnforcement
r[fault.lifecycle]inruntime.md, stating the what: every kind defines both its filing and its clearing condition; identity is the faulty thing, not just the app; at most one active fault per key; clearing keyed no more broadly than filing; a condition fault is active exactly while its condition holds, including across restarts; a latched fault names the event that clears it.file_oncedoes not duplicate; two subjects of one kind clear independently (H8); a sweep clears a fault pre-seeded as if by a previous process, with no in-memory memory of it (the restart case);sync_faultsis idempotent and converges from any starting state; a sweep never touches kinds outside its scope; and aTestOitest that/registries/addclears the fault it resolves.Two existing tests changed: they filed two faults of the same kind for one app with no subject and asserted both stayed active. Under the new invariant that is one fault, so they now use distinct subjects — the behaviour they were testing (kind-wide clear, per-app count) is unchanged.
Not in scope
Computing the wrong condition in the first place — the healthcheck-replace target bug in §12 would survive any amount of fault plumbing. Latched kinds (
crash_loop,health_check_replace_failed) keep their semantics: they deliberately outlive their trigger and must not be converged to "currently observed". The remainingfile_faultsites are correct as they stand and inherit the uniqueness guarantee; porting them to explicit subjects is mechanical follow-up.Overlap with other themes
Theme 3 (observation uncertainty) stacks on this one — it gates the
observe_failedfault on consecutive failures, in the samereconcile/faults.rsandreconcile/pods.rsthis PR edits. Independent of the rest.Generated by Claude Code