From bd6a4f5ea4240bc1aed1eebaad05e51712ceba66 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 22 Aug 2026 06:14:51 -0600 Subject: [PATCH] Serializes datamodel_change as a wire type Adds effect.datamodel_change, the tenth effect.* type: one message per successful datamodel write, carrying the resolved location_path (a heterogeneous JSON array - string segments are object keys, integer segments are array indexes), the raw location_source, and the written values. new_value and prior_value follow the _event.data three-way absence rule, since the engine distinguishes unbound (:undefined) from a stored null here; put_event_data/2 generalizes to put_defined/3 for all three fields. The owner vocabulary gains the widened "invoke" kind for writes. Until now a wire consumer could observe datamodel variable names (session.datamodel snapshots before the binding fold) but never a value; folding these messages over that snapshot reconstructs the datamodel at any point in the run. The format version stays 1, recorded in the new schema section: adding a type is additive under the must-ignore rule. Type index grows to 24; the @coverage table to 19 pairs. Refs: sui-h92 --- changelog.d/sui-h92.md | 7 + docs/wire-format.md | 96 ++++++++++--- lib/statifier_ui/trace/normalizer.ex | 72 +++++++--- test/statifier_ui/trace/normalizer_test.exs | 151 +++++++++++++++++++- 4 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 changelog.d/sui-h92.md diff --git a/changelog.d/sui-h92.md b/changelog.d/sui-h92.md new file mode 100644 index 0000000..97dbb48 --- /dev/null +++ b/changelog.d/sui-h92.md @@ -0,0 +1,7 @@ +### Added + +- Serializes statifier's `DatamodelChange` effect as the new + `effect.datamodel_change` wire type, so consumers can observe datamodel + values as they are written instead of only the variable names + `session.datamodel` carries. The format version stays 1; new types are + additive under the wire format's must-ignore rule. diff --git a/docs/wire-format.md b/docs/wire-format.md index 2a60cb2..c64f6ca 100644 --- a/docs/wire-format.md +++ b/docs/wire-format.md @@ -20,7 +20,7 @@ interpreter's author needs first. - MUST, when tracing is enabled, emit the nine `trace.*` types at the phase boundaries Appendix D names, with the envelope fields this document requires. -- MAY emit further message families (this document also specifies nine +- MAY emit further message families (this document also specifies ten `effect.*` types and four `session.*` lifecycle types) and MAY add fields to any message beyond what this document requires. @@ -503,10 +503,14 @@ pass, including when nothing was finalized or forwarded. | finalized | array of strings | always - `invoke_id`s of every invocation `` ran for | | forwarded | array of strings | always - `invoke_id`s of every invocation `event` was autoforwarded to, in the pass's own walk order | -## The nine `effect.*` schemas +## The ten `effect.*` schemas -The nine core (non-trace) statifier effects, mapped one-to-one onto their -own namespace. ADR-0005 leaves non-trace effect naming to this document +The core (non-trace) statifier effects, mapped one-to-one onto their +own namespace: the nine originals plus `effect.datamodel_change` +(`Statifier.Effect.DatamodelChange`, `st-oef3`). The engine's eleventh +core effect, `Statifier.Effect.DatamodelInit`, is the one exception to +the one-to-one mapping - it serializes as `session.datamodel`, below. +ADR-0005 leaves non-trace effect naming to this document ("their own namespaces as consumers need them"); this document uses one `effect.*` family rather than several separate top-level namespaces, because a bare top-level `done` type would sit confusingly next to @@ -622,6 +626,60 @@ Payload for `` (spec 6.3). | c_index | integer | present only when known | | owner | owner object | present only when known | +### `effect.datamodel_change` + +Emitted once per successful datamodel write (`st-oef3`): an ``, a +`` binding during the binding fold, a `` write, an +`` write, or an empty-`` auto-assign. A failed +write emits nothing - the datamodel did not change, and the failure is +already on the error channel. Together with `session.datamodel`'s starting +snapshot, the sequence of these messages reconstructs the datamodel at any +point in the run from the stream alone. + +| Field | Type | Presence | +|---|---|---| +| location_path | array of strings and integers | always - the resolved write path; see below | +| location_source | string | always - the raw author string that named the location (`items[i].name` as written) | +| new_value | value | present unless the write stored the unbound sentinel; a stored null is present as `null` (three-way absence rule) | +| prior_value | value | present unless nothing stood at the path before the write; a previously stored null is present as `null` (three-way absence rule) | +| d_index | integer | present only for a `` binding - resolves through `session.start`'s `data` table | +| c_index | integer | present only when a content node performed the write (``, ``) | +| owner | owner object | present only when known - which construct performed the write | + +**`location_path` is a heterogeneous JSON array.** Each segment is either a +string - an object key, the variable name first - or an integer, a 0-based +array index: `user.items[0].name` resolved against `i = 0` arrives as +`["user", "items", 0, "name"]`. JSON's own typing carries the distinction - +the string `"0"` is a key, the number `0` is an index - so no tagging or +escaping is needed, and a consumer applies the segments in order to its own +copy of the datamodel to reproduce the write. It is the *resolved* path: +an index expression like `[i]` has already been evaluated by the engine, +which is what makes the path applicable without the pre-assignment +datamodel. `location_source` is the raw author string kept alongside for +display; neither substitutes for the other. + +**`new_value` and `prior_value` follow the `_event.data` three-way rule**, +because the engine genuinely distinguishes unbound from null here +(statifier ADR-0037 spells unbound as `:undefined`): key absence means the +slot was or became unbound - for `prior_value`, that nothing stood at the +path before the write, the common case for a first assignment - while JSON +`null` means a genuinely stored null. + +**`d_index` and `c_index` are mutually exclusive** on this message: a +`d_index` means the write was a `` binding, which belongs to no +content block and therefore also carries no `owner`; a `c_index` means a +content node performed it. The two runner-side writes - the +empty-`` auto-assign and `` - carry neither, +identified by `owner` alone (`"finalize"` and `"invoke"` kinds +respectively). + +**Versioning decision, recorded rather than left implicit:** this type +joined the format after version 1 shipped with 23 types. Adding a type is +exactly what the conformance section's MUST-ignore rule makes additive, so +**the format version stays 1** - unlike `session.datamodel`, which kept +version 1 because its type string was already reserved, this one keeps it +because new types never bump the version at all. + ## Origins `Statifier.Event.Cause.origin/0`'s eight variants, each a tagged object @@ -638,17 +696,18 @@ with `"kind"` naming the tuple's tag: | "invoke" | state_index (integer), invoke_index (integer) | one of an ``'s own arguments failed to evaluate | | "finalize" | state_index (integer), invoke_index (integer) | an empty ``'s own auto-assign write failed | -A `d_index` anywhere in this format - here or on a future -`Effect.DatamodelChange` message - resolves through `session.start`'s +A `d_index` anywhere in this format - here or on +`effect.datamodel_change` - resolves through `session.start`'s `data` table, the same way a `state_index` resolves through `states` and a `t_index` through `transitions`. ## Owners -`Statifier.Machine.Content.owner/0`'s four variants, plus the one case +`Statifier.Machine.Content.owner/0`'s four variants, plus the case `Statifier.Effect.Trace.ContentExecuted` widens it with for a top-level -`