Skip to content

fix(ops-mission-control): never publish the token or policy store over a failed read - #7788

Merged
bolichen97 merged 1 commit into
mainfrom
fix/ops-secrets-lenient-read
Sep 2, 2026
Merged

fix(ops-mission-control): never publish the token or policy store over a failed read#7788
bolichen97 merged 1 commit into
mainfrom
fix/ops-secrets-lenient-read

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

1. What is the problem?

Two reads in ops_mission_control collapse every read failure to an empty document, and
both are the base of a whole-file rewrite:

Site Lenient read Mutations standing on it
backend/secrets.py KeystoneFileBackend._read put, delete
backend/policy_store.py _read set_ceiling, put

Each except clause names OSError, so a transient EACCES/EIO -- a scanner holding the
handle 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. get answering "not configured"
keeps the Settings page rendering and lets the fail-closed has_secrets check refuse the
provider; an unreadable ceiling resolving to observe with no act-rules is the most restrictive
answer 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. put rewrites 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. delete destroys nothing: on an unreadable store the lenient read reported
the provider absent, so it returned False and the audit recorded
secret_delete outcome=not_found. The operator's belief and the disk disagree, and the audit
log 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_store the argument is a
security 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 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 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 serialize
writers 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. Losing
the file drops mode and autonomy_rules back to a re-derived default and the destination and
identity keys to absent -- and absent is how the off-shift refusal and the not_primary gate get
their 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 except
clause covers both
.

The fix cuts the chain at the last link. Each module gains a private _read_for_update used
only as the base of a mutation, in which:

  • a missing file still reads as empty -- nothing has been written yet, so empty is the truth,
    and the operator's first save must not become an error;
  • a genuine read error propagates, so the mutation is abandoned rather than published over
    state nobody read;
  • corruption keeps reading as empty, unchanged. A document that parsed to nothing usable has
    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 applied
exactly this shape to aws_control's library.py and shares.py.

secrets.py had multi-line normalization after the parse, so it was extracted into a shared
_coerce rather than duplicated -- which is what makes the two readers provably identical apart
from 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 OSError land as aiohttp's default 500 (a plain-text body with no code
to branch on -- nothing in the middleware chain converts it, sel_audit_middleware logs and
re-raises). This follows _handle_rotation_arm's existing 503 cron_store_unreadable shape in
the same file, whose own comment states the rule: "escaping here becomes a 500".

  • _handle_put_secret, _handle_delete_secret -> 503 secret_store_unwritable. The revocation
    route 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 the
    three writes that reach policy_store.put through an intermediary rather than naming it:
    slack_out.set_settings (slack_out.py:134, :136) and ledger_sync.set_settings
    (ledger_sync.py:327, :329) 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, not 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
    (website/src/apps/ops-mission-control/api.ts:1077) reads only error from a non-2xx body and
    discards the rest, so a field there would have had no reader. The audit line has one, and mirrors
    the settings_put line the success path already writes.

One gap stated rather than papered over: set_top_level writes the app config, whose read still
collapses a failure to {}, so a transient config.json read failure truncates that file and
returns 200 without the helper ever seeing an OSError. The strict read closing it is in #7794; the
app_config_unwritable code here covers only the write-side failure that store can already raise
today. The helper docstring says so explicitly.

503 rather than 500 throughout because the condition is transient and retrying is the correct
client 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 stored
    token, 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 equivalents
    for set_ceiling and put -- the caller is told, rather than handed a silent no-op that reads
    as 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 where
    empty 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 corruption
    tolerance so the new guard cannot be mistaken for a licence to start failing on corruption.
  • TestAStoreThatRefusesToWriteIsReportedNotCrashed -- six route tests: the coded 503 on a
    refused save (asserting the refusal does not echo the credential), the refused revocation
    (asserting removed is absent, so no removal verdict is claimed), the refused ceiling write, the
    refused destination write through slack_out.set_settings (the intermediary path the first
    pass 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_text fails only for that one
file). 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/delete or policy_store.set_ceiling
to 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 so
does removing the Slack destination guard. The secrets probe reproduces the loss literally: the
store goes from {"pagerduty": {"api_token": ...}} to {"datadog": {...}} -- the PagerDuty token
deleted by a read failure.

Gates: isort and flake8 clean on all 6 changed files. mypy reports 4 errors, all in
transcribe.py and providers/cloudwatch.py -- neither in this diff; they reproduce when those
two files are checked alone. App suite 937 passed, 44 skipped, 257 subtests (from 921 on main,
+16). Run with -o addopts="" to avoid the baked-in xdist parallelism.

