Skip to content

Daemon restart costs a vault lane 300s: module_warming is unclassified and falls through to FailClosed → Decode → non-transient #14

Description

@iceteaSA

A daemon restart costs one vault lane 300s: module_warming is unclassified and falls through to FailClosedDecode → non-transient

Observed on a ck-subc restart (0.14.0 → 0.14.1, daemon commit 49b23913), insula at 35d4e5c, subconscious path-dep pinned at 26d122bd. Timeline from the daemon journal, millisecond-ordered:

13:13:41.207  daemon start
13:13:41.262  aft registered
13:13:41.265559  [ck-quota] warning: anthropic vault credential.get failed (oauth:anthropic): FailClosed
13:13:41.265582  insula registered
13:13:41.266557  vault module registered      <- ~1.0 ms AFTER the failed get

Wire cost, from a 30s recorder on the claude lane:

13:13:54Z … 13:18:58Z   errorClass=decode_failed  "decode error: credential vault rejected the request"
10 consecutive samples, recovered on the next attempt at +300s  (= NON_TRANSIENT_BACKOFF)

Only the first lane to fire lost the race; codex and antigravity fetched a few ms later and found the vault. One credential.get failed line in the whole journal.

What the daemon actually returned

The vault module's own audit log has zero rows in that window (positive control: the same query form finds rows in the prior hour), and it writes nothing at request level for refused gets anyway — so the request never reached it. The reply was the daemon's. For a route.open whose target is supervised but not yet registered, subc-core/src/control.rs (at 49b23913, lines ~1573–1584) answers:

control_error_frame(&frame, if warming { "module_warming" } else { "target_unavailable" }, ...)

module_warming has existed since subconscious 01c6337e ("report warming modules distinctly", #53, 2026-08-21) and is exported as subc_protocol::error_codes::MODULE_WARMING.

Why insula turned that into a 300s penalty

crates/quota-module/src/vault_client.rs:

  • open_route (:539) sees FrameType::Error and calls classify_error_frame — correct.
  • classify_error_frame (:881–:905) recognises unknown_channel | unknown_module | module_reloadingRouteGone and target_unavailable | module_timeout | backend_errorTransport. module_warming is in neither list.
  • It then looks for a vault class field; a control error body has none; read_error_to_outcome _ => VaultGetError::FailClosed (:874).
  • crates/quota-core/src/provider.rs:264 maps FailClosedFetchError::Decode("credential vault rejected the request") → non-transient → flat 300s backoff.

So a "try again in a moment" from the router is treated as a corrupt credential reply. The unknown_module arm one line up prints a warning precisely so the id is "printed nowhere else in this process" — the fallthrough one arm down discards the code it saw, and the only trace is the word FailClosed. Reconstructing this took the daemon journal plus the vault module's audit log as a negative control; from insula's own output it was unattributable.

Suggested fixes, in order of value

  1. Classify MODULE_WARMING as RouteGone (transient; a warming target is the textbook retry-next-tick case). MODULE_REMOVED probably wants its own arm too — it is permanent, but it is not a decode failure either.
  2. Log the discarded code/class at every _ => FailClosed. The wire carried a distinction the log destroyed. Same argument the unknown_module comment already makes.
  3. Read the codes from subc_protocol::error_codes::* rather than string literals — insula links that crate; a new constant would then at least be greppable against the match, and the compiler can't help with strings.
  4. Separately: FailClosedDecode conflates two axes. Refusing to trust an unrecognised reply is right; waiting 300s to ask again is a retry-policy choice, and a 60s retry trusts the reply equally little. Worth deciding whether an unclassifiable route-layer reply should be non-transient at all.

Not the cause here but consistent with it: main.rs:217 spawns the refresher before expect_hello_ack (:275). The vault client is its own client-principal connection, so insula's own registration order is not what raced — the target's was — but a cold-slot tick that can fire before the module's own handshake is a second startup race waiting for a reason.

Base: insula 35d4e5c, subconscious 49b23913 (daemon) / 26d122bd (build pin). Line numbers are against those.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions