fix(cli): a --json payload no longer shares stdout with the log (BACKLOG #1489) - #1005
Merged
Conversation
…LOG #1489) `messagefoundry <cmd> --json` writes a machine-parsed document to stdout, and the engine's default log sink writes human-readable lines to the same descriptor. One log line ahead of the payload makes `json.loads` raise `Extra data: line 1 column 5`: the text format opens with the ISO timestamp, so `2026` parses as a number and the payload becomes trailing garbage. Measured 2026-09-09 across every ci.yml run since 2026-09-08T20:00Z: five occurrences on five different branches including a push to `main` itself, and three of 22 merge-queue batches (14 percent), each evicting a healthy pull request into a full re-merge cycle. PRs 885, 981 and 1003 were evicted this way. `main()` now calls `configure_stderr_logging()` before it dispatches, whenever the parsed arguments carry `--json`. That subcommand's log sink is stderr; stdout carries the payload alone. Three things this deliberately does not do. `logging_guard.py` is untouched. Its rollover notice goes to the ROLLED SINK on purpose, and the module says so: the notice landing is the proof that the replacement stream accepted a write, which is precisely what separates stage 1 (healed) from stage 2 (unwritable). Sending the notice to stderr would leave stage 1 unable to tell a healed sink from a dead one, so the fail-closed halt would lose its trigger. The item's first prescription said to do that and was retracted before it merged; this change does not revive it. `tests/test_checks.py` keeps asserting `json.loads(capsys.readouterr().out)`. That assertion was optimistic while a log sink pointed at stdout, and is sound now. Relaxing it was the other option and was rejected. `serve` and `supervise` are unchanged. They take no `--json` and print no payload, so they still log to the stdout NSSM captures, and `docs/SERVICE.md`'s log-ownership table and `service.out.log` runbook steps stay true. `configure_stderr_logging` is reused rather than rebuilt: it already existed for the ADR 0087 sandbox worker, whose stdout carries MFW2 IPC frames. Its docstring now names both callers. It also carries the PHI-redaction and control-char-scrub filter chain, which the `logging.lastResort` fallback a handler-less subcommand degrades to today does not. Regression test: `tests/test_checks.py::test_check_json_payload_survives_a_log_record` installs the guarded stdout sink `serve` installs, points its stream at a closed object (what a capture teardown or a supervisor file-swap leaves behind), forces one record during `check --json`, and asserts stdout parses and the record reached stderr. Reverted to main's `__main__.py` it fails with exactly the measured `json.decoder.JSONDecodeError: Extra data: line 1 column 5 (char 4)`, on stdout beginning `2026-09-09T...Z WARNING messagefoundry.logging_guard: application log sink 'stdout' was rolled after a write`. Forcing the record is what makes it deterministic; the roll is timing-dependent otherwise, which is why five real failures read as flakes. Checks run: ruff format and ruff check on the three changed .py files (clean); mypy strict over `messagefoundry` (273 files, no issues); `scripts/docs/backlog_status_check.py` (720 items, one status each, 6 advisory warnings, none from this change); pytest over `tests/test_checks.py`, the 25 other test files that pass `--json` to `main`, `tests/test_logging.py` and `tests/test_backlog_status_check.py` (886 passed, 1 skipped), and separately `tests/test_asvs_phase0.py` and `tests/test_audit_offbox_tee.py` (156 passed, 1 skipped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… #1489) The previous commit wrote "measured 2026-09-09 across every ci.yml run since 2026-09-08T20:00Z" into the ledger, a code comment and a test docstring. This seat did not run that census; it came from the seat that dispatched the work. Recording someone else's measurement as your own is how an unverifiable number becomes a fact three readers later. The ledger item now carries the census once, marked ATTRIBUTED, alongside the one thing checked here: PRs 885, 981 and 1003 exist and are MERGED, which is consistent with an eviction followed by a re-merge but does not on its own establish the cause. The code comment and the test docstring stop restating it and point at the item instead, which is also what SDS-3.5 asks for. Nothing about the fix or the regression test changes. The mechanism never rested on the census: it is reproduced deterministically by `tests/test_checks.py::test_check_json_payload_survives_a_log_record`, which fails without the change with the exact measured JSONDecodeError. Checks re-run: ruff format and ruff check on both changed .py files (clean); mypy strict over `messagefoundry` (273 files, no issues); `scripts/docs/backlog_status_check.py` (720 items, one status each, same 6 advisory warnings); `pytest tests/test_checks.py` (39 passed, 1 skipped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
messagefoundry <cmd> --jsonwrites a machine-parsed document to stdout, and the engine's default log sink writes human-readable lines to the same descriptor. One log line ahead of the payload makesjson.loadsraiseExtra data: line 1 column 5: the text format opens with the ISO timestamp, so2026parses as a number and the payload becomes trailing garbage.Cost, measured 2026-09-09 across every
ci.ymlrun since 2026-09-08T20:00Z: five occurrences on five different branches including a push tomainitself, and three of 22 merge-queue batches (14 percent), each evicting a healthy pull request into a full re-merge cycle. PRs 885, 981 and 1003 were evicted this way. That census is ATTRIBUTED to the seat that dispatched this work, not re-derived here -- what this seat checked is that those three pull requests exist and are now MERGED, which is consistent with an eviction followed by a re-merge but does not on its own establish the cause. The mechanism does not rest on the census: the regression test below reproduces it deterministically.The change
main()callsconfigure_stderr_logging()before it dispatches, whenever the parsed arguments carry--json. That subcommand's log sink is stderr; stdout carries the payload alone.Three things it deliberately does not do
logging_guard.pyis untouched. Its rollover notice goes to the rolled sink on purpose, and the module says so: the notice landing is the proof that the replacement stream accepted a write, which is precisely what separates stage 1 (healed) from stage 2 (unwritable). Sending the notice to stderr would leave stage 1 unable to tell a healed sink from a dead one, so the fail-closed halt would lose its trigger. BACKLOG #1489's first prescription said to do that and was retracted before it merged; this change does not revive it.tests/test_checks.pykeeps assertingjson.loads(capsys.readouterr().out). That assertion was optimistic while a log sink pointed at stdout, and is sound now. Relaxing it was the other option and was rejected.serveandsuperviseare unchanged. They take no--jsonand print no payload, so they still log to the stdout NSSM captures.docs/SERVICE.md's log-ownership table and theservice.out.logrunbook steps stay true, and no doc, tray hint or retention comment moves.configure_stderr_loggingis reused rather than rebuilt: it already existed for the ADR 0087 sandbox worker, whose stdout carries MFW2 IPC frames. Its docstring now names both callers. It also carries the PHI-redaction and control-char-scrub filter chain, which thelogging.lastResortfallback a handler-less subcommand degrades to today does not.Regression test, and proof it fails without the change
tests/test_checks.py::test_check_json_payload_survives_a_log_recordinstalls the guarded stdout sinkserveinstalls, points its stream at a closed object (what a capture teardown or a supervisor file-swap leaves behind), forces one record duringcheck --json, then asserts stdout parses and the record reached stderr.Reverted to
main's__main__.pyit fails with exactly the measured error:Forcing the record is what makes it deterministic. The roll is timing-dependent otherwise, which is why five real failures read as flakes.
Checks run
ruff format+ruff checkon the three changed.pyfilesmypy messagefoundry(strict)scripts/docs/backlog_status_check.pytest_checks.py+ the 25 other test files that pass--jsontomain+test_logging.py+test_backlog_status_check.pytest_asvs_phase0.py+test_audit_offbox_tee.pyNot run to completion here: the full
pytestsuite was started but had not finished inside this session. The legs that only ever run on a hosted runner,windows-service-smokeamong them, must be read from CI after merge-queue entry.Verified but worth a second reader
getattr(args, "json", False)covers every subcommand that declares a--jsonflag. A subcommand that prints JSON without declaring the flag would not be covered; in a real CLI process such a command has no root handler at all and degrades tologging.lastResort, which is already stderr, so nothing writes to its stdout either. The collision only ever appears where a stdout log sink has been installed.BACKLOG #1489
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
🤖 Generated with Claude Code