Skip to content

Decision records take a host's changed flag from its last task only, so verify can pass a run that changed the host #76

Closed

Description

@romamo

What happens

_preview in sdk/src/cloudfall/decision.py reads the per-host files the Ansible tree callback leaves in ANSIBLE_CALLBACK_TREE_DIR. That callback rewrites a host's file after every task, so the file holds the last task's result only. A host whose earlier tasks changed it but whose last task reported ok is recorded as unchanged.

Evidence

examples/disk-full-incident/recordings/disk-full-approved/shop-logrotate-20261008204351, a real run on 2026-10-08:

  • Execution log PLAY RECAP: hz1 : ok=5 changed=2 (deleted three 8G rotated logs, truncated the live log)
  • Decision record: execution.changed: [], execution.unchanged: ["hz1"]
  • The check run has the same shape: the diff shows the deletions, yet check.changed is []

The last task, "Install a logrotate rule for the worker", reported ok, and that is what was kept.

Why it matters

The verify step decides VERIFIED from the same fields: the second run must change nothing. A playbook whose verify run changes something in an early task and ends with an unchanged task is recorded VERIFIED. The gate also shows "no host would change" in a proposal that would change the host, which is what a person approves against.

In this recording verify was right by luck (changed=0 in its recap).

Fix direction

Take per-host totals from the play stats, not the tree files, for example a small callback in the engine that writes each host's changed, failed and unreachable counts from v2_playbook_on_stats. Re-check --check runs, executions and verify runs. recording.py replays the same fields and needs to follow.

🤖 Generated with Claude Code

https://claude.ai/code/session_01APTdZZw8Jw9PQF4TD55wMv

Activity

  1. romamo commented on Oct 11, 2026

    @romamo
    CollaboratorAuthor

    It already produced a wrong verdict on a real host. The agent investigate proving run on 2026-10-08 (Nemotron 3 Super, cx23), decision shop-logrotate-20261008162054:

    • Verify log PLAY RECAP: hz1 : ok=3 changed=1 skipped=1. "Truncate the live worker log" reported changed again: the playbook was not idempotent yet
    • Decision record: verify.changed: [], status verified, verdict "the run succeeded and the verify step changed nothing"

    The last task, "Install a logrotate rule for the worker", reported ok, so the host counted as unchanged. The playbook was made idempotent later the same day, and the run recorded afterwards (recordings/disk-full-approved) has changed=0 in its verify recap. Cloudfall's verdict was wrong, though, and nothing in the record shows it.

  2. shipmill-romamo commented on Oct 11, 2026

    @shipmill-romamo
    Contributor

    Triage: implement, for the next release candidate.

    Confirmed on main (c29874a): _preview in sdk/src/cloudfall/decision.py sorts each host into changed or unchanged from the file the tree callback leaves for it, and that callback rewrites the file after every task. Only the last task's result survives. The --check preview, the execution and the verify run all go through _preview, so all three are affected, and the verify verdict (the verify step changed nothing) is read from the same field.

    The plan:

    • Take per-host results from the play stats, not the tree files: a small callback plugin shipped with the SDK that writes, on v2_playbook_on_stats, one JSON file per host with its changed, failed and unreachable counts (ok too). run_command enables it next to (or in place of) tree and points it at the same per-run directory
    • _preview reads those files: a host is changed when its changed count is above 0. The output schema stays as it is (changed and unchanged are host lists); they now mean what they always claimed to
    • recording.py writes what the new callback would have written, so replay keeps working. Old recordings keep their stored fields: they are records of what Cloudfall said at the time and are not rewritten
    • Regression tests: a run whose early task changed the host and whose last task reported ok is recorded changed in the check, the execution and the verify step, and such a verify run is not VERIFIED. A test drives the real callback through ansible-playbook against localhost if the suite already runs Ansible; otherwise the callback is unit-tested on a stats object

    Out of scope: correcting the two decision records that already carry the wrong value (shop-logrotate-20261008162054 and the disk-full-approved recording). #77 reports the first one in the proving-run write-up. I'll link the PR here.

  3. shipmill-romamo commented on Oct 11, 2026

    @shipmill-romamo
    Contributor

    The fix is in #80. A callback that ships with the SDK (cloudfall_host_stats) writes each host's recap counts when the play ends, and the check preview, the execution and the verify run all read changed from those counts. Ansible's tree callback is no longer used, and nothing else read it. The record schema is unchanged. A verify run that changed a host in an early task is now recorded failed, not verified. The new end-to-end test checks this with a real ansible-playbook on localhost.

    Trade-off: ANSIBLE_CALLBACK_PLUGINS replaces the configured path, so the run reads the team's callback path (the env, then their ansible.cfg, then Ansible's default) through ansible-core's ConfigManager and puts the SDK's directory in front of it. That is an internal ansible-core API, kept in ansible_api.py beside the inventory reader, which already depends on ansible-core internals. The stored recordings keep their old values. This issue closes when #80 merges.

  4. shipmill-romamo commented on Oct 11, 2026

    @shipmill-romamo
    Contributor

    Released in v0.7.0rc1.

  5. added this to the 0.7.0 milestone on Oct 19, 2026
  6. shipmill-romamo commented on Oct 20, 2026

    @shipmill-romamo
    Contributor

    Released in v0.7.0. uv tool install cloudfall==0.7.0

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions