Mark a reply unordered from the protocol, and say who forgave an ordering - #109
Merged
Merged
Conversation
… mark A boundary whose CONTRACT says its reply carries no order — the rows of a SELECT with no ORDER BY, the keys of a redis SCAN — returns a Vec, and a type-directed canon (#108) correctly leaves a Vec alone. The fact about order lives in the protocol here, not in the type, and the only party holding both the reply and the protocol's evidence at capture time is the codec that captured it: the DB codec has the statement, the redis kit has the command the site recorded in its args. So the codec MARKS the reply, and never sorts it. The rows are recorded in the order they arrived: a service that (legally, wrongly) takes .first() of an unordered result must get on replay exactly what it got in the recording, and the wire rows the DB capture pairs to its serde rows are paired by index. Reordering at capture would break both. The mark is a reply canon — `bag:$.value[]`, naming the ResultCodec envelope's rows — derived per event and appended as a clause to the site's static declaration, so the comparator reads one declaration and nobody declares a path by hand. An undeclared site with nothing derived still records `declaration: None`, byte-identical to before. The DB side derives it from the statement in args.sql: a multi-row Ok whose SQL has no top-level ORDER BY. Nesting depth is tracked so a subquery's ORDER BY does not count, string literals are skipped, diesel's `-- binds:` tail is cut off, and anything the scan cannot follow answers "ordered", which leaves the result exactly as it was recorded before this existed. The redis side derives it from args.command against the protocol's own list of set-returning and scan commands; list, stream and sorted-set reads are deliberately absent because their order is the value. Only the redis kit pays the args clone this needs; every other preset's extractor is token-identical to before. The comparator change lands in this same commit on purpose. The args/result path resolved a declaration through a single-preset parser whose fallthrough is parse_project_canon, so a `bag:` clause — let alone one appended after a static clause with `;` — resolved to None and was never consulted. Routing it through canon_clauses is one line, and on its own that line would be actively harmful: Canon::equivalent returns false for a per-path bag clause by design ("consulted per difference, not here"), so the clause would resolve, be refused unconditionally, and a correctly stamped recorder declaration would start manufacturing divergences that look exactly like real ones. The per-path answer is therefore here too: the named collections are sorted on both sides — non-recursively, the members of the bag and not the arrays inside them — and the whole values compared. Whole-value equality is what refuses a change of shape: an array facing a missing key or a scalar cannot be made equal by sorting. A rule comparing how many collections each side reached was tried and could not be killed by any mutation, so it is not here; the counts remain for the tests, which use them to prove a path was reached and a sort did work before trusting an equivalence. pure permutation is absorbed with or without the mark. What the mark adds now is attribution; what it adds when that default is removed is that these results stay green for a stated reason instead of going red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtydCoMHX5v44cdUN3ytek
…unt it A matched call whose args or result differed by ordering alone was not counted as a divergence, and nothing said so. The comparison returned a bool, both callers routed on it, and the reason a difference stopped counting was discarded on the way to the scorecard. That is the shape this codebase keeps paying for — a mechanism shipped without the evidence that it works — and it matters here specifically: the strict flip that removes #102's order-blind default waits on knowing how many absorptions the default is carrying versus how many a recorder-stamped clause carries, because when the default goes the first set turns red and the second stays green. The comparison now produces a verdict — Equal, Absorbed(why), Diverged — and the bool the existing callers need is derived from it, with a note on the bool that a caller needing the reason must not take it because it is there. The two sites that compare a matched call's values, the resolved path and the args-free twin path, bump ONE kind, ValueCanonAbsorbed, and key the source into the scorecard as data: `recorder` for a clause on the recorded event, `default` for #102's fallthrough. That is the HTTP reply path's own shape (ReplyCanonAbsorbed, keyed by path and source), chosen over two kind names so a consumer aggregating absorptions across both engines reads one vocabulary; a test pins the labels equal. The source is a two-variant type of its own rather than ClauseSource. Documents declare reply canons for the HTTP reply and are not consulted for matched calls, so two of ClauseSource's four variants cannot occur on this path; reusing it would put two arms in every match that never fire, and a reader would take "documents are handled here" from their presence. A type that names only what can happen is the honest one. A db-infrastructure equivalence — a replay-local SERIAL, an error's diagnostic text, an UPDATE ... RETURNING the statement explains — is named as its own absorption and deliberately NOT counted under the ordering kind, so the number the flip reads means "order alone" and nothing looser. The kind is bumped through bump_kind, which also increments the per- boundary `diverged` tally, exactly as ReplyCanonAbsorbed does on the ingress boundary. Nothing reads that tally for a verdict; it is "everything that was not a plain match", and an absorbed permutation is that. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtydCoMHX5v44cdUN3ytek
maverox
force-pushed
the
work/bag-mark-from-protocol
branch
from
September 8, 2026 08:18
6079b5f to
fff947f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #108. Independent of it in code except that both touch
deja-runtime/src/lib.rs; merges after it.What this is, and what it is not
#108 canonicalises a collection whose Rust type says its order carries no
information. This handles the next case along: a boundary whose contract
says the reply is unordered while the type says
Vec— the rows of aSELECTwith no
ORDER BY, the keys of a redisSCAN. A type-directed canon correctlyleaves a
Vecalone, so here the fact is read from the protocol's own evidenceby the codec that captured the reply: the DB codec has the statement, the redis
kit has the command the site recorded.
It marks, it never sorts. The rows are recorded in the order they arrived: a
service that takes
.first()of an unordered result must get on replay exactlywhat it got in the recording, and the DB capture pairs wire rows to serde rows
by index. The mark is a reply canon,
bag:$.value[], derived per event andappended as a clause to the site's static declaration. Nobody declares a path.
Neither this nor #108 closes the reference run's 25 — those are
Vecby thetime they reach any seam (see #108's body). The producer fix is the vendor
branch
work/deterministic-ordering.Two commits
RecordedOutput::reply_canon, theDB and redis derivations, and — in the same commit, deliberately — the
comparator reading a declaration as the clause list it is and answering a
per-path
bag:clause by sorting the named collections non-recursively onboth sides. Routing the clause alone would have been harmful:
Canon::equivalentrefuses per-path clauses by design, so a correctlystamped declaration would have resolved and then been refused, manufacturing
divergences.
(
Equal | Absorbed(why) | Diverged) and the two comparison sites bump onekind,
ValueCanonAbsorbed, keyed by source —recorderordefault— theHTTP reply path's own shape, one vocabulary. That count is the number the
strict flip waits on: when Treat array order as carrying no meaning unless a path says it does #102's default goes, every
defaulthere turnsred and every
recorderstays green.Review notes
ClauseSource: documents are notconsulted on this path, so
Document/Bothcould never fire, and a type thatnames only what can happen needs no unreachable arm. Labels are pinned equal
to
ClauseSource's by a test.sql_has_top_level_order_bytracks paren depth, skips string literals, cutsdiesel's
-- binds:tail, and answers "ordered" on anything it cannot follow— which leaves the result exactly as it was recorded before.
#[deja::redis]kit pays the args clone the derivation needs; everyother preset's extractor is token-identical to before.
id, error diagnostics,UPDATE … RETURNING) is named as its own absorption and NOT counted under theordering kind.
bump_kindalso bumps the per-boundarydivergedtally, asReplyCanonAbsorbedalready does; nothing reads it for a verdict. Neither kindhas a fold in
counter_disagreements; if one gets a summary counter, bothshould, together.
Verification
just verifyexit 0, 831 passed (21 new across the two commits); MSRV 1.85clean. Eleven mutations, each killing exactly its target, restored and diffed
clean after each — including the one that turns the routing fix into the
harmful routing-only change (kills two tests), and one that could kill nothing:
a reached-count symmetry rule for the asymmetric case, which whole-value
equality already guaranteed, and which was therefore removed rather than
kept as a rule nobody could falsify.
Two test drafts passed for the wrong reason and are recorded in the note:
#102's default still absorbs a pure permutation beneath the clause check (so
"without the clause it diverges" is unassertable end-to-end until the flip),
and integer-
id-only rows compare equal on the db boundary becausedb_normalize_infrastrips replay-local SERIALs.Design and evidence:
deja-handovers/results/normalize-at-record.md§11–§13.🤖 Generated with Claude Code
https://claude.ai/code/session_01VtydCoMHX5v44cdUN3ytek