docs(policy): hot-reload is implemented and inert in production - #490
Merged
Conversation
The plan item said "design live policy reload, the interval is 0". The interval being 0 is not the whole story: reload is already built in PolicyStore.reload_if_stale, wired into PolicyEvaluator, and documented as a supported knob. It cannot swap a policy in any production configuration. Production requires CMCP_POLICY_HASH. That pinned hash is re-used on every reload, so the reload asks a question that answers itself: has the bundle changed, and does it still hash to the value it had before it changed. A genuinely changed bundle raises PolicyHashMismatch, gets swallowed by a bare except, and the old policy keeps being enforced with only a WARNING. The line that would install a new bundle is unreachable whenever a hash is pinned. Measured, not inferred: an allow-all to deny-all edit leaves the gateway enforcing allow-all. And because the failure path never advances _last_reload_at, 50 policy evaluations after the interval elapses produce 50 full bundle re-reads with hashing, on the enforcement hot path. So setting the documented knob in production buys a load amplifier and no policy update. No test caught this because every reload test constructs PolicyStore without expected_hash, which is the dev-mode configuration production never uses. Design doc only, no code. It states the real question a fix has to answer (if not a startup hash, what authorises a new bundle) plus what a reload does to TRACE evidence, and lays out five options with their costs. Also corrects STATUS.md, docs/configuration.md and the README, which all described the knob as merely disabled rather than as a trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 10, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design doc only, no code, per the "design first" call on this item.
The plan item was based on a drifted status file
The item read "cMCP live policy reload —
policy_reload_interval_secondsis 0, design first", which reads as "unimplemented, default off".STATUS.mdsaid the same.Hot-reload is already implemented:
PolicyStore.reload_if_stalepolls, swaps atomically under anRLock, and keeps the current bundle on failure.PolicyEvaluator.evaluatecalls it. It is documented as a supported config knob.It cannot swap a policy in any production configuration.
Why
Production requires
CMCP_POLICY_HASH(POLICY-001/#137). That pinned hash is handed toPolicyStoreand re-used on every reload:So the reload asks a question that answers itself: has the bundle changed, and does it still hash to the value it had before it changed? A changed bundle always raises
PolicyHashMismatch. The line that would install it —— is unreachable whenever a hash is pinned.
Measured, not inferred
An operator edit from allow-all to deny-all, with the pin set:
The gateway keeps enforcing allow-all. The only signal is a
WARNINGwhose text isgateway will not start— a startup message, emitted during steady-state operation.Second defect: it becomes a load amplifier
_last_reload_atonly advances on success. The failure path returns without touching it, so the staleness check stays true. Since the reload always fails in production, the interval stops being an interval.Measured: 50 policy evaluations after the first interval elapsed → 50 full bundle re-reads from disk, each reading every policy file and computing a SHA-256, on the enforcement hot path.
The production effect of
policy_reload_interval_seconds: 60is therefore not "policy updates every minute". It is "after one minute, every tool call performs full bundle I/O and hashing, and the policy never changes" — reached by following the documented configuration.Why no test caught it
Every reload test constructs
PolicyStorewithoutexpected_hash— the dev-mode configuration, where reload genuinely works.test_policy_store_bundle_swap_on_hash_changepasses and correctly proves the swap logic, in the one configuration production never uses.What the doc contains
The real design question, which a fix has to answer before any code: if not a hash fixed at startup, what authorises a new bundle? Plus a second question specific to cMCP: a TRACE claim names the bundle hash a call was evaluated under, so what does a mid-process reload do to evidence?
Five options with their costs: pin a signing key (A), pin a set of hashes (B), re-read the pin from a trusted source (C), operator-triggered reload (D), or keep it dev-only and make that a startup config error (E).
Two fixes that stand regardless of direction: a configuration that cannot work must not start, and the interval must be honoured on failure.
Also corrected
STATUS.md,docs/configuration.mdand the README all described the knob as merely disabled. They now say it is a trap and to leave it at0.Not addressed
CMCP_CATALOG_HASHhas the identical pin andload_catalogthe identical shape. Flagged in the doc as needing a deliberate decision rather than a copy of whatever lands here.🤖 Generated with Claude Code