5. Any other suggestions on the work

  1. The same defect in store.py and providers/__init__.py ships as fix(ops-mission-control): never publish the incident index or app config over a failed read #7794, deliberately split
    rather than bundled. Those two are the mechanical half -- an incident index and a config
    file, no authorization decision -- and store.py alone 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 passes
    raised out of the heartbeat, which its own ordering doctrine forbids.

  2. 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_state feeds _locked_state_update's whole-file
    rewrite, so a transient read failure drops the per-account nightly authorization bit and every
    run 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.

  3. 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's
    prune_closed, and the fall-through in _handle_transition / _handle_decide_proposal still
    answer with a bare untyped 500; that is pre-existing and reachable on main today, since every
    one of those paths writes through atomic_write, which raises.

  4. A non-UTF-8 file still escapes both lookup reads. read_text(encoding="utf-8") raises
    UnicodeDecodeError, a ValueError and not an OSError, so neither except catches it --
    meaning get_secret on 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/loader is the module that names
    UnicodeDecodeError explicitly; these two reads arguably should too.

Sibling fix: #7620 (8064a9bb5), same defect class in aws_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.py and shares.py, this PR's two, #7794's two -- plus two still open in #7789, one of
them 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, but
that half alone is not the defect: ten sites in src/ match it and most are legitimate display or
lookup 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_write in the same file.

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 2, 2026 02:32
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of e0d8b82ef0cac21dbacd94873dff44ce46b978b7 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • Description is stale on one point: it says the settings 503 "carr[ies] the keys that did apply" and that a test asserts "applied is present" — the shipped code sends applied to the audit log only, and test_a_refusal_body_carries_no_field_the_dashboard_discards pins its absence from the body. The shipped behavior is the better one; update the PR text so reviewers and future archaeology don't trust the wrong contract.

[DESIGN-REVIEWED] e0d8b82

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of e0d8b82ef0cac21dbacd94873dff44ce46b978b7 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims verified. The cited idioms exist (mcp_quarantine, config/loader, aws_control/shares.py), the _handle_rotation_arm precedent exists at routes.py:2079, and the cron store already refuses. The one thing the change leaves behind: store.py's incident index has the identical lenient-read-as-rewrite-base pattern, unfixed and undeclared. Final review:

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 store.py.

What this change ships

Intent: stop a transient read failure from silently wiping the credential/policy stores or faking a revocation — a FIX.

  1. A secret save on an unreadable store no longer deletes every stored token — justified
  2. Revoking a credential on an unreadable store errors instead of claiming "already gone" — justified
  3. A settings write on an unreadable policy file no longer drops every operator-only key — justified (keystone invariant)
  4. Secret save/revoke routes answer coded 503 instead of aiohttp's plain 500 — justified (AGENTS.md code rule)
  5. Settings PUT stops at the first refused write, answering coded 503 — justified
  6. A refused settings write logs an audit failure line naming the keys already applied — justified
  7. _coerce extraction keeping the two secret readers provably identical — justified, part of the fix
  8. App-config read (set_top_level) still truncates on failed read — declared, deferred to companion PR

Watch

  • Counted unfixed sibling. Grepped except (FileNotFoundError, OSError, json.JSONDecodeError) as an RMW base across this app: store.py:161 _read_index_unlocked feeds 6 locked whole-file rewrites (store.py:274,337,446,523,741,845), so one transient EACCES plus any mutation truncates every incident, approval and proposal. Same cause, same fix shape, not mentioned anywhere in the description — unlike the providers-config sibling, which is declared and deferred. Name it deferred or fix it.
  • Description says the settings 503 is "carrying the keys that did apply"; the shipped body is {ok,error,code} (pinned by test_a_refusal_body_carries_no_field_the_dashboard_discards) with applied going to the audit log only. Update the description sentence.

[FIRST-PRINCIPLES-REVIEWED] e0d8b82

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of e0d8b82ef0cac21dbacd94873dff44ce46b978b7 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] e0d8b82

False positive or not applicable? A repository writer can comment:
/ai-review override gpt e0d8b82ef0cac21dbacd94873dff44ce46b978b7: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed e0d8b82ef0cac21dbacd94873dff44ce46b978b7 — this comment is updated in place on each push.

Review details

Both candidates fail the falsification bar.

