Skip to content

Make recovery fail closed and let later-added devices recover - #25

Merged
hmirin merged 1 commit into
mainfrom
fix/recovery-fail-closed
Oct 29, 2026
Merged

hmirin merged 1 commit into
mainfrom
fix/recovery-fail-closed

Conversation

@hmirin

@hmirin hmirin commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Step 1 of the refactoring plan from the whole-codebase reviews (Opus 1.1 and 1.2, Astra Category 1 highs 1 and 3).

Recovery may override a head only when it is proven invalid. Before this change, it:

  • treated any unrecognised error as Invalid;
  • skipped candidates it could not classify;
  • kept going after exhausting its budget.

So a corrupt local cache, a future schema, or budget exhaustion could make recover --publish build on an incomplete view and discard a valid head, including a revocation.

Change

  1. Positive Invalid. A new InvalidState error marks only failures of authenticated content:

    • AEAD and signature failures
    • content-id mismatch
    • malformed HEAD, manifest or policy bytes
    • failed validation of authenticated structures

    Only InvalidState classifies as Invalid. Anything unrecognised, including io InvalidData, is now Unverifiable, which blocks publication. A junk HEAD is still Invalid and still recoverable.

  2. Budget exhaustion is fatal. BudgetExhausted during candidate discovery or the floor chain aborts recovery.

  3. Unclassifiable candidates block. Only Invalid candidates are skipped. Unavailable, Unverifiable, Unsupported and Discontinuous candidates block publication, and the reason is reported.

  4. Version first. Manifest headers and policies read format_version, and the manifest authorization variant, before full decoding. A future schema or a multi-admin threshold is Unsupported, not malformed. A threshold of 0 is invalid.

  5. Later-added devices can recover. A candidate whose ID is in the local floor chain is a replay, and this is decided before any unwrap. Before, recovery opened pre-admission heads with the device's own key and was blocked as "unverifiable" for every device except the owner. A head at or below the floor that is not in the floor chain is still opened and never offered: proven Invalid is skipped, anything else blocks.

docs/spec.md states the new rules. No recovery class was added, and storage format v6 is unchanged.

Tests

Each new test fails on main and passes here; this was checked by restoring the original sources.

  • recovery_classification_ignores_storage_controlled_message_text, recovery_classification_follows_error_types_through_context (updated: unknown → Unverifiable)
  • recovery_budget_exhaustion_inside_candidate_discovery_is_fatal (main published)
  • recovery_refuses_to_skip_a_discontinuous_historical_head (main published)
  • a_future_header_schema_is_unsupported_not_malformed, an_unknown_authorization_variant_is_unsupported_not_malformed, a_future_policy_schema_is_unsupported_not_malformed, a_multi_admin_threshold_is_unsupported_and_zero_is_invalid, recover_refuses_a_head_with_a_future_manifest_schema (main: Invalid)
  • carrier_recovery_works_for_a_writer_added_after_genesis (main: blocked as unverifiable) and the control carrier_recovery_works_for_the_owner

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --all-targets (167 passed).

Errors from chain authentication now begin with "authenticated repository state is invalid:".

Recovery may override a head only when it is proven invalid, but it treated
any unrecognised error as Invalid, skipped candidates it could not classify,
and kept going after exhausting its budget. A corrupt local cache, a future
schema, or budget exhaustion could therefore make recovery publish from an
incomplete view and discard a valid head, including a revocation.

- InvalidState marks only failures of authenticated content: AEAD and
  signature failures, content-id mismatch, malformed HEAD/manifest/policy
  bytes and failed validation of authenticated structures. Only that type
  classifies as Invalid; anything unrecognised is Unverifiable and blocks.
- BudgetExhausted during candidate discovery aborts recovery.
- A candidate that is not Valid or proven Invalid blocks publication instead
  of being skipped.
- Manifest headers and policies read their format version and authorization
  variant before full decoding, so future schemas and multi-admin thresholds
  are Unsupported rather than malformed.
- A candidate in the local floor chain is a replay, decided by ID before any
  unwrap. Recovery no longer opens pre-admission heads with a later-added
  device's key, so such devices can recover.
@hmirin
hmirin merged commit 0d7dd4d into main Oct 29, 2026
1 of 2 checks passed
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