Skip to content

fix(planner): a source: file's CONTENT is part of the desired state (Refs #206) - #207

Merged
noahgift merged 1 commit into
mainfrom
fix/source-content-hash-206
Aug 10, 2026
Merged

fix(planner): a source: file's CONTENT is part of the desired state (Refs #206)#207
noahgift merged 1 commit into
mainfrom
fix/source-content-hash-206

Conversation

@noahgift

Copy link
Copy Markdown
Contributor

Fixes #206.

The bug

hash_desired_state hashes resource field strings. For content: that's correct — the content is the field. For source: the field is a path, so editing the referenced file left the hash identical, determine_present_action planned NoOp, and apply skipped the resource:

$ echo VERSION-ONE > payload.txt && forjar apply --yes
Apply complete: 1 converged, 0 unchanged.

$ echo VERSION-TWO > payload.txt && forjar apply --yes
Apply complete: 0 converged, 1 unchanged.

$ cat /tmp/deployed
VERSION-ONE          # <- stale

--force was the only workaround. For a tool whose entire contract is "converge to declared state", silently not converging while printing success is the worst available failure mode.

Found live in paiml/infra PMAT-204 — an edited reconciler script reported "converged" three times while the machine kept executing the previous copy. Same class as the ENOSPC incident there, where a systemd unit was edited but never actually deployed.

The fix

Fold the source file's content hash into the desired state via canonical_source_content(), following the existing canonical_overlay_hosts() pattern — whose doc comment already states this exact invariant for a different field:

two overlay_interface resources differing ONLY in overlay_hosts MUST hash differently or plan will false-report NoOp

source: was violating the same rule.

Care taken:

  • Appended, never inserted. Field order is hash identity; a reorder would invalidate every recorded hash on every machine in the fleet.
  • Pushed only when source: is present, so source-less resources keep their hash exactly.
  • Path read as written, matching resources::file::source_file_base64 — both resolve against the process CWD — so the planner hashes precisely the bytes apply would upload.
  • Unreadable sources fold their error in rather than hashing as absent, so a file appearing/disappearing changes the hash instead of staying pinned at "unchanged".

Verification

End-to-end, with the installed fixed binary:

$ forjar plan   # after editing the source
  ~ repro-file: update (state changed)
Plan: 0 to add, 1 to change, 0 to destroy, 0 unchanged.

$ forjar apply --yes        # NO --force
Apply complete: 1 converged, 0 unchanged.
$ cat /tmp/deployed
VERSION-TWO                 # correct

$ forjar apply --yes        # fixed point still holds
Apply complete: 0 converged, 1 unchanged.
New tests 5 in tests_hash_source.rs (RED-verified before the fix)
Full lib suite 12596 passed, 0 failed
cargo fmt --check clean
cargo clippy clean
TDG quality gates no regressions
pv validate new contract 0 errors, 0 warnings

Contract

contracts/source-content-identity-v1.yaml — the completeness leg of idempotent-apply-v1. That contract asserts "differing hash always plans Update": sound, but vacuous if the hash cannot differ when the deployed artifact differs.

It deliberately declares no Kani harness. The property quantifies over filesystem contents, which is outside Kani's model — a harness could only prove something about a stubbed read, i.e. prove nothing about this bug. The 7 falsification tests discharge the obligations instead, including an end-to-end one that reproduces the reported defect.

Upgrade note

Source-based file resources will show one Update on the first apply after upgrading, as their hash gains the new component. That re-apply is convergent and expected.

Released as 1.12.3.

🤖 Generated with Claude Code

…Refs #206)

`hash_desired_state` hashes resource FIELD STRINGS. For `content:` that is
correct - the content IS the field. For `source:` the field is a PATH, so
editing the referenced file left the hash identical, determine_present_action
planned NoOp, and apply skipped the resource:

    $ echo VERSION-ONE > payload.txt && forjar apply --yes
    Apply complete: 1 converged, 0 unchanged.
    $ echo VERSION-TWO > payload.txt && forjar apply --yes
    Apply complete: 0 converged, 1 unchanged.
    $ cat /tmp/deployed        # -> VERSION-ONE   (stale)

`--force` was the only workaround. For a tool whose entire contract is
"converge to declared state", silently not converging while printing success is
the worst available failure mode.

Found live in paiml/infra PMAT-204: an edited reconciler script reported
"converged" three times while the box kept executing the previous copy. That is
the same class as the ENOSPC incident there, where a systemd unit was edited but
never actually deployed.

The planner now folds the content hash of the source file into the desired
state, via canonical_source_content(), following the existing
canonical_overlay_hosts() pattern - whose doc comment already states this exact
invariant for a different field: two resources differing ONLY in that field MUST
hash differently or plan will false-report NoOp.

Care taken:
  * The component is APPENDED, never inserted. Field order is hash identity;
    a reorder would invalidate every recorded hash on every machine.
  * It is pushed ONLY when `source:` is present, so source-less resources keep
    their existing hash exactly.
  * The path is read as written, matching resources::file::source_file_base64 -
    both resolve against the process CWD - so the planner hashes precisely the
    bytes apply would upload.
  * An unreadable source folds its error in rather than hashing as absent, so a
    file appearing or disappearing changes the hash instead of staying pinned at
    "unchanged".

Upgrade note: source-based file resources show one Update on the first apply
after upgrading, as their hash gains the new component. That re-apply is
convergent and expected.

Verified end-to-end with the fixed binary: plan reports "update (state
changed)", apply deploys the new bytes with NO --force, and a third apply
returns to "unchanged" - the fixed point still holds.

Tests: 5 new in tests_hash_source.rs (content change, determinism, path
identity, source appearance, source-less non-regression). Full suite 12596
passed / 0 failed.

Contract: contracts/source-content-identity-v1.yaml - the completeness leg of
idempotent-apply-v1, which asserts "differing hash always plans Update": sound,
but vacuous if the hash cannot differ when the deployed artifact differs.
Deliberately declares NO Kani harness: the property quantifies over filesystem
contents, which is outside Kani's model, so a harness could only prove something
about a stubbed read. pv validate: 0 errors, 0 warnings.

Closes #206

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahgift
noahgift merged commit c7dea02 into main Aug 10, 2026
22 checks passed
@noahgift
noahgift deleted the fix/source-content-hash-206 branch August 10, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

apply reports "unchanged" when a file resource's source: content changes — silently deploys stale content

1 participant