Skip to content

fix(resolver): restate the hashed marker from the value a reference resolved - #704

Merged
JeroenSoeters merged 1 commit into
mainfrom
fix/resolution-drops-stale-hashed-marker
Aug 29, 2026
Merged

fix(resolver): restate the hashed marker from the value a reference resolved#704
JeroenSoeters merged 1 commit into
mainfrom
fix/resolution-drops-stale-hashed-marker

Conversation

@JeroenSoeters

@JeroenSoeters JeroenSoeters commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

mise ~/.config/mise/config.toml tools: gh@2.98.0

Summary

Reference resolution rebuilds a consumer's envelope by copying every key off the target and then overwriting $value with what it resolved (resolver.go, resolveReference). When that target came from stored state it carries $hashed: true from an earlier at-rest hash, and the copy preserves it regardless of the new value, so the marker stops describing the value beside it.

That breaks in both directions, and they pull opposite ways.

A live plaintext resolution inherits a stale true. The claim is what makes it permanent: the terminal-state hashing pass returns early on any envelope already marked hashed, correctly refusing to hash a digest twice, so nothing ever corrects the mislabelled one. A successful apply hides this, because the completion path replaces the desired properties with hashed read-actual values. When the command fails there is no such replacement, and the plaintext persists at rest under a marker claiming it is a digest.

A resolution that is itself a stored digest needs the opposite. guardNoHashedValues refuses a provider write on the $hashed marker alone (scanHashed matches that key and nothing else), so simply clearing the marker would let a digest reach a provider as though it were the secret.

The fix

Carry the incoming value's hashed-ness through setRefValue onto the resolved Value, and restate the marker from it rather than inheriting whatever the wholesale copy held. Neither direction then depends on what the target envelope happened to carry.

resolveEmbedRef gets the same treatment. It has the identical copy-then-overwrite shape, but no test here reaches it, so its exposure is unproven and the change is made for the invariant rather than for an observed failure. Flagging that so it is reviewed on those terms.

Tests

TestApplyForma_DestroyedReferencedSecret_LeavesNoConsumerPlaintextAtRest is the failing-to-passing case. It destroys a secret a consumer still references, so the consumer's update fails and the command ends Failed, then scans every persistent sink for the plaintext. Without the fix the consumer's stored desired state holds an envelope marked "$hashed": true whose $value is the literal secret.

TestResolvePropertyReferences_HashedMarkerFollowsTheResolvedValue pins both directions. It was checked against a version of this fix that cleared the marker unconditionally: the digest subtest fails there and passes here, so it holds the second direction rather than restating the first.

TestApplyForma_FailedCommand_LeavesNoConsumerPlaintextAtRest covers the adjacent path where a plugin refusal fails the command. It passes without the fix, which is worth knowing: the leak is specific to the destroy path, so a convenient reproduction would have suggested there was nothing to fix.

Verification

go test ./internal/workflow_tests/... ./internal/metastructure/... ./internal/datastore/... ./internal/schema/... -tags unit -count=1 — exit 0, zero failures, re-run after the final change.

Two local environment notes, neither caused by this branch. go build ./... fails inside go-json-experiment/json under Go 1.27 because the pinned commit conflicts with 1.27's stdlib jsonv2 aliases; the repo's go.mod says go 1.26.0, so GOTOOLCHAIN=go1.26.0 builds and tests cleanly, and CI uses the go.mod toolchain so it never sees this. And internal/schema/pkl tests are behind //go:build integration and need make version-semver in the working tree, or they fail on a missing resource rather than on anything real.

…esolved

Reference resolution rebuilds a consumer's envelope by copying every key off
the target and then overwriting $value with what it resolved. When that target
came from stored state it carries $hashed:true from an earlier at-rest hash,
and the copy preserves it whatever the new value is, so the marker stops
describing the value beside it.

That breaks in both directions, and they pull opposite ways.

A live plaintext resolution inherits a stale true. The claim is what makes it
permanent: the terminal-state hashing pass returns early on any envelope
already marked hashed, correctly refusing to hash a digest twice, so nothing
corrects the mislabelled one. A successful apply hides this, because the
completion path replaces the desired properties with hashed read-actual
values; when the command fails there is no such replacement and the plaintext
persists, labelled as a digest.

A resolution that is itself a stored digest needs the opposite. The
plugin-boundary guard refuses a write on the $hashed marker alone, so simply
clearing the marker would let a digest reach a provider as though it were the
secret.

So carry the incoming value's hashed-ness through setRefValue and restate the
marker from it, rather than inheriting whatever the copy happened to hold. The
embedded reference path gets the same treatment: it has the identical
copy-then-overwrite shape, though no test here reaches it, so its exposure is
unproven and the change is made for the invariant rather than an observed
failure.
@JeroenSoeters
JeroenSoeters force-pushed the fix/resolution-drops-stale-hashed-marker branch from 852622c to 7887f52 Compare August 29, 2026 06:02
@JeroenSoeters
JeroenSoeters merged commit f0a9761 into main Aug 29, 2026
56 of 57 checks passed
@JeroenSoeters
JeroenSoeters deleted the fix/resolution-drops-stale-hashed-marker branch August 29, 2026 19:46
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.

1 participant