fix(ops-mission-control): coded 503 on refused ledger writes (#7790) - #8433
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS The truncation fault this PR names survives on one path: Watch
[DESIGN-REVIEWED] 05571af |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/apps/builtins/ops_mission_control/tests/test_routes.py:3457 -- New function-local imports here and in test_store_and_gate.py:1434 violate False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsAll three mutation route calls (upsert:2341, hygiene:2425, remove:2529) are exactly the ones the diff wraps in No findings. [OPUS-REVIEWED] 05571af Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims in the description verify against the repo: the First-Principles-Verdict: CONCERNS The fix is cause-level and every item is declared — but What this change shipsIntent: stop refused ledger reads/writes from escaping as bare 500s or silently truncating the shared ledger — a FIX (issue #7790).
Watch
[FIRST-PRINCIPLES-REVIEWED] 05571af |
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
ebb93d4 to
05571af
Compare
NicholasRBowers
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (5 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix with a clear root cause — refused ops-mission-control ledger writes (OSError from the locked read-modify-write) previously escaped as bare 500s and a lenient read could truncate the ledger; now a propagating read-for-update plus coded 503 (ledger_store_unwritable) on POST/DELETE/hygiene routes, error-path only, no auth/gate/trust-boundary change.
Summary
Closes #7790.
The issue lists seven mutating
ops-mission-controlhandlers whose store-write failures escaped as aiohttp's bare plain-text 500. Six of them (put/delete secret, settings, provider config, transition, decide_proposal) were already covered on main by #7788/#7794 with per-store coded refusals and tests. This PR closes the remaining class: the three ledger mutating routes.Write half (the issue as filed).
POST /ledger,DELETE /ledger, andPOST /ledger/hygieneall rewriteledger.jsonlthroughatomic_writeunder the ledger lock, which raisesOSErroron a refused write. Each now answers the shape_handle_rotation_armestablished:{ok: false, error, code: "ledger_store_unwritable"}, status 503 (transient, retry is correct). The hygiene route additionally skips index/prune/push when the rewrite was refused — publishing a ledger the dedupe pass never committed to is worse than deferring to the next cron run — and audits the refusal as a failure.Read half (found in pre-push review, GPT 5.6 — the worse one).
ledger.read_entriescollapses a failed open to[], and every locked read-modify-rewrite inledger.pystarted from it. So a transientEACCESat hygiene time silently truncated the whole shared ledger,DELETEanswered a coded 404 about an entry still on disk, andupsertre-created instead of merging. The five mutation paths (upsert,record_use,record_miss,remove,hygiene) now start from a newread_entries_for_update()that propagatesOSError; the lenientread_entriesremains for read-only callers (match,stats, GET routes) and delegates to it. Bothrecord_*callers indispatch.pyalready tolerateOSError(record_missis wrapped;record_usepropagates into the claim path's existing degrade-to-claim-without-matches arm).Slug note: the issue text suggested
incident_store_unwritablefor the hygiene route, but the store that fails there is the ledger, not the incident index — the per-store naming rule the same suggestion establishes givesledger_store_unwritable. The incident-index writes on transition/decide already answerdispatch_index_*codes via_store_read_refusalsince #7794.Changes
src/kiro_crew/apps/builtins/ops_mission_control/backend/routes.py: coded 503 arms on the three ledger routes; hygiene 503 carrieschanged: false(the SOP branches on it) and the docstring's fault-tolerance paragraph now states the deliberate exception.src/kiro_crew/apps/builtins/ops_mission_control/backend/ledger.py:read_entries_for_update()(strict) extracted fromread_entries()(lenient, now delegating); five mutation call sites switched.Testing
changed: false).No UI changes, no screenshots required.
Pattern harvest
Rule candidate: review checklist / semgrep
Pattern: a lenient reader that collapses a failed read to an empty default (
[]/{}) feeding a locked read-modify-REWRITE — the rewrite publishes the collapsed default and silently truncates the store. Third recurrence in this app alone (app-configset_top_level, the incident index, now the ledger). Detectable shape:except OSError: return [](or.get(..., {})on a read) whose callers pass the result to a whole-file write (_write_all,atomic_write). Mutation paths must use a strict read that refuses; lenient defaults are for read-only callers.