Skip to main content

justerm_core/
event.rs

1//! Consumer event surface (#12): point-in-time notifications the engine
2//! accumulates while parsing, for the consumer to drain.
3//!
4//! Pull, not push — the engine queues events during `feed` and the consumer
5//! takes them with `drain_events`, mirroring the rest of the pull cadence
6//! (`damage` / `frame` / `reset_damage`). No callback is injected across the
7//! boundary, so the engine stays decoupled from the consumer's event loop
8//! (unlike alacritty's `EventListener`, whose push model would couple them).
9//!
10//! OSC 8 hyperlinks are deliberately absent — a hyperlink is per-cell state
11//! (which cells are links), not a point-in-time event, so it is modelled like
12//! graphemes in its own slice (#26), not here.
13
14use crate::serialize::{MarkerId, MarkerKind};
15
16/// Which byte ended an OSC sequence — and therefore which one ends its reply.
17///
18/// The engine relays this rather than choosing: a query event carries the
19/// terminator the request arrived with, and the consumer hands it back to the
20/// matching `report_*`. Under ADR-0017 the parse-time fact is a *mechanism* only
21/// the engine can observe, while *which* terminator to send is policy — and a
22/// consumer cannot exercise a policy on a fact it was never given, which is what
23/// #836 measured: `bell_terminated` was discarded at the parser boundary before
24/// any event was queued.
25///
26/// **The spec settles the direction, not just the reference tally.**
27/// `ctlseqs.txt:2020` — *"XTerm accepts either BEL or ST for terminating OSC
28/// sequences, and when returning information, uses the same terminator used in a
29/// query. While the latter is preferred, the former is supported for legacy
30/// applications."* Under ADR-0004 that outranks every implementation, this one
31/// included. **On the colour path** all three implementations that echo carry the
32/// terminator *outward with the request* rather than remembering it: alacritty
33/// binds it into the reply formatter it sends its consumer
34/// (`alacritty_terminal/src/term/mod.rs:1678`), ghostty makes it a field on the
35/// parsed command (`src/terminal/osc.zig:87`, written at
36/// `src/termio/stream_handler.zig:1497`), and xterm threads it as a parameter
37/// (`misc.c:3567`, emitted at `:3593`). xterm.js is the one that always sends ST
38/// (`CoreBrowserTerminal.ts:239`) — which is what this engine used to do.
39///
40/// **The colour qualifier is load-bearing, because `OSC 52` has a counterexample.**
41/// xterm *does* store the terminator for the clipboard: `int base64_final;` on the
42/// screen (`ptyx.h:2637`), written when the request is parsed (`misc.c:3389`) and
43/// read a file away when the paste finally arrives (`button.c:2218`), because its
44/// X selection retrieval is asynchronous. That is shape (b) — the one this engine
45/// weighed and did not take — on the exact sequence `report_clipboard` answers.
46///
47/// It is recorded here because it **strengthens** the choice rather than undoing
48/// it. xterm stores precisely because the reply is detached from the request, and
49/// its store is a *single scalar*, so two overlapping paste requests would collide
50/// exactly as one stored terminator does here. Detachment is the condition
51/// [`crate::Engine::drain_events`] creates for every family at once, not just one —
52/// so carrying answers it where storing only postpones it.
53///
54/// **Carrying beats remembering for a reason that is this channel's.**
55/// [`crate::Engine::drain_events`] hands over a batch, so a consumer can hold two
56/// colour queries at once and answer them in either order; one remembered scalar
57/// could not say which exchange it belonged to. An occurrence's payload is
58/// detached from its instant by the queue — ADR-0029 D4 records the same shape
59/// for coordinates — so there is no re-ask and the fact must ride the event.
60///
61/// **Exhaustive on purpose (#843's rule).** The space is closed at exactly two,
62/// with a date for each (`ctlseqs.txt:2024-2028`), so there is no member a later
63/// slice may name and nothing for `#[non_exhaustive]` to preserve.
64///
65/// ⚠ **The closure rests on the input space, not on the spec alone (#847).** ECMA-48
66/// gives `ST` a third encoding — the 8-bit C1 `0x9C` — and it is absent here because
67/// [`crate::Engine::feed`] does not treat a lone `0x80..=0x9F` byte as a control at
68/// all, not because the spec stops at two. A reader who finds `0x9C` in `ctlseqs.txt`
69/// and concludes this enum is missing a member has the reasoning backwards: were that
70/// contract ever revisited, the member would follow, and it is the contract that is
71/// load-bearing.
72/// ghostty reaches the same shape independently — `src/terminal/osc.zig:252` is a
73/// two-member `{ st, bel }` with no trailing `_`, which is Zig's marker for an
74/// open enum and is used at 23 other sites in that tree. Convergence on both the
75/// partition and the closure is the non-arbitrariness signal.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
77pub enum Terminator {
78    /// `ESC \` (ST), the terminator ECMA-48 documents and xterm prefers.
79    ///
80    /// The default, and what an OSC ended by **any other byte that ends one**
81    /// resolves to. Those streams are real rather than theoretical: `vte` ends a
82    /// string on three byte classes — `BEL`, the cancel pair `CAN`/`SUB` (`0x18` /
83    /// `0x1a`), and a bare `ESC` opening the next sequence — and only the first is
84    /// reported as bell-terminated. So a cancelled query is still relayed, and
85    /// answered ST.
86    ///
87    /// **Read "any other byte that ends one" strictly: the 8-bit C1 `ST` (`0x9C`) is
88    /// not a fourth class (#847).** It does not end the string, so there is no event
89    /// to carry a terminator and nothing resolves to this variant — the OSC stays
90    /// open instead. See [`crate::Engine::feed`] for why that is a contract.
91    ///
92    /// That is the right answer, not a fallback: xterm hardcodes ST on exactly this
93    /// shape (`charproc.c:8964`, *"should be ST"*) and ghostty's `Terminator.init`
94    /// returns `.st` for a missing byte (`src/terminal/osc.zig:263`).
95    #[default]
96    St,
97    /// `BEL` (`0x07`), supported for legacy applications.
98    ///
99    /// Real applications still emit it: `nvim` 0.8.0 asks `ESC ] 11 ; ? BEL` in
100    /// this crate's own `cursor_color_nvim.raw` fixture. And the shell idiom
101    /// `printf '\e]11;?\a'; read -d $'\a'` reads *until* BEL, so an ST answer to
102    /// a BEL question blocks that `read` until it times out.
103    Bel,
104}
105
106impl Terminator {
107    /// The bytes that end a reply carrying this terminator.
108    pub(crate) fn bytes(self) -> &'static [u8] {
109        match self {
110            Terminator::St => b"\x1b\\",
111            Terminator::Bel => b"\x07",
112        }
113    }
114}
115
116/// Which selection an `OSC 52` clipboard request names (#828).
117///
118/// A *value*, never the protocol byte, so a consumer never parses the sequence —
119/// the same reason [`TermEvent::SetPaletteColor`] carries a `u8` index rather
120/// than the field it was written in.
121///
122/// **Three members, and `p` is kept apart from `s` deliberately.** The
123/// sequence's target field admits `c`, `p`, `q`, `s` and the eight cut buffers
124/// (`ctlseqs.txt:2156`); justerm models the three a consumer can act on and
125/// ignores the rest rather than folding them onto a neighbour, since mapping `q`
126/// onto a selection would be the engine inventing an equivalence the application
127/// did not ask for. ghostty folds every unrecognised kind onto the clipboard
128/// (`src/termio/stream_handler.zig:1013`); alacritty ignores them
129/// (`alacritty_terminal/src/term/mod.rs:1714`), and so does this. Read
130/// ghostty's from the `switch` and not from the comment four lines above it,
131/// which says *"we ignore the 'kind' field and always use the standard
132/// clipboard"* and is contradicted by the code under it — only the `else` arm
133/// goes to `.standard`.
134///
135/// **The first draft collapsed `p` and `s` into one member, and that was wrong
136/// on the wire.** alacritty collapses them
137/// (`alacritty_terminal/src/term/mod.rs:1713`) but replies with the byte the
138/// application sent (`:1744`), so the collapse never reaches a client. This
139/// engine hands the consumer a value and gets it back at `report_clipboard`, so
140/// a collapse here would answer `ESC ] 52 ; s ; ?` naming `p` — a selector the
141/// application never wrote, in the one field a client could pair a reply on. The
142/// spec lists the two separately (`ctlseqs.txt:2157`), xterm binds them to
143/// different atoms (`misc.c:3327`) and echoes the recognised list back
144/// (`misc.c:3384`), and ghostty keeps three locations apart in both directions
145/// (`src/Surface.zig:5954`, `src/terminal/c/terminal.zig:2942`). Splitting the
146/// member is what lets the value round-trip without the engine remembering
147/// anything.
148///
149/// **`#[non_exhaustive]` (#843).** The set is open by the paragraph above: `q` and
150/// the eight cut buffers are in the sequence and unmodelled here, so a later slice
151/// may name one. A consumer meeting a member it does not know can decline the
152/// request, which is already how it refuses any of them.
153///
154/// **ghostty reaches the same shape independently**, which #843 had recorded as
155/// impossible — its issue says Zig "has no such construct", and Zig does: a
156/// trailing `_` marks a non-exhaustive enum, used at 23 sites in that tree. The
157/// one that matters here is `src/terminal/clipboard.zig:2`, whose `Location` is
158/// `{ standard, selection, primary, _ }` — the same three members this type
159/// carries, and open for the same reason. Convergence on both the partition and
160/// the openness is the non-arbitrariness signal; it was invisible while the
161/// corpus was recorded as having no vote.
162#[non_exhaustive]
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164pub enum ClipboardTarget {
165    /// The system clipboard — the `c` field, **and the empty field**.
166    ///
167    /// The empty field is the common form in the wild rather than an edge case:
168    /// it is what `tmux` 3.2a was measured emitting for both an ordinary
169    /// copy-mode copy and `set-buffer -w` (#828). Reading it as "unrecognised"
170    /// would drop the only emission this project has observed.
171    Clipboard,
172    /// The primary selection — the `p` field. On a platform with no primary
173    /// selection a consumer may treat it as [`Clipboard`](Self::Clipboard); the
174    /// engine does not make that choice for it.
175    Primary,
176    /// The `s` field — *"the configurable primary/clipboard selection"*
177    /// (`ctlseqs.txt:2161`), which is to say: whichever of the two the user has
178    /// configured.
179    ///
180    /// **Relayed rather than resolved, and that is the boundary working.** The
181    /// thing that decides what `s` means is a setting: xterm resolves `SELECT`
182    /// through `DefaultSelection`, which is the `selectToClipboard` resource
183    /// (`button.c:2081`), and under ADR-0017 a setting is the consumer's. So the
184    /// application's choice is carried through unchanged and the consumer
185    /// resolves it against the configuration it owns.
186    ///
187    /// **A consumer with no such setting should treat this as
188    /// [`Primary`](Self::Primary), because that is what xterm-as-shipped does**
189    /// — `selectToClipboard` defaults to false. Worth stating rather than left
190    /// to taste, since the alternative reading sends the copy somewhere the
191    /// reference would not.
192    ///
193    /// And the setting is not purely out of reach: **DECSET 1041 sets the same
194    /// resource from the stream** (`ctlseqs.txt:1008`), so an engine that
195    /// tracked that mode could resolve `s` itself. justerm does not model 1041,
196    /// which is a *declined* capability rather than an impossible one — the
197    /// honest form of the claim, and the mode is unimplemented here like the
198    /// rest of the tail (#47).
199    Selection,
200}
201
202/// A consumer-facing event emitted while parsing the VT stream.
203///
204/// **`#[non_exhaustive]`, so a consumer must carry a `_` arm and a new variant
205/// never breaks one.** Decided 2026-09-02, by the maintainer, while #828 was
206/// adding two — and what decided it was neither this slice nor any consumer we
207/// can see.
208///
209/// **What decided it is `CLAUDE.md`'s own identity statement**: *"`justerm-core`
210/// is not penterm-only — it is a reusable, independent crate."* That sentence
211/// says there are consumers we cannot edit, which is precisely what this
212/// attribute defends; a crate whose identity were "internal, used by penterm"
213/// would want the opposite, because there a broken build is the compiler doing
214/// us a favour. So this follows from a call already made rather than from a
215/// preference, and it reverses only if that identity does.
216///
217/// Three measurements, so the next reader does not have to retake them:
218///
219/// - **crates.io reverse dependencies: zero** (the single row the API returns is
220///   this crate itself), across 248 downloads split over 11 versions — i.e. no
221///   external consumer exists *today*. That is why the identity statement had to
222///   decide it: there was nothing to observe.
223/// - **Cost in this workspace: zero.** `cargo test --workspace` (87 suites) and
224///   `clippy -D warnings` both stay green. A same-crate `match` may still be
225///   exhaustive, `justerm-wasm-decode` and `justerm-renderer` never name
226///   `TermEvent`, and `justerm-web`'s `events.ts` is a deliberately narrower
227///   union (title / bell / cwd).
228/// - **The window closes at `1.0.0`.** Adding this is free while the crate is
229///   `0.x` and is *itself* a breaking change afterwards, while an enum without
230///   it turns every future variant into a major bump. Conformance here is
231///   cumulative by design (#47 is a perpetual tail) and the two slices before
232///   this one added three variants and two, so that rate is measured rather than
233///   assumed.
234///
235/// **The argument that lost, recorded because it is a real cost.** An exhaustive
236/// match is a *feature* for a consumer: the compiler tells them a new event
237/// exists and makes them decide about it. penterm's `route_event` is the worked
238/// example — its `ColumnMode` and `ColorSchemeQuery` arms carry a comment
239/// explaining why each is dropped, written by someone the compiler had just
240/// informed. That signal is given up here, and it now has to come from release
241/// notes. It loses because this is a *notification* channel where ignoring an
242/// unknown event is documented as safe, so the guarantee belongs in prose rather
243/// than in the type — but a consumer who wanted the old behaviour is not
244/// imagining the loss.
245///
246/// (penterm was the evidence that an outside exhaustive matcher can exist — its
247/// five-variant `match` predates nine minor versions of this enum — and not the
248/// reason. It is being reimplemented, which is exactly why it could not be.)
249#[non_exhaustive]
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum TermEvent {
252    /// The window title is now this string.
253    ///
254    /// Read the tense carefully: since #823 this is **not** only "the
255    /// application set a title". Two paths emit it — `OSC 0`/`OSC 2`, and an
256    /// XTWINOPS title *pop* (`CSI 23 t`) restoring what an earlier `CSI 22 t`
257    /// saved. A consumer that treats it as "the title is now this" is correct
258    /// for both; one that treats it as "the application just chose this" is
259    /// wrong for the second, which is why there is no separate pop event.
260    ///
261    /// A pop can legitimately restore the **empty** string — every application
262    /// measured pushes at startup, before setting a title of its own — and that
263    /// means "go back to whatever you would show by default", not "show a blank
264    /// title".
265    Title(String),
266    /// The terminal bell rang (BEL, `0x07`).
267    Bell,
268    /// The working directory was reported (OSC 7), e.g. `file://host/path`.
269    Cwd(String),
270    /// The app requested 80/132-column mode (DECCOLM `?3`). justerm is
271    /// dimension-free, so this is a *request* — the consumer may honor it by
272    /// calling `resize(cols, rows)`, or ignore it. `cols` is 80 or 132 (#82).
273    ColumnMode { cols: usize },
274    /// The app queried the light/dark color scheme (DSR `CSI ? 996 n`). justerm
275    /// is theme-agnostic, so the consumer (which knows the scheme) answers by
276    /// calling `Engine::report_color_scheme` (#85).
277    ColorSchemeQuery,
278    /// The app set ANSI palette entry `index` to `spec` (OSC 4). One event per
279    /// `index ; spec` pair in the sequence. The cell still references
280    /// `Indexed(index)` — only the consumer's `palette[index]` changes, so the
281    /// engine stays theme-agnostic (#122).
282    SetPaletteColor { index: u8, spec: String },
283    /// The app set the default foreground colour (OSC 10). Raw spec, forwarded
284    /// for the consumer to apply — theme-agnostic, like [`SetBackground`](Self::SetBackground) (#122).
285    SetForeground(String),
286    /// The app set the default background colour (OSC 11). The engine is
287    /// theme-agnostic, so it forwards the raw spec string (`rgb:…`/`#…`) for the
288    /// consumer to parse and apply to its palette — it never holds hex (#122).
289    SetBackground(String),
290    /// The app reset palette entries to the theme default (OSC 104). `None` =
291    /// the whole table (no argument); `Some(index)` = one entry, one event per
292    /// index given. The consumer restores its palette (#122).
293    ResetPaletteColor(Option<u8>),
294    /// The app queried ANSI palette entry `index` (OSC 4 with `?` for that pair);
295    /// the consumer answers with `report_palette_color` (#122).
296    QueryPaletteColor {
297        index: u8,
298        /// The terminator `report_palette_color` must answer with (#836).
299        terminator: Terminator,
300    },
301    /// The app set the cursor colour (OSC 12, #832). The third slot of the same
302    /// dynamic-colour sequence `SetForeground` and `SetBackground` ride, and
303    /// theme-agnostic for the same reason: the raw spec is forwarded and the
304    /// consumer — which owns the palette *and* the cursor's contrast guard —
305    /// applies it.
306    SetCursorColor(String),
307    /// The app queried the cursor colour (OSC 12 with `?`); the consumer answers
308    /// with `report_cursor_color` (#832).
309    QueryCursorColor {
310        /// The terminator `report_cursor_color` must answer with (#836).
311        terminator: Terminator,
312    },
313    /// The app reset the cursor colour to the theme default (OSC 112, #832). The
314    /// third member of the 110/111/112 reset family, and the one real
315    /// applications emit most: `nvim` sends it on startup, on every alt-screen
316    /// transition and on exit.
317    ResetCursorColor,
318    /// The app reset the default foreground to the theme default (OSC 110, #122).
319    ResetForeground,
320    /// The app reset the default background to the theme default (OSC 111, #122).
321    ResetBackground,
322    /// The app queried the default foreground colour (OSC 10 with `?`); the
323    /// consumer answers with `report_foreground` (#122).
324    QueryForeground {
325        /// The terminator `report_foreground` must answer with (#836).
326        terminator: Terminator,
327    },
328    /// The app queried the default background colour (OSC 11 with `?`). The
329    /// theme-agnostic engine relays it; the consumer answers with
330    /// `report_background` (#122), mirroring `ColorSchemeQuery`.
331    QueryBackground {
332        /// The terminator `report_background` must answer with (#836).
333        terminator: Terminator,
334    },
335    /// The app asked for `text` to be put on `target` (`OSC 52` with a payload,
336    /// #828). The engine has already base64-decoded it, and holds no clipboard
337    /// of its own.
338    ///
339    /// **This is a request, not a fact.** Whether the copy happens is the
340    /// consumer's: it owns the platform clipboard, any permission model and any
341    /// prompt, and a consumer that drops this event has refused the copy. The
342    /// engine carries no allow/deny knob, which is where it parts company with
343    /// alacritty — alacritty gates the same sequence behind a four-state config
344    /// (`alacritty_terminal/src/term/mod.rs:1706`) because alacritty *is* the
345    /// consumer. Under ADR-0017 that gate lives one layer out.
346    ///
347    /// **An empty `text` means "clear it".** `ESC ] 52 ; c ; ESC \` carries a
348    /// payload that decodes to nothing, and both the spec and xterm end that
349    /// exchange with an empty selection — the spec because `Pd` *"becomes the
350    /// new selection"* whatever it is (`ctlseqs.txt:2166`), xterm because it
351    /// clears the buffer before appending anything (`misc.c:3410`). Note this is
352    /// **not** the spec's *"neither a base64 string nor `?`"* clause at
353    /// `ctlseqs.txt:2174`, which the engine diverges from: an empty payload is a
354    /// perfectly well-formed encoding of no bytes, so it never reaches that
355    /// sentence. Citing `:2174` here, as an earlier draft did, would have the
356    /// same line standing as authority followed and as authority departed from.
357    /// ghostty pins the same input under a test named *"clear clipboard"*
358    /// (`src/terminal/osc/parsers/clipboard_operation.zig:93`). It needs no rule
359    /// of its own here, which is the argument for having none: an empty payload
360    /// *is* a well-formed encoding of no bytes, so it reaches the consumer
361    /// through the ordinary path.
362    ClipboardStore {
363        target: ClipboardTarget,
364        text: String,
365    },
366    /// The app asked what is on `target` (`OSC 52` with a `?` payload, #828).
367    /// The consumer answers by calling `report_clipboard`, which encodes the
368    /// reply — or declines, which is how a clipboard *read* is refused
369    /// independently of a write.
370    ///
371    /// The engine cannot answer this itself and deliberately holds nothing that
372    /// would let it: a query is answered from the consumer's clipboard or not at
373    /// all, so there is no engine state here for a hostile application to read
374    /// back. Same `Query…` + `report_…` shape as `OSC 4`/`10`/`11`/`12`.
375    QueryClipboard {
376        target: ClipboardTarget,
377        /// The terminator `report_clipboard` must answer with (#836).
378        terminator: Terminator,
379    },
380    /// A decoration marker's line left the buffer — evicted past the scrollback
381    /// cap, or scrolled out of an in-screen region (#118). The handle is now
382    /// dead; the consumer drops the decoration bound to it. This is the
383    /// frame-mode equivalent of xterm's `IMarker.onDispose` — disposal is a
384    /// point-in-time fact (a marker absent from a frame may merely be scrolled
385    /// off-screen), so it rides the event queue, not the frame overlay.
386    MarkerDisposed(MarkerId),
387    /// A marker was created (#490) — by `add_marker`, or by the *stream* through an
388    /// OSC 133 command mark, which the consumer never called for.
389    ///
390    /// The mirror of [`TermEvent::MarkerDisposed`], and it exists for the same reason
391    /// ADR-0020 R1 gives: an appearance is an occurrence, not state, so it rides this
392    /// queue rather than a frame field. Without it a consumer that pulled a marker
393    /// index (`Engine::marker_index`) has no way to learn of a marker born after its
394    /// pull — the population would only ever shrink.
395    ///
396    /// `line` is absolute at the moment of creation, and `evicted_total` / `epoch` are the
397    /// instant it is absolute at — the same triple [`crate::MarkerIndex`] carries, because
398    /// this event is that pull's incremental mirror. The consumer appends the entry with
399    /// the basis it arrived on and rebases it exactly like a pulled one.
400    ///
401    /// **The two are one fact and neither is usable alone (#737).** A single `feed` can
402    /// create a marker and then evict, so by the end of the batch the buffer's origin has
403    /// moved out from under the line this event already carries. `Frame::evicted_total` is
404    /// the basis at the *end* of that batch, so reading `line` against it misplaces the
405    /// marker by however much the batch evicted after the birth — measured at three lines,
406    /// with the event line, both frame bases, the epoch and `Frame::marker_count` all
407    /// identical to the batch that evicted *first* and needs no adjustment at all.
408    ///
409    /// **And a basis dates only a uniform move (#741).** Eviction shifts every marker by
410    /// the same amount, which is what one scalar can say; a reflow or a region rotate
411    /// moves them *individually*, which is what `epoch` is for. A birth still queued when
412    /// the epoch moves describes a buffer that no longer exists, and carrying only the
413    /// basis leaves that indistinguishable from a birth in the current generation —
414    /// measured, a mark at absolute 3 reflowed to 5 with the basis unmoved at 0.
415    ///
416    /// Deliberately not an epoch bump: a bump costs a whole re-pull, and creation is
417    /// `O(1)` information.
418    MarkerCreated {
419        id: MarkerId,
420        line: u32,
421        kind: MarkerKind,
422        /// Lines evicted since RIS at the moment of creation — the basis `line` is
423        /// absolute at. Carried rather than inferred so that placement does not depend on
424        /// whether the consumer drains this queue before or after it reads the frame,
425        /// which nothing in the API specifies.
426        ///
427        /// It is the same quantity `Frame::evicted_total` reports, so a consumer whose
428        /// transport crosses a language boundary owes it the same treatment: the wasm
429        /// frame getter hands its `u64` over as an `f64` deliberately (exact to 2^53),
430        /// because a `BigInt` on one side of a subtraction and a `number` on the other is
431        /// a `TypeError`, not a rounding question.
432        evicted_total: u64,
433        /// The marker generation this line belongs to — [`crate::MarkerIndex::epoch`] at
434        /// the moment of creation (#741). Two lines dated with different epochs are
435        /// answers about different buffers and nothing rebases one onto the other, so a
436        /// consumer adopts this entry only into the generation it names and lets the
437        /// re-pull that the bump already forces supply it otherwise.
438        ///
439        /// Compare it for **equality**, never for order: the counter is
440        /// `wrapping_add`, so `<` is meaningless across a wrap while `==` is exact.
441        epoch: u32,
442    },
443}