Skip to content

purge --check-policy ends in a traceback, with no JSON envelope, when --config or the audit log is not valid UTF-8 #1070

Open

Description

@cemililik

Summary

forgelm purge --check-policy is documented to exit EXIT_CONFIG_ERROR (1) with a {"success": false, "error": ...} envelope when its --config cannot be loaded. A config file, or an audit_log.jsonl, that is not valid UTF-8 raises an uncaught UnicodeDecodeError instead: Python prints a traceback, stdout stays empty, and the exit status is 1 only because that is the interpreter's default for an uncaught exception.

Evidence

Reproduced on the head of #1068; that pull request does not touch either path.

  • forgelm purge --check-policy --config bad.yaml --output-dir out --output-format json, where bad.yaml is valid YAML followed by a 0xff byte: UnicodeDecodeError, nothing on stdout, exit 1. _load_retention_and_config (forgelm/cli/_config_load.py) runs yaml.safe_load on a text-mode file and lets the error through, and _run_purge_check_policy (forgelm/cli/subcommands/_purge.py) catches only (ConfigError, OSError, yaml.YAMLError).
  • The same command with a valid retention-only config and an out/audit_log.jsonl that has a non-UTF-8 line: _build_audit_age_lookup catches OSError only, so reading the log raises UnicodeDecodeError in the middle of the report. Same result: traceback, empty stdout, exit 1.
  • For contrast, a --config path that does not exist gives the documented envelope: {"success": false, "error": "--check-policy could not load --config: Configuration file not found: ..."}, exit 1.

Why it matters

--check-policy is meant for CI and scheduled jobs: the purge reference documents gating on jq -e '.success == true and has("count") and .count == 0' under set -o pipefail. A consumer that parses stdout gets an empty stream instead of success: false, and the failure reads as a crash in ForgeLM rather than as a bad input. The command is read-only, so no data is at risk.

The row-erasure path in the same file, which runs after the row is already gone, was hardened against the same error in #1068 (_scan_run_ids_from_granted_events, _maybe_load_config); that is how this gap was noticed.

Acceptance criteria

  • A --config that is not valid UTF-8 makes --check-policy exit EXIT_CONFIG_ERROR with the success: false envelope under --output-format json, and an operator-actionable error in text mode, like any other unloadable config.
  • A non-UTF-8 audit_log.jsonl does not abort the report. Triage decision: do not fail and do not drop the whole log. Read it with iter_audit_events(strict=False) (forgelm/cli/subcommands/_audit_log_reader.py), which decodes line by line, skips undecodable lines and logs one WARNING with the skip count. Intact lines still anchor the per-run and genesis ages; age from mtime (age_source=mtime) only when no usable event is left, as the report already does when the log is absent. State in the docs that when the skipped line carried the earliest timestamp, ages anchor on the next intact event.
  • Tests cover both inputs through _run_purge_cmd(..., output_format="json") and assert the exit code and the envelope.
  • docs/reference/purge_subcommand.md and its TR mirror still describe the behaviour; update both if the chosen behaviour differs from what the exit-code table says.

Context

Found while fixing #102 in #1068. Related: #1054 (retention-only config for --check-policy).

Tracked finding IDs this overlaps (name them in the pull request so they can be ticked): the audit-log half is the unfixed remainder of CR-A10-040 (#555, _build_audit_age_lookup); the config half shares its root cause with CR-A01-094 (#661) and CR-A01-137 (#664), which describe load_config / _load_config_or_exit, not this loader.

Activity

  1. added
    needs-triageNot triaged yet: every new issue starts with it; only a maintainer removes it
    bugSomething isn't working
    on Oct 23, 2026
  2. added
    severity: lowMinor defect, inaccuracy or rough edge
    wave: 4Roadmap wave 4: low / info / watch, when touching the module
    on Oct 23, 2026
  3. cemililik commented on Oct 23, 2026

    @cemililik
    CollaboratorAuthor

    Triaged on development e6bf28d, after #1068 merged during this triage. Each finding was first checked on 83e62f6 and on #1068's head (ab5ce5f), whose code is identical to the merge.

    Accepted: bug, severity low, wave 4, under the W4 module-sweep epic #875.

    Reproduced with a 0xff byte, on 83e62f6, on #1068's head and again on the merged development:

    input stdout exit
    --config that is valid YAML followed by 0xff empty, UnicodeDecodeError traceback 1
    valid retention-only config + audit_log.jsonl with one non-UTF-8 line empty, traceback 1
    control: --config path that does not exist {"success": false, "error": ...} 1

    So the defect is on development and is not introduced by #1068 (it changed the loader's return shape, not its decoding).

    Why low: the command is read-only, exit 1 happens to be the documented config-error code, and the CI recipe fails closed (printf '' | jq -e '.success == true' exits 4), so there is no false pass. What is missing is the envelope and an actionable message.

    Overlap with tracked findings (so the ticks are not lost)

    Decision on acceptance criterion 2: neither fail nor fall back to mtime for the whole log. Read the log with iter_audit_events(strict=False), which decodes per line, skips undecodable lines and logs one WARNING with the skip count. The lookup only needs the first timestamp per run and the genesis, so intact lines still anchor the ages; mtime stays the fallback when no usable event is left. Documented limitation: if the skipped line carried the earliest timestamp, ages anchor on the next intact event. The body is updated.

    Implementation notes

    Not open for contribution yet: this needs a maintainer decision first. If you would like to work on it, please comment with your proposed approach before opening a pull request.

  4. removed
    needs-triageNot triaged yet: every new issue starts with it; only a maintainer removes it
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: operator-cmdsCode area: operator-cmdsbugSomething isn't workingseverity: lowMinor defect, inaccuracy or rough edgewave: 4Roadmap wave 4: low / info / watch, when touching the module

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions