Skip to content

fix(plugin): fence lifecycle compatibility by daemon - #60

Draft
ahrav wants to merge 1 commit into
stack/mc-host-12-runtime-identityfrom
stack/mc-host-13-client-compatibility
Draft

fix(plugin): fence lifecycle compatibility by daemon#60
ahrav wants to merge 1 commit into
stack/mc-host-12-runtime-identityfrom
stack/mc-host-13-client-compatibility

Conversation

@ahrav

@ahrav ahrav commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Summary

Managed callers now evaluate daemon, module, epoch, and readiness data from one authenticated daemon identity. Route and application publication fail before use when that identity rotates, while unsupported-platform and typed compatibility outcomes remain intact.

Verification

  • bun run typecheck
  • bun run lint
  • Focused lifecycle, client, module-transport, and Synapse suites: 235 tests passed

Stack

  1. fix(mc-host): authenticate daemon version transcripts #58 authentication transcript
  2. fix(mc-host): bind runtime execution to verified identity #59 native runtime identity
  3. fix(plugin): fence lifecycle compatibility by daemon #60 client compatibility
  4. test(release): bind lifecycle evidence to attested runs #61 release evidence
  5. fix(ci): restore portable lifecycle source checks #62 CI portability

Comment on lines +231 to +239
if (status === null) {
return {
...compatibility,
readiness: {
transport: { state: "ready", reason: "healthy" },
storage: { state: "unavailable", reason: "storage_unavailable" },
synapse: { state: "degraded", reason: "synapse_degraded" },
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the compatibility probe short-circuits before host.status (daemon or module mismatch, status === null), this fabricates storage: "unavailable" / synapse: "degraded" readiness records that were never actually observed.

In McHostLifecyclePolicy.observationalCommand (policy.ts), addCheck runs unconditionally over observed.readiness.*, so these fabricated values get published as real readiness.storage/readiness.synapse "fail" checks (with specific reasons like storage_unavailable) in the status/doctor output — even though storage/synapse were never probed. The top-level ok/reason correctly prefer the compatibility verdict, but the checks array itself misrepresents unknown state as a specific, actionable failure, which will mislead anyone reading mc-host doctor output to troubleshoot storage/synapse when the real (and only known) problem is a daemon/module version mismatch.

Consider marking these as "unsupported"/not-yet-known rather than synthesizing "unavailable"/"degraded", or omitting the storage/synapse checks entirely when status === null.

Comment on lines +1728 to +1734
const expectedDaemonKey =
options.expectedDaemonId === undefined
? ""
: Array.from(options.expectedDaemonId, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
const key = `${routeCacheKey(target, identity, consumerIdentity)}\0${expectedDaemonKey}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folding expectedDaemonId into the managed-route cache key means every daemon restart mints a brand-new this.routes entry (a fresh key per daemon incarnation) instead of reusing/replacing the old one for the same (target, identity, consumerIdentity). Entries are only ever removed from this.routes via closeRoute's explicit this.routes.delete(key) (client.ts:739) — there's no bulk eviction on connection invalidation/generation change and no cleanup keyed on "stale expectedDaemonId no longer matches the current daemon". Over a long-lived process that lives through many mc-host restarts (e.g. upgrades), this.routes will accumulate one dead CachedManagedRoute per prior daemon generation indefinitely.

The per-entry cost is small, but this is an unbounded-growth pattern tied directly to a mechanism (daemon rotation) this PR now actively causes to happen more visibly. Worth considering pruning entries whose expectedDaemonKey no longer matches this.compatibleDaemonId-equivalent state when a new connection is established, or keying by target/identity only and revalidating identity per-use (as assertExpectedDaemon already does) rather than fragmenting the cache.

@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review summary

This PR extends lifecycle compatibility checking to fence live traffic to a specific authenticated daemon generation (expectedDaemonId), rejecting route-open/request/managed calls before any bytes hit the wire if the daemon rotated after compatibility was validated. The core mechanism (assertExpectedDaemon in mc-host-client/client.ts, the daemon-id threading through module-transport.ts and embedding-synapse.ts, and the ordered daemon → module → epoch compatibility gate in compatibility.ts/policy.ts) is sound and well covered by new tests, including the rotation-mid-flight and TOCTOU-during-probe cases.

Two lower-severity issues found, posted inline:

  • managed-policy.ts (probeManagedReadiness): when the compatibility probe short-circuits before host.status (daemon/module mismatch), it fabricates storage: "unavailable" / synapse: "degraded" readiness records that were never actually observed. These flow into status/doctor checks as real-looking "fail" entries, which can mislead troubleshooting even though the top-level ok/reason correctly reflect the real (compatibility) cause.
  • mc-host-client/client.ts (managedRouteHandle cache key): folding expectedDaemonId into the managed-route cache key means each daemon restart adds a new cache entry rather than replacing the old one; stale entries are never pruned (only closeRoute removes by key), so this.routes grows unboundedly across many daemon restarts in a long-lived process.

Also worth noting (not flagged inline, lower confidence): contract.ts's relaxed restart-effects validation (command === "restart" && record.ok && (...)) no longer catches a raw native result claiming ok: false while also reporting state: "running", reason: "started", start_committed: true — a self-contradictory combination the old check rejected. This looks like an intentional, test-backed relaxation (the added tests show legitimate ok: false restarts with committed effects), so likely fine, but worth a second look if that specific contradiction is reachable in practice.

No security concerns beyond the two items above — the fencing logic is a genuine hardening (defense against cross-daemon-generation request delivery), not a regression.

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

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