diff --git a/changelog.d/sui-67d.md b/changelog.d/sui-67d.md new file mode 100644 index 0000000..ce3567b --- /dev/null +++ b/changelog.d/sui-67d.md @@ -0,0 +1,8 @@ +### Added + +- Every `effect.*` wire message now carries the engine's `round` stamp in + its envelope, alongside `macrostep` and `microstep` - + `effect.budget_exhausted` already carried it, and the other nine types + gain it. An additive field under the wire format's must-ignore rule, so + the format version stays 1; consumers reading older recorded streams + must still tolerate `effect.*` messages without the key. diff --git a/docs/wire-format.md b/docs/wire-format.md index c64f6ca..57653e7 100644 --- a/docs/wire-format.md +++ b/docs/wire-format.md @@ -43,9 +43,9 @@ Every message is a JSON object. Every message carries: | type | string | always | | session | string | always | | seq | integer | always | -| macrostep | integer | `trace.*` only | -| microstep | integer | `trace.*` only | -| round | integer | `trace.*` only | +| macrostep | integer | `trace.*` and `effect.*` | +| microstep | integer | `trace.*` and `effect.*` | +| round | integer | `trace.*` and `effect.*` | `type` is a dotted-namespace string identifying the message shape; the remaining sections of this document define every value it may take. @@ -54,19 +54,22 @@ consumer can share one channel across a session and, later, an invoke tree of related sessions. `seq` is a per-session monotonic integer, described fully under "Ordering" below. -**`effect.*` messages carry `macrostep` and `microstep` but not `round`, -even though the engine now stamps `round` on every core effect.** As of -ADR-0046 (`st-xb2b`), every core (non-trace) statifier effect payload -carries `macrostep`, `microstep`, and `round` - effects emitted before that -fold carry `round: 0`. This producer does not yet propagate the engine's -`round` onto `effect.*` messages; that propagation is tracked as -`sui-t36.5`. This is not an oversight this document papers over: the gap is -in this producer, not upstream, and inventing a value on the wire ahead of -the propagation work would be a guess baked into the format. When this -producer's `effect.*` payloads gain `round`, this document's `effect.*` -schemas gain the key. That is an additive change under the versioning rule -above, not a version bump. The envelope table above reflects what this -producer emits today: `round` on `trace.*` only. +**`effect.*` messages carry all three counters, `round` included** +(`sui-67d`). The value is the engine's own stamp, carried verbatim: as of +statifier ADR-0046 (`st-xb2b`), every core (non-trace) statifier effect +payload carries `macrostep`, `microstep`, and `round` - effects emitted +before that fold carry `round: 0` - and this producer propagates the +stamp rather than inventing one. Between ADR-0046 landing upstream and +`sui-67d` landing here, this producer emitted `round` on `trace.*` +messages and `effect.budget_exhausted` only; the other `effect.*` types +gained the key later. **Versioning decision, recorded rather than left +implicit:** adding `round` to the remaining `effect.*` envelopes is an +additive field change, exactly what the conformance section's MUST-ignore +rule makes safe, so **the format version stays 1** - the same reasoning +`effect.datamodel_change` records below for adding a whole type. A +consumer reading an older recorded stream must still tolerate `effect.*` +messages without `round` (the must-ignore rule's mirror image: absence of +a field a newer producer would have written is not an error). Beyond the fields in the table, every message carries a **payload**: the type-specific fields, documented below one type at a time. A payload key @@ -516,13 +519,12 @@ ADR-0005 leaves non-trace effect naming to this document because a bare top-level `done` type would sit confusingly next to `trace.done` - a different message about the same moment in the run. `effect.*` and `trace.*` are two visibly distinct halves of the vocabulary: -`trace.*` are the nine Appendix D phase boundaries, stamped with all three -counters; `effect.*` are the core effect vocabulary, stamped with -`macrostep`/`microstep` only (see "The envelope" above for why `round` is -absent). +`trace.*` are the nine Appendix D phase boundaries and `effect.*` are the +core effect vocabulary, both stamped with all three counters (see "The +envelope" above; `round` joined the `effect.*` envelopes in `sui-67d`, +after `effect.budget_exhausted` had carried it from the start). -Every `effect.*` message carries `macrostep` and `microstep`, never -`round`. +Every `effect.*` message carries `macrostep`, `microstep`, and `round`. ### `effect.log` @@ -547,8 +549,9 @@ The terminal effect, emitted once after top-level final entry. ### `effect.budget_exhausted` Emitted when a macrostep's fold spends its round budget without reaching -quiescence (ADR-0019). This is the one core effect that does carry `round` -- see "The envelope" above. +quiescence (ADR-0019). This was the one core effect that carried `round` +before `sui-67d` propagated the key onto the rest of the `effect.*` +family - see "The envelope" above. | Field | Type | Presence | |---|---|---| @@ -912,9 +915,11 @@ that test rather than drifting silently. - ADR-0003 (`docs/adr/0003-fixtures-as-the-example-data-contract.md`) - the fixtures sidecar object `session.start`'s `fixtures` field carries verbatim. -- `st-nbmj` - the upstream gap behind `effect.*` messages carrying no - `round`, closed by ADR-0046; this producer's own propagation of `round` - onto `effect.*` messages is tracked separately as `sui-t36.5`. +- `st-nbmj` - the upstream gap behind `effect.*` messages once carrying no + `round`, superseded by `st-xb2b`, whose ADR-0046 settled it (the two ids + name one thread of work: `st-nbmj` filed the gap, `st-xb2b` decided it); + this producer's own propagation of `round` onto `effect.*` messages was + `sui-67d`, above. - `st-r6l9` - the upstream reordering seam behind the old ordering warning above, closed by ADR-0044. - `st-1xwh` - the upstream effect (`Statifier.Effect.DatamodelInit`) behind diff --git a/lib/statifier_ui/datamodel_explorer.ex b/lib/statifier_ui/datamodel_explorer.ex index a1468f8..36fd240 100644 --- a/lib/statifier_ui/datamodel_explorer.ex +++ b/lib/statifier_ui/datamodel_explorer.ex @@ -146,10 +146,12 @@ defmodule StatifierUI.DatamodelExplorer do least one write stamped at that macrostep carried a decoded `prior_value` different from its decoded `new_value` - so a first binding, which has no `prior_value` at all, counts as a change from - `:undefined`. This is macrostep granularity, not round: every - `effect.*` type but `effect.budget_exhausted` carries a `nil` `round` - (`normalizer.ex:271`), so finer marking would need an upstream wire - change. + `:undefined`. This is macrostep granularity, not round - chosen when + `effect.datamodel_change` still carried a `nil` `round` envelope. + sui-67d has since stamped `round` on every `effect.*` message, so + round-granular marking is now a possible refinement here rather than + an upstream wire change; this pane deliberately stays at macrostep + granularity, and never reads the `round` field at all. 7. `shape` and `label` are computed once, from each entry's final decoded value, so an entry written more than once in one macrostep is labelled from its end state. diff --git a/lib/statifier_ui/event_log.ex b/lib/statifier_ui/event_log.ex index ffe0756..885bf94 100644 --- a/lib/statifier_ui/event_log.ex +++ b/lib/statifier_ui/event_log.ex @@ -23,15 +23,15 @@ defmodule StatifierUI.EventLog do ## Routing `effect.*` messages - Every `trace.*` message carries a `round`; eight of the nine `effect.*` - types do not and are routed to their macrostep's `effects` instead. The - ninth, `effect.budget_exhausted`, is "the one core effect that does carry - `round`" (`docs/wire-format.md:546-547`), so it is routed to its round - bucket instead of `effects`. The routing rule is therefore "does this - message carry a `round`", never a check against the `"effect."` prefix - - which is also what makes a future propagation of `round` onto the - remaining `effect.*` types (tracked separately, not part of this plan) a - pure data change that needs no change here. + The routing rule is "does this message carry a `round`", never a check + against the `"effect."` prefix. When this module was written only + `trace.*` messages and `effect.budget_exhausted` carried `round`, so the + other `effect.*` types landed in their macrostep's `effects`; sui-67d + then propagated `round` onto every `effect.*` type, and - exactly as + planned - that was a pure data change needing no change here: an + `effect.*` message carrying a `round` is routed to its round bucket, and + `effects` still catches any message without one (an older recorded + stream, for example - the must-ignore rule cuts both ways). ## Errors diff --git a/lib/statifier_ui/trace/message.ex b/lib/statifier_ui/trace/message.ex index 094372b..435c0b0 100644 --- a/lib/statifier_ui/trace/message.ex +++ b/lib/statifier_ui/trace/message.ex @@ -3,8 +3,9 @@ defmodule StatifierUI.Trace.Message do One message of the trace wire format (`docs/wire-format.md`), held as a struct in process and rendered to the documented JSON object by `to_map/1`. - The envelope fields (`type`, `session`, `seq`, and the `trace.*`-only - `macrostep`/`microstep`/`round`) live on the struct directly; everything + The envelope fields (`type`, `session`, `seq`, and the counters + `macrostep`/`microstep`/`round`, carried by `trace.*` and `effect.*` + messages and by no `session.*` message) live on the struct directly; everything type-specific lives in `payload`, already in wire shape - a string-keyed map ready to merge over the envelope. This is decision 1 of the plan: one envelope struct rather than one struct per message type, so the spec stays @@ -73,7 +74,8 @@ defmodule StatifierUI.Trace.Message do `"type"`, `"session"`, and `"seq"` are always present; `"macrostep"`, `"microstep"`, and `"round"` are present only when non-`nil` (decisions 3 - and 5 - `effect.*` messages carry no `round`, `session.*` messages carry + and 5, amended by sui-67d - `trace.*` and `effect.*` messages carry all + three, `session.*` messages carry none of the three). Specced for a valid message: call `validate/1` first, or construct through `StatifierUI.Trace.Normalizer`, which always does. """ diff --git a/lib/statifier_ui/trace/normalizer.ex b/lib/statifier_ui/trace/normalizer.ex index 3376cd7..d1d96f8 100644 --- a/lib/statifier_ui/trace/normalizer.ex +++ b/lib/statifier_ui/trace/normalizer.ex @@ -268,7 +268,7 @@ defmodule StatifierUI.Trace.Normalizer do with {:ok, base} <- put_owner(base, p.owner), {:ok, base} <- put_defined(base, "new_value", p.new_value), {:ok, base} <- put_defined(base, "prior_value", p.prior_value) do - {:ok, {"effect.datamodel_change", p.macrostep, p.microstep, nil, base}} + {:ok, {"effect.datamodel_change", p.macrostep, p.microstep, p.round, base}} end end @@ -278,7 +278,7 @@ defmodule StatifierUI.Trace.Normalizer do with {:ok, base} <- put_owner(base, p.owner), {:ok, base} <- put_value(base, "value", p.value) do base = put_present(base, "c_index", p.c_index) - {:ok, {"effect.log", p.macrostep, p.microstep, nil, base}} + {:ok, {"effect.log", p.macrostep, p.microstep, p.round, base}} end end @@ -286,7 +286,7 @@ defmodule StatifierUI.Trace.Normalizer do base = %{"configuration" => configuration(p.configuration)} with {:ok, payload} <- put_value(base, "donedata", p.donedata) do - {:ok, {"effect.done", p.macrostep, p.microstep, nil, payload}} + {:ok, {"effect.done", p.macrostep, p.microstep, p.round, payload}} end end @@ -315,13 +315,13 @@ defmodule StatifierUI.Trace.Normalizer do with {:ok, base} <- put_value(base, "params", p.params), {:ok, base} <- put_value(base, "content", p.content) do - {:ok, {"effect.invoke", p.macrostep, p.microstep, nil, base}} + {:ok, {"effect.invoke", p.macrostep, p.microstep, p.round, base}} end end defp core_message(%CancelInvoke{} = p) do payload = %{"invoke_id" => p.invoke_id, "state_index" => p.state_index} - {:ok, {"effect.cancel_invoke", p.macrostep, p.microstep, nil, payload}} + {:ok, {"effect.cancel_invoke", p.macrostep, p.microstep, p.round, payload}} end defp core_message(%Autoforward{} = p) do @@ -332,7 +332,7 @@ defmodule StatifierUI.Trace.Normalizer do "event" => event_obj } - {:ok, {"effect.autoforward", p.macrostep, p.microstep, nil, payload}} + {:ok, {"effect.autoforward", p.macrostep, p.microstep, p.round, payload}} end end @@ -345,7 +345,7 @@ defmodule StatifierUI.Trace.Normalizer do with {:ok, base} <- put_owner(base, p.owner), {:ok, base} <- put_value(base, "data", p.data) do base = put_present(base, "c_index", p.c_index) - {:ok, {"effect.send", p.macrostep, p.microstep, nil, base}} + {:ok, {"effect.send", p.macrostep, p.microstep, p.round, base}} end end @@ -363,7 +363,7 @@ defmodule StatifierUI.Trace.Normalizer do with {:ok, base} <- put_owner(base, p.owner), {:ok, base} <- put_value(base, "data", p.data) do base = put_present(base, "c_index", p.c_index) - {:ok, {"effect.send_delayed", p.macrostep, p.microstep, nil, base}} + {:ok, {"effect.send_delayed", p.macrostep, p.microstep, p.round, base}} end end @@ -371,7 +371,7 @@ defmodule StatifierUI.Trace.Normalizer do base = put_present(%{"send_id" => p.send_id}, "c_index", p.c_index) with {:ok, base} <- put_owner(base, p.owner) do - {:ok, {"effect.cancel", p.macrostep, p.microstep, nil, base}} + {:ok, {"effect.cancel", p.macrostep, p.microstep, p.round, base}} end end diff --git a/test/statifier_ui/event_log_test.exs b/test/statifier_ui/event_log_test.exs index 3c713da..57bc878 100644 --- a/test/statifier_ui/event_log_test.exs +++ b/test/statifier_ui/event_log_test.exs @@ -475,6 +475,31 @@ defmodule StatifierUI.EventLogTest do assert macrostep.rounds == [] end + # Since sui-67d the live producer stamps `round` on every `effect.*` + # message, so this is the live-stream shape; the round-less case above + # remains what an older recorded stream looks like. + test "a round-carrying effect lands in its round, not in Macrostep.effects" do + messages = [ + %Message{ + type: "effect.log", + session: "s", + seq: 0, + macrostep: 2, + microstep: 1, + round: 3, + payload: %{"label" => "hi"} + } + ] + + {:ok, log} = EventLog.build(messages) + [macrostep] = log.macrosteps + [round] = macrostep.rounds + + assert macrostep.effects == [] + assert round.round == 3 + assert [%Message{type: "effect.log"}] = round.messages + end + test "effect.budget_exhausted lands in its round, not in Macrostep.effects" do payload = %{ "budget" => 10, diff --git a/test/statifier_ui/trace/normalizer_test.exs b/test/statifier_ui/trace/normalizer_test.exs index ca60270..8e866c8 100644 --- a/test/statifier_ui/trace/normalizer_test.exs +++ b/test/statifier_ui/trace/normalizer_test.exs @@ -211,10 +211,10 @@ defmodule StatifierUI.Trace.NormalizerTest do owner: {:transition, 0}, macrostep: 1, microstep: 0, - round: 0 + round: 2 } - assert {:ok, %Message{type: "effect.log", macrostep: 1, microstep: 0, round: nil} = message} = + assert {:ok, %Message{type: "effect.log", macrostep: 1, microstep: 0, round: 2} = message} = Normalizer.normalize({:log, payload}, @ctx) assert message.payload == %{ @@ -234,14 +234,14 @@ defmodule StatifierUI.Trace.NormalizerTest do round: 0 } - assert {:ok, %Message{type: "effect.done", round: nil} = message} = + assert {:ok, %Message{type: "effect.done", round: 0} = message} = Normalizer.normalize({:done, payload}, @ctx) assert message.payload == %{"configuration" => [0]} refute Map.has_key?(message.payload, "donedata") end - test "effect.budget_exhausted carries round, unlike every other core effect" do + test "effect.budget_exhausted carries round, like every core effect since sui-67d" do pending = [%Event{name: "go", type: :internal}] payload = %BudgetExhausted{ @@ -292,7 +292,7 @@ defmodule StatifierUI.Trace.NormalizerTest do round: 0 } - assert {:ok, %Message{type: "effect.invoke", round: nil} = message} = + assert {:ok, %Message{type: "effect.invoke", round: 0} = message} = Normalizer.normalize({:invoke, payload}, @ctx) assert message.payload["invoke_id"] == "inv1" @@ -349,7 +349,7 @@ defmodule StatifierUI.Trace.NormalizerTest do round: 0 } - assert {:ok, %Message{type: "effect.send", round: nil} = message} = + assert {:ok, %Message{type: "effect.send", round: 0} = message} = Normalizer.normalize({:send, payload}, @ctx) assert message.payload["id_from_author"] == true @@ -714,7 +714,7 @@ defmodule StatifierUI.Trace.NormalizerTest do type: "effect.datamodel_change", macrostep: 2, microstep: 1, - round: nil + round: 0 } = message} = Normalizer.normalize({:datamodel_change, payload}, @ctx) assert message.payload == %{