Skip to content

Unordered sections: SectionSet<Model, Sections...> (#513) - #515

Merged
Yaraslaut merged 13 commits into
masterfrom
sections-unordered
Sep 10, 2026
Merged

Yaraslaut merged 13 commits into
masterfrom
sections-unordered

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 10, 2026

Copy link
Copy Markdown
Member

Closes #513.

The gap

FlowSession::set<> throws std::logic_error on a field belonging to any step but the current one. That is right for a wizard and wrong for a screen whose blocks have no order — a settings page, a tab strip, a column of cards — where a user may edit the third block first and never touch the second. Before this, such a screen either hand-wired one execute call site per block (re-implementing draft accumulation and the readiness gate each time) or misused a wizard and got a logic_error for editing its own form out of order.

SectionSet keeps everything FlowSession does per action — per-action draft accumulation, the readiness gate, result capture, error routing, the callback lifetime gate — and drops only the position.

using ProfileSection = morph::forms::Section<UpdateProfile, "Profile">;
using PrefsSection   = morph::forms::Section<UpdatePrefs, "Preferences",
                                             morph::forms::Bind<"profileId", "UpdateProfile.id">>;

morph::forms::SectionSet<SettingsModel, ProfileSection, PrefsSection> sections{handler};
sections.set<&UpdatePrefs::theme>("dark");   // fires -- no ordering
sections.set<&UpdateProfile::name>("ada");   // fires

What is here

Commit
4cb331a3 Extract Bind, the tuple/pack walkers and the distinctness trait to forms/detail/session_common.hpp. Pure refactor; morph::flows::Bind stays as an alias.
064a4ee6 Section, SectionGroup, SectionGroupTraits, BRIDGE_REGISTER_SECTION_GROUP, sectionGroupSchemaJson<G>().
26982ae6 SectionSetset<>, reset<A>(), draft<A>(), resolved(path), result capture, error routing, the callback scope.
3950cef8 Nine test cases.
76f79f12 docs/spec/forms/sections.md.
(last) clang-tidy on changed lines.

app.hpp reached into flows::detail for a walker it uses on its own menu and screens tuples — nothing to do with wizards. It now includes the shared header directly.

Two decisions worth flagging

The schema emits no index or order key. A renderer arranges the sections itself, and a position in the wire format would suggest a sequence a section group does not have. That is the one thing distinguishing s-* from w-*, so emitting an index would erase the distinction the type exists to make.

Prefill is a declaration, not a write — exact parity with FlowSession. An earlier draft of the design had SectionSet reactively assign a bound field the moment its source resolved. With no ordering that is a coherent design, but it would silently overwrite a value the user had already typed, with no signal, and only for bound fields. The renderer knows whether its widget is dirty; the session does not.

Verification

Nine cases, each checked to fail with its implementation removed. Two of them measured nothing as first written, and that mutation run is what said so:

  • "a not-ready draft" checked the recorder immediately after an incomplete set<>. Dispatch is asynchronous, so the check passed whether or not the gate existed. It now watches onError, which is where a missing gate is actually visible: BridgeHandler::execute enforces ActionValidator on its own path, so an ungated draft never reaches execute() either — it comes back as a validation failure. The round trip that can only fail is the cost the gate avoids. (My first comment on that test claimed an ungated draft would "dispatch a half-filled action". It would not. Corrected.)
  • the lifetime case destroyed the set with nothing genuinely in flight, so it exercised no window at all. It now uses a section whose model call blocks until the test releases it, released only after the scope closes.

One limit, stated plainly: that lifetime case cannot distinguish the destructor's explicit requestStop() from CallbackScope's own destructor — both stop delivery, and the window a member-reordering regression would open is a few instructions wide, which neither ASan nor a blocking action reliably hits. It guards the property that matters (a completion arriving after destruction delivers nothing). The explicit call stays as forward-defence for a destructor body that might one day pump, which is what its comment claims and all it claims.

What CI caught that I did not

A segfault on Windows / cl-debug, in the #513 regression test. Not a flake and not a Windows quirk — a real use-after-free my tests caused, which TSan reproduces locally about once in fifteen runs.

CallbackScope::requestStop() does not wait for a callback already past its token check; that is its documented contract, and FlowSession has the same property. So a test may only destroy a SectionSet once no completion is running, and mine could not tell:

  • the recorder is written inside execute(), which runs before the completion that touches the SectionSet, so waiting on it proved nothing;
  • resolved() is no better — captureResult publishes its first key while still writing the rest, so a test that waits for one key and then leaves scope tears the set down underneath its own callback. That is exactly what TSan reports, in captureResult's write loop.

Every case except the lifetime one now uses StepExecutor and an explicit drain(), so the thread that delivers the completion is the thread that destroys the set — the case the guarantee actually covers. That removes the race rather than narrowing it, and the tests assert exact counts instead of polling for them. 0 failures in 100 TSan runs and 60 ASan runs, from ~1 in 15 before. The rule is now written down in the spec, since a caller can get this wrong the same way.

clang-tidy findings my local run missed. Filtering clang-tidy's output by filename hid glaze-DOM operator[] findings that the default run suppresses as non-user code; only an explicit --checks run surfaced them. Cleared with the marker forms.hpp already uses for the same DOM.

Mutations that the suite does catch: readiness gate removed, reset clearing every section, result fields not captured, draft fields not captured, onError branch dropped.

Local pre-flight, all green: tree-wide clang-format --dry-run -Werror; -Wdocumentation -Werror; clang and GCC full suites (1437 cases); TSan, ASan and Valgrind on [sections]; check_spec_citations.sh; clang-tidy clean on every changed line.

The spec-citation lint also caught a dangling section citation in the design doc — callback_scope.md's heading is "Thread safety and the boundary of the guarantee", not "Boundary of the guarantee". Fixed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv

Yaraslaut and others added 9 commits September 10, 2026 18:01
SectionSet<Model, Sections...> -- N independently editable action drafts, each
gated on its own ActionValidator<A>::ready, no sequencing. The sibling to
FlowSession for the screen shape 779bd8a's removal left without a target.

Decisions the design records, and why each was chosen over the alternative:

- Full parity with FlowSession (prefill binds, result capture, schema JSON)
  rather than the minimal draft-and-fire mechanism #513 literally describes.
- Prefill is reactive -- a bound field fills when its source fires, whenever
  that is -- rather than resolved lazily at read time (which pushes the work
  back onto the consumer, the thing #513 objects to) or requiring an
  already-fired source (which reintroduces sequencing through the back door).
- **A prefill write never fires its section.** This is the one constraint
  chosen rather than requested: without it, A completing could cascade-fire B,
  and two mutually bound sections would ping-pong. The cost is accepted and
  written down -- a section made ready purely by prefill waits for one user
  edit.
- No latch: a ready section re-fires on every set<>, as FlowSession does.
- Parallel Section/SectionGroup declaration types rather than reusing
  WizardStep, so a consumer declaring an unordered screen does not write
  "WizardStep". The shared machinery moves to forms/detail/session_common.hpp;
  flows.hpp keeps every name it exports today.

The testing section names nine cases and, for each, what it fails without --
case 5 asserts both halves of the prefill rule, since a test that only checked
the field filled would pass under a cascading implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
…513)

The design I wrote and got approved said a bound field is written into its
section's draft when the source fires, and called that parity with
`FlowSession`. It is not. Found while gathering the concrete code for the
implementation plan, before any of it was built.

`binds` are consumed in exactly one place in flows.hpp (:166-171): emitted into
the schema document under a `prefill` node, for a renderer. The runtime side is
`resolved(path)` (:419), which hands the caller a JSON string. `FlowSession`
never writes a prefill value into a draft, and no such mechanism exists anywhere
in the tree -- it would need JSON-to-typed-field deserialization keyed by field
name.

So the section is rewritten to what parity actually is: binds are a declaration,
`sectionGroupSchemaJson` emits them, results are captured into
`_resolvedValues`, and `resolved(path)` exposes them. The renderer decides
whether to overwrite a field the user has already touched -- a judgement the
framework has no basis for making.

Two consequences recorded rather than quietly dropped:

- The "a prefill write never fires its section" rule is gone. It existed only to
  constrain a write the framework will not be doing, so it was a constraint on
  nothing.
- Test case 5 changes from "B's field fills and B does not fire" to asserting
  `resolved()` on both a captured path and an uncaptured one.

The rejected mechanism is written into Out of scope with its cost, so the next
reader does not re-propose it without knowing what it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
`Bind`, the tuple/pack walkers and the distinctness trait were written inside
`morph::flows` but describe how a form session declares its units, not how a
wizard sequences them. Move them to `morph/forms/detail/session_common.hpp`;
`morph::flows::Bind` stays as an alias, since that is the name consumers write.

app.hpp reached into `flows::detail` for a walker it uses on its own menu and
screens tuples -- nothing to do with wizards. It now includes the shared header
directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
The declaration half of unordered sections (#513): a Section names a
registered action, a title and its Bind prefills; a SectionGroup collects
them. sectionGroupSchemaJson emits the s-* document a renderer consumes.

The document carries no index or order field. A renderer arranges the
sections itself, and a position in the wire format would suggest a sequence
these do not have -- that distinction is the entire reason this exists
beside Wizard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
FlowSession::set<> throws std::logic_error on a field belonging to any step
but the current one, which makes it unusable for a screen whose blocks have
no order -- tabs, cards, a settings page. That is morph#513.

SectionSet keeps FlowSession's per-action draft accumulation and readiness
gate and drops the position: every declared section is editable at every
moment and dispatches on its own the instant its draft validates. Nothing is
keyed to a "current" section, so a late reply cannot be stale -- there is no
position for it to be stale relative to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
Nine cases, each verified to fail with its implementation removed except
where noted below.

Two of them measured nothing as first written, and the mutation run is what
said so:

- "a not-ready draft" checked the recorder immediately after an incomplete
  set<>. Dispatch is asynchronous, so the check passed whether or not the
  gate existed. It now watches onError instead, which is where a missing
  gate is actually visible: the bridge enforces ActionValidator on its own
  path (bridge.hpp), so an ungated draft never reaches execute() either --
  it comes back as a validation failure. The round trip that can only fail
  is the cost the gate avoids.

- the lifetime case destroyed the set with nothing genuinely in flight, so
  it exercised no window at all. It now uses a section whose model call
  blocks until the test releases it, released only after the scope closes.

That case still cannot distinguish the destructor's explicit requestStop()
from CallbackScope's own destructor -- both stop delivery, and the window a
member-reordering regression would open is a few instructions wide. It
guards the property that matters (a completion arriving after destruction
delivers nothing); the explicit call stays as forward-defence for a
destructor body that might one day pump, as its comment says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
Also fixes a dangling section citation the prose lint caught in the design
doc: callback_scope.md's heading is "Thread safety and the boundary of the
guarantee", not "Boundary of the guarantee".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
CI's clang-tidy-diff analyses changed lines in headers too, and moving the
session helpers into a new file makes every one of their lines changed. Real
fixes where there was one (redundant typename, a result copied per
invocation, three test model params, three exception_ptr lambdas); the
repo's established NOLINT markers for the two that have no fix -- a visitor
invoked once per element must not be moved from, and a registration macro is
the intended public API (forms.hpp, views.hpp, registry.hpp all say so).

The NOLINTNEXTLINE reasons are on the marker's own line: a reason wrapped
onto a second comment line makes the *comment* the next line, and the
suppression silently misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.01980% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/forms/sections.hpp 97.53% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 4 commits September 10, 2026 22:32
The morph#513 regression test segfaulted on Windows / cl-debug. Not a flake
and not a Windows quirk -- a genuine use-after-free my tests caused, and TSan
reproduces it about once in fifteen runs locally.

CallbackScope::requestStop() does not wait for a callback that is already
past its token check; that is its documented contract, and FlowSession has
the same property. So a test may only destroy a SectionSet once no completion
is running. Mine could not tell:

- the recorder is written inside execute(), which runs *before* the
  completion that touches the SectionSet, so waiting on it proved nothing;
- resolved() is no better. captureResult publishes its first key while still
  writing the rest, so a test that waits for one key and then leaves scope
  tears the set down underneath its own callback. That is the actual
  use-after-free TSan reports, in captureResult's write loop.

Every case except the lifetime one now uses StepExecutor and an explicit
drain(), so the thread that delivers the completion is the thread that
destroys the set -- the case CallbackScope's guarantee actually covers. That
removes the race rather than narrowing the window, and the tests get to
assert exact counts instead of polling for them. 0 failures in 100 TSan runs
and 60 ASan runs, from ~1 in 15 before.

The lifetime case keeps InlineExecutor deliberately: it exists to destroy the
set with a dispatch outstanding, and its blocking action guarantees the
completion starts only after the destructor has finished.

Also clears the glaze-DOM operator[] findings CI's clang-tidy reported on the
changed schema-emitter lines, with the marker forms.hpp already uses. My
earlier local run missed them -- filtering clang-tidy's output by filename
hid findings the default run suppresses as non-user code; only an explicit
--checks run surfaced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
The Windows segfault came from believing resolved() returning a value meant
the completion had finished. It does not -- capture publishes each key as it
writes it. Say so where a reader looking for the teardown rule will find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
"Unreachable, since the same writer already served schema generation" is not
true: schema generation writes the DOM, not the member, so it is not evidence
about this call. Use flows.hpp's honest wording -- the arm is untested
because these action fields are plain data the writer cannot fail on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
The plan's test steps wait on resolved() before leaving scope, which is the
exact shape that segfaulted on Windows -- a reader following them verbatim
would reintroduce it. Say so at the top, with the two other corrections and
the FILE_SET gate the plan missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
@Yaraslaut
Yaraslaut merged commit 1573be6 into master Sep 10, 2026
49 checks passed
@Yaraslaut
Yaraslaut deleted the sections-unordered branch September 10, 2026 23:07
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.

forms: no unordered-sections replacement for the removed handler-side reactive draft

1 participant