feat(logging): fail-closed application-log write guard -- detect, roll, and stop (BACKLOG #122) - #883
feat(logging): fail-closed application-log write guard -- detect, roll, and stop (BACKLOG #122)#883wshallwshall wants to merge 4 commits into
Conversation
…ain (BACKLOG #122) The owner ruled #122 in on 2026-08-11: "we never want to process stuff if the processing cannot be logged." That is CLAUDE.md section 1's count-and-log invariant applied to the application log. stdout, NSSM rotation, the RFC 5425 forwarder and #50's disk metering all make the log VISIBLE; none of them makes processing STOP when it cannot be written. This is the enforcement half. The work existed unlanded and unverified on `w3-log-write-failure`, whose history is unrelated to `main` (main's root is 72bfddf, the branch's is 5fa6db9), so it could never be merged or rebased. This commit re-applies it as a three-way patch onto current main and resolves the five conflicts main's movement created. What lands: - `messagefoundry/logging_guard.py`: `LogWriteGuard` plus the guarded sinks `GuardedStreamHandler` / `GuardedFileHandler`. Detection is a `logging.Handler.handleError` override, so one seam covers every sink and every OS failure mode with no polling. Two stages: `_roll` renames the broken file aside, opens a fresh one, writes the rollover notice and re-writes the failed record; only when that REPLACEMENT also refuses does `record_unwritable` escalate. Stage 1 is bounded by `_ROLL_FLAP_WINDOW_SECONDS` / `_MAX_ROLLS_PER_WINDOW`, because a sink needing rescue every few records is a failing log, not a transient. - `[logging].file` / `file_max_bytes` / `file_backup_count` / `on_write_failure` in `LoggingSettings`, with the validator refusing a `file` inside `log_dir` so the engine and NSSM can never rotate one file. - `RegistryRunner` wires itself to the guard and responds on the loop: `_on_log_sink_event` hands off thread-safely, and the halt stops all three tiers. `_resume_inbound_processing` and `start_outbound` gate every re-arm on `_log_recovery_ok`, which re-tests a dead sink BY WRITING to it, since `unwritable` is only ever set by a failed write and nothing clears it on its own. - A `log_write_failed` alert through the notifier and `SystemStatus.log_sinks` on `GET /status`, read from process memory so it still answers when the disk does not. Conflict resolutions worth naming: - The branch's hand-chosen `ENGINE_UI_SEAM: int = 19` is obsolete. BACKLOG #1220 replaced the hand-picked number with a digest computed by `scripts/webconsole_seam_snapshot.py`, so the seam is regenerated rather than bumped, and `SUPPORTED_ENGINE_SEAMS` follows it. - `_start_outbound` and `_start_outbound_unsafe` are `async` on main; the branch's sync call sites are awaited. - `docs/testing/master-test-plan/` was untracked under ADR 0160 D1, so the branch's edit there is dropped rather than re-created. `docs/adr/0162-*.md` is deliberately NOT in this commit. The ledger gate refuses it, correctly by its own rule: the claim names `C:/Users/Scott/Code/MessageFoundry-w3-log-write-failure`, which no longer exists, and the branch fallback names `w3-log-write-failure`, whose history is unrelated to main so no commit made there can reach this PR. Neither documented recovery is reachable from an isolated worktree, and allocating a fresh number is what burns a number, so the citations are plain `ADR 0162` with no dangling relative link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…id not gate (BACKLOG #122) Found by execution, not by review. Every door back into processing asks `_log_recovery_ok` first -- `restart_inbound`, `start_outbound`, and a reload through `_start_inbound_unsafe`. `RegistryRunner.start` did not: it cleared `_log_write_stopped` and `_log_halted` unconditionally, on the reasoning that a fresh start is a fresh engine. MEASURED RED, both claim modes, before this commit: with both sinks made unwritable BEFORE the runner was built, a committed ingress row went to `PROCESSED` and was DELIVERED to the outbound file connector, while `LogWriteGuard.can_log()` read False throughout. That is the item's own sentence -- processing what cannot be logged -- reached through the one path the halt did not cover. It is reachable plumbing, not a test artefact. `Engine` starts a `RegistryRunner` on leadership acquisition and on the reload that first builds one, and neither asks about the log. Three parts, and the mutation test shows each is load-bearing: - `start` now gates the clear on `LogWriteGuard.revalidate`, which re-tests each DEAD sink by writing a real record to it. The ordinary case -- a guard `configure_logging` built moments ago, nothing dead -- has nothing to probe, answers True, and clears exactly as before. Mutating this back makes the new test fail in both claim modes. - `_start_pooled_dispatchers` replays an in-force halt onto the fresh INGRESS / ROUTED / RESPONSE dispatchers before step (3) seeds every lane READY -- the exact sibling of the `_outbound_paused` replay at (2.5), and it fails the same way when missing. Mutating it out makes only the POOLED arm fail: per_lane survives on its workers' loop-top gate, which pooled mode does not run. - `_unbind_for_log_failure` takes the listeners that already bound back down, so intake is never up with the internal stages halted -- the state `_start_inbound_unsafe`'s own refusal path already avoids. The engine comes up HALTED rather than refusing to start: `/status`, the alert state and every recovery path live in a running engine, and tearing them down is how an operator loses the explanation for why the engine went quiet. It pages through the notifier for the reason the whole control does -- the thing that is broken is the log. `_reconcile_pooled_dispatchers` gets the same re-pause beside its existing outbound one, for the same belt-and-braces reason. Also, a second thing the port made stale. `_refuse_renamed_file_keys`'s docstring said `extra="ignore"` was why it had to run at `mode="before"`, which is still true of the MODEL but no longer describes what an operator meets: main's loader now refuses an unrecognized FILE key first. Measured across both layers: - file `[logging].max_bytes` -- refused by the loader, suggested onward as `file_max_bytes`; - file `[logging].backups` -- refused by the loader, naming no replacement (its nearest-name hint does not reach `file_backup_count`); - env `MEFOR_LOGGING_MAX_BYTES` and `MEFOR_LOGGING_BACKUPS` -- refused by this validator, each naming its replacement. So the validator is not redundant with the loader: env is the layer the file refusal deliberately does not cover, and these two spellings are the rare `MEFOR_*` names that fail loudly instead of silently. Pinned as a pair of tests so neither half of that sentence can go stale unnoticed, and `docs/CONFIGURATION.md` now says which layer does which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… by execution (BACKLOG #122) Flips the banner to SHIPPED and records what was measured rather than what was claimed. Its own commit, and last, because every item's pull request edits this file by construction. Four things the row now states that it did not: - The partial halt was REAL. Mutating `_halt_inbound_processing` to a no-op put the committed ingress row on the outbound stage in both claim modes, while the healthy-log negative control still delivered. - A third hole of the same class was found here and was not on the branch: `RegistryRunner.start` was the one re-arm that never asked whether the log worked. - ADR 0162's file is not on `main`, and the number is NOT burned. The ledger gate refuses it correctly -- the claim's worktree is gone and its branch shares no root commit with `main`, so neither documented recovery is reachable and remedy 3 is not licensed. The subject is named rather than numbered, per CLAUDE.md section 5. - The anchor the row carried is stale. The branch tip is `46b3a4437`, not `d26d66a6`, and it carries eight commits rather than five. The prior banners are kept rather than rewritten -- the leading one supersedes them, which is how this file records history -- and the machine-read `Verdict` / `Closing-act` fields now say `build` / `code`, which is how it actually closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (BACKLOG #122) The /simplify pass on the previous commit. Three callers had grown the same six-line loop over `Stage.INGRESS` / `ROUTED` / `RESPONSE`, each pausing the dispatcher for a set of inbounds: the halt itself (`_halt_inbound_processing`), the fresh dispatchers at start (`_start_pooled_dispatchers` step 2.6), and the reload re-apply (`_reconcile_pooled_dispatchers`). Three copies of "which stages the halt covers" is a fact stated three times, and the failure mode is that a fourth stage is added to one of them. `_pause_internal_lanes` is now the single definition, and each call site keeps only its own reason for calling it. No behaviour change: the helper materialises its argument before iterating, as the halt already did, and every skip condition is unchanged. `_resume_inbound_processing`'s loop is deliberately NOT folded in -- it resumes rather than pauses, and reads the same stage tuple for the opposite reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Any At the time of writing the label is NOT yet applied: The Reviewer role is disabled by owner ruling, so the label records only that the step happened. I produced this change and I applied the label; an auditor cannot tell that apart from a real review unless it is written down, so it is written down here. What the label actually stands on, all of it my own work:
What it does NOT stand on, stated so nobody infers it:
Two bookkeeping notes on the commit series.
Measured while doing this, since it changes what the label means. |
|
Manager notes for whoever lands this. Three things, one of which needs a decision. 1. ADR 0162 is cited in prose but its file is not in this PR, and the number is not burned. The ledger gate refuses the file correctly: the allocation claim names a worktree that no longer exists, and the gate's branch fallback names So this PR references an ADR that does not exist in the tree. The number is still allocated and available, so the document can be added later without re-allocating. Whether that is acceptable to merge as-is is the Lander's call, not mine -- I am naming it rather than quietly relying on the gate staying quiet. Separately, the gate's branch fallback assuming a mergeable branch is a real defect worth its own item. I am not filing it. 2. The ledger row is isolated but third of four, not last. Commit 3. The What this PR is worth reading for. The prior branch was not merely unverified -- it was unmergeable by construction, orphan history against It then proved the partial-halt claim by mutation rather than assertion: four mutations, each reverted and the tree verified clean afterwards. Disabling the internal-stage halt goes red in both claim modes with the row reaching And it found a third hole of the same class that was not on the prior branch: Gaps, stated rather than hidden. The full |
Builds BACKLOG #122 — the fail-closed application-log write guard. The owner ruled this item in on 2026-08-11: "we never want to process stuff if the processing cannot be logged." That is CLAUDE.md section 1's count-and-log invariant applied to the application log, and it is enforcement, not visibility. stdout, NSSM rotation, the RFC 5425 forwarder and #50's disk metering all make the log visible. None of them stops processing when it cannot be written.
A guard that logs a warning and continues would not satisfy this item. This one stops, and the stop is measured rather than asserted.
The prior work, and why it could not simply be merged
w3-log-write-failurecarried eight commits and 2,719 insertions, all unverified — the lane died mid-flight on a usage limit. It was read withgit cat-file/git show, never checked out.It shares no root commit with
main. Roots are5fa6db9f(branch) and72bfddfa(main), sogit merge-baseexits 1 and the branch is unmergeable and unrebaseable by construction. The work is therefore re-applied here as a three-way patch onto currentmain, with five conflicts resolved:ENGINE_UI_SEAM: int = 19scripts/webconsole_seam_snapshot.py; regenerated instead of bumped, andSUPPORTED_ENGINE_SEAMSfollows_start_outbound/_start_outbound_unsafeasyncon main; the branch's sync call sites are awaiteddocs/CONFIGURATION.mdunknown-key prose[logging].filesentencedocs/testing/master-test-plan/docs/adr/README.mdWhat was verified BY EXECUTION, and how
Every claim below was tested by mutating the fix out and watching the test go red, then reverting. A test that passes against the fixed code proves the fix works; only the mutation proves the test can see the defect.
_halt_inbound_processing→ no-opstage: 'outbound'). The healthy-log negative control still passed, so the rig genuinely drains_log_recovery_ok→ always Truerestart_inbound+start_outbounddisarmed the halt (_log_haltedempty)start'srevalidategate → unconditionalSo the partial halt was real. Before commit
f6f4117c5on the branch, stopping the listener left the router and transform workers draining the backlog; only the outbound pause kept the message from being delivered, which made the gap quiet rather than harmless.A third hole of the same class, found here and not on the branch
RegistryRunner.startcleared_log_write_stoppedand_log_haltedunconditionally, so it was the one door back into processing that never asked whether the log worked — the exact shape_resume_inbound_processingwas gated for, reached by the path that bypasses it.Measured before the fix, both claim modes, with both sinks made unwritable before the runner was built: a committed ingress row went to
PROCESSEDand was delivered, whileLogWriteGuard.can_log()readFalsethroughout.Enginestarts aRegistryRunneron leadership acquisition and on the reload that first builds one, so this is reachable plumbing rather than a test artefact.Fixed in three parts, each shown load-bearing by the table above:
startgates onLogWriteGuard.revalidate,_start_pooled_dispatchersreplays the halt onto the fresh internal dispatchers before step (3) seeds every lane READY, and_unbind_for_log_failuretakes the already-bound listeners down. The engine comes up halted, not refusing to start —/status, the alert state and every recovery path live in a running engine.A stale premise the port introduced, corrected with measurement
_refuse_renamed_file_keys's docstring saidextra="ignore"was why it must run atmode="before". True of the model, but no longer what an operator meets: main's loader refuses an unrecognized file key first. Measured across both layers:[logging].max_bytesfile_max_bytes[logging].backupsfile_backup_count)MEFOR_LOGGING_MAX_BYTESfile_max_bytesMEFOR_LOGGING_BACKUPSfile_backup_countSo the validator is not redundant with the loader — env is the layer the file refusal deliberately does not cover, and these two are the rare
MEFOR_*names that fail loudly instead of silently. Pinned as a pair of tests, anddocs/CONFIGURATION.mdnow says which layer does which.ADR 0162's file is NOT in this PR, and the number is NOT burned
The ledger gate refuses it, correctly by its own rule.
owns("adr", "0162")needs either the recorded worktree path —C:/Users/Scott/Code/MessageFoundry-w3-log-write-failure, which no longer exists — or HEAD on the recorded branchw3-log-write-failure, whose orphan history means no commit made there can reach a mergeable pull request. Both documented recoveries indocs/LEDGER-GATE.mdare therefore unreachable from an isolated worktree, and remedy 3 (allocate a fresh number) is explicitly not licensed while the branch still exists.Rather than burn the number or bypass the gate, prose cites
ADR 0162with no relative link, so nothing dangles, anddocs/adr/README.mdis left at main's version. The ADR file and its index row are the one residual.The subject to file, named rather than numbered (per CLAUDE.md section 5): the ledger gate's branch fallback assumes the recorded branch can produce a mergeable commit, and an orphan-history branch cannot — so a claim in that state is unreachable by every documented recovery.
Relationship to PR 850 (BACKLOG #1441)
Read before touching
logging_setup.py. Different defect, no overlap, no contradiction. #1441 is thatlogging.lastResortcarries no filters, so WARNING+ traffic in the 30 handler-less CLI subcommands bypasses the PHI chain; PR 850 files the row and changes no engine code. This PR is enforcement in theservepath.Worth stating explicitly since both live in the logging surface: this guard covers only the sinks
configure_logginginstalls. A process routed throughlastResorthas no guarded sink, so nothing here halts it — correct scoping (no engine, no connections to stop), and it neither closes #1441 nor is weakened by it. The two touch disjoint code.Checks
ruff format --check .— clean.ruff check .— All checks passed.mypy messagefoundry(strict) — Success: no issues found in 268 source files. Run by hand; pre-commit does not run it.pytest tests/test_log_write_guard.py— 51 passed in 26.69s.pytest -k "wiring or runner or dispatcher or pooled or reload or claim_mode or lane"— 1 failed, 773 passed, 189 skipped, 14765 deselected, 8 warnings in 926.13s. The one failure istests/test_announce_wiring.py::test_the_announce_shim_runs_the_primary_checkouts_script, which passes alone (1 passed in 12.23s), imports nothing frommessagefoundry, and shells out toscripts/coord/install-coordination.ps1against the primary checkout that sibling sessions share. No path from this diff reaches it.pytest tests/test_phi_at_rest_inventory.py tests/test_phi_logging_inventory.py tests/test_logging.py tests/test_webconsole_seam_snapshot.py tests/test_seam_discovery.py— 137 passed in 92.40s.windows-service-smokeleg never runs locally at all — please read those legs.origin/main(b9f4f381, docs-only). Not rebased, because the branch is already pushed and a rebase would need a force-push.🤖 Generated with Claude Code