Candidate 1 (ledger_sync mislabel as policy_store_unwritable): The branch-only write path is real — set_settings with only branch_name runs only set_top_level (config.json). But the "observable wrong outcome" collapses: the dashboard's req helper reads only error (the diff's own test_a_refusal_body_carries_no_field_the_dashboard_discards pins this), and error = str(exc), the true OSError message. The audit line logs "refused after {applied}" and never records the code. No consumer reads code and misbehaves, so (c) is not established in practice. Below 80 — drop.

Candidate 2 (blanket except OSError as retryable 503): stated in "could"/"any OSError bug"/edge-condition terms with no concrete input producing a concrete wrong outcome. Speculative robustness concern — drop.

No new Step-2 finding grounds at 80+: the _read_for_update split correctly distinguishes missing (→{}) from unreadable (→raise), the helper propagates non-OSError, and the secrets _coerce refactor is behavior-preserving.

No findings.

[OPUS-REVIEWED] e0d8b82

Verdict parsed from the review's SHA-scoped output markers for commit e0d8b82ef0cac21dbacd94873dff44ce46b978b7.

False positive or not applicable? A repository writer can comment:
/ai-review override fable e0d8b82ef0cac21dbacd94873dff44ce46b978b7: <one-sentence reason>

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 1 dispositions -- all four lanes

Pushed 01cd4dbe4. Every finding is either fixed or answered below; nothing is deferred silently.

GPT 5.6

BLOCKING, test_providers.py:2012, cleanup registered too late -- FIXED. Correct, and I had the
better pattern in front of me: _HomeIsolated in test_policy_store.py carries a comment about this
exact hazard and uses addCleanup for it. I copied the sibling class in test_providers.py instead,
which uses tearDown, and inherited the defect. Now addCleanup(shutil.rmtree, ...) on the line
after mkdtemp, with the home restore moved to addCleanup as well -- registered second so LIFO
restores the variable before the directory is removed.

FINDING, method-local imports -- PARTIALLY FIXED, and I disagree with the rest. The stdlib
imports I added were straightforwardly wrong: contextlib is now at module scope in all four test
files and mock at module scope in test_security.py, with the local copies removed.

The app-module imports (policy_store, store, models, providers) I have left local, because
module scope would make my additions inconsistent with the files they live in. test_policy_store.py
imports policy_store inside the body of every one of its existing tests and never at module scope;
test_store_and_gate.py and test_dispatch.py do the same for store/models while keeping
rotation, routes at the top. Matching the surrounding file is the stronger rule here, and hoisting
one new class's imports while its neighbours keep theirs local would read as an oversight rather than
a convention. Happy to hoist them if the top-level-imports anchor is meant to apply repo-wide -- in
which case the existing tests want the same treatment, and that is its own change.

Design Review (Fable 5) -- CONCERNS

The heartbeat regression -- REAL, and fixed. I verified this rather than taking it on trust, and
it was worse than the summary suggested: expire_stale_proposals is at dispatch.py:584, which is
before resolve_shift and before the poll, so an unreadable index cost the entire cycle including
every claim -- where previously it cost nothing at all. sweep_stale at :724 sits immediately
before the Slack mirror, the notification bus and the SEL entry. And run_cycle's own comments state
the rule being broken at three separate points: the shared-repo pull is "never fatal ... never worth
losing a claim over", the mirror is "after the claim, so a Slack outage can never cost us a claim",
and the notifications come after both "so a bus fault can cost neither".

Both calls now log and continue with an empty result. That is the pre-fix outcome minus the silence,
and it does not weaken the guard: the mutation was abandoned before any write, so nothing was
published. The loss guard lives in the store; this is purely caller policy, which is how you framed
it.

I did not extend this to the claim path, and want to flag the boundary explicitly rather than
leave you to wonder whether I missed it. _claim_one stays unguarded because a compare-and-set has
no safe degraded answer -- None already means "another instance owns this signal" -- and because it
already aborted the loop on an atomic_write failure, so its behaviour is unchanged by this PR.
Three tests pin the new policy (TestAMaintenancePassCannotCostTheCycle), including the realistic
case where one read failure fails both passes at once; removing either guard reds them.

File the section-5 follow-ups -- DONE: #7789 and #7790.

First Principles (Fable 5) -- CONCERNS

The completeness claim was overstated -- CORRECTED, and you were right to grep it. Both sites
check out. aws_control/backend/backup.py:78 is the same defect with durable harm: read_state is
lenient and _locked_state_update is read-mutate-write_state over the whole file, under the same
sidecar-lock shape #7620 already fixed on the share ledger -- so one transient read failure drops the
per-account nightly bit and every run record. ledger_index.py:77 is the low-harm case you
described: _write_cursor(cursor | newly) is a union, so an empty base drops imported ids and the
next import re-checks them, and the docstring already reasons about degrading to "re-check
everything" deliberately.

