diff --git a/PLAN.md b/PLAN.md index 393487f..20340af 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ encryption, retention, backup, and deployment policy remain host concerns. - **Build:** add an optional durable command-log adapter around platform transactional storage; persist versioned command ids and app payload before projection; restore projection after reload; expose queue state, export/delete/reset, quota/corruption failure, and migration refusal. - **Refusals:** no server reconciliation, CRDT, mandatory IndexedDB, credential storage, or policy hidden in core. - **Requirements:** `local/001-004`, `sync/009`, `sync/014-015`, `sync/020`, `security/007`, `accessibility/004`, `client_local/013`, `performance/003`, `performance/005-006`. -- **Proof:** `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves transactional persist-before-project ordering, app-owned shell caching, current-version replay after a real Firefox reload with the fixture server unreachable, stable identity metadata, and explicit unknown-schema refusal through real WASM. `cargo test -p hemx-wasm --test browser kanban_command_export_delete_and_reset_are_recoverable -- --exact` proves accessible export/delete/reset entry points, versioned credential-free export, confirmation before destructive actions, preserved identity after queue deletion, and fresh identity/baseline projection after reset. `cargo test -p hemx-wasm --test browser kanban_persistence_failure_does_not_project_and_recovers -- --exact` proves transactional failure does not project or emit `kanban:command-persisted`, reports non-payload stage/code diagnostics, and recovers through the ordinary deletion path. `cargo test -p hemx-wasm --test browser kanban_corrupt_command_refuses_projection_and_recovers -- --exact` proves strict current-schema validation, no partial projection, visible non-payload diagnostics, raw versioned export for recovery, and ordinary deletion recovery. `cargo test -p hemx-wasm --test browser kanban_replay_is_bounded_and_within_budget -- --exact` proves 64-command preflight/replay within the 100 ms browser budget, zero partial projection at 65 commands, and export/delete recovery. `cargo test -p hemx-wasm --test browser kanban_quota_failure_is_fail_closed_and_recoverable -- --exact` proves quota-specific fail-closed behavior, no false durability event, transactional metadata rollback, and export/delete/reset recovery. `cargo test -p hemx-wasm --test browser kanban_queued_status_precedes_durable_projection_within_budget -- --exact` proves queued/busy feedback within the `performance/003` and `client_local/013` 100 ms budget while persistence is blocked, followed by durable projection only after commit. `cargo test -p hemx-kanban-example --test browser_e2e server_first_route_does_not_load_optional_client_assets -- --exact` proves the server-first route loads only its fingerprinted base runtime and creates no optional client root, PWA/WASM request, service worker, or IndexedDB database. +- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` proves transactional persist-before-project ordering, app-owned shell caching, current-version replay after a real Firefox reload with the fixture server unreachable, stable identity metadata, and explicit unknown-schema refusal through real WASM. `cargo test -p hemx-wasm --test browser kanban_command_export_delete_and_reset_are_recoverable -- --exact` proves accessible export/delete/reset entry points, versioned credential-free export, confirmation before destructive actions, preserved identity after queue deletion, and fresh identity/baseline projection after reset. `cargo test -p hemx-wasm --test browser kanban_persistence_failure_does_not_project_and_recovers -- --exact` proves transactional failure does not project or emit `kanban:command-persisted`, reports non-payload stage/code diagnostics, and recovers through the ordinary deletion path. `cargo test -p hemx-wasm --test browser kanban_corrupt_command_refuses_projection_and_recovers -- --exact` proves strict current-schema validation, no partial projection, visible non-payload diagnostics, raw versioned export for recovery, and ordinary deletion recovery. `cargo test -p hemx-wasm --test browser kanban_replay_is_bounded_and_within_budget -- --exact` proves 64-command preflight/replay within the 100 ms browser budget, zero partial projection at 65 commands, and export/delete recovery. `cargo test -p hemx-wasm --test browser kanban_quota_failure_is_fail_closed_and_recoverable -- --exact` proves quota-specific fail-closed behavior, no false durability event, transactional metadata rollback, and export/delete/reset recovery. `cargo test -p hemx-wasm --test browser kanban_queued_status_precedes_durable_projection_within_budget -- --exact` proves queued/busy feedback within the `performance/003` and `client_local/013` 100 ms budget while persistence is blocked, followed by durable projection only after commit. `cargo test -p hemx-kanban-example --test browser_e2e server_first_route_does_not_load_optional_client_assets -- --exact` proves the server-first route loads only its fingerprinted base runtime and creates no optional client root, PWA/WASM request, service worker, or IndexedDB database. ## Slice 4 — authoritative reconnect and convergence @@ -46,23 +46,23 @@ encryption, retention, backup, and deployment policy remain host concerns. - **Build:** materialize `hemx-sync` over an integration transport with idempotent server command processing, snapshot/change cursor, durable acknowledgements, bounded ordered replay, current auth checks, rejection/conflict results, canonical replacement, reconnect jitter/backoff, multi-tab coordination, and redacted diagnostics. - **Refusals:** no default CRDT, transport in core, cached enqueue-time permission, unbounded queue, or silent last-write-wins policy. - **Requirements:** `sync/001-023`, `operations/001-005`, `security/002-005`, `performance/004-005`. -- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. `cargo test -p hemx-kanban-example --test browser_e2e adversarial_wire_inputs_are_rejected_before_partial_application -- --exact` proves malformed, oversized, truncated, trailing, unknown-version, and invalid-kind batch/state/command inputs fail without UI mutation; `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally proves unknown-version and oversized event/state inputs are rejected at the generated real-WASM handler boundary before application code. The runtime now bounds total payloads, fields, vectors, varints, UTF-8, enums, and option discriminants before allocation or dispatch, and the durable queue validates every loaded command before replay, closing `security/005`. `cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact` proves the mutation extractor rejects missing/unexpected media types with 415 and an Axum host-configured oversized body with 413 before handler dispatch, while a valid bounded form dispatches exactly once; the extractor documentation names `DefaultBodyLimit`, and CSRF remains the existing documented application/middleware policy, closing `security/003`. `cargo test -p hemx-kanban-example --test browser_e2e acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation -- --exact` closes `operations/004`: the live stream exposes a finite 15-second production heartbeat (25 ms under proof configuration), exactly three bounded reconnect delays (100/250/500 ms), converts 65 pending events over its 64-event transport budget into one canonical snapshot recovery event, then emits heartbeats after recovery; both proof streams are explicitly closed, and the page teardown path closes its owned stream and reports cancellation. `cargo test -p hemx-kanban-example --bin hemx-kanban-example timeout -- --nocapture` completes `operations/003`: ordinary server routes are cancelled after a finite 10-second production deadline and return 504, startup store read/replay is cancelled after five seconds, oversized startup state is rejected before decoding, and focused tests prove dropped in-flight work plus named timeout diagnostics. `cargo test -p hemx-kanban-example --test browser_e2e ordinary_browser_request_exposes_deadline_and_cancels_on_pagehide -- --exact` proves the ordinary browser request boundary exposes its finite 10-second deadline, aborts the real form request with `AbortError` on page teardown, and clears pending UI state; stream routes remain governed by the `operations/004` lifecycle rather than the ordinary-handler timeout. The complete Slice 4 browser audit now passes (`cargo test -p hemx-kanban-example --test browser_e2e -- --test-threads=1` and `cargo test -p hemx-wasm --test browser -- --test-threads=1`). The audit also fixed three stale v1 command fixtures to exercise the real v2 durable schema, preserved an offline leader lease without retrying while connectivity is absent, and made finite acknowledgement responses close unless the caller explicitly requests a reconnecting stream. `cargo test -p hemx-kanban-example --test browser_e2e typed_broadcast_applies_generated_batch_over_sse -- --exact` closes `sync/004`: public `SyncEffect::broadcast(Channel, EffectBatch)` preserves an ordinary typed batch, the board subscription selects the validated typed channel, and a generated-resource presence batch crosses the real SSE runtime to update browser DOM. `cargo test -p hemx-kanban-example --test browser_e2e typed_presence_join_leave_updates_generated_atom_over_sse -- --exact` closes `sync/001` and `sync/005`: public channel-scoped `PresenceTracker` makes duplicate joins and absent leaves idempotent, `#[hemx_sync::presence]` turns one synchronous typed projection into a `PresenceUpdate`, and real join/duplicate-join/join/leave requests produce counts 1/1/2/1 in a generated presence atom over typed SSE broadcast. The macro rejects arguments, async functions, missing return types, wrong arity, and destructured inputs with local diagnostics. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_updates_generated_atom_over_ordinary_batch -- --exact` closes `sync/006`: public `SyncEffect::ack(Atom)` emits an ordinary atom update plus a queue-observable acknowledgement event, and a tenant-authorized canonical acknowledgement updates a generated atom and status slot in the real browser while preserving command identity and server sequence. The runtime now materializes atom updates into matching generated DOM owners as well as root atom state. The final post-API audit passes both complete browser suites (`cargo test -p hemx-kanban-example --test browser_e2e -- --test-threads=1` and `cargo test -p hemx-wasm --test browser -- --test-threads=1`), strict all-target Clippy, and focused public presence/broadcast/acknowledgement proofs. Every Slice 4 requirement now has a concrete public-API citation and browser proof. Slice 4 is complete. +- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. `cargo test -p hemx-kanban-example --test browser_e2e adversarial_wire_inputs_are_rejected_before_partial_application -- --exact` proves malformed, oversized, truncated, trailing, unknown-version, and invalid-kind batch/state/command inputs fail without UI mutation; `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally proves unknown-version and oversized event/state inputs are rejected at the generated real-WASM handler boundary before application code. The runtime now bounds total payloads, fields, vectors, varints, UTF-8, enums, and option discriminants before allocation or dispatch, and the durable queue validates every loaded command before replay, closing `security/005`. `cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact` proves the mutation extractor rejects missing/unexpected media types with 415 and an Axum host-configured oversized body with 413 before handler dispatch, while a valid bounded form dispatches exactly once; the extractor documentation names `DefaultBodyLimit`, and CSRF remains the existing documented application/middleware policy, closing `security/003`. `cargo test -p hemx-kanban-example --test browser_e2e acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation -- --exact` closes `operations/004`: the live stream exposes a finite 15-second production heartbeat (25 ms under proof configuration), exactly three bounded reconnect delays (100/250/500 ms), converts 65 pending events over its 64-event transport budget into one canonical snapshot recovery event, then emits heartbeats after recovery; both proof streams are explicitly closed, and the page teardown path closes its owned stream and reports cancellation. `cargo test -p hemx-kanban-example --bin hemx-kanban-example timeout -- --nocapture` completes `operations/003`: ordinary server routes are cancelled after a finite 10-second production deadline and return 504, startup store read/replay is cancelled after five seconds, oversized startup state is rejected before decoding, and focused tests prove dropped in-flight work plus named timeout diagnostics. `cargo test -p hemx-kanban-example --test browser_e2e ordinary_browser_request_exposes_deadline_and_cancels_on_pagehide -- --exact` proves the ordinary browser request boundary exposes its finite 10-second deadline, aborts the real form request with `AbortError` on page teardown, and clears pending UI state; stream routes remain governed by the `operations/004` lifecycle rather than the ordinary-handler timeout. The complete Slice 4 browser audit now passes (`cargo test -p hemx-kanban-example --test browser_e2e -- --test-threads=1` and `cargo test -p hemx-wasm --test browser -- --test-threads=1`). The audit also fixed three stale v1 command fixtures to exercise the real v2 durable schema, preserved an offline leader lease without retrying while connectivity is absent, and made finite acknowledgement responses close unless the caller explicitly requests a reconnecting stream. `cargo test -p hemx-kanban-example --test browser_e2e typed_broadcast_applies_generated_batch_over_sse -- --exact` closes `sync/004`: public `SyncEffect::broadcast(Channel, EffectBatch)` preserves an ordinary typed batch, the board subscription selects the validated typed channel, and a generated-resource presence batch crosses the real SSE runtime to update browser DOM. `cargo test -p hemx-kanban-example --test browser_e2e typed_presence_join_leave_updates_generated_atom_over_sse -- --exact` closes `sync/001` and `sync/005`: public channel-scoped `PresenceTracker` makes duplicate joins and absent leaves idempotent, `#[hemx_sync::presence]` turns one synchronous typed projection into a `PresenceUpdate`, and real join/duplicate-join/join/leave requests produce counts 1/1/2/1 in a generated presence atom over typed SSE broadcast. The macro rejects arguments, async functions, missing return types, wrong arity, and destructured inputs with local diagnostics. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_updates_generated_atom_over_ordinary_batch -- --exact` closes `sync/006`: public `SyncEffect::ack(Atom)` emits an ordinary atom update plus a queue-observable acknowledgement event, and a tenant-authorized canonical acknowledgement updates a generated atom and status slot in the real browser while preserving command identity and server sequence. The runtime now materializes atom updates into matching generated DOM owners as well as root atom state. The final post-API audit passes both complete browser suites (`cargo test -p hemx-kanban-example --test browser_e2e -- --test-threads=1` and `cargo test -p hemx-wasm --test browser -- --test-threads=1`), strict all-target Clippy, and focused public presence/broadcast/acknowledgement proofs. Every Slice 4 requirement now has a concrete public-API citation and browser proof. Slice 4 is complete. ## Slice 5 — local-first multiplayer Kanban milestone - [ ] **User value:** the complete north-star app demonstrates SSR-first startup, direct manipulation, offline durability, optimistic projection, reconciliation, and live presence as one comprehensible workflow. -- **State:** Active; Slice 4 is complete. +- **State:** In progress — the single named milestone journey passes; closure still requires replacing the example-owned `offline.js`, `command-log.js`, and `sync.js` behavior because `v1_release/002` forbids app-authored JavaScript. - **Build:** connect the previous slices in the canonical Kanban example; keep native server-rendered fallback; add presence and server-canonical conflict presentation; exercise deploy fingerprint recovery and accessible online/offline/conflict state. - **Refusals:** no demo-only runtime, hidden app JS, proprietary service, or requirement to load collaboration code for server-first apps. -- **Requirements:** `milestone/001`, `v1_release/001-002`, `accessibility/001-007`, `operations/006`, `performance/006`. -- **Proof:** one browser journey covers SSR/no-script fallback, local drag, keyboard reorder, offline/reload, concurrent peer edit, reconnect/convergence, presence, conflict/rejection recovery, mixed-version reload, and optional-asset isolation. +- **Requirements:** `ms/001-003`, `v1_release/001-002`, `accessibility/001-007`, `operations/006`, `performance/006`. +- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` composes the production Kanban page, generated client-local WASM, durable command queue, and real sync endpoints in one named browser journey. It proves native no-script movement, keyboard movement, local drag/drop, offline/reload durability, concurrent peer mutation and canonical convergence, presence projection, rejection and corrupt-queue recovery with deletion, mixed-version reload, and optional-asset isolation through the real public entry points. This does not yet close `v1_release/002`: the app still loads example-authored JavaScript for offline queue and sync orchestration. -Execution cursor: add that single named milestone journey by composing the existing public entry points and real browser fixtures; it must prove the whole workflow without introducing a demo-only path. +Execution cursor: move the app-owned durable command/event/projection orchestration behind the generated Rust/WASM boundary and rerun the named milestone journey with no example-authored JavaScript loaded. ## Slice 6 — production integration reference - [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy. -- **State:** Ready after public execution/sync contracts stabilize. +- **State:** Blocked by the remaining Slice 5 app-authored JavaScript gap. - **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery. - **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework. - **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts. diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index 74074a6..5aca3f7 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -1,8 +1,8 @@ -use axum::extract::{Query, Request, State}; +use axum::extract::{Form, Query, Request, State}; use axum::http::{HeaderMap, StatusCode}; use axum::middleware::{self, Next}; use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::response::{IntoResponse, Response}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use futures_util::{stream, StreamExt}; @@ -500,6 +500,7 @@ async fn main() { let ordinary_routes = Router::new() .route("/", get(home).post(interact)) + .route("/move", post(move_card_without_script)) .route("/events", get(events)) .route("/sync/broadcast", get(sync_broadcast)) .route("/sync/ack", get(sync_ack)) @@ -554,6 +555,36 @@ async fn home(State(state): State>, request: PageRequest) -> impl .fingerprint(ui::BUILD_FINGERPRINT) } +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum MoveDirection { + Left, + Right, +} + +#[derive(Deserialize)] +struct MoveCardForm { + card_id: u64, + direction: MoveDirection, +} + +// req: accessibility/001 req: ms/001 +async fn move_card_without_script( + State(state): State>, + Form(command): Form, +) -> Result { + let mut board = state.board.lock().unwrap(); + let moved = update_card(&mut board, Some(command.card_id), |card| { + card.column = match command.direction { + MoveDirection::Left => card.column.saturating_sub(1), + MoveDirection::Right => (card.column + 1).min(COLUMNS.len() - 1), + }; + }); + moved + .then(|| Redirect::to("/")) + .ok_or(StatusCode::BAD_REQUEST) +} + async fn runtime() -> impl IntoResponse { runtime_js() } diff --git a/examples/kanban/templates/partials/board_card.heml b/examples/kanban/templates/partials/board_card.heml index ae8aba2..a8074ba 100644 --- a/examples/kanban/templates/partials/board_card.heml +++ b/examples/kanban/templates/partials/board_card.heml @@ -1,10 +1,16 @@
{+ self.title +} - - - - + +
+ + +
+ +
+ + +
diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index fbf6f21..21ecdd6 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; +use thirtyfour::common::capabilities::firefox::FirefoxPreferences; use thirtyfour::prelude::*; const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); @@ -359,8 +360,9 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity( } #[tokio::test] -async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_replay( -) -> WebDriverResult<()> { +async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> { + // test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001 + // test req: accessibility/001 req: accessibility/002 // test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -382,6 +384,13 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl .map(PathBuf::from) .unwrap_or_else(|| workspace.join("target")); let host_binary = target_dir.join("debug/hemx-kanban-example"); + let host_port = available_port(); + let host_addr = format!("127.0.0.1:{host_port}"); + let mut host_command = Command::new(&host_binary); + host_command.env("HEMX_KANBAN_ADDR", &host_addr); + let _host = ProcessGuard::start(host_command, &host_addr); + let host_url = format!("http://{host_addr}"); + let (package, bootstrap, rendered) = build_kanban_artifact(&workspace); let runtime = workspace.join("hemx-js/runtime/hemx.js"); let mut server = StaticServer::start( @@ -395,13 +404,97 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl let webdriver_port = available_port(); let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); + let webdriver_url = format!("http://{webdriver_addr}"); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg(webdriver_port.to_string()); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); + + let mut no_script_preferences = FirefoxPreferences::new(); + no_script_preferences.set("javascript.enabled", false)?; + let mut no_script_caps = DesiredCapabilities::firefox(); + no_script_caps.set_headless()?; + no_script_caps.set_preferences(no_script_preferences)?; + let no_script_driver = WebDriver::new(&webdriver_url, no_script_caps).await?; + no_script_driver.goto(&host_url).await?; + no_script_driver + .find(By::XPath( + "//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']", + )) + .await? + .click() + .await?; + let moved_without_script = no_script_driver + .find(By::XPath( + "//section[contains(@class,'column')][h2='Doing']//strong[text()='Write requirements']", + )) + .await; + let _ = no_script_driver.quit().await; + moved_without_script?; + let mut caps = DesiredCapabilities::firefox(); caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; + let driver = WebDriver::new(&webdriver_url, caps).await?; let result = async { + driver.goto(&host_url).await?; + wait_until(&driver, "return Boolean(window.hemx)").await?; + let optional_asset = driver + .execute_async( + r#" + const done = arguments[arguments.length - 1]; + fetch('/optional-avatar.webp') + .then((response) => done({ status: response.status, runtime: Boolean(window.hemx) })) + .catch((error) => done({ error: String(error) })); + "#, + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(optional_asset["status"], 404); + assert_eq!(optional_asset["runtime"], true); + + driver + .find(By::XPath( + "//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']", + )) + .await? + .send_keys(Key::Enter) + .await?; + wait_until( + &driver, + "return [...document.querySelectorAll('.column')].find((column) => column.querySelector('h2')?.textContent === 'Done')?.textContent.includes('Write requirements')", + ) + .await?; + + let presence = driver + .execute_async( + r#" + const done = arguments[arguments.length - 1]; + const root = document.querySelector('[data-hemx-root]'); + const source = new EventSource('/sync/broadcast?channel=board&action=join&member=milestone-peer'); + const timeout = setTimeout(() => { source.close(); done({ error: 'presence timed out' }); }, 5000); + source.addEventListener('hemx', (event) => { + clearTimeout(timeout); + const normalized = event.data.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); + const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + window.hemx.applyBatch(bytes.buffer, root); + source.close(); + done({ text: document.body.textContent }); + }); + source.onerror = () => { clearTimeout(timeout); source.close(); done({ error: 'presence failed' }); }; + "#, + Vec::new(), + ) + .await? + .json() + .clone(); + assert!(presence["error"].is_null(), "presence failed: {presence}"); + assert!( + presence["text"].as_str().unwrap_or_default().contains("tick #1"), + "presence projection was not applied: {presence}" + ); + driver.goto(&server.url()).await?; wait_until( &driver, @@ -424,7 +517,12 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl Vec::new(), ) .await?; - driver.find(By::Css("[data-card-id='1']")).await?.click().await?; + driver + .execute( + "const transfer = new DataTransfer(); const card = document.querySelector('[data-key=\"1\"]'); const drop = document.querySelector('[data-hemx-client-event=\"drop\"]'); card.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: transfer })); drop.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })); return true", + Vec::new(), + ) + .await?; wait_until( &driver, "return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'", @@ -485,7 +583,7 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl assert_eq!(restored["order"], "2|1"); assert_eq!(restored["count"], "1"); assert!(restored["error"].is_null()); - assert_eq!(restored["notice"], "Moved 1 with click"); + assert_eq!(restored["notice"], "Moved 1 with drop"); let app_addr = server.address.to_string(); let mut app_command = Command::new(&host_binary); @@ -511,6 +609,8 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl const conflict = await conflictResponse.json(); const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }}); const rejection = await rejectionResponse.json(); + const peerResponse = await fetch('/sync/commands?command_id=peer%3A1&card_id=2&column=done', {{ method: 'POST' }}); + const peer = await peerResponse.json(); const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json(); const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text(); const open = indexedDB.open('hemx-kanban-v1'); @@ -523,6 +623,8 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl conflict, rejectionStatus: rejectionResponse.status, rejection, + peerStatus: peerResponse.status, + peer, snapshot, history, queueCount: count.result, @@ -552,10 +654,15 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl assert_eq!(convergence["rejectionStatus"], 400); assert_eq!(convergence["rejection"]["kind"], "invalid-command"); assert_eq!(convergence["rejection"]["error"], "unknown card_id"); + assert_eq!(convergence["peerStatus"], 200); + assert_eq!(convergence["peer"]["commandId"], "peer:1"); + assert_eq!(convergence["peer"]["serverSequence"], 2); assert_eq!(convergence["queueCount"], 0); - assert_eq!(convergence["snapshot"]["serverSequence"], 1); + assert_eq!(convergence["snapshot"]["serverSequence"], 2); assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1); assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done"); + assert_eq!(convergence["snapshot"]["cards"][1]["id"], 2); + assert_eq!(convergence["snapshot"]["cards"][1]["column"], "done"); assert_eq!( convergence["history"] .as_str() @@ -612,6 +719,18 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl "restore: unsupported durable command future:2" ); assert_eq!(rejected["ready"], false); + + let delete_commands = driver + .find(By::Css("[data-kanban-command-action='delete']")) + .await?; + delete_commands.click().await?; + assert_eq!(delete_commands.text().await?, "Confirm delete commands"); + delete_commands.click().await?; + wait_until( + &driver, + "const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-kanban-command-ready') === true && root.getAttribute('data-kanban-command-count') === '0' && !root.hasAttribute('data-kanban-command-error')", + ) + .await?; Ok(()) } .await;