Skip to content

Commit 387b7bf

Browse files
committed
fix(warp-core): preserve the legacy operation_result_id hash for updates
operation_result_id routed previous_value_digest through hash_optional_id, which prepends a discriminant tag for both Some and None. This silently changed EchoOperationResultIdV1 (and everything chained from it -- preparation_id, receipt identities) for the update path too, not only the new create path, contradicting this branch's own "byte-for-byte unchanged" claims. A prior commit corrected the ADR's wording to document this honestly rather than overclaim; this commit fixes it for real instead, per Codex's automated review on PR #686 and my own self-review's finding #1. Some(digest) now hashes exactly digest -- the same raw 32 bytes the pre-ADR-0024 code always wrote, no tag -- so the update path's result identity is truly unchanged, not merely behaviorally equivalent. None uses a new domain-separated marker (RESULT_ID_ABSENT_PREVIOUS_VALUE_DOMAIN) whose byte length differs from every possible 32-byte digest, so it cannot collide with Some(digest) for any digest value -- unlike current_application_basis's Option<Hash> field (which needed hash_optional_id's shared tag scheme because that field's encoding is otherwise fixed-width), this field's own two encodings already differ in length, so no tag is needed here. RED: operation_result_id_preserves_the_legacy_hash_for_updates (new unit test in echo_operation.rs's own mod tests) asserted Some(...) matches a hand-reconstructed reference hash and failed against the tagged encoding. GREEN: same test passes after the fix. Updated ADR 0024's Verification Evidence Grade and Consequences sections to state the digests are unchanged, not merely judged acceptable to differ. Full warp-core suite (81 binaries, 687 library tests, doctests) green; clippy identical pre-existing error set.
1 parent c2e438b commit 387b7bf

2 files changed

Lines changed: 115 additions & 24 deletions

File tree

