fix(ops-mission-control): never publish the token or policy store over a failed read - #7788
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Strict read-for-update at the mutation base is the established in-tree idiom, correctly leaves lookup reads lenient, and the refusal routes follow an existing coded-503 shape. Watch
[DESIGN-REVIEWED] e0d8b82 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. The cited idioms exist ( First-Principles-Verdict: CONCERNS Every item is aimed at the named cause, but the same root cause has an unfixed, undeclared sibling: the incident index in What this change shipsIntent: stop a transient read failure from silently wiping the credential/policy stores or faking a revocation — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] e0d8b82 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates fail the falsification bar. Candidate 1 (ledger_sync mislabel as Candidate 2 (blanket No new Step-2 finding grounds at 80+: the No findings. [OPUS-REVIEWED] e0d8b82 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
74c7983 to
01cd4db
Compare
Round 1 dispositions -- all four lanesPushed GPT 5.6BLOCKING, FINDING, method-local imports -- PARTIALLY FIXED, and I disagree with the rest. The stdlib The app-module imports ( Design Review (Fable 5) -- CONCERNSThe heartbeat regression -- REAL, and fixed. I verified this rather than taking it on trust, and Both calls now log and continue with an empty result. That is the pre-fix outcome minus the silence, I did not extend this to the claim path, and want to flag the boundary explicitly rather than File the section-5 follow-ups -- DONE: #7789 and #7790. First Principles (Fable 5) -- CONCERNSThe completeness claim was overstated -- CORRECTED, and you were right to grep it. Both sites Filed as #7789, with the durable one written up as the actionable half and the ratchet proposal
Opus 4.8No findings. Full app suite |
01cd4db to
00b13d8
Compare
Re-scoped: this PR is now the two authorization-deciding stores onlyForce-pushed Still in this PR (
Moved to #7794:
New in this revision, and the reason for the re-push beyond the split: the handler question I I had argued against this on the grounds that a bare 500 was already reachable on Also reframed in the description, because the two findings are not one finding. App suite |
…r a failed read `secrets._read` and `policy_store._read` collapse every read failure to an empty document. Both are correct as LOOKUP reads -- `get` answering "not configured" keeps the Settings page rendering and lets the fail-closed `has_secrets` check refuse the provider, and an unreadable ceiling resolving to `observe` with no act-rules is the most restrictive answer available. Both are also the base of a whole-file rewrite, and there an empty document is not "nothing to carry forward": it is "delete every stored provider token", and "discard every operator-only key". One transient EACCES/EIO published either. A provider token is not derivable from anything else on the box, so that file is the only copy. Truncating it means the operator mints new credentials at PagerDuty and Datadog while every poll fails closed. The revocation half is a different failure, and the worse one. It destroys nothing: on an unreadable store the lenient read reported the provider absent, so `delete` returned False and `delete_secret` audited `secret_delete outcome=not_found`. That is a FAILED REVOCATION REPORTED AS COMPLETED -- the operator's belief and the disk disagree, and the audit log sides with the operator. A wipe announces itself the next time something needs the token; this is silent, and a person who believes a credential is revoked stops treating it as live. For the policy file the argument is a security one rather than a data-loss one. That one file holds every operator-only key -- the autonomy ceiling, the ledger sync remote, the Slack channel, the rotation identity, the primary-instance flag -- and each is fenced onto the keystone floor precisely because the agent must not be able to set it. Each also falls back to a value the constrained party CAN influence. So this was a fail-open on the file that defines who is constrained, and one transient read failure reproduced by accident the exact bypass the keystone exists to prevent. Each module gains a private reader for its mutation path where only a MISSING file is empty and an unreadable one propagates, so the mutation is abandoned rather than published over state nobody read. Corruption keeps reading as empty in both, matching the lookup reads. Same idiom as `mcp_quarantine._load_for_update`, `config/loader.read_config_for_update`, and 8064a9b on the aws-control ledgers. Every route that writes these stores answers a refusal with the app's machine-readable coded error instead of aiohttp's default untyped 500, following `_handle_rotation_arm`'s `503 cron_store_unreadable` shape in the same file. That includes the three destination writes which reach `policy_store.put` through an intermediary rather than naming it: `slack_out.set_settings` and `ledger_sync.set_settings` own operator-only keys, so guarding only the call sites that spell `policy_store` left the Slack channel and the ledger remote -- the two keys an agent must not be able to redirect -- answering a plain 500. The partial-apply set goes to the audit log rather than the refusal body. Phase 2 is a sequence of writes, so an earlier one may already have committed and a partial ceiling apply is a security state worth recording; but the dashboard's own `req` helper reads only `error` from a non-2xx body, so a field there would have had no reader. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
00b13d8 to
e0d8b82
Compare
…fig over a failed read Same defect and same idiom as #7788, on the two stores that decide no authorization. `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it simply stops polling every provider the operator switched on, while their credentials stay in the keystone store and Settings still shows each provider as configured. Each module gains a private reader for its mutation path where only a MISSING file is empty and an unreadable one propagates. Corruption keeps reading as empty in both, matching the display reads. The six locked mutations in `store.py` move to the strict reader; the display path is unchanged except that both display reads now LOG when they degrade for any reason other than an absent file -- the harm they degrade into looks exactly like health, and nothing else would prompt an operator to look. `dispatch.run_cycle` degrades instead of aborting, in two places rather than one. Its two maintenance passes used to turn an unreadable index into a silent no-op and would now raise out of the heartbeat, so both log and continue. But guarding only those left the guard unreachable wherever it mattered: the pre-filter above the claim loop reads the index LENIENTLY, so an unreadable index empties `owned`, every firing signal becomes a candidate, and the claim raises before the webhook ack, the sweep, the Slack pin mirror, the notification bus and the cycle's SEL entry. Worse in the transient case than the persistent one -- a signal claimed earlier in the same loop is durably on disk yet never mirrored or notified, an in-flight investigation the team is never told about. The loop now logs and breaks on the first failure, so the cycle carries the claims it did make through to the end. `claim` itself still raises: a compare-and-set has no safe degraded answer, because `None` already means "another instance owns this signal". Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. The two failure modes are deliberately NOT given one handler, and closing that took three sites rather than two. `JSONDecodeError` subclasses `ValueError`, which three tolerant callers already caught for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them. `dispatch.verify_pending_actions`, `slot_watch.reconcile` and `routes._schedule_verification` each carry an explicit clause ahead of the tolerant one whose only job is to stop that accident. The last is the sharpest: it returns `("", "")` on failure, so a swallowed corruption means the action executes with NO verification scheduled, and in `act` mode that is a real write whose outcome is never re-checked. The distinction is persistence. An unreadable index is transient, so the next heartbeat retries and skipping one annotation costs nothing -- those paths keep their `OSError` tolerance. A corrupt index fails identically forever until a person intervenes, so swallowing it degrades the app indefinitely while the board still renders. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Corruption is not caught at either place. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now refuse with a code, matching the helper #7788 added to this file: `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous, and `PUT .../providers/{id}/config` answers 503 `app_config_unwritable`. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is now discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…fig over a failed read (#7794) * fix(ops-mission-control): never publish the incident index or app config over a failed read `store._read_index_unlocked` and `providers.read_config` collapse every read failure to an empty document. Both are correct as DISPLAY reads -- the board must render on an index it could not load, and every config accessor resolves to the caller's default -- and both are also the base of a whole-file rewrite, where an empty document means "delete every incident" and "drop every other provider's configuration". The index is not a view, it is the CLAIM ledger: `claim` is a compare-and-set against those rows. Emptied, every signal reads as unowned, so the next heartbeat re-claims alarms already being worked and opens a duplicate investigation of each one -- and in `act` mode a duplicate investigation is a second real write against the operator's production paging. An emptied config does not error, which is what makes it quiet: `provider_enabled` defaults to False, so it stops polling every provider the operator switched on while their credentials stay in the keystone store and Settings still shows each one as configured. Each module gains a private reader for its mutation path where only a MISSING file reads as empty. Both an unreadable file and a CORRUPT one propagate, so the mutation is abandoned rather than published over state nobody could read. Corruption propagating is a DELIBERATE divergence from the four merged siblings of this idiom -- `library.py:95`, `shares.py:60`, `secrets.py:231`, `policy_store.py:147` all still read an unparseable document as empty. Their justification is real: a document that failed to parse carries nothing to merge into. But "cannot merge into" is not "safe to destroy". A truncated file still holds most of its records verbatim, and replacing it discards the operator's only chance to recover them by hand. The divergence is temporary and tracked in #7805, `secrets.py` first, since there the discarded bytes are provider credentials that exist nowhere else on the box. The display reads stay lenient, and that asymmetry is the point: failing a render would turn a recoverable file into an unusable app. They now LOG when they degrade for any reason other than an absent file, because the state they degrade into looks exactly like health. Corruption and an unreadable file are deliberately NOT given one handler anywhere this change reaches, because `JSONDecodeError` subclasses `ValueError` and three tolerant callers already caught `ValueError` for the unrelated illegal-transition and raced-away cases -- so propagating alone would have been swallowed at every one of them, at debug level, by a handler written for something else. The distinction is persistence: an unreadable index is transient and the next pass retries, while a corrupt one fails identically forever until a person intervenes. What each of the three does about it differs by what has already happened when it runs. `dispatch.verify_pending_actions` and `slot_watch.reconcile` refuse, because nothing outside the app has changed yet. `routes._schedule_verification` REPORTS instead: both its callers have already performed the real external write by the time it runs, so raising would turn a completed action into a 500 and invite a retry that writes to the operator's production tooling a second time. It logs and records a SEL audit entry that the action ran with no recheck scheduled, then returns the same empty pair the transient case does. That is the same choice #7788 made for a partial ceiling apply in this file: the audit log is the durable reader, and it needs no `verification` value the dashboard cannot render. `claim` raises on both: a compare-and-set has no safe degraded answer, since `None` already means "another instance owns this signal". The same ordering rule is applied wherever the new strictness sits upstream of work worth keeping. `dispatch.run_cycle` degrades on its two maintenance passes, and its claim LOOP logs and breaks -- without that the maintenance guards were unreachable wherever they mattered, since the pre-filter reads leniently, so an unreadable index makes every firing signal a candidate and the claim raises before the webhook ack, the sweep, the Slack mirror, the notification bus and the SEL entry. On the hygiene cron, `prune_closed` degrades to zero pruned because it runs BEFORE `ledger_sync.sync_safely(direction="push")`: one EACCES there would otherwise skip pushing the ledger `hygiene` just deduped, which every other instance is waiting on, to save a prune the next run repeats. Two route handlers reach the newly-strict stores and could only answer a bare 500, so both now answer with a code, matching the helper #7788 added to this file. `POST .../proposal/decide` answers 503 `dispatch_index_unreadable`, because that request is a human's approval of a production action and "did my approval land?" must not be ambiguous. `PUT .../providers/{id}/config` answers 503 `app_config_unwritable` when the file is merely unwritable, and 500 `app_config_corrupt` when it is malformed -- corruption is not retryable, so advertising it as a 503 would tell the operator to retry something that cannot succeed until they repair the file. That helper's own docstring said `set_top_level` had no strict read "yet" and pointed at the companion PR for `providers/__init__.py`; this is that PR, so the caveat is discharged. The strict read makes "raise while holding `_IndexLock`" reachable for the first time. The release is already exception-safe and a test now pins it, because that regression would WEDGE rather than error: `flock` is per-descriptor, so a leaked lock deadlocks the same process against itself. No lock line moves, and the new reader holds no lock primitives of its own. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * chore: prune two graduated entries from the black baseline `test_providers.py` and `test_store_and_gate.py` became black-clean while being edited, and the gate requires a graduated file be removed so the baseline keeps shrinking. Separated per AGENTS.md, which says formatting a baselined file is optional and belongs in its own commit. --------- Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
## Problem
`PinnedFilesService._reload_pins_from_disk` caught `(FileNotFoundError,
json.JSONDecodeError)` and returned with `_pins` unchanged, and all four of its
callers then `_persist()` a whole-file rewrite. A corrupt `pinned-files.json`
was therefore silently replaced by whatever the in-memory list held, discarding
the rows another process wrote that this one never loaded -- the unparseable
file was their only remaining copy.
The MCP server is the second writer of the same file and had the worse half of
the same bug: `_tool_pin_file` / `_tool_unpin_file` read through `_read_json`
with a `{"version": 1, "pins": []}` default, so a corrupt store was ZEROED
rather than merely reverted to one process's view. Fixing only the service
would have left that writer free to destroy what the service just refused to
touch, so both go through one reader.
Same lenient-read-feeding-whole-file-rewrite class as #7620, #7788, #7794 and
#7805.
## What changed
`read_pins_for_update(file_path)` is the shared update-path reader. It returns
the stored list, `None` when the file is absent (the one case where an empty
base is true, so a first pin on a fresh install still lands), and raises the
new public `PinsCorruptError` on the four shapes that all reach the same
whole-file rewrite: a parse failure, a read error the filesystem raised
(transient EACCES/EIO -- the store is still there), valid JSON whose root is
not an object or whose `pins` is not a list (no parse failure at all), and an
undecodable byte.
That last one decodes STRICTLY where `load` and the Node original use
`errors="replace"`. The leniency only surfaces as a parse failure when the bad
byte falls OUTSIDE a JSON string; inside a quoted value -- a pin label, or a
path on a filesystem that does not enforce UTF-8 -- it becomes U+FFFD,
`json.loads` SUCCEEDS, and the whole-file rewrite persists the mangled text.
Proven directly: a store holding `"label": "caf\xe9 notes"` came back from a
mutation with byte `0xe9` gone and `\ufffd` in its place. Raised by GPT 5.6
review (span=617a8d5961a6).
`load` keeps the lenient decode: it does not write, and a file it accepts is
preserved as a `.bak.<now_ms>` sidecar first. A mangled label can still reach
memory at startup; it can no longer reach disk, because every write path
re-reads through this reader.
The refusal is a named public type rather than the bare `json.JSONDecodeError`
the aws-control readers raise in #8084: that app reached for the plain type
only because the named one lived in another app, which does not apply to a type
declared in the module both writers already import. Modelled on
ops-mission-control's `CorruptDocumentError`.
`add_pin` / `remove_pin` / `mark_seen` propagate it, and the HTTP handlers map
it to `500 {"code": "pins_corrupt"}`. Returning `{"ok": false}` would read as
"no such pin", and for mark-seen -- which has no failure return -- the old
behaviour reported success while the store was being replaced. The exception
text is not echoed: pin labels and paths are agent-authored and redacted on the
way out of the GET handler.
`_process_watch_event` is the one path that swallows the refusal. It fires from
the owner's tick loop, where an escaping error would take down every other tick
(stats, watchlist, presence) over a file it merely wanted to stamp; dropping
the stamp is the same degradation a failed `_persist` already takes there. The
paths a user drove raise, so the operator still learns the store needs repair.
Refuse-only was chosen over extending the sidecar to the update path (#7789's
shape) because this file already has the sidecar where a replacement is
intended, and the update path's whole problem is that no replacement was
intended at all.
## Tests
`test/test_mochi_pinned_files_cov80.py`, 15 new tests. The mutation cases
assert on the FILE's bytes rather than the exception type, so they fail on the
buggy code for the reason that regressed; `TestRefusalIsTheNamedType` pins the
type separately.
Red-before, against pristine `origin/main` source -- 13 failed / 27 passed,
each on a behavioural assertion:
- `add_pin` rewrote the corrupt file from its in-memory list
- `remove_pin` rewrote it, landing `"pins": []`
- `mark_seen` rewrote it
- a debounced watch event rewrote it from the tick loop
- `_tool_pin_file` returned `{"ok": true, "pins": 1}` and zeroed it
- `_tool_unpin_file` zeroed it
The two UTF-8 tests were separately proven against the pre-strict-decode
revision of this branch -- 2 failed / 1 passed, the mutation case on the raw
bytes.
Gates: 612 tests green (all 17 `test_mochi_*` files plus the black-gate
contract), black, isort, flake8, mypy (23 files), sync-io-in-async gate.
## Pattern harvest
Rule candidate: semgrep
Pattern: `read_text(errors="replace")` -- or any lossy decode -- on a read whose
value is written back to the same file.
A lossy decode is a repair, and a repair is only safe on a path that does not
persist what it read. On a read-modify-write path the substituted character is
written over the original bytes, so the loss is silent and irreversible -- and,
uniquely among the corruption shapes, it produces VALID JSON, so no
parse-failure clause downstream can catch it. The same file legitimately keeps
the lenient decode on its display/startup path, which is why the rule has to
key on the read reaching a writer rather than on the decode call alone.
This is the fifth instance of the enclosing class (#7620, #7788, #7794, #7805,
this PR), and the decode variant is the one the earlier four did not cover --
they raised on `UnicodeDecodeError` because they decoded strictly to begin with.
Closes #8088
#8092) ## Problem `PinnedFilesService._reload_pins_from_disk` caught `(FileNotFoundError, json.JSONDecodeError)` and returned with `_pins` unchanged, and all four of its callers then `_persist()` a whole-file rewrite. A corrupt `pinned-files.json` was therefore silently replaced by whatever the in-memory list held, discarding the rows another process wrote that this one never loaded -- the unparseable file was their only remaining copy. The MCP server is the second writer of the same file and had the worse half of the same bug: `_tool_pin_file` / `_tool_unpin_file` read through `_read_json` with a `{"version": 1, "pins": []}` default, so a corrupt store was ZEROED rather than merely reverted to one process's view. Fixing only the service would have left that writer free to destroy what the service just refused to touch, so both go through one reader. Same lenient-read-feeding-whole-file-rewrite class as #7620, #7788, #7794 and #7805. ## What changed `read_pins_for_update(file_path)` is the shared update-path reader. It returns the stored list, `None` when the file is absent (the one case where an empty base is true, so a first pin on a fresh install still lands), and raises the new public `PinsCorruptError` on the four shapes that all reach the same whole-file rewrite: a parse failure, a read error the filesystem raised (transient EACCES/EIO -- the store is still there), valid JSON whose root is not an object or whose `pins` is not a list (no parse failure at all), and an undecodable byte. That last one decodes STRICTLY where `load` and the Node original use `errors="replace"`. The leniency only surfaces as a parse failure when the bad byte falls OUTSIDE a JSON string; inside a quoted value -- a pin label, or a path on a filesystem that does not enforce UTF-8 -- it becomes U+FFFD, `json.loads` SUCCEEDS, and the whole-file rewrite persists the mangled text. Proven directly: a store holding `"label": "caf\xe9 notes"` came back from a mutation with byte `0xe9` gone and `\ufffd` in its place. Raised by GPT 5.6 review (span=617a8d5961a6). `load` keeps the lenient decode: it does not write, and a file it accepts is preserved as a `.bak.<now_ms>` sidecar first. A mangled label can still reach memory at startup; it can no longer reach disk, because every write path re-reads through this reader. The refusal is a named public type rather than the bare `json.JSONDecodeError` the aws-control readers raise in #8084: that app reached for the plain type only because the named one lived in another app, which does not apply to a type declared in the module both writers already import. Modelled on ops-mission-control's `CorruptDocumentError`. `add_pin` / `remove_pin` / `mark_seen` propagate it, and the HTTP handlers map it to `500 {"code": "pins_corrupt"}`. Returning `{"ok": false}` would read as "no such pin", and for mark-seen -- which has no failure return -- the old behaviour reported success while the store was being replaced. The exception text is not echoed: pin labels and paths are agent-authored and redacted on the way out of the GET handler. `_process_watch_event` is the one path that swallows the refusal. It fires from the owner's tick loop, where an escaping error would take down every other tick (stats, watchlist, presence) over a file it merely wanted to stamp; dropping the stamp is the same degradation a failed `_persist` already takes there. The paths a user drove raise, so the operator still learns the store needs repair. Refuse-only was chosen over extending the sidecar to the update path (#7789's shape) because this file already has the sidecar where a replacement is intended, and the update path's whole problem is that no replacement was intended at all. ## Tests `test/test_mochi_pinned_files_cov80.py`, 15 new tests. The mutation cases assert on the FILE's bytes rather than the exception type, so they fail on the buggy code for the reason that regressed; `TestRefusalIsTheNamedType` pins the type separately. Red-before, against pristine `origin/main` source -- 13 failed / 27 passed, each on a behavioural assertion: - `add_pin` rewrote the corrupt file from its in-memory list - `remove_pin` rewrote it, landing `"pins": []` - `mark_seen` rewrote it - a debounced watch event rewrote it from the tick loop - `_tool_pin_file` returned `{"ok": true, "pins": 1}` and zeroed it - `_tool_unpin_file` zeroed it The two UTF-8 tests were separately proven against the pre-strict-decode revision of this branch -- 2 failed / 1 passed, the mutation case on the raw bytes. Gates: 612 tests green (all 17 `test_mochi_*` files plus the black-gate contract), black, isort, flake8, mypy (23 files), sync-io-in-async gate. ## Pattern harvest Rule candidate: semgrep Pattern: `read_text(errors="replace")` -- or any lossy decode -- on a read whose value is written back to the same file. A lossy decode is a repair, and a repair is only safe on a path that does not persist what it read. On a read-modify-write path the substituted character is written over the original bytes, so the loss is silent and irreversible -- and, uniquely among the corruption shapes, it produces VALID JSON, so no parse-failure clause downstream can catch it. The same file legitimately keeps the lenient decode on its display/startup path, which is why the rule has to key on the read reaching a writer rather than on the decode call alone. This is the fifth instance of the enclosing class (#7620, #7788, #7794, #7805, this PR), and the decode variant is the one the earlier four did not cover -- they raised on `UnicodeDecodeError` because they decoded strictly to begin with. Closes #8088 Co-authored-by: Joe Guo <zejiangg@amazon.com>
POST /ledger, DELETE /ledger, and POST /ledger/hygiene were the last
mutating routes in routes.py whose store-write failures escaped as
aiohttp's bare plain-text 500 with no machine-readable code. All three
rewrite ledger.jsonl through atomic_write under the ledger lock, which
raises OSError on a refused write. Each now answers the same coded
shape _handle_rotation_arm set for a refusing store:
{ok: false, error, code: "ledger_store_unwritable"}, status 503.
The pre-push review (GPT 5.6) found the read half of the same fault,
and it is the worse one: read_entries collapses a failed OPEN to [],
and every locked read-modify-rewrite in ledger.py starts from it - so
a transient EACCES at hygiene time silently truncated the whole shared
ledger, remove answered a coded 404 about an entry still on disk, and
upsert re-created instead of merging. Mutation paths (upsert,
record_use, record_miss, remove, hygiene) now start from
read_entries_for_update, which propagates OSError; the lenient
read_entries stays for read-only callers, delegating to the strict
read. Both record_* callers in dispatch.py already tolerate OSError.
The hygiene route additionally skips the push when the rewrite was
refused, so a ledger the dedupe pass never committed to is not
published, and audits the refusal as a failure.
The other handlers the issue lists (put/delete secret, settings,
provider config, transition, decide_proposal) were already covered on
main by #7788/#7794 with per-store coded refusals and tests.
Closes #7790
POST /ledger, DELETE /ledger, and POST /ledger/hygiene were the last
mutating routes in routes.py whose store-write failures escaped as
aiohttp's bare plain-text 500 with no machine-readable code. All three
rewrite ledger.jsonl through atomic_write under the ledger lock, which
raises OSError on a refused write. Each now answers the same coded
shape _handle_rotation_arm set for a refusing store:
{ok: false, error, code: "ledger_store_unwritable"}, status 503.
The pre-push review (GPT 5.6) found the read half of the same fault,
and it is the worse one: read_entries collapses a failed OPEN to [],
and every locked read-modify-rewrite in ledger.py starts from it - so
a transient EACCES at hygiene time silently truncated the whole shared
ledger, remove answered a coded 404 about an entry still on disk, and
upsert re-created instead of merging. Mutation paths (upsert,
record_use, record_miss, remove, hygiene) now start from
read_entries_for_update, which propagates OSError; the lenient
read_entries stays for read-only callers, delegating to the strict
read. Both record_* callers in dispatch.py already tolerate OSError.
The hygiene route additionally skips the push when the rewrite was
refused, so a ledger the dedupe pass never committed to is not
published, and audits the refusal as a failure.
The other handlers the issue lists (put/delete secret, settings,
provider config, transition, decide_proposal) were already covered on
main by #7788/#7794 with per-store coded refusals and tests.
Closes #7790
POST /ledger, DELETE /ledger, and POST /ledger/hygiene were the last
mutating routes in routes.py whose store-write failures escaped as
aiohttp's bare plain-text 500 with no machine-readable code. All three
rewrite ledger.jsonl through atomic_write under the ledger lock, which
raises OSError on a refused write. Each now answers the same coded
shape _handle_rotation_arm set for a refusing store:
{ok: false, error, code: "ledger_store_unwritable"}, status 503.
The pre-push review (GPT 5.6) found the read half of the same fault,
and it is the worse one: read_entries collapses a failed OPEN to [],
and every locked read-modify-rewrite in ledger.py starts from it - so
a transient EACCES at hygiene time silently truncated the whole shared
ledger, remove answered a coded 404 about an entry still on disk, and
upsert re-created instead of merging. Mutation paths (upsert,
record_use, record_miss, remove, hygiene) now start from
read_entries_for_update, which propagates OSError; the lenient
read_entries stays for read-only callers, delegating to the strict
read. Both record_* callers in dispatch.py already tolerate OSError.
The hygiene route additionally skips the push when the rewrite was
refused, so a ledger the dedupe pass never committed to is not
published, and audits the refusal as a failure.
The other handlers the issue lists (put/delete secret, settings,
provider config, transition, decide_proposal) were already covered on
main by #7788/#7794 with per-store coded refusals and tests.
Closes #7790
…8433) POST /ledger, DELETE /ledger, and POST /ledger/hygiene were the last mutating routes in routes.py whose store-write failures escaped as aiohttp's bare plain-text 500 with no machine-readable code. All three rewrite ledger.jsonl through atomic_write under the ledger lock, which raises OSError on a refused write. Each now answers the same coded shape _handle_rotation_arm set for a refusing store: {ok: false, error, code: "ledger_store_unwritable"}, status 503. The pre-push review (GPT 5.6) found the read half of the same fault, and it is the worse one: read_entries collapses a failed OPEN to [], and every locked read-modify-rewrite in ledger.py starts from it - so a transient EACCES at hygiene time silently truncated the whole shared ledger, remove answered a coded 404 about an entry still on disk, and upsert re-created instead of merging. Mutation paths (upsert, record_use, record_miss, remove, hygiene) now start from read_entries_for_update, which propagates OSError; the lenient read_entries stays for read-only callers, delegating to the strict read. Both record_* callers in dispatch.py already tolerate OSError. The hygiene route additionally skips the push when the rewrite was refused, so a ledger the dedupe pass never committed to is not published, and audits the refusal as a failure. The other handlers the issue lists (put/delete secret, settings, provider config, transition, decide_proposal) were already covered on main by #7788/#7794 with per-store coded refusals and tests. Closes #7790
1. What is the problem?
Two reads in
ops_mission_controlcollapse every read failure to an empty document, andboth are the base of a whole-file rewrite:
backend/secrets.pyKeystoneFileBackend._readput,deletebackend/policy_store.py_readset_ceiling,putEach
exceptclause namesOSError, so a transientEACCES/EIO-- a scanner holding thehandle on Windows, a torn read, a permissions blip -- is indistinguishable from "the file is
not there yet". The mutation then rewrites the whole document from that empty base. The write
half is flawless; it just writes nothing.
Both lenient reads are correct where they are used as reads.
getanswering "not configured"keeps the Settings page rendering and lets the fail-closed
has_secretscheck refuse theprovider; an unreadable ceiling resolving to
observewith no act-rules is the most restrictiveanswer available, not a permissive one. The defect is not the leniency -- it is reusing a lookup
answer as a mutation base.
Three distinct failures come out of that, and they are not variations of one thing.
Truncation of the credential store.
putrewrites the whole file, so an empty base means"delete every stored provider token". A provider token is not derivable from anything else on
the box, so this file is the only copy.
A failed revocation reported as completed. This is a separate harm class, not a corollary of
the truncation above.
deletedestroys nothing: on an unreadable store the lenient read reportedthe provider absent, so it returned
Falseand the audit recordedsecret_delete outcome=not_found. The operator's belief and the disk disagree, and the auditlog sides with the operator. They asked for a credential to be revoked and were told there was
nothing there to revoke, while a live, working token sits on disk.
A fail-open on the file that defines who is constrained. For
policy_storethe argument is asecurity one, not a data-loss one. That single file holds every operator-only key -- the autonomy
ceiling (
mode,autonomy_rules), the ledger sync remote, the Slack channel, the rotationidentity, the primary-instance flag -- and each is fenced onto the keystone floor precisely
because the agent must not be able to set it. Each also falls back to a value the constrained
party can influence. So one transient read failure reproduced, by accident, the exact bypass
the keystone exists to prevent.
The per-file locks already in place (
_SecretLock,_PolicyLock) do not help: they serializewriters and say nothing about a read that failed.
2. Why this issue matters to the user
None of the three reports itself, and they fail in different directions.
The truncation is loud, eventually. Every stored token vanishing means the operator goes back
to PagerDuty and Datadog to mint new credentials, and every poll fails closed until they do.
Disruptive, but it announces itself the next time something needs the token.
The false revocation is silent, and that makes it worse. A person who believes a credential is
revoked stops treating it as live: they stop rotating it, stop counting it in an access review,
and stop caring where it has been. Meanwhile the token still authenticates. There is no later
moment when this surfaces, because everything keeps working -- that is precisely the problem. Of
the three failures here this is the one to fix first, even though it loses no data.
The fail-open ceiling is an accident that looks exactly like an attack.
effective = min(app_mode, rule_mode)is only a ceiling if the party it constrains cannot raise it. Losingthe file drops
modeandautonomy_rulesback to a re-derived default and the destination andidentity keys to absent -- and absent is how the off-shift refusal and the
not_primarygate gettheir inputs. The threat model this app documents at length is an agent writing those values; the
bug arrives at a similar place with no agent involved.
3. How our fix solves it
The chain from symptom to root cause: stored state disappears, or a revocation is falsely
reported <- a whole-file write publishes an empty document <- the mutation's base read answered
"empty" <- the base read cannot tell an absent file from an unreadable one, because one
exceptclause covers both.
The fix cuts the chain at the last link. Each module gains a private
_read_for_updateusedonly as the base of a mutation, in which:
and the operator's first save must not become an error;
state nobody read;
no stored token or ceiling left to lose by being replaced, which matches the lookup reads.
This is the idiom already in the tree, not a new one:
mcp_quarantine._load_for_update,config/loader.read_config_for_update, and most directly #7620 (8064a9bb5), which appliedexactly this shape to
aws_control'slibrary.pyandshares.py.secrets.pyhad multi-line normalization after the parse, so it was extracted into a shared_coercerather than duplicated -- which is what makes the two readers provably identical apartfrom which failures answer "empty", the one thing that is supposed to differ.
The three routes that write these stores now refuse with a coded error rather than letting
the newly-propagating
OSErrorland as aiohttp's default 500 (a plain-text body with nocodeto branch on -- nothing in the middleware chain converts it,
sel_audit_middlewarelogs andre-raises). This follows
_handle_rotation_arm's existing503 cron_store_unreadableshape inthe same file, whose own comment states the rule: "escaping here becomes a 500".
_handle_put_secret,_handle_delete_secret->503 secret_store_unwritable. The revocationroute needs it most: the failure being replaced was not a 500 but
{"ok": true, "removed": false}, and any 2xx there is the bug in section 1._handle_put_settings->503 policy_store_unwritable/app_config_unwritable. This covers thethree writes that reach
policy_store.putthrough an intermediary rather than naming it:slack_out.set_settings(slack_out.py:134,:136) andledger_sync.set_settings(
ledger_sync.py:327,:329) own operator-only keys, so guarding only the call sites that spellpolicy_storeleft the Slack channel and the ledger remote -- the two keys an agent must not beable to redirect -- answering a plain 500.
writes, so an earlier one may already have committed and a partial ceiling apply is a security
state worth recording -- but the dashboard's own
reqhelper(
website/src/apps/ops-mission-control/api.ts:1077) reads onlyerrorfrom a non-2xx body anddiscards the rest, so a field there would have had no reader. The audit line has one, and mirrors
the
settings_putline the success path already writes.One gap stated rather than papered over:
set_top_levelwrites the app config, whose read stillcollapses a failure to
{}, so a transientconfig.jsonread failure truncates that file andreturns 200 without the helper ever seeing an
OSError. The strict read closing it is in #7794; theapp_config_unwritablecode here covers only the write-side failure that store can already raisetoday. The helper docstring says so explicitly.
503rather than500throughout because the condition is transient and retrying is the correctclient behaviour.
4. What tests we did
16 new tests. Five per store module plus six on the route layer.
test_a_read_that_failed_never_truncates_*-- asserts the durable harm directly: the storedtoken, and the operator's other keystone keys, are still there after a failed-read mutation is
attempted. Asserts on disk state, not on the exception.
test_an_unreadable_store_refuses_the_save/..._the_revocation, and the policy equivalentsfor
set_ceilingandput-- the caller is told, rather than handed a silent no-op that readsas success. The revocation one exists separately because that half loses no data and still lied.
test_a_missing_*_is_still_a_first_write-- negative control. Absent is the one failure whereempty is the truth; the guard must not turn a first save into an error.
test_a_corrupt_*_still_repairs_on_write-- negative control. Pins the existing corruptiontolerance so the new guard cannot be mistaken for a licence to start failing on corruption.
TestAStoreThatRefusesToWriteIsReportedNotCrashed-- six route tests: the coded 503 on arefused save (asserting the refusal does not echo the credential), the refused revocation
(asserting
removedis absent, so no removal verdict is claimed), the refused ceiling write, therefused destination write through
slack_out.set_settings(the intermediary path the firstpass missed), a check that the refusal body carries no field the dashboard discards, and a negative
control that an ordinary settings write still returns 200.
Each unreadable-file fixture is scoped to the target path (
read_textfails only for that onefile). A blanket failure would also break home resolution and the lock sidecars, and the test
would pass for the wrong reason.
Mutation-verified, six probes. Reverting
secrets.put/deleteorpolicy_store.set_ceilingto the lenient reader reds exactly that module's loss guard plus its refusal, while all negative
controls stay green. Removing the revocation route's mapping reds its test with
500 != 503, and sodoes removing the Slack destination guard. The
secretsprobe reproduces the loss literally: thestore goes from
{"pagerduty": {"api_token": ...}}to{"datadog": {...}}-- the PagerDuty tokendeleted by a read failure.
Gates:
isortandflake8clean on all 6 changed files.mypyreports 4 errors, all intranscribe.pyandproviders/cloudwatch.py-- neither in this diff; they reproduce when thosetwo files are checked alone. App suite
937 passed, 44 skipped, 257 subtests(from 921 onmain,+16). Run with
-o addopts=""to avoid the baked-in xdist parallelism.5. Any other suggestions on the work
The same defect in
store.pyandproviders/__init__.pyships as fix(ops-mission-control): never publish the incident index or app config over a failed read #7794, deliberately splitrather than bundled. Those two are the mechanical half -- an incident index and a config
file, no authorization decision -- and
store.pyalone carries six locked read-modify-writes,which is where the review surface of this change lives. Splitting on the risk boundary keeps a
nit on the ops board's index from holding up the credential fix. That PR carries a caller-policy
change too: making the index read strict meant
dispatch.run_cycle's two maintenance passesraised out of the heartbeat, which its own ordering doctrine forbids.
Two further sites of this pattern remain, tracked in Three remaining lenient-read-feeding-whole-file-rewrite sites, and a ratchet to close the class #7789, one with durable harm
(
aws_control/backend/backup.py:78--read_statefeeds_locked_state_update's whole-filerewrite, so a transient read failure drops the per-account
nightlyauthorization bit and everyrun record). Six sites in two apps have now been fixed by hand in two days, so that issue also
proposes an AST ratchet keyed on the write rather than the read -- most lenient reads in
src/are legitimate lookup reads, so the write is the discriminator.
ops-mission-control: store-write failures answer a bare untyped 500, not the app's coded error #7790 remains open for the handlers this PR does not touch. The coded-error mapping added
here covers the secret and settings routes.
_handle_put_provider_config, the hygiene handler'sprune_closed, and the fall-through in_handle_transition/_handle_decide_proposalstillanswer with a bare untyped 500; that is pre-existing and reachable on
maintoday, since everyone of those paths writes through
atomic_write, which raises.A non-UTF-8 file still escapes both lookup reads.
read_text(encoding="utf-8")raisesUnicodeDecodeError, aValueErrorand not anOSError, so neitherexceptcatches it --meaning
get_secreton a mojibake'd store raises rather than answering "not configured".Unchanged by this PR (it propagates before and after, and propagating is the safe direction for a
mutation), and fix(aws-control): never publish the share or library ledger over a failed read #7620 left the same edge.
config/loaderis the module that namesUnicodeDecodeErrorexplicitly; these two reads arguably should too.Sibling fix: #7620 (
8064a9bb5), same defect class inaws_control.Pattern harvest
Rule candidate: semgrep, plus a ratchet test for the half a pattern rule cannot see (tracked in #7789)
Pattern: a read that collapses every failure to an empty document, used as the base of a whole-file rewrite
Not a one-off, and the count is the evidence: six sites across two apps in two days -- #7620's
library.pyandshares.py, this PR's two, #7794's two -- plus two still open in #7789, one ofthem with durable harm. Three rounds of manual discovery for one mechanical shape says the class
needs retiring, not the instance.
Semgrep can match the lenient
except (..., OSError, json.JSONDecodeError): return {}cheaply, butthat half alone is not the defect: ten sites in
src/match it and most are legitimate display orlookup reads, so a rule keyed on the read would be almost entirely false positives. The
discriminator is the coupling -- the same function's result reaching a writer that replaces the
whole document -- which is a dataflow question rather than a syntactic one. That is why #7789
proposes keying the ratchet on the WRITE: for each module, find the lenient reader, then check
whether its return value flows into an
atomic_writein the same file.