Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+595
View File
@@ -0,0 +1,595 @@
# Hemx effect and attribute kernel handoff
## Status and authority
This document is design evidence, not product authority and not an implementation plan. Repository-root `INTENT.tsv` and `SPEC.tsv` are the sole authorities. Before changing public behavior, update those canonical TSV records, derive a current `PLAN.md` slice, and add direct Redgate proof.
Do not publish this file, `INTENT.tsv`, `SPEC.tsv`, `PLAN.md`, or requirement-proof scripts to the public mirror.
The current public Hemx 0.3 behavior remains supported until a separately authorized breaking release changes it.
## Product objective
Hemx should cover ordinary server-owned web applications with a very small set of mechanisms:
```text
semantic Hemplate
-> generated typed resources and actions
-> ordinary Rust handler and application state
-> ordered typed effects
-> one canonical wire format
-> root-scoped browser interpreter
```
The target is broad capability, not a feature for every product noun. Forms, validation, toasts, dialogs, tables, navigation, search, pagination, server push, sortable lists, uploads, and similar behavior must compose from the same kernel. High-frequency browser-owned behavior remains an explicit island.
## Design laws
1. The server owns application state, authorization, validation, and recovery.
2. HTML owns document semantics, forms, accessibility, and ordinary navigation.
3. Hemplate owns rendering and escapes interpolation by default.
4. Hemx owns generated identities, ordered effects, canonical bytes, and root-scoped browser application.
5. Generated concrete resource types expose valid operations; invalid combinations do not compile.
6. Application code never copies resource IDs, selectors, opcodes, fingerprints, or generated attributes.
7. Effects address generated resources, never arbitrary CSS selectors.
8. An effect batch is an ordered fail-stop script, not a claimed atomic DOM transaction.
9. Decode and ABI validation happen before application; target validation occurs immediately before each ordered effect so later effects may address content created earlier in the batch.
10. HTML, CSS, URLs, native controls, and browser APIs are reused before adding Hemx protocol.
11. Optional behavior is a small direct adapter with explicit lifecycle, not a universal plugin framework.
12. Public APIs describe current capability, never removed experiments or private repository structure.
## Comparative evidence
The design was compared against local source checkouts using `peek` plus targeted source tracing:
| Reference | Inspected commit | Materialized lesson |
| --- | --- | --- |
| htmx | `ad56dff71e55d9c717447437b4c942a64575d4b2` | server-controlled swap modes, out-of-band updates, response navigation/events, and conditional state-preserving `moveBefore()` |
| Turbo | `f3faa2daf3f9b96c986e7ac3ec4022d7c1eb0dbf` | small ordered stream actions: append, prepend, before, after, replace, update, remove, refresh |
| Phoenix LiveView | `a7dc7b65226ef42ba264c2724a6a66fdf5c113af` | ordered command algebra, focus/transition/navigation commands, but a component-diff model Hemx should not copy |
| Livewire | `4616d99586c4dbcb48602db871d2c0ca1150f463` | response-envelope effects for morph, dispatch, redirect, download, script, and streaming; several are intentionally too permissive for Hemx core |
| Unpoly | `deb75d6ebab1733f67928fb4a7dfe6bb52202e56` | layered fragment changes and overlay policy; useful product patterns but not irreducible protocol primitives |
| Datastar | `cbe24718784dee446ddea3ef5625b7a696f8c044` | very small SSE event vocabulary and explicit patch modes; signal state is not required for Hemx's server-owned model |
The references prove useful mechanisms, not Hemx product authority.
## Local experiments
### Direct Move versus full keyed reconciliation
Artifact: `/tmp/silly-orbit-dragon-ISwOgl/RESULTS.md`
A visible Chromium experiment used 1,000 existing keyed rows. For 100 last-to-first operations per sample:
- direct `Element.moveBefore()` had a median below the 0.1 ms timer resolution and p95 around 0.1 ms;
- complete keyed-order reconciliation had a median around 13.1 ms and p95 around 15.3 ms.
A focused-input probe showed:
- `insertBefore()` retained node identity and value but lost focus and selection;
- `moveBefore()` retained identity, value, focus, and selection.
Decision: retain direct `Move`. Do not lower a known move to full Morph. Prefer `Element.moveBefore()` where supported. Any fallback to `insertBefore()` has a known state-preservation limitation that requires explicit browser coverage.
### Typed capability API
Artifact: `/tmp/hemx-kernel-proof/RESULTS.md`
The dependency-free Rust prototype materialized toast insertion, invalid-form render plus focus, dialog render plus focus, typed autocomplete dispatch, navigation, and direct item movement. Unit tests pass. Separate fixture crates fail to compile for:
- append on a focus-only resource;
- focus on a non-focusable slot;
- wrong payload type for a generated event.
Decision: generated concrete capability types are ergonomic and valuable. Do not expose a public trait lattice. The prototype also showed that combining update and insertion into one generic `RenderMode` obscures target semantics, so retain separate erased `Patch` and `Insert` operations.
### Trigger adapters
Artifact: `/tmp/hemx-trigger-proof/RESULTS.md`
A generic bridge required 58 nonblank lines and the instrumented timer/reveal/confirm adapters another 32. The current direct timer/reveal regions are about 78 lines before surrounding request logic. A new universal bridge therefore does not earn its complexity.
Decision:
- keep native confirmation in the request seam;
- do not create a universal plugin registry or callback bus;
- make polling and revealed behavior optional direct adapters if retained;
- give adapters only explicit `scan(fragment)` and `dispose(fragment)` lifecycle calls from Patch, Insert, Remove, and initial root binding;
- do not add a global `MutationObserver` solely as an extension mechanism.
### Atom evidence
No public example or real application journey depends on `Atom`, `AtomState`, `SetAtom`, or `data-hemx-st`. Their evidence is limited to implementation, isolated tests, generated surface, and testkit documentation. `SetAtom` stores bytes in a browser map and emits an event, creating a second client-state channel without owning rendering.
Decision candidate: remove Atom state from core. Use server-owned state plus Patch, typed Dispatch, or an explicit island.
## Proposed erased effect kernel
The future canonical wire algebra should contain exactly these semantic operations:
```rust
enum Effect {
Patch {
target: ResourceRef,
mode: PatchMode, // Morph | Replace
html: SafeHtml,
},
Insert {
parent: ResourceRef,
position: InsertPosition, // First | Last | Before(item) | After(item)
html: SafeHtml,
},
Remove {
target: ResourceRef,
},
Move {
target: ResourceRef,
position: MovePosition, // First | Last | Before(item) | After(item)
},
Focus {
target: ResourceRef,
prevent_scroll: bool,
},
Scroll {
target: ResourceRef,
block: ScrollBlock,
behavior: ScrollBehavior,
},
Visit {
url: Url,
history: HistoryMode, // Push | Replace
},
Dispatch {
event: EventRef,
payload: Bytes,
},
}
```
### Why each operation remains
| Operation | Irreducible job |
| --- | --- |
| `Patch` | update an existing resource while optionally preserving local DOM identity and browser state |
| `Insert` | create new sibling/list content at a declared position |
| `Remove` | delete one existing resource |
| `Move` | reposition an existing stateful node without re-rendering it |
| `Focus` | control keyboard and accessibility focus |
| `Scroll` | reveal content without changing focus |
| `Visit` | update URL/history through a server-owned partial navigation with ordinary navigation fallback |
| `Dispatch` | carry typed non-visual intent or notification without inventing DOM mutation |
### Normalization from the current algebra
- `Put` becomes `Patch`.
- Current `Insert` and `Prepend` become one `Insert` plus position.
- `Remove { key: Option<_> }` becomes removal of an already resolved typed `Item` resource.
- `Move { key, before }` becomes movement between typed item resources.
- `Navigate` becomes `Visit`; title and scroll are not bundled into it.
- `Emit { name, payload }` becomes generated typed `Dispatch`.
- Internal form commands must not be magic public event names.
Do not add wire effects for classes, styles, arbitrary attributes, validation errors, pending indicators, toasts, dialogs, downloads, clipboard, script evaluation, timers, overlays, or transport selection. Those are HTML, CSS, compositions, direct adapters, or islands.
## Effect batch semantics
```text
decode all bytes
-> validate magic, ABI version, fingerprint, lengths, UTF-8, tags, trailing bytes
-> for each effect in order:
resolve against current root state
validate resource capability and ownership
apply effect
stop on first failure
```
Later effects may address resources produced by earlier effects. Do not preflight every target only against pre-batch DOM state. Do not claim rollback or atomicity the browser cannot provide.
On failure:
- stop applying remaining effects;
- emit one structured root-scoped runtime diagnostic;
- retain enough context for development diagnostics;
- never guess a selector, opcode, resource, or compatibility fallback;
- recover through the next authoritative render or full navigation when compatibility requires it.
## Generated Rust API
Application code should use generated resource capabilities, not construct raw effects.
```rust
ui::profile.render(&view)
ui::toasts.append(&toast)
ui::todo(id).move_before(ui::todo(other))
ui::email.focus()
ui::errors.scroll_into_view()
ui::dialog.remove()
ui::search_changed.emit(&SearchChanged { query })
hemx::visit("/account")
```
Recommended generated concrete types:
```text
Slot<View> -> render, replace, remove
List<View> -> prepend, append
Item<View> -> render, replace, remove, move_before, move_after, move_first, move_last
Field -> focus, scroll_into_view
Form<Input> -> typed extraction and action vocabulary
Event<Payload> -> emit only its generated payload type
```
The generated module may lower these methods to erased wire effects internally. Application-facing APIs should not expose raw resource IDs, numeric opcodes, arbitrary targets, or interchangeable primitive parameters.
## Minimal generated HTML protocol
Generated markers should converge on one uniform vocabulary:
```html
data-hemx-root
data-hemx-build
data-hemx-resource
data-hemx-key
data-hemx-action
data-hemx-island
```
| Marker | Job |
| --- | --- |
| `root` | ownership and effect scope |
| `build` | generated contract fingerprint |
| `resource` | opaque generated resource identity |
| `key` | stable identity for repeated content and keyed movement/morphing |
| `action` | opaque generated handler identity |
| `island` | explicit client-owned preservation boundary |
Slots, lists, items, handles, forms, and fields remain distinct Rust capabilities while sharing one browser resource marker. Authors do not copy generated marker values.
Remove or collapse protocol markers that expose internal resource kinds separately. Never add `data-hemx-target` or selector-based targeting.
## Minimal authorable request policy
Only behavior that genuinely varies per interaction should remain authorable:
```html
data-hemx-on
data-hemx-policy
data-hemx-debounce
data-hemx-throttle
data-hemx-confirm
data-hemx-nav
data-hemx-history
```
Rules:
- native event defaults require no attribute;
- `on` exists only to override the native event;
- `policy` owns concurrency only: `latest`, `queue`, `drop`, or `parallel`;
- `debounce` and `throttle` own timing only;
- `confirm` uses the boring native confirmation gate and the cancellable request seam;
- navigation uses real anchors and GET forms, with partial navigation as progressive enhancement;
- history changes are explicit and retain ordinary browser fallback.
Remove `delay` from core unless a real journey proves it cannot be represented by debounce, CSS, a timer adapter, or an island.
Runtime-global request timeout is initialization configuration, not per-element markup.
## Optional direct adapters
Possible optional adapters:
```html
data-hemx-every
data-hemx-revealed
data-hemx-sse
data-hemx-ws
```
They are not new effect schemas. SSE and WebSocket transport the same canonical `EffectBatch` bytes. Timer and reveal adapters dispatch generated actions through the same request seam.
Each adapter owns exactly:
```text
scan(fragment)
dispose(fragment)
```
Required adapter behavior:
- no duplicate binding;
- cleanup on removal;
- binding for content created by Patch or Insert;
- root ownership enforcement;
- deterministic error reporting;
- focused browser proof.
If an adapter needs global lifecycle orchestration, hidden application state, a second payload schema, or domain policy, it is no longer a leaf and must remain application-owned.
## Input and validation contract
### Native browser layer
Use semantic controls and browser behavior directly:
```html
<input
type="email"
name="email"
required
minlength="3"
autocomplete="email"
>
```
The browser continues to own keyboard input, IME, autofill, password managers, file selection, native constraint validation, and accessibility semantics.
### Authoritative server layer
```text
HTTP/browser input
-> media-type, size, origin, authentication and authorization checks
-> typed parsing
-> domain validation
-> state transition or typed validation view model
-> generated resource effects
```
An invalid form is ordinary rendering plus focus/scroll:
```rust
ui::signup.render(&submitted_form)
.then(ui::email.focus())
.then(ui::error_summary.scroll_into_view())
```
Rendered markup owns field state:
```html
<input
name="email"
value="submitted value"
aria-invalid="true"
aria-describedby="email-error"
>
<p id="email-error">Enter a valid email address.</p>
```
Validation rules:
- retain submitted valid values;
- do not replace file inputs or focused controls unnecessarily;
- focus the first invalid field for small forms;
- focus or scroll an error summary for large forms;
- use `aria-invalid`, `aria-describedby`, labels, and live regions;
- render expected validation errors in context, not as toasts;
- never expose infrastructure error text as validation feedback;
- do not add `SetFieldError`, `SetValidity`, or `FormError` wire effects.
## Pending and transport errors
The core request seam should expose standard browser state:
```html
aria-busy="true"
disabled
```
CSS composes directly:
```css
button[aria-busy="true"] .spinner { display: inline-block }
form[aria-busy="true"] { cursor: progress }
```
Only controls that are safe to disable should be disabled; do not disable fields whose successful values must remain part of form submission.
Transport failure emits one structured root-scoped runtime event. Domain validation remains rendered application content.
Remove framework-specific presentation configuration where standard state is enough:
```text
data-hemx-indicator
data-hemx-pending-class
data-hemx-disable-while-pending
data-hemx-error
data-hemx-error-for
```
## Common product compositions
| Product behavior | Composition |
| --- | --- |
| Toast | typed toast list + `Insert::Last`; later `Remove` or optional timeout adapter |
| Dialog | native `<dialog>` view + `Patch` + `Focus`; close through action and `Remove`/Patch |
| Field validation | typed form Patch + `Focus` + optional `Scroll` |
| Error summary | semantic summary + `Focus`/`Scroll` |
| Autocomplete | debounce action + result-list Patch |
| Pagination | real links/GET forms + progressively enhanced `Visit` |
| Infinite list | optional revealed action + `Insert::Last` |
| Sortable list | generated reorder action + direct `Move` |
| Inline edit | item Patch + field Focus |
| Chat/server push | SSE or WebSocket carrying canonical batches |
| Upload | native multipart form; optional progress adapter or island |
| Tabs | links/buttons plus CSS, URL state, or a small Patch |
| CRUD/table UI | semantic forms, typed views, Patch/Insert/Remove |
| Charts, maps, canvas, rich editor | explicit island receiving typed boundary data/events |
A product noun does not earn a protocol opcode. Toasts and dialogs remain first-class ergonomic generated helpers built from the kernel.
## Island boundary
Use an island only when browser-owned high-frequency state materially improves the interaction: editor internals, canvas, maps, drag motion, media timelines, chart interaction, or similar behavior.
An island owns its local DOM subtree and lifecycle. Hemx may Patch around it, remove it explicitly, or Dispatch typed boundary events. Hemx must not silently morph through island-owned nodes.
Do not use islands for ordinary form validation, CRUD, navigation, toasts, dialogs, pagination, or server push.
## Explicit cuts
Remove from future core unless a confirmed current requirement proves otherwise:
- `Atom`, `AtomState`, `SetAtom`, and `data-hemx-st`;
- separate Insert and Prepend wire tags;
- optional key fields on generic Remove;
- magic public event names for internal form reset/error commands;
- selector targets and server-selected DOM queries;
- class, style, and arbitrary attribute wire operations;
- script evaluation or execute-script effects;
- a client application store mirroring server truth;
- a universal adapter/plugin registry;
- global mutation observation solely for extension discovery;
- transport-specific effect schemas;
- product-specific core effects for toast, dialog, download, clipboard, overlay, timer, or validation.
Keep:
- native HTML, forms, URLs, history, focus, scroll, and accessibility;
- escaped Hemplate rendering and explicit `SafeHtml` trust boundaries;
- typed generated resources and actions;
- deterministic ordered canonical bytes and fingerprints;
- root-scoped application;
- direct state-preserving Move;
- explicit islands;
- transport adapters carrying unchanged canonical bytes.
## Compatibility and release posture
Do not preserve redundant public primitives merely for internal convenience. Do not silently break Hemx 0.3 either. This is a deliberate future breaking-contract candidate.
A migration release should provide semantic source-level replacements where useful, but must not retain two permanent effect or attribute languages. Compatibility helpers may lower old source calls into the new kernel for one documented transition window only if they do not preserve old wire semantics or duplicate runtime branches.
## Required authority reconciliation
Before implementation, revise and confirm at least these current requirement areas:
- `core/001`–`core/010`: closed algebra, resource targeting, safe HTML, ordered wire behavior, compatibility, navigation, generated helpers, Atom removal;
- `build/001`–`build/003`: one generated resource/action vocabulary and capability-specific APIs;
- `derive/001`–`derive/004`: generated surface and compile-fail capability proof;
- `axum/001`–`axum/005`: canonical bytes, fingerprint, partial navigation and diagnostics;
- `runtime/001`–`runtime/007`: new interpreter, request policy, adapters, focus/history/push behavior;
- `test/001`–`test/005`: inspector support for the normalized effects and generated types;
- `wasm/001`: portable server-Wasm boundary remains unchanged.
Do not treat this list as an automatic requirement change. `create-specification` must split and confirm individually falsifiable obligations.
## Recommended delivery order
Keep the work in independently useful breaking-release slices rather than one rewrite.
### Slice 1 — Remove the second client-state channel
- remove Atom resource generation, state bootstrap, `SetAtom`, runtime map, events, tests, docs, and inspector support;
- preserve typed Dispatch and islands as explicit alternatives;
- prove no real public example depends on Atom.
### Slice 2 — Correct state-preserving movement
- retain `Move`;
- apply it with `Element.moveBefore()` where available;
- define and test fallback behavior;
- add real-browser focus, selection, form-value, media/animation, listener, and custom-element lifecycle proof;
- retain keyed Morph for complete desired-tree updates, not known single moves.
### Slice 3 — Normalize the erased wire algebra
- introduce Patch, Insert+position, Remove, Move+position, Focus, Scroll, Visit, Dispatch;
- remove duplicate tags and magic internal events;
- update deterministic encode/decode, compatibility fixtures, malformed-input tests, inspectors, and TypeScript declarations;
- make batch application sequential against current post-effect state.
### Slice 4 — Generate capability-specific APIs and uniform markers
- generate Slot/List/Item/Field/Form/Event concrete types and methods;
- collapse browser resource-kind markers to `data-hemx-resource` plus key;
- preserve root, build, action, and island boundaries;
- add compile-fail proof for invalid capability combinations;
- reject copied IDs and selector targets.
### Slice 5 — Reduce request attributes and standardize UX state
- retain native event defaults, concurrency policy, debounce, throttle, confirm, navigation/history;
- replace custom pending/error presentation attributes with standard `aria-busy`, safe disabled state, semantic server-rendered validation, and one structured transport-error event;
- prove form values, focus, error summaries, password/file controls, and accessibility behavior in a real browser.
### Slice 6 — Extract optional leaf adapters
- polling, revealed, SSE, and WebSocket remain direct optional modules;
- all adapters use scan/dispose and canonical action/effect boundaries;
- no plugin registry or second schema;
- prove dynamic insertion, removal cleanup, no duplicate binding, reconnection, root ownership, and cancellation.
### Slice 7 — Public migration and registry proof
- update public docs to current behavior only;
- provide bounded source migration notes without describing removed private experiments;
- run full package, registry-only native/Axum/Wasm, browser, and hygiene gates;
- publish only after explicit effect-boundary authorization.
## Proof matrix
| Risk | Required proof |
| --- | --- |
| invalid capability combination | compile-fail test against generated API |
| malformed/incompatible wire | canonical fixture, truncation/tag/UTF-8/magic/version/trailing-byte rejection |
| dependent ordered effects | Patch/Insert then Focus/Dispatch/Move to newly created resource in one batch |
| identity-preserving Morph | real browser: node identity, input value, focus, selection, open/details state, island preservation |
| direct Move | real browser using `moveBefore()`, including focus/selection and custom element callbacks |
| fallback Move | explicit browser matrix and documented state limitation or equivalent preservation algorithm |
| validation UX | real Axum route and browser: retained values, field association, summary, focus, announcement |
| request concurrency | latest/queue/drop/parallel tests with cancellation and pending cleanup |
| partial navigation | URL, history back/forward, title, scroll restoration, full-navigation fallback |
| adapter lifecycle | initial and inserted fragments, dispose on removal, no duplicate timers/listeners/observers |
| SSE/WebSocket | same canonical bytes, root ownership, reconnection, incompatibility rejection |
| server-Wasm | actual generated Hemplate/Hemx consumer compiles without parser or Tree-sitter target dependencies |
| public package integrity | fmt, clippy, workspace tests, cargo-deny, archive checks, registry-only consumers, hygiene scans |
## Full mandatory repository checks
```console
redgate list
redgate refs
redgate lint
redgate check
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
cargo deny check licenses sources
cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown
cargo tree -p hemx-server-wasm-test --target wasm32-unknown-unknown --edges normal,no-proc-macro
```
The target tree must contain neither `tree-sitter` nor `hemplate-parser`. Publishable packages also require archive-based `cargo package --list` and `cargo package` checks.
## Open decisions requiring explicit confirmation
1. Whether Atom removal is accepted as a breaking product decision.
2. Browser support floor for mandatory `Element.moveBefore()` versus fallback behavior.
3. Final public naming: `Patch`/`Insert`/`Visit`/`Dispatch` and source helper names.
4. Whether `Scroll` remains standalone or a confirmed workload demonstrates that Focus and URL fragments cover all supported cases.
5. Whether polling and revealed adapters ship in `hemx-js` as optional modules or remain application snippets.
6. Exact migration window from Hemx 0.3 source APIs and wire ABI.
These decisions must be resolved in `INTENT.tsv` and `SPEC.tsv`, not by implementation convenience.
## Final recommendation
Adopt the architecture direction, but do not implement it as one rewrite. The strongest immediately supported decisions are:
1. keep direct `Move` and correct it toward state-preserving `moveBefore()`;
2. remove Atom as the unproved second client-state channel after authority confirmation;
3. normalize duplicate insertion operations;
4. generate capability-specific concrete APIs;
5. reduce browser markers to one resource namespace;
6. keep validation in semantic HTML plus typed server rendering;
7. reject a universal plugin framework;
8. use small direct adapters and explicit islands for the remainder.
The result is not a smaller product. It is a smaller machine capable of building a large product surface.