crates/warp-core/src/echo_operation.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ const ATOM_VALUE_DOMAIN: &[u8] = b"echo:operation-atom-value:v1\0";
7373
const APPLICATION_BASIS_VALUE_DOMAIN: &[u8] = b"echo:operation-anchored-node-alpha-basis:v1\0";
7474
const APPLICATION_BASIS_ABSENT_DOMAIN: &[u8] =
7575
b"echo:operation-anchored-node-alpha-basis-absent:v1\0";
76+
const RESULT_ID_ABSENT_PREVIOUS_VALUE_DOMAIN: &[u8] =
77+
b"echo:operation-result-absent-previous-value:v1\0";
7678
const FOOTPRINT_DIGEST_DOMAIN: &[u8] = b"echo:operation-footprint:v1\0";
7779
const RECEIPT_DIGEST_DOMAIN: &[u8] = b"echo:operation-receipt:v1\0";
7880
const COMPOSITION_DIGEST_DOMAIN: &[u8] = b"echo:operation-singleton-composition:v1\0";
@@ -3814,7 +3816,19 @@ fn operation_result_id(
38143816
hasher.update(&profile_digest(RESULT_SCHEMA));
38153817
hasher.update(invocation.node.warp_id.as_bytes());
38163818
hasher.update(invocation.node.local_id.as_bytes());
3817-
hash_optional_id(&mut hasher, previous_value_digest);
3819+
// PR #686 review finding #5 (Codex + self-review): preserve the exact
3820+
// pre-ADR-0024 hash input for Some(...) -- a raw 32-byte digest, no
3821+
// discriminant tag -- so the update path's EchoOperationResultIdV1 is
3822+
// truly unchanged, not merely documented as an acceptable change.
3823+
// RESULT_ID_ABSENT_PREVIOUS_VALUE_DOMAIN's length differs from every
3824+
// possible digest's 32 bytes, so it cannot collide with any Some(digest)
3825+
// regardless of that digest's value -- no tag byte is needed here the
3826+
// way current_application_basis needs one for a fixed-width Option<Hash>
3827+
// field; this field's own two encodings already differ in length.
3828+
match previous_value_digest {
3829+
Some(digest) => hasher.update(&digest),
3830+
None => hasher.update(RESULT_ID_ABSENT_PREVIOUS_VALUE_DOMAIN),
3831+
};
38183832
hasher.update(&replacement_value_digest);
38193833
hasher.update(&patch_digest);
38203834
EchoOperationResultIdV1(hasher.finalize().into())
@@ -4700,4 +4714,82 @@ mod tests {
47004714
EchoOperationArtifactErrorKindV1::InvalidStructure
47014715
);
47024716
}
4717+
4718+
#[test]
4719+
fn operation_result_id_preserves_the_legacy_hash_for_updates() {
4720+
// PR #686 review finding #5 (Codex + self-review): `Some(...)` must
4721+
// hash exactly like the pre-ADR-0024 code did -- a raw 32-byte
4722+
// digest, no discriminant tag -- so EchoOperationResultIdV1 (and
4723+
// everything chained from it) is truly unchanged for the update
4724+
// path this PR did not intend to touch. `None` must be
4725+
// domain-separated by construction, not merely by convention: its
4726+
// marker has a different byte length than any 32-byte digest, so no
4727+
// `Some(digest)` can ever produce the same input stream as `None`
4728+
// regardless of which digest value is chosen.
4729+
let installed = retained_fixture_installation();
4730+
let node = node_key_from_bytes([20; 64]);
4731+
let invocation = EchoOperationInvocationV1::anchored_node_attachment_compare_and_set(
4732+
installed.package_id(),
4733+
installed.operation_coordinate(),
4734+
EchoOperationEvaluationBasisV1::new(
4735+
WriterHeadKey {
4736+
worldline_id: crate::WorldlineId::from_bytes(digest(21)),
4737+
head_id: crate::HeadId::from_bytes(digest(22)),
4738+
},
4739+
WorldlineTick::ZERO,
4740+
None,
4741+
digest(23),
4742+
digest(24),
4743+
EchoOperationApplicationBasisV1::new(digest(25), digest(26)),
4744+
),
4745+
digest(27),
4746+
EchoOperationBudgetV1::new(8, 512, 512),
4747+
node,
4748+
Some(digest(28)),
4749+
Vec::new(),
4750+
);
4751+
let replacement_value_digest = digest(29);
4752+
let patch_digest = digest(30);
4753+
let previous = digest(28);
4754+
4755+
let mut expected = Hasher::new();
4756+
expected.update(RESULT_ID_DOMAIN);
4757+
expected.update(&installed.installed_operation_id.as_hash());
4758+
expected.update(&profile_digest(RESULT_SCHEMA));
4759+
expected.update(invocation.node.warp_id.as_bytes());
4760+
expected.update(invocation.node.local_id.as_bytes());
4761+
expected.update(&previous);
4762+
expected.update(&replacement_value_digest);
4763+
expected.update(&patch_digest);
4764+
let expected_some = EchoOperationResultIdV1(expected.finalize().into());
4765+
4766+
assert_eq!(
4767+
operation_result_id(
4768+
&installed,
4769+
&invocation,
4770+
Some(previous),
4771+
replacement_value_digest,
4772+
patch_digest,
4773+
),
4774+
expected_some,
4775+
"Some(...) must hash identically to a plain untagged digest write"
4776+
);
4777+
4778+
assert_ne!(
4779+
RESULT_ID_ABSENT_PREVIOUS_VALUE_DOMAIN.len(),
4780+
32,
4781+
"the absent marker's length must differ from every possible digest length"
4782+
);
4783+
let none_result = operation_result_id(
4784+
&installed,
4785+
&invocation,
4786+
None,
4787+
replacement_value_digest,
4788+
patch_digest,
4789+
);
4790+
assert_ne!(
4791+
none_result, expected_some,
4792+
"None must not collide with any Some(digest) result"
4793+
);
4794+
}
47034795
}

