Skip to content

Mark a reply unordered from the protocol, and say who forgave an ordering - #109

Merged
maverox merged 2 commits into
mainfrom
work/bag-mark-from-protocol
Sep 8, 2026
Merged

maverox merged 2 commits into
mainfrom
work/bag-mark-from-protocol

Conversation

@maverox

@maverox maverox commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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 a SELECT
with no ORDER BY, the keys of a redis SCAN. A type-directed canon correctly
leaves a Vec alone, so here the fact is read from the protocol's own evidence
by 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 exactly
what 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 and
appended 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 Vec by the
time they reach any seam (see #108's body). The producer fix is the vendor
branch work/deterministic-ordering.

Two commits

  1. Mark from the protocol; read the mark. RecordedOutput::reply_canon, the
    DB 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 on
    both sides. Routing the clause alone would have been harmful:
    Canon::equivalent refuses per-path clauses by design, so a correctly
    stamped declaration would have resolved and then been refused, manufacturing
    divergences.
  2. Say who forgave it. The matched-result comparison returns a verdict
    (Equal | Absorbed(why) | Diverged) and the two comparison sites bump one
    kind, ValueCanonAbsorbed, keyed by source — recorder or default — the
    HTTP 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 default here turns
    red and every recorder stays green.

Review notes

  • The source is a two-variant type, not ClauseSource: documents are not
    consulted on this path, so Document/Both could never fire, and a type that
    names only what can happen needs no unreachable arm. Labels are pinned equal
    to ClauseSource's by a test.
  • sql_has_top_level_order_by tracks paren depth, skips string literals, cuts
    diesel's -- binds: tail, and answers "ordered" on anything it cannot follow
    — which leaves the result exactly as it was recorded before.
  • Only the #[deja::redis] kit pays the args clone the derivation needs; every
    other preset's extractor is token-identical to before.
  • A db-infrastructure equivalence (SERIAL id, error diagnostics,
    UPDATE … RETURNING) is named as its own absorption and NOT counted under the
    ordering kind.
  • bump_kind also bumps the per-boundary diverged tally, as
    ReplyCanonAbsorbed already does; nothing reads it for a verdict. Neither kind
    has a fold in counter_disagreements; if one gets a summary counter, both
    should, together.

Verification

just verify exit 0, 831 passed (21 new across the two commits); MSRV 1.85
clean. 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 because
db_normalize_infra strips 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

@maverox maverox self-assigned this Sep 7, 2026
@maverox
maverox changed the base branch from work/normalize-at-record to main September 8, 2026 08:15
maverox and others added 2 commits September 8, 2026 13:47
… 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
maverox force-pushed the work/bag-mark-from-protocol branch from 6079b5f to fff947f Compare September 8, 2026 08:18
@maverox
maverox merged commit 08588bd into main Sep 8, 2026
10 checks passed
@maverox
maverox deleted the work/bag-mark-from-protocol branch September 24, 2026 07:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant