1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
//! FJ-004: Desired-state hashing for the planner.
//!
//! # What is hashed, and why it is a denylist
//!
//! EVERYTHING on the resolved `Resource` except [`NON_IDENTITY_FIELDS`].
//!
//! It used to be an ALLOWLIST — 14 core fields, 20 phase-2 fields, the type —
//! covering 35 of the 122 serialised fields. `determine_present_action`
//! returns `NoOp` iff the recorded lock hash equals this hash, and
//! `executor::resource_ops::should_skip_single` then reports `unchanged`. So a
//! changed `uid`, `ssh_authorized_keys`, release `tag`, `driver_version`,
//! model `checksum`, `working_dir`, `timeout` or `sudo` hashed IDENTICALLY:
//! `plan` reported no change and `apply` printed `unchanged` over a machine
//! that still held the old value, permanently. Measured in the CRUX audit
//! (E01, #403): two six-resource configs differing in eleven identity fields
//! produced byte-identical `state.lock.yaml` files while `codegen` emitted
//! visibly different apply scripts.
//!
//! An allowlist fails silently — a field added later is simply not converged.
//! A denylist fails loudly, and `tests_hash_completeness` reflects over the
//! serialised `Resource` to make sure it keeps failing loudly.
//!
//! # Hash identity is versioned, not ordered
//!
//! The old collector's field ORDER was hash identity, so inserting a component
//! invalidated every recorded hash on every machine. The canonical form below
//! sorts mapping keys instead, so a field can now be declared ANYWHERE in the
//! `Resource` struct without moving anything.
//!
//! Sorting does NOT buy addition-independence, and it is worth being exact
//! about that because the cost lands on a fleet. `Resource` carries no
//! `skip_serializing_if`, so `serde_yaml_ng::to_value` emits all 122 of its
//! keys for EVERY resource — a `None` renders `~` just as loudly as a value
//! renders itself. ADDING A FIELD TO `Resource` THEREFORE MOVES EVERY RECORDED
//! HASH ON EVERY MACHINE, whether or not any config sets it, and is itself a
//! fleet re-converge. Skipping default-valued keys would buy that back and
//! reintroduce this module's own defect one level up: a field whose DEFAULT
//! later changed would then not re-converge, silently, which is the failure
//! this file exists to stop. The loud side is the one to keep — but say so in
//! the release note when you add a field.
//!
//! [`HASH_GENERATION`] gates the break when the CANONICAL FORM ITSELF changes.
//! Every resource then replans as `Update` exactly once — and every resource
//! declared `state: absent` replans as `Destroy` and RE-RUNS its destroy step,
//! because `determine_absent_action` separates "already converged to absent"
//! from "converged as present, now redeclared absent" using this same hash.
//! That is the correct outcome, not a regression — forjar cannot know whether
//! the 76 previously unhashed fields drifted while nothing was watching them,
//! so it re-converges rather than assuming they did not.
use crate*;
use cratehasher;
use ;
/// Hash-identity generation for the canonical form.
///
/// Bump ONLY when the canonicalisation itself changes shape. Every recorded
/// lock hash on every machine stops matching, so the whole fleet re-converges
/// once — see the module header.
const HASH_GENERATION: &str = "forjar-desired-state-v2";
/// Fields that describe HOW, WHERE or WHETHER a resource is applied — never
/// WHAT it converges to. Excluded from the desired-state hash BY NAME.
///
/// Everything else on `Resource` is hashed. That polarity is the whole point:
/// the previous allowlist covered 35 of 109 fields, so a changed `uid`, `tag`,
/// `checksum` or `timeout` produced an identical hash and `apply` reported
/// `unchanged` over a machine that still held the old value (#403 / audit E01).
/// A denylist fails the other way — a field added later is hashed until
/// somebody decides it is not identity — and `tests_hash_completeness`
/// enforces exactly that.
pub const NON_IDENTITY_FIELDS: & = &;
/// Injectively render one YAML value, sorting mapping keys.
///
/// Injective on purpose: strings are length-prefixed so `content: "a;b"` can
/// never render the same bytes as a two-element list. A collision here is a
/// resource silently reported `unchanged`, which is the defect this module
/// exists to stop.
///
/// Sorting is what makes it deterministic. `overlay_hosts`, `inputs` and
/// `backup_remote_config` are `HashMap`s whose iteration order varies run to
/// run; unsorted, the same declaration would hash differently on every plan
/// and every resource would replan as `Update` forever.
/// Render a mapping with its entries in canonical-key order.
/// Canonical form of the whole RESOLVED declaration, minus the denylist.
///
/// This is the material fix for #403 / audit E01: the hash now covers every
/// field serde can see, so a field added to `Resource` is converged by default
/// instead of being silently ignored until someone remembers to list it.
/// GH-206: fold the CONTENT of a `source:` file into the desired state.
///
/// `source:` names a PATH, but the bytes it points at are what actually gets
/// deployed. Hashing only the path meant editing the referenced file left the
/// hash identical, so `plan` reported `NoOp` and `apply` skipped the resource
/// while printing "unchanged" over stale content on the machine. For a tool
/// whose entire contract is "converge to declared state", silently not
/// converging while reporting success is the worst available failure mode.
/// Observed live in paiml/infra PMAT-204.
///
/// This is exactly the invariant `canonical_declaration` above already states
/// for a different field: two resources differing ONLY in that field MUST hash
/// differently or `plan` will false-report `NoOp`.
///
/// Returns an EMPTY string when there is no `source:`, so nothing is appended
/// and every source-less resource keeps its existing hash. Field order is hash
/// identity; only resources that declare `source:` gain a component.
///
/// The path is read exactly as written, matching `resources::file`'s own
/// `source_file_base64` - both resolve relative to the process CWD - so the
/// planner hashes precisely the bytes apply would upload.
/// FJ-036: canonical form of the reaper a `disk_budget` resource GENERATES.
///
/// Every other resource's desired state is fully described by its declaration.
/// A `disk_budget` is not: its real payload is a shell script synthesised by
/// forjar, so two forjar versions can produce different reapers from an
/// identical YAML block. Without this component the planner compares only the
/// declaration, reports "unchanged", and leaves the machine running the OLD
/// generated reaper indefinitely — which is precisely the silent desync the
/// resource exists to eliminate, reintroduced one level up.
///
/// Empty for every other resource type, so no existing hash changes.
/// Compute a hash of the desired state for comparison.
///
/// FJ-2200: Contract — determinism: same resource always produces same hash.
///
/// Three components, joined with NULs:
/// 1. [`HASH_GENERATION`] — the canonical-form version (see module header).
/// 2. The whole declaration minus [`NON_IDENTITY_FIELDS`].
/// 3. Anything the declaration does NOT describe: the bytes behind
/// `source:`, and the scripts a `disk_budget` GENERATES.
///
/// Components 2 and 3 exist for the same reason. A resource's desired state is
/// whatever `apply` would make true; a field, a referenced file's contents and
/// a generated reaper all change that, and all three must move the hash or
/// `plan` false-reports `NoOp`.