Filed as #7789, with the durable one written up as the actionable half and the ratchet proposal
attached -- keyed on the write rather than the read, since most of the ten pattern matches in
src/ are legitimate display reads. The PR description now says these are the four sites in this
app
that feed a whole-file rewrite, and carries an explicit correction of the earlier wording.

_config_path undeclared -- FIXED in the description. It is a path helper extracted for the same
reason as _coerce: two readers and the writer each spelled the same path expression inline. Now
listed in section 3 alongside the other two helpers.

Opus 4.8

No findings.


Full app suite 944 passed, 44 skipped, 257 subtests (+23 versus main). isort and flake8 clean
on all 10 changed files. mypy's 4 errors are in transcribe.py and providers/cloudwatch.py,
neither in this diff -- they reproduce when those two files are checked alone.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 2, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/ops-secrets-lenient-read branch from 01cd4db to 00b13d8 Compare September 2, 2026 02:55
@chenmingwei23 chenmingwei23 changed the title fix(ops-mission-control): never publish a store over a failed read fix(ops-mission-control): never publish the token or policy store over a failed read Sep 2, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 2, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Re-scoped: this PR is now the two authorization-deciding stores only

Force-pushed 00b13d856. The store/config half moved out to #7794, so the round-1 review above
was against a wider diff than what is here now. Mapping, so no finding gets lost in the move:

Still in this PR (secrets.py, policy_store.py, routes.py):

  • GPT's method-local-imports finding, for the part I accepted -- contextlib and mock are at
    module scope in test_security.py and test_policy_store.py. My pushback on the app-module
    imports stands for the same reason (every existing test in test_policy_store.py imports
    policy_store inside the body).
  • First Principles' _config_path note is answered in section 3, and the completeness claim is
    corrected -- the description no longer says "the four remaining instances".

Moved to #7794:

New in this revision, and the reason for the re-push beyond the split: the handler question I
had explicitly deferred is now settled in this PR rather than tracked. _handle_put_secret,
_handle_delete_secret and _handle_put_settings map a refused write to the app's coded error
(503 secret_store_unwritable / policy_store_unwritable / app_config_unwritable), following
_handle_rotation_arm's existing shape in the same file. The settings route carries the keys that
did apply in the refusal body, because phase 2 is a sequence of writes and a partial ceiling
apply is a security state.

I had argued against this on the grounds that a bare 500 was already reachable on main via
atomic_write, which is true but is an argument about novelty, not about correctness -- and on the
revocation route specifically it misses the point: the failure being replaced there was never a
500, it was {"ok": true, "removed": false}. Four route tests cover it, including that the refusal
does not echo the credential and does not claim a removal verdict.

Also reframed in the description, because the two findings are not one finding. delete now has
its own place in the problem statement rather than reading as a corollary of put: it destroys
nothing, returns False, and audits secret_delete outcome=not_found -- a failed revocation
reported as completed, where the operator's belief and the disk disagree and the audit log sides
with the operator. And policy_store is stated as a security argument rather than a data-loss one:
every operator-only key in that one file falls back to a value the constrained party can influence,
so this was a fail-open on the file that defines who is constrained.

App suite 935 passed, 44 skipped, 257 subtests (+14 versus main). isort/flake8 clean on all
6 files. Five mutation probes: reverting either reader reds that module's loss guard and its
refusal; removing the revocation route's mapping reds its test with 500 != 503.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 2, 2026
…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>
@chenmingwei23
chenmingwei23 force-pushed the fix/ops-secrets-lenient-read branch from 00b13d8 to e0d8b82 Compare September 2, 2026 03:14
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 2, 2026
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
chenmingwei23 added a commit that referenced this pull request Sep 2, 2026
…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>
iamwhatever pushed a commit that referenced this pull request Sep 2, 2026
…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>
iamwhatever pushed a commit that referenced this pull request Sep 3, 2026
## 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
iamwhatever added a commit that referenced this pull request Sep 3, 2026
#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>
CrysisDeu added a commit that referenced this pull request Sep 4, 2026
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
CrysisDeu added a commit that referenced this pull request Sep 4, 2026
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
CrysisDeu added a commit that referenced this pull request Sep 4, 2026
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
NicholasRBowers pushed a commit that referenced this pull request Sep 4, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants