A tick covers the values it does not show, through a value fingerprint
Decision record 0102
Amends 0008 (the digest of values it rejected is built, as a fingerprint next to the hash and not in it, and the offline guess it feared is stated as the cost of the default), 0021 (a hash of values leaves the adapter, never a value), 0052 (
dashboard.redactno longer keeps every digest of a value out of the issue), 0055 (a Pulumi drift change carries a fingerprint of the values the check read), 0003 and 0009 (one more payload key and one more row marker key), 0014 (promise 3 says what a fingerprint is and is not) and 0051 (a second refusal of a tick with a comment).
Record 0008 accepted a hole and our own security page states it: someone ticks a row that says web: update, image, another merge moves the image from v2 to v3 before the deploy starts, the same resource changes the same property, the hash is the same, and v3 goes out. Record 0052 closed it for the paths a repo lists, and left every other value approved “at whatever value the code has when the deploy runs”. Issue 232 asked for the rest: the hash can cover the values that are not shown, as a fingerprint, so that the promise becomes one sentence. If anything about the change differs from what you ticked, nothing deploys. The owner decided on 2026-09-24 that the check is on by default for every repo, with a switch per repo and per stack.
Decision
- A row carries a value fingerprint next to its diff hash. The fingerprint is a hash of the values of the diff that the row does not show. Nothing new is printed anywhere: not a value, not its length, not which side moved. The row marker gets the key
fingerprint, after every older key, andresolvecopies it onto the deployment record’s payload asfingerprint, an added key, so the payload stays version 1. A queued record, an on-merge record and a record the scan after a merge opens carry it the same way. The confirm box of a bulk tick names hashes only, as before: the fingerprint of each row is read from the live row when the tick is judged. applycompares it after the hash. When the fresh preview gives the diff hash the tick approved and a value fingerprint that is not the one on the record, nothing deploys. The record ends aserrorwith the reasona value changed since the tick, the row comes back pending with the fresh diff and its failure line, and one comment on the dashboard tells the ticker in plain words that a value changed since the tick, that the row shows the change as it is now, and to look at it and tick again. It names no value, no path and no side, like the comment for a moved change (0051). Theoutcomeoutput isrefused. A record that carries no fingerprint while the fresh preview gives one is refused the same way, which is the safe direction of 0008: a tick from before this version, or from before the check was turned on, aborts once. A fresh preview that gives no fingerprint deploys: the check is off for the stack, or the diff holds no value to cover.- The fingerprint stays out of the diff hash. A row has one hash and one fingerprint, and they are compared one after the other, so the refusal can say which of the two moved and the dashboard can tell a value that differs on every run from a change of the code. Folding the fingerprint into the hash would give one word, “moved”, for both.
- A value that differs on every run is detected the way the pending-again line is (onboarding log, hurdle 21), from data and never from words. In
apply: the hash matches, the fingerprint does not, and the dashboard’s last scan was of this run’s commit, so the fresh preview and the row come from the same code. The reason then readsa value changed since the tick with no new commit, so it may differ on every run: see valueFingerprint in sluiceway.yaml, and the comment says the same and names the switch. In a scan: a fresh pending or drifted row whose hash is the live row’s, whose fingerprint is not, and whose scan is of the commit the live body’s last scan was of, gets a line under it that says a value the row does not show differed between two previews of the same commit, that a tick would be refused, and how to turn the check off for the stack. The line decides nothing, like the pending-again line. valueFingerprint: trueinsluiceway.yaml, on by default, andstacks[].valueFingerprintper stack. With it off for a stack, its preview reads no value for the fingerprint, its row carries none, its records carry none andapplycompares none. The default is on for every repo, because the safe reading is the one people assume: a person who ticksweb: update, imagebelieves they approved the image they read the code for, and no one reads the security page before their first tick. A repo turns it off where a program makes a value that differs on every run, and the line and the refusal both say so and name the switch.- Drift gets the same answer. A drift change of a Pulumi stack carries a fingerprint of what the check read, the fingerprint of a row covers its drift the way its hash does, and drift whose values moved after the tick is refused with the same words. Helm and kubectl drift checks read no value, so their drift changes carry none.
dashboard.redactdoes not turn the fingerprint off. Redact keeps names out of the issue (0023). The fingerprint is a hash of sixteen hex characters and names nothing, and the switch is its own key. This amends the consequence of 0052 that a redacted issue carries no digest of a value it does not show: it carries the fingerprint, unless the repo turns it off.
What is hashed, exactly
A reviewer can check the fingerprint from the tool’s own output and this section.
Per change, the hidden values are a list of entries, one per leaf value, in one of four shapes: {"path":p,"old":v}, {"path":p,"new":v}, {"path":p,"new":v,"old":v} and {"path":p,"secret":true}. p is the property path in the notation of 0046, the one every adapter writes. v is the value as the tool printed it in its JSON, whole and not shortened: a string, a number or a boolean. A side that is absent or null is left out. The list is sorted by path in plain code unit order, an entry with the same path by its text, and written as JSON with sorted keys and no whitespace. The change fingerprint is the SHA-256 of that text as UTF-8, first 16 hex characters in lower case. A change without an entry has no fingerprint.
Which leaves enter, by op:
create: every leaf of the new object. Pulumi:newState.inputsof the step. OpenTofu and Terraform:change.after. Kubernetes manifests: the merged object, without the server’s own fields. Helm: none, because the diff plugin prints no manifest for an object it adds.updateandreplace: every leaf that differs between the old and the new object, except a path the row shows a value for, which the diff hash already covers (0052). Pulumi: at each path the tool names indetailedDiff,diffReasonsorreplaceReasons, the old side isoldState.inputswhere the entry saysinputDiffandoldState.outputsotherwise, as 0052 reads it, and the new side isnewState.inputs. OpenTofu and Terraform:change.beforeagainstchange.after. Kubernetes manifests: the live object against the merged one, both without the server’s fields. Helm:oldValueandnewValueof each change the plugin lists.delete, and a tracking change on its own: none. What is deleted is named by its address, and a value it had is not what the deploy does.- Drift
updateof a Pulumi stack: every leaf that differs between the state and what the check read,old.outputsagainstnew.outputsof the event that closes the resource.
Two objects are walked together over the union of their keys, a list by index, so a leaf that is on one side only enters with that side alone. Where one side is an object or a list and the other is a scalar, the scalar enters at the path and the leaves of the object enter under it. An empty object or list has no leaf.
A value the tool marks secret never reaches the fingerprint in the clear. Where the tool marks a value or a subtree secret on either side, one entry {"path":p,"secret":true} enters at the mark, and nothing under it. The marks are the tool’s: Pulumi prints [secret] in place of the value, OpenTofu and Terraform say so in before_sensitive and after_sensitive, a Kubernetes Secret is marked at data and stringData, and every field of a Secret that the Helm plugin lists. So a secret that changed after the tick is not caught by the fingerprint. Its path is in the diff hash, and a secret that changes on every deploy, which is what a rotated token does, would otherwise refuse every tick of its stack.
The fingerprint of a row is the SHA-256, first 16 hex characters, of {"changes":[{"address":a,"fingerprint":f},...],"drift":[...],"stackId":s}: every change that has a fingerprint, sorted by address, the drift changes that have one under drift, left out when none has, and the stack id. A row whose changes and drift carry no fingerprint has none, and its marker has no key.
The cost of the default, stated
Record 0008 rejected a digest of values because a low-entropy value that a person forgot to mark secret could be guessed offline from an issue that may be public, and a salt has nowhere to live without a store. Both are still true. Someone who can read the dashboard, knows the rest of the diff from the row, and can guess a value that is neither shown nor marked secret can confirm the guess against sixteen hex characters. What bounds it: a value worth guessing is one the tool should hold as secret, and the tool then prints a mark and not the value, for the state file and the plan file as much as for Sluiceway; a value nobody marked is already in the state that anyone with the backend’s credentials can read; and the fingerprint covers every hidden value of the change together, so a guess has to be right about all of them at once. The owner weighed that against the hole and chose the default. A repo that keeps unmarked secrets in its code and a public dashboard turns the check off, and the docs say so next to the key. A salt would close the guess, and it waits on a place to keep one (later.md).
Rejected
- Folding the values into the diff hash. One field would do, and 0008 says a row never has two hashes. But then a tick refused for a value and a tick refused for a moved change are the same “moved”, and a value that differs on every run cannot be told from a change of the code, which is what hurdle 21 taught. The fingerprint is not a second hash of the diff: it hashes what the hash leaves out.
- Off by default, on for the repos that ask. The safe reading is the one people assume, and the first refusal on a noisy value is one line that names the switch. The other way round, the hole stays open for everyone who never read the security page.
- A salted fingerprint. There is no salt that the scan job and the
applyjob share and the issue does not hold. The workflow token is per run, a repo secret would be a credential input (0014, promise 1), and a cache can be evicted, which would refuse every outstanding tick. - Hashing a secret’s value, salted or not. Without a salt it is the offline guess of 0008 against the one kind of value that matters most. Hashing the tool’s own mark is what the issue offered as the alternative, and it is what a reviewer can check.
- A commit SHA on the row, to tell a noisy value from a changed one. It would be a new key on every row for one line. The last scan’s commit on the root marker says the same thing for every row the last scan wrote or carried, and the line decides nothing.
Consequences
Changegets one optional field,fingerprint, the change fingerprint above.Diffdoes not change.core/value-fingerprint.tsholds the leaf walk, the change fingerprint, the fingerprint of a diff and the rule for a value that differs on every run, so every adapter hashes the same way. A value still never leaves an adapter: what leaves is the fingerprint.PreviewOptionsgetsvalueFingerprint, and an adapter reads values for it only when asked, so every fixture and every test that does not ask reads as it always did.- The words of a refusal are Sluiceway’s own and name no value: the deploy failure reason
value-changed, with its every-run form, the comment to the ticker, the line on a row and the job log. The reason fits a deployment status description, so the failure line on the row can carry it. - Turning the check on or off, in a repo or for a stack, gives every pending row of that stack a fingerprint or takes it away. A tick on a row written before the change is refused once and the row asks for a fresh tick, which is the same cost as changing
dashboard.showValues(0052). - The check that
sluiceway checkruns does not mention the key. The row and the refusal say what to do, where a person meets it. docs/later.mdloses the line that rejected a digest of values, and gains the salt, a fingerprint for an object the Helm plugin adds, and one for Helm and kubectl drift.- The security page’s one deliberate gap becomes the sentence of issue 232, with the cost above next to it.