Unordered sections: SectionSet<Model, Sections...> (#513) - #515
Merged
Merged
Conversation
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
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #513.
The gap
FlowSession::set<>throwsstd::logic_erroron 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 oneexecutecall site per block (re-implementing draft accumulation and the readiness gate each time) or misused a wizard and got alogic_errorfor editing its own form out of order.SectionSetkeeps everythingFlowSessiondoes per action — per-action draft accumulation, the readiness gate, result capture, error routing, the callback lifetime gate — and drops only the position.What is here
4cb331a3Bind, the tuple/pack walkers and the distinctness trait toforms/detail/session_common.hpp. Pure refactor;morph::flows::Bindstays as an alias.064a4ee6Section,SectionGroup,SectionGroupTraits,BRIDGE_REGISTER_SECTION_GROUP,sectionGroupSchemaJson<G>().26982ae6SectionSet—set<>,reset<A>(),draft<A>(),resolved(path), result capture, error routing, the callback scope.3950cef876f79f12docs/spec/forms/sections.md.app.hppreached intoflows::detailfor 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-*fromw-*, 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 hadSectionSetreactively 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:
set<>. Dispatch is asynchronous, so the check passed whether or not the gate existed. It now watchesonError, which is where a missing gate is actually visible:BridgeHandler::executeenforcesActionValidatoron its own path, so an ungated draft never reachesexecute()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.)One limit, stated plainly: that lifetime case cannot distinguish the destructor's explicit
requestStop()fromCallbackScope'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, andFlowSessionhas the same property. So a test may only destroy aSectionSetonce no completion is running, and mine could not tell:execute(), which runs before the completion that touches theSectionSet, so waiting on it proved nothing;resolved()is no better —captureResultpublishes 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, incaptureResult's write loop.Every case except the lifetime one now uses
StepExecutorand an explicitdrain(), 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--checksrun surfaced them. Cleared with the markerforms.hppalready uses for the same DOM.Mutations that the suite does catch: readiness gate removed,
resetclearing every section, result fields not captured, draft fields not captured,onErrorbranch 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