Reducers
This content is for the 0.1 version. Switch to the latest version for up-to-date documentation.
A reducer is a pure function from (state, action) to state.
Every AHP client ships the same seven — one per channel — and they must behave
identically, because that is the only reason two clients converge on the same
view of a session.
They are hand-ported, not generated
Section titled “They are hand-ported, not generated”The wire types in this package are generated from the protocol’s TypeScript sources. The reducers are not.
They are logic rather than schema, so every client hand-ports them from
types/channels-*/reducer.ts and proves parity against a shared corpus of
246 fixtures — the same ones the Rust, Go, Swift, and Kotlin clients assert
against.
dart test test/reducers_fixture_test.dart # from a checkoutAll 246 pass with nothing skipped.
The clock seam
Section titled “The clock seam”Reducers are pure with one exception: the chat reducer stamps modifiedAt.
That makes it the one place a wall-clock read would break determinism, so the clock is a parameter:
chatReducer(state, action, now: AhpClock.system);The fixture corpus assumes Date.now() === 9999, which the harness supplies as
AhpClock.fixtureFrozen.
Parity is unreachable without matching it.
Unlike the Go client, which uses a process-global SetNowProvider, this is a
parameter — Dart tests share an isolate and would race each other over a
global.
Reconciliation needs the same seam for a second reason.
A pending action is replayed on every rebase, and if each replay re-stamped
with a fresh now, the optimistic state would change identity on every
recompute — non-deterministic tests, and an endless rebuild loop in any bound
UI.
Pending entries therefore capture a stamp once and replay with that frozen
value.
Two allocation contracts
Section titled “Two allocation contracts”These are not about what a reducer computes but about what it allocates, and the Flutter bindings depend on both.
A no-op arm returns state identically, never an equal copy:
if (action is! SessionAction) return state; // not state.copyWith()Generated state classes deliberately do not override ==, so ValueNotifier
compares by identity.
An identical return is free rebuild suppression all the way up the tree.
Untouched sub-objects are structurally shared:
ChatState _appendToken(ChatState s, String id, String token) => s.copyWith( messages: {...s.messages, id: s.messages[id]!.append(token)}, messageIds: s.messageIds, // the same list instance — do not rebuild it);Appending a streamed token must not reallocate the message-id list. If it does, the structural selector described in narrowing rebuilds fires on every token and the whole scheme collapses.
The fixture corpus cannot check either of these — they are about identity, not value — so they are enforced by review.
Unknown actions are ignored
Section titled “Unknown actions are ignored”A host on a newer protocol version may send actions this client has never heard
of.
The spec says to ignore them silently, and the generated StateAction union
has an Unknown variant that preserves the raw payload, so a decode-encode
round-trip re-emits it unchanged.
Reducers treat that variant as a no-op rather than a failure. Your client keeps working against a newer host; it simply does not act on what it does not understand.