docs/adr/0024-anchored-node-creation-from-absence.md

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -176,18 +176,21 @@ All 13 pre-existing tests in that file pass unmodified in behavior (only
176176
mechanically rewrapped in `Some(...)` at call sites, never changed in intent).
177177
This evidences that `EchoOperationInvocationV1`'s canonical byte encoding and
178178
`prepare_operation_v1`'s/`current_application_basis`'s update-path _behavior_
179-
are unchanged for `Some(...)`. It does **not** evidence that every internal
180-
digest is byte-identical: `operation_result_id`'s hash input now goes through
181-
`hash_optional_id`, which adds a one-byte discriminant tag ahead of the
182-
32-byte digest for _both_ `Some` and `None`, not only `None`. This changes
183-
`EchoOperationResultIdV1` -- and everything chained from it, including
184-
`preparation_id` and receipt identities -- for the update path too, not only
185-
the new create path. This is judged acceptable, not a correctness defect: per
186-
the CHANGELOG.md entry for the executable-operation-runtime slice, no real
187-
committed result or receipt identity exists yet for this corridor to break.
188-
A future change that needs `operation_result_id`'s update-path output to stay
189-
byte-identical across a wire revision is a separate, explicit decision, not
190-
something this ADR's "unchanged" claim should be read to already cover.
179+
are unchanged for `Some(...)`. `operation_result_id` preserves this precisely
180+
at the digest level too: `Some(digest)` hashes exactly `digest` -- the same
181+
raw 32 bytes the pre-ADR-0024 code always wrote, no discriminant tag -- so
182+
`EchoOperationResultIdV1` and everything chained from it (`preparation_id`,
183+
receipt identities) are truly unchanged for the update path, not merely
184+
behaviorally equivalent. `None` is domain-separated by construction rather
185+
than by a shared tag scheme: its marker
186+
(`RESULT_ID_ABSENT_PREVIOUS_VALUE_DOMAIN`) has a different byte length than
187+
any 32-byte digest, so it cannot collide with `Some(digest)` for any digest
188+
value, without needing to add a tag to the `Some` case the way
189+
`current_application_basis`'s fixed-width `EchoOperationApplicationBasisV1`
190+
field does. `operation_result_id_preserves_the_legacy_hash_for_updates`
191+
(in `echo_operation.rs`'s own `mod tests`) asserts this directly against a
192+
hand-reconstructed reference hash, not only indirectly through pipeline
193+
tests passing.
191194

192195
The full `warp-core` suite (81 test binaries, 686 library tests, doctests
193196
included) passes with this change; `cargo clippy --all-targets` reports the
@@ -256,17 +259,13 @@ generated client.
256259
dead code, not defense in depth.
257260
- The update path's _behavior_ -- which preconditions succeed, which refuse,
258261
and with which obstruction -- is unchanged, evidenced by the pre-existing
259-
test suite passing without behavioral modification. Its digests are not
260-
entirely unchanged, though: `operation_result_id` now hashes
261-
`previous_value_digest` through `hash_optional_id`, which prepends a
262-
discriminant tag for both `Some` and `None`. `EchoOperationResultIdV1` (and
263-
everything chained from it) therefore differs from what it would have been
264-
under the prior code, for the update path as well as the new create path.
265-
`EchoOperationInvocationV1`'s own canonical bytes are unaffected for
266-
`Some(...)` -- only this one internal hash's input layout changed. Judged
267-
acceptable for the same no-real-cutover-yet reason as the invocation
268-
encoding change above; a future decision that needs this specific digest to
269-
stay byte-identical across the change is separate and not yet made.
262+
test suite passing without behavioral modification. Its digests are
263+
unchanged too: `operation_result_id` hashes `Some(digest)` as exactly
264+
`digest`, the same raw 32 bytes the prior code always wrote, so
265+
`EchoOperationResultIdV1` (and everything chained from it) is identical to
266+
what the prior code would have produced. `None` is domain-separated by a
267+
distinctly-sized marker rather than a shared tag, so it never needed to
268+
touch the `Some` encoding to stay unambiguous.
270269
- Echo still creates nodes of only the package-declared, static
271270
`required_node_type`; it still contains no application-specific intrinsic
272271
and no knowledge of what any caller is building.

0 commit comments

Comments
 (0)