Hold the order-canonical recompute to the kernel's findings - #159
Conversation
Two engines read every response. The kernel's `diff_json` decides WHETHER two bodies differ: it sees through the encodings a value is carried in, and it alone applies the run's body allowlist. The order-canonical recompute exists for a narrower job — deciding WHERE a difference belongs once ordering is resolved, so a permutation is one difference at its collection rather than however many positions this run's two orders scattered it across. Nothing held it to that job. `classify_http_body_diff` replaced the kernel's rows with the recompute's whenever bodies were present, so the less-informed engine had the final say and could manufacture a difference the authority never found. It did: a system that echoes the connector request it built serializes a headers map whose key order is seeded per process, the kernel read the embedded document and reported nothing, this engine read the same string as bytes, and a replay of a recording BY THE IMAGE THAT RECORDED IT failed 99 of its 100 correlations on identical behaviour. A recompute row now survives only where some kernel row speaks about the same region, compared as declarations so that either engine may describe one difference deeper, shallower, or at another position of the same collection. Rows standing over a region the kernel judged the same are dropped. This costs no signal — the recompute recurses the same structure the kernel does, so every region the kernel can report in already carries a row here — and it fixes the class rather than the instance: no encoding has to be taught to two engines for them to agree, and an allowlisted path can no longer block through an engine that never saw the allowlist.
maverox
left a comment
There was a problem hiding this comment.
Reviewed by checking out the branch and running it: just verify is green, 950 passing.
The diagnosis is right and the change is the right shape. Two engines read the same bodies; only one of them sees through encodings and only one of them applies the run's allowlist; letting the less-informed one have the final say is the defect. Holding the recompute to re-describing rather than deciding fixes the class rather than the instance.
The allowlist consequence is the strongest part and slightly undersold in the description: the allowlist is applied inside the kernel, so before this change an allowlisted path could block through an engine that had never seen it. That is a correctness bug independent of the ordering incident, and it falls out of the same fix.
Verified rather than taken on trust
The tests are load-bearing. Two mutations, each killed by the test that should kill it:
| mutation | killed by |
|---|---|
reconciliation removed (Some(rows) — the bug restored) |
a_recompute_row_the_kernel_never_reported_does_not_block, a_hash_ordered_map_inside_an_echoed_document_does_not_block |
segment-aware prefix replaced with inner.starts_with(outer) |
coverage_is_by_path_segment_not_by_string_prefix |
canonical_set_path supports the alignment argument: it rewrites indices to [] and strips a trailing [], so $.items[1] and $.items[2] both canonicalise to $.items. The "alignment moved a changed member" case works as described.
One gap, and I think it should be closed before merge
The invariant is stated in one direction — the recompute may re-shape the kernel's findings and may not invent one. The code permits the other direction: it may erase one.
classify_http_body_diff does
let rows = canonical.as_deref().unwrap_or(&diff.body_diff);so the reconciled rows replace the kernel's rather than merging with them. If reconciliation empties a non-empty kernel set, the kernel's own findings are discarded and nothing blocks.
This is reachable. Probed on the branch with bodies differing only at $.alpha while the kernel reports $.beta:
kernel rows = ["$.beta"]
reconciled = [] <- kernel's finding erased, nothing blocks
Whether it is reachable in real data depends on the claim in the doc comment — "this engine recurses the same structure the kernel does, so every region the kernel can report in already has a row here". That is the assumption the safety of dropping rests on, and it is asserted rather than tested. order_canonical_diff is an independent traversal of the raw bodies, not a filter over the kernel's rows, so nothing in the types guarantees it.
Suggested addition, four lines, reusing the existing None path which already means "use the kernel's rows":
let reconciled = reconciled_with_kernel(rows, &diff.body_diff);
// The invariant holds in BOTH directions: the recompute may re-shape the
// kernel's findings and may not invent one — and may not erase one either.
// If nothing it produced speaks about any region the kernel reported, the
// re-shaping has failed and the kernel's own rows stand.
if reconciled.is_empty() && !diff.body_diff.is_empty() {
return None;
}
Some(reconciled)I applied this locally: all five new tests still pass, the probe above passes, 449 orchestrator tests green. It costs nothing and makes the invariant symmetric.
Smaller notes, neither blocking
- A kernel row at the root keeps every recompute row, since
path_covers("$", x)is true for anyx. That is the conservative direction and probably intended, but it is not stated anywhere. reconciled_with_kernelis O(recompute x kernel) per body. Fine at observed sizes; worth remembering if bodies grow.
Happy to approve once the erase direction is closed, or to hear why it cannot arise — if the superset property is guaranteed somewhere I have missed, a line saying so in the doc comment would do the same job.
maverox
left a comment
There was a problem hiding this comment.
Follow-up on my review above, and this one changes my recommendation rather than adding to it.
The tolerated differences are silenced, not absorbed
This repo already has a settled pattern for "an ordering difference that carries no meaning": absorb it and name it. ValueCanonAbsorbed for args/result, ReplyCanonAbsorbed for the HTTP reply path — both emitted, both counted, both keyed by who said the order carries no meaning.
The table in the description shows this change does not follow it:
| Case | Blocks |
|---|---|
| Top-level array reordered | absorbed by default, named, as before |
| Object key order inside an embedded document | no |
| Distinct form keys reordered | no |
The first is absorbed and named. The two new ones are dropped by filter — no kind, no counter, no ledger row, nothing. The PR's own test confirms it: assert!(classification.canon_absorbed.is_empty()).
That matters here more than it would elsewhere, because this codebase has already paid for the lesson: a kind without a folded summary field is invisible by construction, which is why 41 ReplyCanonAbsorbed once sat behind a headline of 3. A difference dropped by filter is a step further down — it is not even a kind.
The practical consequence is that the incident this fixes becomes unobservable. A run where all 100 correlations are silently tolerating hash-ordered header maps looks identical to a run where the echo is byte-stable. The thing worth knowing — that ordering tolerance is load-bearing for this suite — leaves no trace.
There is already a counter for exactly this, and it has never fired
// divergence/mod.rs:4414
let order_nondeterminism_warnings = 0u64;Summary.order_nondeterminism_warnings is declared, has its folds(...) entry expecting kind OrderNondeterministicWarning, and is reported on every scorecard. It is hardcoded to zero and never incremented. OrderNondeterministicWarning appears exactly once in the tree — in that folds declaration. It is emitted nowhere.
I checked the effect on real data: across 136 runs in the API window, order_nondeterminism_warnings is 0 in every one, alongside inconclusive_races and undeclared_concurrency_warnings. The field reads as "no ordering nondeterminism has ever been observed", when what it means is "nothing has ever written here".
So the slot this change wants already exists, was designed for this, and is empty.
Revised recommendation
Two things before merge, not one:
- The erase direction from my earlier comment — reconciliation must not empty a non-empty kernel set.
- Name what is tolerated. Emit
OrderNondeterministicWarning(or route throughcanon_absorbedwith a source, matchingReplyCanonAbsorbed) for each row dropped because the kernel judged its region the same. The counter, the fold and the reporting are already in place; what is missing is the increment.
(2) does not change what blocks — the verdict stays exactly as the table describes. It changes whether a reader can tell the difference between "the bodies agreed" and "the bodies disagreed and we decided it did not matter", which is the whole reason the counter was added.
If the intent was global order tolerance with a warning, (2) is the warning half and it is currently absent.
…inding Two things the previous commit left open, both raised in review. The invariant was stated in one direction. The recompute could not invent a difference the kernel never found, but it could still erase one: the reconciled rows replaced the kernel's outright, so a reconciliation that emptied a non-empty kernel set discarded the kernel's own findings and nothing blocked. Only a broken artifact produces that shape, since the recompute recurses the same structure the kernel does, but nothing in the types guaranteed it and the safety of setting rows aside rested on an asserted claim. It no longer does. A reconciliation that would leave a non-empty kernel set with nothing is refused, and the kernel's rows stand. The rows set aside were dropped by a filter: no kind, no counter, no warning. This repository has already paid for that shape once, and here it made the incident itself unobservable. A suite tolerating a hash-ordered headers map on every one of its responses read identically to one whose echo was byte-stable, and the thing worth knowing about such a suite, that the kernel's judgment is load-bearing for it, left no trace. The slot for saying so already existed. `order_nondeterminism_warnings` was declared, folded from `OrderNondeterministicWarning`, reported on every scorecard, and hardcoded to zero: it once counted UPDATE inherited-row ordering noise, was retired when UPDATE results began comparing only assigned columns, and has written zero on every scorecard since. It now counts each path the recompute reported and the kernel judged the same, the kind is noted without counting a divergence, exactly as `ReplyCanonAbsorbed` is, and a warning names the path and says why it did not count. The field's doc says what it meant before and that no non-zero value has ever carried that meaning. The verdict is unchanged by both. What changed is that a reader can tell "the bodies agreed" from "the bodies disagreed and the kernel decided it did not matter", and that the scorer can no longer turn a broken artifact into a pass.
|
Both taken, in 8044dae. Erase direction. Added the guard as suggested: a reconciliation that would empty a non-empty kernel set returns Naming. Each set-aside path notes One correction to the history of that field: it was not designed for this. Its doc described retired UPDATE inherited-row ordering noise, always zero on new scorecards. Repurposed, with the doc stating what it meant before and that no non-zero value has ever carried that meaning.
|
maverox
left a comment
There was a problem hiding this comment.
Re-review at 8044dae
Both findings from the last round are addressed, and I checked them by breaking the new guards rather than by reading them. just verify green: 952 passing, 0 failed (was 950 — the two new tests are the delta).
Each new guard is killed by exactly one test, and it is the right one:
| mutation | result |
|---|---|
if false && reconciled.rows.is_empty() && ... |
a_recompute_that_accounts_for_none_of_the_kernels_findings_yields_to_them FAILED — 449 passed, 1 failed |
drop note_kind + the increment |
the_scorecard_names_what_the_kernel_judged_the_same FAILED — 449 passed, 1 failed |
One open item, inline at the guard: the invariant is stated in both directions but enforced all-or-nothing. It is your call whether the symmetry is worth the test churn — everything else here is verified and I have no objection to it landing.
| /// allowlist covers. Named so a suite that leans on that judgment leaves a | ||
| /// trace. Folds from `OrderNondeterministicWarning` on `http_incoming`. | ||
| /// | ||
| /// Formerly UPDATE inherited-row ordering noise, retired when UPDATE results |
There was a problem hiding this comment.
Reusing the retired counter — I can corroborate this from the other side. I measured order_nondeterminism_warnings across 136 runs earlier in this work and found zero on every one (bounded by the API's 200-row window), so the claim holds where I can see it.
Two things that make the reuse safer than the comment claims, worth having on the record:
- the old emitter is gone —
OrderNondeterministicWarningnow has exactly one emit site in the tree (5110); - the counter feeds no verdict reason any more. The old
"N order-nondeterminism warning(s) (non-blocking)"line is not in the code, soverdict.passholding is structural, not a property of the new test's fixture.
Nit, outside the diff: docs/design/HANDOFF-confirm-9of9.md:44 still records a run with order_nondeterminism_warnings:1 under the retired meaning (payment_attempt div 2 demoted). Your wording is correctly scoped to scorecards written since the retirement, but a reader grepping the repo for this field lands on a doc that maps a non-zero value to the DB case. One line there would close it.
| // kernel's findings and may not invent one — and may not erase one either. | ||
| // If nothing it produced speaks about any region the kernel reported, the | ||
| // re-shaping has failed and the kernel's own rows stand. | ||
| if reconciled.rows.is_empty() && !diff.body_diff.is_empty() { |
There was a problem hiding this comment.
This closes the erase direction I raised, and the mutation confirms it is load-bearing. One thing it does not cover: the guard fires only when the reconciliation is completely empty, so a kernel row that nothing backs is still dropped silently whenever at least one other kernel row is backed.
Probe — kernel reports $.alpha and $.beta, stored bodies differ only at $.alpha:
PROBE reconciled = Some(["$.alpha"])
PROBE blocking_leaf_count = 1 (kernel reported 2)
$.beta is neither counted nor named — kernel_equivalent_paths collects unbacked recompute rows, so an unbacked kernel row leaves no trace at all.
The precondition, since it decides whether this is worth fixing: reaching it needs the bodies stored beside the diff to disagree with what the kernel actually compared, because this engine recurses the same structure and a consistent artifact puts a recompute row over every kernel region. That is the same precondition the total-erase case needs. So the argument is symmetry, not a new failure mode — adding the guard concedes the precondition is worth defending, and if it is, it is worth defending exactly.
The exact form drops the special case; keep any kernel row nothing speaks about:
let mut reconciled = reconciled_with_kernel(rows, &diff.body_diff);
// A kernel row that no surviving recompute row speaks about was not
// re-shaped, it was dropped; it stands as the kernel wrote it.
for found in &diff.body_diff {
if !reconciled
.rows
.iter()
.any(|row| paths_overlap(&row.json_path, &found.json_path))
{
reconciled.rows.push(found.clone());
}
}
Some(reconciled)I ran it: the probe above goes to blocking_leaf_count = 2, and 449 of 450 tests pass unchanged. See the note on the one that moves.
| stats.note_kind("OrderNondeterministicWarning"); | ||
| order_nondeterminism_warnings += 1; |
There was a problem hiding this comment.
This is the part that answers the earlier objection — the newly-tolerated cases are now named rather than silenced. Confirmed killed by the_scorecard_names_what_the_kernel_judged_the_same when both lines are dropped.
Worth noting what that test covers beyond the counter itself: it asserts counter_disagreements().is_empty(), so the summary field is a fold of the kind rather than a number maintained beside it — which is the failure this file documents at kind_total.
Volume is fine too: kernel_equivalent_paths_seen is keyed by path, so the 100-correlation prism case emits one warning line with a count, not 100 lines. And the existing assert_eq!(card.summary.order_nondeterminism_warnings, 0) tests at 9897/10472 are a useful vacuity guard that the increment does not fire everywhere.
| ); | ||
| assert!( | ||
| order_canonical_body_diff(&diff).is_none(), | ||
| "a reconciliation that would empty a non-empty kernel set is refused" |
There was a problem hiding this comment.
This is the one assertion that moves under the exact form suggested at the guard above, and it is worth being clear that only the mechanism changes, not the outcome.
is_none() asserts the refusal. Appending every kernel row to an empty reconciliation is the same thing as yielding to them, so the outcome this test goes on to assert — blocking_leaf_count == 1 — is identical under both forms.
The kernel_equivalent_paths.is_empty() assertion below would also need restating: $.alpha then gets named as a path the kernel judged the same, which is accurate for this fixture and arguably the more useful reading of it.
…m are The erase guard was stated in both directions but enforced all-or-nothing: it fired only when the reconciliation was completely empty, so a kernel row that nothing backed was still dropped silently whenever one other kernel row was backed. Reaching that needs the same broken artifact the total-erase case needs, so this is the same precondition defended exactly rather than a new failure mode: if it is worth a guard, it is worth guarding row by row. A kernel row that no surviving recompute row speaks about now stands as the kernel wrote it. The unspoken-for rows are judged against the recompute's survivors alone, so two of them in one collection cannot speak for each other and lose one. The special case goes; the `None` path means only what it meant before this series, that the run predates stored bodies. The erase test is restated for the exact form. Its outcome is unchanged: the kernel's row blocks. What changes is the mechanism, and that the recompute's own unbacked row is now named as one the kernel judged the same, which is the accurate reading of that fixture. A second test pins the partial case, with two positional kernel rows in one collection both carried through. The handoff document that recorded a run under the counter's retired meaning now says so, so a reader grepping for the field is not led to the DB case.
|
Exact form adopted, in ba0a2f1. Guard. Row by row now: every kernel row no surviving recompute row speaks about is carried through as the kernel wrote it, and the Tests. The erase test is restated as Docs. One historical note under the cycle entry in Your two facts on the counter, single emit site and no verdict line, both hold in the tree. |
Summary
A replay of a recording by the same image that made it failed 99 of 100 correlations, every one at
$.rawConnectorRequest.value. The system under test echoes the connector request it built as a serialized JSON document; theheadersobject inside is iterated from a hash map, so its key order is seeded per construction and differs on every call. Same headers, same values, different bytes.The kernel's
diff_jsonreads that string as the document it is and reported nothing. The orchestrator's order-canonical recompute read it as bytes and reported one blocking leaf.classify_http_body_diffreplaced the kernel's rows with the recompute's whenever bodies were present, so the less-informed engine had the final say.The change
The recompute exists to decide WHERE a difference belongs once ordering is resolved, so a permutation is one difference at its collection rather than one per scattered position. It was never meant to decide WHETHER a difference exists; the kernel is the authority on that, and it alone applies the run's body allowlist.
The recompute's rows are now held to the kernel's findings, in both directions:
canonical_set_path, so either engine may describe one difference deeper (the kernel decoded an embedded document), shallower (the recompute stopped at the encoding), or at a different index of the same collection (alignment moved a changed member).OrderNondeterministicWarning(without counting a divergence, asReplyCanonAbsorbeddoes), incrementsorder_nondeterminism_warnings, and pushes a warning naming the path and why it did not count. A suite that leans on the kernel's judgment on every response no longer reads identically to one whose bodies agreed.This fixes the class, not the instance: no encoding has to be taught to two engines for them to agree, and an allowlisted path can no longer block through an engine that never saw the allowlist.
On the counter:
order_nondeterminism_warningswas declared, folded from a kind nothing emitted, reported on every scorecard, and hardcoded to zero. Its doc described retired UPDATE inherited-row ordering noise. It is repurposed with the doc stating what it meant before and that every scorecard since wrote zero, so no non-zero value has ever carried the old meaning.What still blocks
Probed against the changed code:
The only differences newly tolerated are the two the kernel deliberately equates, JSON object member order and distinct-key order in a form body, and each is named on the scorecard.
Tests
Eight new tests. The invariant in both directions: if the kernel found nothing, nothing blocks; if the recompute accounts for none of the kernel's findings, the kernel's rows stand. The measured incident is pinned as the pipeline builds it, and its path is named. A real change inside the same echoed document still blocks. Path coverage is by segment, not string prefix. At scorecard level, the counter folds from the kind,
counter_disagreements()is empty, and the warning names the path.just verify(fmt check, clippy with warnings denied, workspace tests) is clean.Relation to #94
#94 taught the recompute to decode embedded strings by importing the kernel's policy. This supersedes it with a change that removes the possibility of the two engines disagreeing at all, adds no string sniffing to the recompute, and is strictly more conservative (an array reordered inside an embedded document still blocks here). #94's decoding arm composes with this and could follow as a precision improvement, since with this in place it can no longer invent a block.