djust 1.1.0rc5

Pre-releaseSecurityReleased

This is a pre-release. djust 1.1.0 has shipped since: read the djust 1.1.0 release notes.

Security

  • The template getattr sidecar now enforces the serialization floor across every access path — closes a denylisted-field leak (password / is_superuser / is_staff / get_session_auth_hash) to the client + a worker DoS (#1986 review, ADR-024). djust's serialization floor (_ALWAYS_EXCLUDED_FIELDS, SECURE_DEFAULTS Pattern 1 / #1868) strips sensitive fields from the eager state dict, but the Rust engine's lazy sidecar getattr walk — the fallback that resolves {{ obj.attr }} on live model instances — consulted no denylist, so sensitive fields rendered straight into client HTML. The PR #1986 adversarial review found this was mostly pre-existing/shipped (request-scoped user has always been sidecar-only) with one variant that this release's raw-model retention would have newly introduced, across seven entangled vectors — reducible to two mechanisms: a floor field read off a raw model during the getattr walk (1, 2, 4, 6), and a raw model __dict__-dumped during value conversion (3, 5, 7): (1) direct {{ user.password }}; (2) manager/queryset traversal {{ x.groups.first.user_set.first.password }} — a model returned by an auto-called manager method was unwrapped; (3) {% for u in qs %}{{ u.password }}{% endfor %} — queryset items went through the Rust FromPyObject__dict__ bulk-dump (crates/djust_core/src/lib.rs), which filtered only _-prefixed keys, so it dumped password for any model converted to a value; (4) {{ obj._meta }} — a _-prefixed getattr that segfaulted the worker (Options extraction) + {{ obj._meta.db_table }} schema disclosure; (5) .values() / .values_list() projections — {% for x in qs.values %}{{ x.password }} / {{ qs.values.first.password }} — which yield raw dict/tuple rows with no model identity, so .first/.get/index/iteration each returned an unfiltered row; and (6) a non-model intermediary object placed in the context (a "presenter"/view-model) exposing a raw model/manager/queryset — {{ presenter.user.password }}, {% for x in presenter.qs %}{{ x.password }}, and a model method returning a model ({{ obj.get_related.password }}, whose Rust-auto-called result never re-enters a Python proxy); and (7) a raw list/tuple of models reached via a non-model intermediary — {% for x in presenter.items %}{{ x.password }} — whose elements reach the Rust FromPyObjectVec<Value> extraction as raw models and hit the __dict__ bulk-dump. Fix: _SidecarModelProxy + _SidecarQuerySetProxy (python/djust/serialization.py) wrap every model/manager/queryset entering the sidecar and transitively protect everything they return (_protect_sidecar_value), refusing exactly what the eager path (DjangoJSONEncoder) refuses — the same field floor/allowlist via _field_is_serializable and the same sensitive-method set (extracted to shared _SENSITIVE_MODEL_METHODS / _SENSITIVE_MODEL_METHOD_PREFIXES constants so the two paths can't drift, #1646). _-prefixed names are refused outright (Django parity — closes vector 4). Model→value conversion now routes through a __djust_serialize__ hook that returns a denylist-filtered dict/list (via normalize_django_value, the same serializer the eager path uses) instead of the __dict__ bulk-dump — closing vector 3 while keeping {% for %} field access working. .values()/.values_list() projections are refused wholesale in the sidecar (vector 5) — their rows carry no per-field floor and every access path (.first/index/iteration) would leak; they never rendered in the sidecar auto-call walk before this release anyway (auto-call is new), so refusing is fail-closed with zero regression (precompute projected rows in get_context_data(), where the eager floor applies). And because Python-side proxies alone cannot cover a raw intermediary object (no proxy __getattr__) or a Rust-auto-called method result, the Rust resolve walk gained a single protect_sidecar chokepoint (crates/djust_core/src/context.rs) that routes every just-materialized value — after both getattr and the auto-call — through _protect_sidecar_value, so a model/manager/queryset is floor-wrapped however it was reached (vector 6). And the value-conversion root — FromPyObject for Value (crates/djust_core/src/lib.rs) — now routes any raw Django model through normalize_django_value (the denylist serializer) instead of the __dict__ bulk-dump, so a raw model reaching a Value via a list/tuple/dict container is floor-filtered too (vector 7). These are the two durable chokepoints — the getattr-walk protect_sidecar and the conversion-root model routing — so the fix is one authority per mechanism (#1646), not N surface-path patches; a future surface variant of either mechanism is already covered. The floor is not gated on the template_auto_call kill-switch. Legit access (safe fields, get_full_name, managers/.count, relations, {% for %}{{ g.name }}, safe fields reached through a presenter object, and a raw list of models) is unaffected. 28 tests in test_template_auto_call_1985.py (TestSidecarSerializationFloor covers all seven vectors + legit preservation + a proxy unit-pin) — gate-off verified (neutering the wrapping, the transitive protection, the _-prefix refusal, the projection guard, the Rust protect_sidecar chokepoint, or the FromPyObject model routing makes the corresponding leak test RED). Field-type-based exclusion (always-drop BinaryField, encrypted-field types) is a follow-up hardening of both paths (#1987).

  • TYPE-based serialization floor — always-drop BinaryField + encrypted-field types + a configurable sensitive_field_types list, on both client-bound paths (#1987, follow-up to #1986). The #1986 floor drops sensitive fields by NAME (password / is_superuser / is_staff + DJUST_SENSITIVE_FIELDS + per-model djust_exclude_fields). #1987 adds a complementary, name-independent axis that drops a field whose type should never reach the client: BinaryField (raw bytes) unconditionally; best-effort encrypted-field types (an MRO class name case-insensitively containing encrypted/fernet — django-encrypted-fields / django-fernet-fields and similar — no hard dependency, excluded fail-closed, with a one-shot DEBUG breadcrumb per class so a heuristic false-positive is diagnosable rather than a silent vanish); and any class named in the new LIVEVIEW_CONFIG['sensitive_field_types'] (a project-configurable list, empty by default; case-exact). FileField/ImageField are explicitly NOT excluded — they serialize a URL, the intended payload. Both client-bound paths — the eager encoder (DjangoJSONEncoder._serialize_model_safely) and the lazy template sidecar proxy (_SidecarModelProxy.__getattr__) — call the SAME authority _field_type_is_excluded (sidecar via _field_type_excluded_for), so the name floor's #1646 parallel-path lesson holds for the type floor too: one authority, no drift. 18 tests in python/djust/tests/test_field_type_exclusion_1987.py (authority unit tests + eager-path + sidecar-path + configured-type + case-insensitive/false-positive/one-shot-breadcrumb + gate-off sentinels — reverting either wired check makes the BinaryField-leak test RED). See SECURE_DEFAULTS Pattern 1.

  • ViewRuntime.dispatch_mount gained the signed state-snapshot HMAC restore + emit WebSocket has — byte-identical caps — and it goes LIVE for the SSE mount path (#1913, ADR-022 Iter 3 Phase 3.1). The opt-in state-snapshot feature (enable_state_snapshot = True) restores a view's public state from a client-echoed payload on back-navigation in lieu of mount(); the payload is a server-signed TimestampSigner blob (CWE-345 → CWE-915) whose restore is the SECURITY BOUNDARY. The runtime mount path — which is the SSE mount path since Iter 1 (#1887) — previously had NO snapshot restore at all, so converging SSE onto it without porting the restore would either drop the feature for SSE or (worse, if added carelessly) open an unsigned-snapshot injection vector. dispatch_mount now ports the WS restore VERBATIM (websocket.py:2491-2587): the same unsign_snapshot(blob, slug=view_path, sid=session_key) HMAC binding (a snapshot signed for view A / session S1 / older than DJUST_STATE_SNAPSHOT_MAX_AGE does NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, the DJUST_STATE_SNAPSHOT_ENABLED operator master-switch, and the _should_restore_snapshot(request) view-level veto. The session key for the sid binding is sourced from request.session and stamped on the view (_django_session_key) so the runtime/SSE path validates the SAME session binding the WS path does. The matching emit (sign_snapshot on the mount frame, websocket.py:2754-2792) is also ported, opt-in only. Gated enable_state_snapshot — default views never restore or emit (#1552); for SSE the restore is a no-op unless the view opts in AND a snapshot is present. WS UNTOUCHEDhandle_mount keeps its own copy until the Phase 3.3b flip; RUNTIME_OWNED_VERBS / WS routing / handle_mount_batch are unchanged (websocket.py has no diff). New suite python/djust/tests/test_runtime_mount_state_restore_1913.py — doc-claim-verbatim HMAC-caps TDD (#1046): a snapshot signed for a different view / a foreign session / past the TTL / forged-unsigned / tampered / oversized / over-keyset / vetoed does NOT restore via the runtime path (state stays at the mount() default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap in unsign_snapshot makes the cross-view restore wrongly succeed (RED); gating the runtime restore/emit/hook-redirect off makes the corresponding tests RED. The existing WS pins (test_state_snapshot_signing.py, test_ws_reconnect_state_1465.py) stay green.

  • ViewRuntime gained a transport.recheck_event_auth(view) hook for opt-in per-event auth re-check (reauth_on_event, #1777 threat-model T3), and it goes LIVE for SSE (#1905, ADR-022 Iter 2 Phase 2.3a). Auth runs once at mount and the mount-time principal is cached on the session, so a user who logs out / loses a permission mid-session would keep dispatching events on the open connection until they reconnect. The bespoke WS handle_event already re-checks per-event auth when LIVEVIEW_CONFIG['reauth_on_event'] is set + the view requires auth (websocket.py:3193-3222), but the runtime had no equivalent — so the SSE event path (converged onto the runtime since Iter 1, #1887) had NO mid-session deauth gate at all. New Transport.recheck_event_auth(view) -> bool (default-True = no re-check) wired into ViewRuntime._dispatch_event_inner at the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler. WSConsumerTransport replays the WS bespoke logic verbatim (re-resolve the user from the scope session via channels.auth.get_user, reflect onto view.request.user, re-run check_view_auth_lightweight; on failure navigate to the login url + close(4403)). SSESessionTransport re-checks against the LIVE event-POST request (session._event_request, stamped by the /event/ + /message/ endpoints just before dispatch — the current POSTer's request.user, not the stale mount request) — covering the case owner-binding (Finding #24) cannot: a still-authenticated, still-owning POSTer whose permission was revoked mid-session — and on failure sends an auth-error frame + ends the stream. Both fail-safe (any error skips the re-check, never breaks an event) and gated on reauth_on_event + login_required/permission_required (default views pay nothing). #291 multiplexed-path care: the runtime clears view_instance UNCONDITIONALLY on a False return (the state change that closes the security gap — no later frame on the session dispatches against the deauthorized view); the transport-terminating close is OWNED + gated by the hook (events are not batched today — mount_batch is mount-only — but the close stays gateable if events are ever collected, matching the WS bespoke view_instance = None after close). LIVE for SSE; DORMANT for WS — WS events still run on the bespoke _handle_event_inner (which keeps its own inline re-check) until the Phase 2.3b flip; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED, websocket.py's reauth block is unchanged. New suite python/djust/tests/test_runtime_reauth_async_1905.py (TestSSEReauthOnEvent, TestReauthHookShape291, TestWSReauthAdapterPort): real-SSE end-to-end (mount with a permission, POST with it revoked → refused + error frame + stream end + view_instance cleared; still-authorized → renders; default-OFF → no re-check) + the #291 shape (state cleared even when the close is gated, via a fake transport) + the WS-adapter port. Reproduce-first + gate-off (#1468) verified: gating the recheck off makes the deauthorized SSE event wrongly render (RED) and the #291 state-clear assertion fail (RED). test_event_reauth_1777 (the bespoke WS path) stays green.

  • Closed a latent object-permission gap (IDOR-class) in ViewRuntime.dispatch_mount before it could go live (#1885, ADR-022 Iter 0). The WebSocket handle_mount enforces the ADR-017 post-mount object-permission check (check_object_permission), but ViewRuntime.dispatch_mount did not — so a view whose has_object_permission() returns False (or whose get_object() denies) would have mounted, rendered, and sent the denied object to the client through the runtime path. The gap was not yet exploitable (dispatch_mount has zero production call sites today), but Iter 1 of the ViewRuntime convergence (routing SSE through the runtime) would have made it live. The runtime mount now routes through the SAME shared enforce_object_permission chokepoint the other transports use (runtime.py, mirroring websocket.py:2554-2573), placed AFTER mount() (so get_object() can read URL-derived attrs) and BEFORE handle_params + render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a custom get_object (behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only a permission_denied error frame after. New cases in TestDispatchMountObjectPermission (python/djust/tests/test_transport_behavioral_parity.py).

Added

  • Template callable auto-call — Django parity in variable resolution (#1985, ADR-024). Django's template engine auto-calls callables during variable resolution; djust's Rust engine did not, and the divergence was silent: {{ request.user.get_full_name }} rendered the literal <bound method AbstractUser.get_full_name of <User: jordan>> and {{ workspace.memberships.count }} rendered empty (DJUST_LESSONS gotcha #7, hit in downstream production builds). The bug class was #1646 parallel-path drift — the eager serialization path already auto-called (codegen.pyget_*/all/count/exists; serializer properties + explicit get_*), but the lazy sidecar getattr walk (Context::resolve, crates/djust_core/src/context.rs) — the path serving request-scoped objects (user) and reverse relations/managers — never invoked callables, and the un-called bound method fell to the FromPyObjectstr() catch-all. The walk now implements Django's exact Variable._resolve_lookup semantics at every segment (root, mid-path, final): no-arg call0(); do_not_call_in_templates → used as-is (Model classes, Choices enums); alters_datanever called, renders empty (the data-destruction guard — {{ user.delete }} cannot destroy data); TypeError from the call runs the inspect.signature(...).bind() probe (args-required → empty; internal TypeError propagates); other exceptions propagate as render errors. Explicit-context models are now also kept raw in the sidecar (the eager dict wins every hit; the raw model serves only nested paths the dict lacks), so {{ workspace.memberships.count }} works for explicitly-assigned models too — not just request-scoped ones. The pre-existing eager auto-call sites gained the same guards (codegen.py ×2 generated-code sites, serialization.py::_add_safe_model_methods). Observability: a debug-only, one-shot-per-path warning fires when an auto-call is bound to a Manager/QuerySet — in a LiveView that is a DB query per re-render (per WebSocket event), so precompute in get_context_data() on hot paths. Kill-switch: LIVEVIEW_CONFIG["template_auto_call"] (default True); False restores the pre-ADR no-call walk. 16 doc-claim-verbatim tests in python/djust/tests/test_template_auto_call_1985.py (one per semantics row + both reported symptoms through the real render path + side-effect sentinels + kill-switch gate-off). See docs/adr/024-template-callable-auto-call.md.

  • LiveView.set_changed_keys(keys) — public escape hatch to force a re-render after an in-place mutation of nested state (#1981). djust's change detection uses a fast identity + shallow-fingerprint snapshot (_snapshot_assigns) that deliberately does NOT deep-copy state (~100× faster than copy.deepcopy), so an in-place mutation of a nested container — self.rows[0]["cards"].append(x), self.columns[0]["cards"].pop() — shares the previous snapshot's object and is invisible, producing zero patches (the Phoenix-style immutability trade-off, documented in state-primitives.md). The _snapshot_assigns fingerprint-truncation warnings and docstring already advised calling self.set_changed_keys({...}), but no such method existed (it would AttributeError). This adds the method to RustBridgeMixin (inherited by LiveView): it marks the given keys changed and sets _force_full_html to force the re-render the auto-skip would otherwise drop. _changed_keys and _force_full_html are now in _FRAMEWORK_INTERNAL_ATTRS (excluded from the assigns snapshot), so assigning self._changed_keys directly is genuinely ineffective — previously it perturbed the snapshot fingerprint and triggered a render by side effect rather than by the sanctioned mechanism (caught by the PR #1982 adversarial review). The _force_full_html skip-bypass is honored — and the flag consumed after the render — on every live path: the runtime event spine, the WS deferred-activity path, and the WS tick loop (the latter two gained the guard/reset in this PR, the #1646 parallel-path sweep). Accepts a single attr name or an iterable; calls accumulate within an event. Prefer an immutable update (self.rows = [...]) where a targeted diff matters — because the aliased previous state can't be diffed, set_changed_keys forces a full re-render. Verified on the production ViewRuntime.dispatch_event path (not just LiveViewTestClient, which bypasses the skip); gate-off (#1468): neutering the method turns the in-place-mutation render test RED. See docs/state-management/STATE_MANAGEMENT_API.md.

  • Strict type enforcement on components/rust_handlers — the ADR-023 ratchet is COMPLETE (M4g, final module). This flips the LAST lenient holdout — the Rust template-engine component tag-registration shim (~193 inline/block render() handlers that parse untyped Rust-engine arg lists ["key=val", ...] into object-valued dicts and emit component HTML) — from the lenient mypy default to a strict island ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). This module was the sole sanctioned lenient exception (the genuinely-dynamic Rust-FFI boundary; an earlier attempt, M4b-1, found ~344 errors and documented it as intractable). It flipped clean with ZERO new # type: ignore (the only one in the file is the pre-existing _rust[import]). Two patterns did the work: (a) a typed module-level _safe() wrapper that casts Django's @keep_lazy-decorated (untyped → Any) mark_safe to str, absorbing the ~200-strong no-any-return cascade across every handler return without ignores; (b) inline cast(...) / str(...) (runtime no-ops) at each int()/float()/dict-key/attribute site of the kw.get(...) -> object cascade, plus a handful of explicit var: float/list[...]/dict[...] annotations. Render output is proven byte-identical — a deterministic-UUID parity harness rendered every handler against the pre-flip version and confirmed 382 outputs across all 193 handler classes are identical bytes (the cast/str coercions are runtime no-ops; the only str() wraps that touch lookup keys were converted to cast to guarantee key identity). mypy python/djust stays GREEN (822 files) with djust.components.rust_handlers strict; gate-off-verified (#1468) — a wrong-typed int return injected into a handler (ModalHandler.render, declared str) turns the gate RED ([return-value]), reverting restores GREEN. With M4g, no lenient exception remains in the components/ package — the global lenient default now parks only legacy non-components modules. Full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the scaffolding/ + template_tags/ + theming/gallery/ subpackages — 20 modules (ADR-023 M4e, group 2). The next ratchet step flips three more subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans: the CRUD scaffolding generator (scaffolding/gen_live/gen_live_templates/generator/templates: the JSON/interactive schema-to-LiveView+admin code generator); the Rust-engine custom template-tag handlers (template_tags/{% url %}/{% static %}/{% djust_pwa %}/{% templatetag %}/{% dj_flash %}/{% djust_markdown %}/{% djust_client_config %}/{% live_render %} registered with the Rust renderer; this is the underscoretemplate_tags/ package, distinct from the Django-engine templatetags/ package already flipped in M4d group 1); and the theme-gallery / component-storybook surface (theming/gallery/views the gallery/editor/diff + storybook DEBUG/staff-gated views, context the example-context + token-serialization builders, component_registry, urls, storybook). None of the three subpackages has a tests/ dir, so the ratchet completes each in one PR with no test sub-package to defer. Annotated with real types (params + returns — not Any cosmetics): HttpRequest/HttpResponse on the gallery views, list[dict[str, Any]] on the example builders, Callable[[Type[TagHandler]], Type[TagHandler]] on the @register decorator factory. Render output is byte-identical — the SafeString/HTML boundaries (format_html in flash, escape in markdown, Template.render in pwa, reverse in url, static in static, _client_config_html in client_config, the dynamic component .render() in component_registry) return Any under the lenient global config (Django + the cross-island live_tags._client_config_html are seen as untyped), so each is coerced with str(...) at the boundary to satisfy warn_return_any WITHOUT changing the returned (already-safe) HTML. One real type fix: scaffolding/generator.pylist_display_fields annotated list[str] (was an un-annotated [] flagged var-annotated). mypy python/djust stays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return injected into template_tags/url.UrlTagHandler.render (declared str) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/themes/ theme-definition subpackage — 66 modules (ADR-023 M4f). The next ratchet step flips the per-theme definition subpackage from the lenient mypy default to a strict island via a single glob [[tool.mypy.overrides]] module = ["djust.theming.themes.*"] (ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any) — mirroring the djust.security.* glob pattern, so a new built-in theme file added to this directory is strict-by-default with no further pyproject edit. The subpackage is 63 per-theme data modules (default / nord / dracula / catppuccin / tokyo_night / gruvbox / … — each a flat set of module-level ColorScale/ThemeTokens/ThemePreset/DesignSystem/ThemePack literals, zero functions), the dependency-free re-export hub _base, the package __init__ (pure re-exports), and the deprecated _legacy module (the Theme/THEMES dataclass API kept for backward compat). 64 of the 66 modules were already strict-clean (data + re-exports), so M4f is mostly a config-flip; the only annotation work was on _legacy._DeprecatedThemesDict's nine untyped dict overrides (__getitem__/__contains__/get/items/keys/values/__iter__/__len__) — annotated to match the dict[str, Theme] superclass signatures (the three view methods declare -> Any for the un-nameable concrete dict_items/dict_keys/dict_values return types, the established codebase pattern). No real bugs found — the theme modules are pure data and _legacy's overrides were behaviorally correct, just unannotated (logic byte-identical; deprecation-warning behavior unchanged). mypy python/djust stays GREEN (822 files) with theming/themes/* strict; gate-off-verified (#1468) two ways — a wrong-typed str return on _legacy._DeprecatedThemesDict.__len__ (declared int) turns the gate RED ([override] + [return-value]), AND an untyped def injected into a theme-DATA module (nord.py) turns it RED ([no-untyped-def]), proving the glob covers the data modules and not just _legacy; reverting either restores GREEN. Behavior is byte-identical (annotations are runtime no-ops); full suite 8604 passed / 0 failed. Note: the top-level theming/ modules are already strict (M4c part 3), but mypy's djust.theming.* glob matches only direct children, not the deeper djust.theming.themes.X submodules, so this subpackage needed its own override entry. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the loose top-level modules + backends/ + db/ — 22 modules (ADR-023 M4e, group 1). The next ratchet step flips the independent loose top-level modules and the two leaf subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the loose top-level modules (apps DjustConfig, audit_ast AST security-audit walker, audit_live runtime auditor, bug_capture harness, checks_css_proposal proposed CSS system-checks, hooks lifecycle registry, hot_view_replacement HVR engine, state_backend/template_backend back-compat re-export shims, template_filters helpers, time_travel recorder, utils shared helpers + BackendRegistry, __main__ entry point) and the two leaf subpackages: the presence backends (base, memory, redis, registry, __init__) and the PostgreSQL LISTEN/NOTIFY bridge (decorators, exceptions, notifications, __init__). Annotated with real types (params + returns — not Any cosmetics): db/decorators.notify_on_save.decorate typed type[models.Model] so _meta/label resolve, with narrow # type: ignore[attr-defined]s on the dynamic _djust_notify_channel/_djust_notify_receivers introspection attrs stashed on/deleted from the decorated model class; the signal receivers _on_save/_on_delete annotated (sender: type, instance: Any, **_kw: Any) -> None. Four genuine clean-up fixes (the kind strict-flips surface, ADR-023, all behavior-preserving): backends.registry.get_presence_backend now cast(PresenceBackend, _registry.get()) mirroring the already-strict state_backends.registry pattern (the generic registry returns Any); backends.redis.RedisPresenceBackend.count wraps the untyped zcount Any-return in int(...); db.notifications._import_psycopg gained its -> tuple[Any, Any] return; and db.notifications._dsn_from_url's URL-field loop variable was renamed (valdsn_val) to stop colliding with the earlier str-typed parse_qsl loop var so the mixed str | int | None field tuple type-checks. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed int return in backends.registry.get_presence_backend (declared PresenceBackend) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + the four wraps/rename are runtime no-ops); full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/templatetags/ + theming/management/ subpackages — 8 modules (ADR-023 M4e, group 3). The continuation of the theming ratchet: M4c (part 3) made the theming MACHINERY strict but explicitly deferred the user-facing render surface (the templatetag modules — theme_components was the heaviest at ~51 errors — plus the management command). This group finishes theming by flipping those deferred leaves from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the four template-tag modules (theme_components — the ~26 themed component tags theme_button/theme_card/theme_alert/theme_input/theme_modal/theme_table/theme_nav/etc.; theme_pages — the auth/error/utility page-fragment tags theme_login_page/theme_404_page/theme_maintenance_page/etc.; theme_tags — the theme_head/theme_css/theme_switcher/theme_preset/theme_mode accessors + the shared build_theme_head_context builder; theme_form_tagstheme_form/theme_form_errors/get_css_prefix) and the djust_theme management command (tailwind-config / export-colors / list-presets / shadcn-import-export / init / create-theme / validate-theme / create-package / check-compat / marketplace-info subcommands). These tags RENDER theme components into pages, so their mark_safe/format_html return values are annotated SafeString (the HTML-safe boundary) and context/request/form params get Context/HttpRequest | None/BaseForm; output is byte-identical. The management command uses the established CommandParser/*args: Any, **options: Any shape mirroring djust_setup_css/djust_doctor. Four real type fixes to clean the islands (the kind strict-flips surface, ADR-023): theme_components.theme_progress annotates percentage: float (the min(100, (int(value)/int(max))*100) reassignment is a float; the = 0 seed inferred int[assignment]); theme_tags.theme_framework_overrides narrows the format_html result through a str local at the unstubbed-django boundary ([no-any-return]); the three _css_prefix() helpers + theme_pages._csrf_token_value wrap the untyped get_theme_config().get(...)/get_token(...) boundary in str(...); and djust_theme.handle_marketplace_info reads the required-positional mp_theme_name via subscript (not .get()) so it stays non-Optional for the themes_dir / theme_name Path division + get_component_coverage(str, ...) call ([operator]/[arg-type]). mypy python/djust stays GREEN (822 files) with all 8 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in theme_pages._css_prefix (declared str) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + the str(...) boundary coercions are runtime no-ops) apart from the four genuine fixes above; full suite 8604 passed / 0 failed (1878 theming tests green). This completes theming/ except the optional theming/gallery subpackage, which remains for a continuation batch. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the pwa/ + optimization/ + tenants/ + observability/ subpackages — 31 modules (ADR-023 M4d, part 2). The next ratchet step after M4c (theming/ + admin_ext/) flips every non-test module of these four optional-extra subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the PWA layer (mixins PWAMixin/OfflineMixin/SyncMixin, storage offline backends + OfflineAction/SyncQueue, sync SyncManager/ConflictResolver, manifest, service_worker, utils), the optimization layer (fingerprint StateFingerprint/SectionCache/IncrementalStateSync, codegen serializer code-gen, query_optimizer select/prefetch analysis, cache SerializerCache, __init__), the multi-tenant layer (resolvers, managers TenantManager/TenantQuerySet, backends redis/memory presence, middleware ContextVar tenant binding, mixin TenantMixin/TenantScopedMixin, audit, security, models, __init__ — annotations only; tenant-isolation logic byte-identical), and the observability layer (views localhost-gated endpoints, middleware localhost gate, sql/timings/log_handler/tracebacks capture buffers, dry_run side-effect blocker, registry, urls, __init__). Annotated with real types (params + returns — not Any cosmetics), using the established mixin-collaborator pattern (# type: ignore[misc] on cooperative super().get_context_data()/dispatch() calls mirroring wizard.py/tenants; TYPE_CHECKING-only push_event/sync_queue stubs on the PWA mixins documenting the co-mixed-LiveView contract) and a narrow # type: ignore[import-untyped] on dry_run's lazy import requests (a known-stub package mypy won't silence via ignore_missing_imports). Two real bugs fixed to clean the islands (the kind strict-flips surface, ADR-023): pwa.storage.OfflineAction.id widened to Union[str, int] (callers forward an int model pk as obj_id; the SyncQueue action-id params widened to match), and pwa.mixins.delete_offline now passes the required OfflineAction(data={}) — omitting it raised TypeError at runtime on every call (a guaranteed crash in an untested path). mypy python/djust stays GREEN (822 files) with all 31 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed str return in optimization.fingerprint.StateFingerprint.version (declared int) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations are runtime no-ops) apart from the two genuine bug fixes above; full suite 8604 passed / 0 failed. Remaining for a continuation batch: the pwa/{templatetags,management}-style leaf packages do not exist for these four subpackages, so M4d(2) completes their non-test surface. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the tutorials/, api/, template/, and state_backends/ subpackages — 20 modules (ADR-023 M4d, part 3). The next ratchet step flips four independent transport/render/persistence subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the declarative guided-tour state machine (tutorials/ — the TutorialStep dataclass + TutorialMixin async tour loop, with if TYPE_CHECKING: declarations for the sibling-mixin surface it cooperates with — push_commands/_flush_pending_push_events/wait_for_event), the opt-in HTTP-API transport (api/ — the @event_handler(expose_api=True) + @server_function dispatch views, the pluggable BaseAuth/SessionAuth contract, the view registry, the OpenAPI schema builder, and the URL wiring), the Rust template engine's Django backend (template/DjustTemplateBackend.get_template/from_string, the multi-line {# #}get_contents loaders, the DjustTemplate rendering pipeline incl. the {% extends %}/{% block %} parser + {% url %} resolver, and the serialize_valueJSONValue serializer), and the LiveView state-persistence backends (state_backends/ — the StateBackend ABC, the in-memory + Redis backends, and the registry). api/ and state_backends/ are security/correctness-relevant — annotations only, logic byte-identical: the _snapshot_assigns/_compute_changed_keys diff, the CSRF/auth/object-perm gates, the rate-limit checks, the msgpack round-trip + identity-guarded cache pop, and the zstd compression path are UNTOUCHED. Three PyO3 methods consumed by the state backends (RustLiveView.serialize_msgpack / deserialize_msgpack / get_timestamp) were added to the _rust.pyi wire-boundary stub (they existed at runtime but were missing from the stub). The only narrow coded # type: ignores are at genuine dynamic edges (the optional-JIT DjangoJSONEncoder = None / _get_model_hash = None import fallbacks in template/rendering.py; the transient None-view health-check probe entry in state_backends/memory.py); cast(...) is used at the Django/zstd/Rust unstubbed-boundary Any leaks, and assert ... is not None narrows already-guarded optionals (the next_start.end() block-parser sites, the _get_compressor() compress path gated by _compression_enabled). mypy python/djust stays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in state_backends/registry.get_backend ([return-value]) turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. None of the four subpackages has a tests/ subdir, so the ratchet completes each in a single PR (no test sub-package to defer). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the management/, checks/, auth/, and templatetags/ subpackages + 8 loose top-level modules — 53 modules (ADR-023 M4d, group 1). The ratchet step after the M4c subpackages (mixins/ + admin_ext/ + theming/) flips four more subpackages and the independent loose modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans every management command (djust_audit, djust_check, djust_doctor, djust_setup_css, djust_typecheck, djust_gen_live, djust_new, djust_schema, djust_mcp, djust_ai_context, generate_sw, cleanup_liveview_sessions) + the shared _introspect helper; the full Django system-check family (configuration/security/templates/quality/components/integrations/accessibility + the shared utils); the auth layer (the check_view_auth/run_pre_mount_auth/enforce_object_permission security core, the LoginRequiredLiveViewMixin/PermissionRequiredLiveViewMixin, the social_auth_providers context processor, the signup/login views + forms, and the djust_admin plugin + its OAuthProvidersView/SocialAccountsView LiveView pages); all five template-tag modules (live_tags — the big one with {% live_render %}/{% colocated_hook %}/{% dj_activity %} + the lazy-thunk emitter, plus djust_flash/djust_formsets/djust_pwa/djust_tutorials); and the loose modulescli, dev_server, deploy_cli, drafts, http_streaming, session_utils, push, middleware. Annotated with real types (params + returns — not Any cosmetics): SafeString at the mark_safe/format_html boundary; CheckMessage for system-check errors lists + returns; argparse.Namespace/CommandParser for the management commands; ast.* node types (ast.ClassDef/ast.Call/ast.expr/ast.Module) for the AST-based checks; AsyncIterator[bytes] for the ChunkEmitter streaming surface. The only narrow coded # type: ignores are at genuine dynamic edges: the djust.checkssetattr re-export (_root.* — the patch-by-path contract from the #1822 monolith split), the djust-admin optional-dependency fallback class (no-redef/assignment), the optional _rustversion export (not in the .pyi), the auth-mixin cooperative super().dispatch (provided by the combined View), and the Django model._meta access (no django-stubs). checks/ + auth/ logic is byte-identical — annotations + cast(...)/bool(...) boundary coercions are runtime no-ops; the system-check AST walkers, suppression logic, and the auth precedence (login → permission → custom hook → Django AccessMixin → object-permission) are UNTOUCHED. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return in a flipped module (templatetags/djust_flash.dj_flashint) turns the gate RED ([no-any-return]), reverting restores GREEN. Full suite 8604 passed / 0 failed. requests (consumed by deploy_cli) joins yaml in the untyped-third-party override. Remaining for a continuation batch: the management/templatetags-adjacent long tail is already covered; the tenants/, backends/, and state_backends/ subpackages + the last few loose modules remain. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/ subpackage — 39 top-level modules (ADR-023 M4c, part 3). The next ratchet step after the components/ batches (M4b) flips every top-level module of the theming system — including its small rust_handlers (unlike the components/ one, this one was already well-typed and is NOT the iceberg) — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the registry layer (_registry_accessor singleton + registry discovery wiring), the ThemeManager + ThemeState state/session machinery, the CSS generators (theme_css_generator, pack_css_generator, component_css_generator, design_system_css, css_generator), the color machinery (palette, colors, accessibility, high_contrast, presets, design_tokens), the render paths (context_processors, template_resolver, mixins ThemeMixin, components, forms renderer), the build/adapters/tooling (build_themes, shadcn, tailwind, inspector, checks, manifest, loaders, theme_packs, compat, contracts), the apps AppConfig, views, urls, and the leaf _config/_constants/_types/_builtin_presets modules. Annotated with real types (params + returns — not Any cosmetics), using the cast(str, mark_safe(html)) boundary pattern for the theme-component renderers (django's safestring is unstubbed, so mark_safe returns Any; SafeString itself resolves to Any without django-stubs, so a str cast is the honest no-Any-leak shape). mypy python/djust stays GREEN (822 files) with all 39 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in manager.get_css_prefix ([return-value]) turns the gate RED, reverting restores GREEN. Rendering byte-identical — annotations + cast(...) + int(hue_offset) casts are runtime no-ops; the only behavior-adjacent additions are defensive if self._theme_manager is None: return guards in the four ThemeMixin event handlers (no-ops on the real post-mount path, matching the existing _setup_theme_context guard). Full suite 8604 passed / 0 failed; 1863 theming tests pass (incl. the previously-flaky test_theme_tags_rust_engine_1721, green via #1929's fixture). Remaining theming/ for a continuation batch: the theming/{templatetags,management,gallery} subpackages (the templatetag modules are the heaviest — theme_components ~51 errors — so they're a separate batch). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the mcp/ + contrib/uploads/ + uploads/ subpackages — 14 modules (ADR-023 M4d, group 4). The next ratchet step flips three independent subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans: the MCP server (mcp/server, mcp/__init__, mcp/__main__ — the AI-assistant introspection/scaffolding tool: create_server() -> "FastMCP" via a TYPE_CHECKING-guarded import so the optional mcp dep is never imported at module load, _ensure_django() -> bool, main() -> None, and the observability-tool returns); the binary-WebSocket-frame upload system (uploads/__init__ — the UploadWriter base + BufferedUploadWriter + UploadConfig + UploadManager, uploads/resumable — the resumable chunk protocol, uploads/storage — the in-memory + Redis UploadStateStore impls, uploads/views — the UploadStatusView HTTP endpoint with HttpRequest/JsonResponse annotations); and the contrib upload-writer adapters (contrib/__init__, contrib/uploads/{__init__,azure,errors,gcs,s3_events,s3_presigned} — the S3 presigned/event, GCS resumable, and Azure block-blob direct-to-storage writers). None of these has a tests/ dir, so the ratchet completes in one PR with no test sub-package to defer. Annotated with real types (params + returns — not Any cosmetics); the only narrow coded edges are: # type: ignore[override] on the legacy write_chunk(self, chunk) adapters (BufferedUploadWriter, GCSMultipartWriter, AzureBlockBlobWriter) — the dropped trailing chunk_index default is an INTENTIONAL, runtime-dispatched part of the UploadWriter contract (_writer_accepts_chunk_index introspects the signature; documented on the base method), and cast(...) narrows at the untyped boundaries (json.loads in uploads/storage, boto3.generate_presigned_url in s3_presigned, requests.Response.text + session.session_key in mcp/server/uploads/views). Two mcp/server observability-tool params dicts inferred homogeneous-then-mutated-with-the-other-type were annotated dict[str, object]. Third-party requests (consumed by mcp/server + contrib/uploads/gcs, no stubs) is marked untyped in the shared ["yaml", "requests"] override. Upload logic is byte-identical — these are security-relevant binary-frame handlers; annotations + cast(...) are runtime no-ops and NO chunk-dispatch, size-cap, or HMAC logic was altered. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return injected into uploads/storage.delete turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed (413 upload/mcp-related tests pass). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the admin_ext/ subpackage — 13 modules (ADR-023 M4c, part 2). The next ratchet step after the components/ batches (M1 foundation → M2 public-API quartet → M3 dispatch core → M4a loose top-level → M4b-1/2/3 components/) flips the entire Django-admin integration from the lenient mypy default to a strict island ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans every non-test admin_ext/ module: the DjustAdminSite (model + plugin registration, URL generation, app-list / plugin-nav / widget collection), DjustModelAdmin (the list/detail/form/action config + queryset auto-optimization), the plugin system (AdminPlugin / AdminPage / AdminWidget / NavItem), the LiveView-based admin views (AdminIndexView, ModelListView, ModelDetailView, ModelCreateView, ModelDeleteView, LoginView, LogoutView + the admin_login_required wrapper and the _VIEW_REGISTRY plumbing), the AdminFormMixin (FK/M2M option loading, date/time field detection, readonly handling, real-time field validation), the bulk-action progress widget + @admin_action_with_progress decorator, the AdminTailwindAdapter admin CSS-framework adapter, the register/action/display decorators, the DjustAdminConfig AppConfig, the autodiscover package __init__, and the admin template-tag helpers (get_item/get_field/concat/admin_url). Excludes admin_ext/tests/, which stays on the lenient global default. Annotated with real types (params + returns — not Any cosmetics): HttpRequest/Optional[models.Model] on request/obj params, typed class-attr config (list_filter: List[Any], formfield_overrides: Dict[Any, Any], widget_id: Optional[str], …), List[URLPattern] URL builders, and the established mixin-collaborator pattern (request: Any / _model: Any / _model_admin: Any annotation-only attrs on AdminBaseMixin + AdminFormMixin documenting the co-mixed-LiveView contract, plus a # type: ignore[misc] on the cooperative super().as_view() mirroring wizard.py); decorator function-attribute stamping (wrapper.short_description = ...) carries narrow # type: ignore[attr-defined] at the genuine dynamic edge. mypy python/djust stays GREEN (822 files) with all 13 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (adapters.register_admin_adaptersint) turns the gate RED ([return]), reverting restores GREEN. Behavior is byte-identical — annotations are runtime no-ops; the 95 admin tests (test_admin_basic/test_admin_plugins/test_admin_widgets_per_page/test_bulk_progress + admin checks) and the full suite (8604 passed / 0 failed) confirm no regression. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the mixins/ subpackage — 21 modules (ADR-023 M4c, part 1). The eighth ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level, M4b-1/2/3 all of components/) flips the entire mixins/ subpackage — the LiveView mixin layer that composes the public LiveView class — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans all 21 modules: the small leaf mixins (flash, layout, page_metadata, post_processing, async_workstart_async/defer/assign_async, waiterswait_for_event, model_binding — the dj-model mass-assignment guard, components — the child-component lifecycle), the already-clean leaves (__init__, activity, handlers, navigation, notifications, push_events, sticky, streams), the context/JIT serialization mixins (contextget_context_data/_apply_context_processors/_deep_serialize_dict, jit_jit_serialize_queryset/_jit_serialize_model/_get_template_content), the HTTP request mixin (get/aget/post + the streaming _make_streaming_response/_is_asgi_context), the Rust-bridge / change-detection mixin (rust_bridge_sync_state_to_rust/_initialize_rust_view/_normalize_db_values), and the large template rendering mixin (render/render_full_template/render_with_diff/arender_chunks + the HTML extraction/stripping helpers). Annotated with real types (params + returns — not Any cosmetics), using the established if TYPE_CHECKING: host-attribute-declaration pattern (mirroring streaming.py) for the cross-mixin/host-class surface each mixin cooperates with (get_context_data, _rust_view, template_name, etc.) — a runtime no-op resolved only at type-check time, since a mixin is never instantiated standalone. The only narrow coded # type: ignores are at genuine dynamic edges (the optional-Rust RustLiveView = None / extract_template_variables = None import fallbacks; the event_handler direct-file-import fallback shim; the dynamic component_id/_auto_id attribute sets on the Component | LiveComponent union). rust_bridge/jit change-detection is byte-identical — annotations are runtime no-ops; the _sync_state_to_rust change-detection, the _framework_attrs-class filter conventions, and all id()/value comparison logic are UNTOUCHED (no comparison or filter expression was altered). mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict mixin (template.get_templateint) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. The mixins/ ratchet completes in a single PR (no mixins/tests/ sub-package exists to defer). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the FINAL components/ modules — 68 modules (ADR-023 M4b, part 3). The seventh and last components/ ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level, M4b-1 core machinery, M4b-2 UI catalog) flips every remaining components/ module — except the deliberately-lenient rust_handlers — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the component template-tag layer (templatetags/djust_components ~373 fns, _advanced ~84, _forms ~26, _charts ~23 — every Node.render(self, args/content, context) -> SafeString, do_*(parser, token) -> template.Node, and @register.simple_tag/inclusion_tag function, with inclusion_tags correctly typed -> dict[str, Any] since they return a context dict, not HTML), the per-widget mixins/data_table (the DataTableMixin — its ~21 on_table_* event handlers, handle_* override hooks, get_*/_apply_* pipeline, and the safe-arithmetic expression parser), the gallery LiveView surface (gallery/live_viewsGalleryCategoryMixin + 9 category views, with the template_name Liskov conflict resolved by a TYPE_CHECKING-only LiveView base alias; views, examples, registry, context_processors, and the component_gallery management command), the ~24 remaining components/components/* widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/_eval_expression/etc.), the layout/tabs/data/pagination/ttyd/terminal leaves, and the ui/*_simple stateless widgets + ui/dropdown (the over-narrow nav-item dict widened to the honest Any contract per #1108; the optional-Rust import shims — from djust._rust import RustX / RustX = None fallbacks for built-but-unstubbed and declared-but-unbuilt Rust component classes — carry narrow # type: ignore[attr-defined]/[assignment, misc] at the genuine dynamic edge). Annotated with real types (params + returns), using the mark_safe(...) -> SafeString boundary pattern (no Any leak). rust_handlers is deliberately left LENIENT — it is a genuinely-dynamic Rust-bridge registry whose 193 handlers parse untyped Rust-engine arg lists into dict[str, object] (the kw.get() -> object cascade), so strict typing surfaces 344 errors (203 no-any-return + 91 call-overload + …) that would need >200 narrowing changes / # type: ignores with real rendering-behavior risk; the global lenient default is the correct home for it (documented exception in pyproject + this entry). mypy python/djust stays GREEN (823 files) with all 68 strict and rust_handlers lenient; gate-off-verified (#1468) — a wrong-typed return in mixins/data_table ([return-value]) and a dropped annotation in templatetags/djust_components ([no-untyped-def]) each turn the gate RED, reverting restores GREEN. Rendering byte-identical — annotations are runtime no-ops, verified by diffing the rendered HTML of all 7 chart tags, 8 representative widgets (diff_viewer/prompt_editor/heatmap/treemap/json_viewer/org_chart/pivot_table/animated_number), and 8 djust_components simple_tags against the pre-change versions (identical output), plus 143 component/data_table tests passing. Full suite 8604 passed / 0 failed. This completes the ADR-023 components/ ratchet (only the documented rust_handlers exception remains lenient within components/). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the components/ UI catalog + templatetag helpers — 185 modules (ADR-023 M4b, part 2). The sixth ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level batch, M4b-1 core component machinery) flips the component UI catalog and small leaf modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the full components/components/ widget catalog (146 modules — alert, badge, card, spinner, kanban-adjacent leaves, charts, etc.), the ui/ stateless widgets (8 — spinner, modal, alert, progress, badge, button, card, list_group), the data//forms//layout//gallery//management//ttyd/ leaf packages, the descriptor-based components (descriptors/* — the DEP-002 Accordion/Tabs/Modal/Sheet/Dropdown/Collapsible/Carousel/Tooltip + base), and the 8 deprecated state mixins (mixins/tooltip, tabs, sheet, modal, dropdown, collapsible, carousel, accordion). Annotated with real types (params + returns — not Any cosmetics): typed *_instances class vars (Optional[Dict[str, XState]]), instance_id: str / component_id: str / is_open: bool params, get_*_ctx(...) -> Dict[str, Any] accessors, and render() -> SafeString (mirroring the markdown.py island — mark_safe(...) returns Any under django's unstubbed safestring, so SafeString is the correct str-compatible annotation that cleanly absorbs the Any without a # type: ignore). Rendering is byte-identical — annotations are runtime no-ops, verified by diffing the rendered HTML of representative UI components (spinner/alert/modal) against the pre-change versions (identical output). mypy python/djust stays GREEN (822 files) with all 185 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (ui/spinner, descriptors/modal) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Remaining components/ for the continuation batch: the Rust-bridge handler registry (rust_handlers — the ~360-error mark_safe/kw.get()-object iceberg, a separate decision), the per-widget mixins/data_table, the big templatetag modules (templatetags/djust_components/_advanced/_forms/_charts), the gallery/live_views/views/examples LiveViews, the ~24 components/components/* widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/etc.), and the union-typed ui/*_simple widgets + ui/navbar_simple/modal/dropdown (over-narrow dict inference + the declared-but-unbuilt RustNavBar/Rust* fallback imports). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the components/ machinery — 15 core modules (ADR-023 M4b, part 1). The fifth ratchet step (after M1's foundation, M2's public-API quartet, M3's dispatch core, M4a's loose top-level batch) flips the core component-system machinery — NOT the UI catalog — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any): components.__init__, components.apps, components.registry (the LiveComponent name registry), components.assigns, components.dependencies (the DependencyManager CSS/JS asset registry), components.function_component (the @component decorator + {% call %}/{% slot %} dispatch handlers), components.helpers, components.presets (the tag-preset registry), components.icons (the Heroicons SVG renderer + render_icon), components.suspense (the {% dj_suspense %} fallback renderer), components.server_event_toast (ServerEventToastMixin), components.utils (shared format_cell/interpolate_color/interpolate_color_gradient + CURRENCY_SYMBOLS), components.mixins.base (the per-component interactive mixin base — ComponentMixin + the TypedState dict subclass), components.templatetags._registry (the shared template.Library + the security-sensitive safe_url scheme-validator + _resolve/_parse_kv_args), and components.templatetags._dev_tools (the Terminal/MarkdownEditor/JsonViewer/LogViewer/FileTree dev-tool template tags). Annotated with real types (params + returns — not Any cosmetics); the only narrow coded # type: ignore[attr-defined] are at genuine dynamic edges (the @component decorator stamping _djust_* metadata onto a plain Callable; the per-invocation _slots/_children attached to a LiveComponent instance for template render). The mark_safe-returns-Any boundary is handled with typed-local narrowing (a small _safe(html: str) -> str wrapper in _dev_tools, str-typed locals elsewhere) — no Any leak. mypy python/djust stays GREEN (822 files) with all 15 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (utils.interpolate_color) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Remaining components/ for the continuation batch: the Rust-bridge handler registry (rust_handlers, ~360 errors once -> str is added — the mark_safe/kw.get()-object iceberg), the per-widget mixins.data_table, and the big templatetag modules (djust_components/_advanced/_forms/_charts), plus the UI catalog (ui/, data/, forms/, gallery/, charts UI). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on 15 loose top-level modules (ADR-023 M4a). The fourth ratchet step (after M1's foundation, M2's public-API quartet, M3's dispatch core) flips a batch of independent, low-cross-risk top-level modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any): serialization (the wire-boundary JSON/normalizer — DjangoJSONEncoder._serialize_model_safely + the finding-#19 denylist/allowlist/opt-out field gate + normalize_django_value), config, __init__, routing (the live_session URLconf walk + auth-filtered route-map emit), formsets, simple_live_view, testing (the public LiveViewTestClient + SnapshotTestMixin + LiveViewSmokeTest fuzz/smoke harness), react, rust_components, frameworks (the CSS framework adapters), js (the JS command-chain builder), wizard (WizardMixin), performance, profiler, and presence (PresenceMixin + LiveCursorMixin). Annotated with real types (params + returns — not Any cosmetics); the only # type: ignore[misc] are at genuine mixin super()-delegation edges (wizardmount/get_context_data, which the LiveView MRO supplies at runtime). mypy python/djust stays GREEN (822 files) with all 15 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. The ratchet continues one batch per PR (the remaining long tail: mixins/components/theming/CLI). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the dispatch/runtime core — runtime, websocket, sse, streaming, websocket_utils (ADR-023 M3). The five modules that form the WebSocket/SSE/ViewRuntime dispatch spine (every mount + event flows through them) are now mypy strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any), the third ratchet step after M1's foundation and M2's public-API quartet. Safe to type now that the ADR-022 ViewRuntime convergence has settled (no spine code about to move). Annotated with real types (params + returns — not Any cosmetics): runtime.py (42 strict errors — ViewRuntime dispatch helpers, _build_request/_check_auth/_extract_*, the actor-mount path, _tenant_context), websocket.py (73 — LiveViewConsumer lifecycle connect/disconnect/receive, the handle_* verb handlers, the Channels event handlers server_push/db_notify/presence_event/etc., _run_async_work/_dispatch_single_event, _mount_one's 5-tuple return, the module helpers _snapshot_assigns/_compute_changed_keys/render_embedded_child_html), sse.py (17 — the DjustSSE*Viewget/post HTTP handlers, the owner-binding helpers, the SSE event-stream async generator), streaming.py (6 — StreamingMixin, with TYPE_CHECKING host-class attribute declarations), and websocket_utils.py (7 — the shared event-security pipeline). Only two narrow coded # type: ignore[arg-type] for genuine frame-dynamic edges (the dormant actor-event-name forward; the no-binary receive() text frame). mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. The ratchet continues one module per PR (M4: mixins/components/theming/long tail). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the public-API quartet — live_view, component, decorators, forms (ADR-023 M2). The four developer-facing modules that py.typed exposes to downstream consumers are now mypy strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any), the second ratchet step after M1's foundation. Annotated: live_view.py (as_view/__init__/live_view decorator + private-state helpers) and its PEP 561 stub live_view.pyi; components/base.py (the Component + LiveComponent public bases — descriptor protocol, render waterfall, event-handler factory); decorators.py (@event_handler, @action, @server_function, @reactive, @state, @computed, @optimistic, @background + their nested wrappers/descriptors); and forms.py (FormMixin + LiveViewForm). mypy python/djust stays GREEN (822 files); the strict flip is gate-off-verified (#1468) — injecting a wrong-typed return into one of the four turns the gate RED, the same error in a lenient module stays GREEN. The ratchet continues one module per PR (M3: the dispatch/runtime core). See docs/adr/023-incremental-type-enforcement.md.

  • Enforced incremental type-checking — a mypy merge gate + strict islands + the _rust.pyi boundary (ADR-023). djust ships py.typed (PEP 561 — downstream consumers type-check against djust's hints), and pyproject.toml declared a strict [tool.mypy] config — but mypy was invoked nowhere (CI / Makefile / pre-commit), so the strict config was dead and mypy python/djust reported 8,421 errors (≈6,814 missing annotations + ~750 missing-stub imports + ~700 real type errors). This PR restructures [tool.mypy] for incremental adoption: a lenient global default (ignore_missing_imports = true + ignore_errors = true) that parks the legacy baseline so the gate is GREEN, plus per-module strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any) that genuinely enforce every error class on a 22-module starter set led by the security boundary (djust.security.*) and the PyO3 wire boundary (djust._rust, typed by _rust.pyi), plus rate_limit, validation, permissions, markdown, schema, signals, async_result, test_isolation, and the well-annotated _-prefixed leaf modules (_client_ip, _log_utils, _html, _view_resolution, _deprecation, _context_provider). The gate is enforced: a non-continue-on-errormypy step in the python-tests CI job (a new MERGE GATE — #1236 governance — wired into the test-summary AND-condition; ships gating because it is green by construction, per #1534), a make typecheck target (in make check), and a scoped pre-commit hook on python/djust/**.py{,i} changes. The _rust.pyi stub's top-level names are pinned to exactly match the compiled module's runtime exports and a strict island (markdown) imports through it, so the wire/serialization boundary is type-checked, not merely declared. Empirical canary (#1459): an injected missing-annotation / wrong-typed-return in a strict island makes the gate RED, while the same error in a lenient module stays GREEN — the gate is real, not cosmetic. The ratchet is one-module-per-PR, prioritising the developer-facing public API (live_view/component/decorators/forms) since py.typed exposes it. See docs/adr/023-incremental-type-enforcement.md.

  • Mount-spine parity nets + 6 real-WebsocketCommunicator flip gap-tests for the WS mount convergence (#1911, ADR-022 Iter 3 Phase 3.0). The regression net the eventual mount flip (Phase 3.3b) will ride. python/djust/tests/test_ws_mount_flip_parity_1911.py characterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespoke handle_mount over a real channels WebsocketCommunicator (each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (a use_actors view renders an actor-backed mount frame, NOT the SSE refusal — Finding D), sticky_hold-before-mount-frame ORDERING via live_redirect_mount (Finding B), Channels group_add server-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (a source="tick" frame arrives with no client event), optimistic_rules + upload_configs on the mount frame, and live_redirect re-mount idempotency (mount A → live_redirect to B → B actually mounts, not a no-op — THE Finding-A net: the bespoke path nulls self.view_instance before re-mounting, and a naive flip that forgets to also reset runtime.view_instance would silently no-op the re-mount since dispatch_mount early-returns when view_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling. python/djust/tests/test_transport_behavioral_parity.py grows the mount-spine nets (mount-stash + dirty-baseline pins, mount-async/push-drain parity, mount-frame wire-version parity per Finding C's no-arm baseline, a two-queues-not-_flush_all_pending source pin) and extends _WS_ONLY_MARKERS with the WS-only mount behaviors (create_session_actor, state_snapshot_signed, _find_sticky_slot_ids, tick_interval, register_view) so a future "moved to runtime" of one trips RED. No WS routing change: RUNTIME_OWNED_VERBS ({"url_change", "event"}) and handle_mount/handle_mount_batch are UNTOUCHED.

Changed

  • Perf (cold-start): warm the Django→Rust custom-filter bridge at startup instead of on the first mount. Request-path profiling showed the first mount after server boot paid a one-time ~20 ms cost: rust_bridge._ensure_custom_filters_bridged() lazily triggers Django to import every templatetag library (via engine.template_libraries) on first access. It's memoized after, so steady-state is unaffected — but the first request ate the latency. DjustConfig.ready() now eagerly runs the bridge (new _warm_filter_bridge() helper) so that one-time cost lands at startup, not in the first user's request. Idempotent + non-fatal; skipped under pytest (mirrors the hot-reload gate); opt out via LIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases in test_auto_hot_reload.py (TestFilterBridgeWarm-class behaviors, gate-off via the opt-out test). No steady-state behavior change.

  • CI: CodeQL config excludes py/ineffectual-statement (false-positive noise from the ADR-023 TYPE_CHECKING stub idiom). The strict-mypy ratchet added if TYPE_CHECKING: forward-declaration blocks across the mixins (cooperating-attribute/method stubs with ... bodies so each strict-island mixin resolves names supplied by sibling classes at MRO time — zero runtime effect). CodeQL's py/ineffectual-statement flags every ... Ellipsis body; all 50 hits were this idiom (the genuine useless-expression class is covered by ruff). Added the rule to .github/codeql/codeql-config.yml's query-filters so it doesn't recur as the type-checking blocks grow. Also converted a cast("Any", …) string forward-ref to a direct cast(Any, …) in components/rust_handlers.py so CodeQL sees the Any import as used (#2553).

  • CI: the shared Playwright harness now waits for the demo server's actual canary route to be ready before the browser run — removes the cold-cache page.goto flake on the BLOCKING browser-smoke gate (#1943). The blocking browser-smoke gate (#1869) intermittently red-barred unrelated PRs with Page.goto: Timeout 30000ms exceeded navigating to /demos/browser-smoke/: on a cold-cache run that compiles the djust_components Rust crate from scratch, the demo uvicorn server wasn't ready within the fixed 30s page.goto deadline (it cleared on a warm-cache re-run — not a real break). The .github/actions/djust-playwright-server readiness step (shared by every playwright job) is fixed at the source: (1) its poll bound is bumped 30s → 120s (60 attempts × 2s); (2) it now polls the ACTUAL canary target route (/demos/browser-smoke/) in addition to /, using curl -fsS so a half-initialized app's 5xx does NOT count as ready — this warms the route (first-hit lazy import/compilation) DURING the bounded loop so page.goto lands on an already-warm route instead of racing its own deadline. tests/playwright/test_browser_smoke.py's page.goto also gets an explicit 60s timeout (belt-and-suspenders, up from the 30s default). The gate's SIGNAL is preserved: a genuinely-down server still fails LOUD (exit 1 + server.log dump) once the 120s bound is hit, so a real runtime break of either #1849/#1848 class still red-bars the PR.

  • CI: the Playwright browser-smoke canary is now a HARD merge gate, and the #1848 inline-script check is now a hard assertion (#1869, Action Tracker #314). The #1849/#1848 runtime-break canary (tests/playwright/test_browser_smoke.py, which drives /demos/browser-smoke/ and guards the 1.0.7 runtime-break class — a LiveView refused at WS mount, and an inline <script> inside the dj-root whose delegated listener never registers under the #1610 mount morph) was carved out of the already-non-blocking playwright-tests leg into its OWN dedicated browser-smoke CI job (no continue-on-error) and wired into the test-summary aggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors the demo-checks blocking-job pattern, #1708/#1713). Promoted per #1534 only after the canary shipped green on the runner across multiple PRs in the non-blocking leg. The rest of the playwright suite (loading_attribute / cache_decorator / draft_mode / nav_hooks) stays in the non-blocking playwright-tests leg — the full suite can be flaky; only this stable two-class canary gates. The inline-script (#1848) branch of the canary, previously a tolerated known-xfail (warn-not-fail when the inline <script> never ran), is flipped to a HARD assertion now that PR #1871 fixed #1848 (re-execute classic <script> on the #1610 mount morph via window.djust._runInsertedScripts); a future regression of that fix now hard-fails the now-gating canary.

  • WS mounts now route through ViewRuntime.dispatch_mount — THE MOUNT FLIP, the #1646 mount convergence COMPLETE (#1919, ADR-022 Iter 3 Phase 3.3b)."mount" joins "url_change" + "event" in RUNTIME_OWNED_VERBS, so receive() routes every WS mount frame through the single dispatch_messagedispatch_mount chokepoint, and the ~870-line bespoke handle_mount body is DELETED — reduced to a THIN SHIM over dispatch_mount (mirroring the event flip #1907 and handle_url_change). Phases 3.0-3.3a had already grown dispatch_mount into a functional superset (F22 view resolver, run_pre_mount_auth pre-mount auth+tenant via _check_auth, on_mount hooks, session + signed-snapshot state restore, post-mount object-permission, handle_params, actor mount, no-arm mount wire version, the sticky_hold pre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) via WSConsumerTransport hooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the _dispatch_runtime_owned mount arm, disconnect, and the live_redirect teardown all null runtime.view_instance BEFORE dispatch, so a reconnect / live_redirect re-mount is never silently no-op'd by dispatch_mount's if view_instance is not None early-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads back self.view_instance = runtime.view_instance, and the WS post-mount consumer setup the bespoke body did but the runtime did NOT (server-push / presence / db_notify group_add, the periodic tick_interval task, the use_actors flag, the real-scope _websocket_path/_websocket_query_string stamps, the _sticky_auto_reattached reset) is folded into the now-LIVE WS on_view_mounted transport hook — made async to awaitgroup_add — Finding B residual; (C) mount wire version via the next_mount_version hook (the no-arm consumer counter). The object-perm denial now closes the socket via finalize_mount_auth (Finding E — the bespoke unconditional close(4403) had no runtime equivalent). A mount_batch bug the flip surfaced is also fixed: ViewRuntime._instantiate_view fire-and-forgot its error frame via asyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor to failed[]); it now stashes the frame and dispatch_mountawait-sends it inside the correct _mount_one window. handle_mount_batch / _mount_one stay WS-only (the collector contract is unchanged; finalize_mount_auth still gates the redirect-verdict close on not _mounting_in_batch per #291/#1780). Boundary pins updated to the post-flip reality: the RUNTIME_OWNED_VERBS contract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth / object-perm / validated_host_from_scope converged onto runtime.py), _WS_ONLY_MARKERS (group_add / channel_layer / tick_interval moved to the runtime hook), and the handle_mount source-grep pins (snapshot sign/unsign, skip-html, _ensure_tenant-before-restore, has_ids, mount-url validation, next-version) moved to dispatch_mount; the fake consumers in test_sw_advanced.py / test_sw_advanced_flow.py gained a permissive _rate_limiter so they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes the live_redirect re-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering the on_view_mounted fold makes the group_add-reachability + tick-at-mount nets RED. Full CI-way suite (tests/ python/tests/ python/djust/tests/ -n auto): 8577 passed, 0 failed.

  • The 5 transport mount-hooks (#1916) are now WIRED into ViewRuntime.dispatch_mount — it is a functional SUPERSET of the WS handle_mount, and the hooks go LIVE for the SSE/runtime mount path (#1917, ADR-022 Iter 3 Phase 3.3a). The last build-up before the Phase 3.3b atomic flip. Routing stays bespoke — RUNTIME_OWNED_VERBS is UNCHANGED ({"url_change", "event"}), handle_mount / handle_mount_batch are UNTOUCHED (websocket.py has no diff) — but the dormant hooks are now called by dispatch_mount at their WS-faithful positions (read off handle_mount): (1) on_view_instantiated(view) right after instantiation (WS stamps _ws_consumer / _push_events_flush_callback / observability register_view / validated host; SSE no-op) (Finding B). (2) uses_actors_for_mount / dispatch_actor_mount (Finding D) — the hard actor REFUSAL is replaced: a WS use_actors view now RENDERS through the actor system at the render step (verbatim handle_mount ordering — after auth + mount() + handle_params, html sent without strip/extract, websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount → False, so the structured use_actors is not supported over SSE envelope is now reached only when the transport does NOT support actor mounts). (3) next_mount_version(html, rust_version) (Finding C) — the mount-frame version routes through the NO-ARM hook (WS consumer._next_version() — establishes the baseline, does NOT arm request_html recovery so _recovery_html stays None; SSE returns the raw Rust render_with_diff() version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to (html, rust_version=1) mirroring next_client_version so the runtime hands every transport the same inputs; the default keeps the 3.2 single-arg callers working. Crucially mount does NOT route through the ARMING next_client_version the event path uses. (4) on_mount_render_ready(view, html) (Finding B residual) runs after render, before the mount frame (WS sticky preservation + the sticky_hold frame emitted BEFORE the mount frame; SSE returns html unchanged). (5) finalize_mount_auth(view, verdict) (Finding E) on the three auth-block verdicts (_check_auth permission_denied + redirect; dispatch_mountrun_on_mount_hooks redirect) — the runtime already sent the verdict frame + cleared view_instance, so the hook adds ONLY the transport-level close(4403) (WS unconditional for permission-denial, gated on not _mounting_in_batch for the redirect verdicts per #291/#1780; SSE no-op); it does NOT re-send the frame. Every hook is getattr-guarded so duck-typed test fakes (and the default-bearing Protocol) keep working. dispatch_mount is now a clean superset (Findings A/B prep) — the idempotency guard + view_instance ownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (the runtime.view_instance reset + read-back) only. New cases in TestRuntimeBasicMountParity, TestRuntimeActorMountParity, TestRuntimeNoArmVersionWiring, TestRuntimeAuthBlockFinalize, TestRuntimeStateRestoreParity (python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drives dispatch_mount over a REAL WSConsumerTransport (direct-call shim, NOT via RUNTIME_OWNED_VERBS) and proves WS-equivalent mount for basic mount, ACTOR mount (renders not refuses), no-arm version, auth-block #291-not-in-batch, and state restore (Phase 3.1), plus two routing-untouched pins; gate-off-verified (#1468) — the actor branch off → the view is refused again, the next_mount_version wiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins in python/djust/tests/test_transport_mount_hooks_1915.py are INVERTED to load-bearing WIRED pins (each hook is now referenced in dispatch_mount / the auth helper; SSE next_mount_version returns rust_version).

  • The 5 transport mount-hooks the WS-mount flip needs are now DEFINED — DORMANT scaffolding, not yet wired into dispatch_mount (#1915, ADR-022 Iter 3 Phase 3.2). Internal scaffolding PR — zero live behavior change. Mirrors how Phase 2.3a defined the event hooks (event_context / on_event_recorded / dispatch_actor_event) DORMANT before the event flip wired + routed them. The 5 hooks land on the Transport protocol (behavior-preserving no-op / refuse defaults), WSConsumerTransport (the real WS impl, each encapsulating the verbatim bespoke handle_mount logic for its cited site), and SSESessionTransport (no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1) on_view_instantiated(view) — WS stamps view._ws_consumer + wires _push_events_flush_callback (websocket.py:2128/2134-2135), registers the view in the observability registry (2161-2167), and stashes the validated _websocket_host/_websocket_secure (2243-2270) (Finding B); SSE: no-op. (2) uses_actors_for_mount(view) + dispatch_actor_mount(view, data) — WS: use_actors and create_session_actor is not None (websocket.py:2213) → create_session_actor + actor_handle.mount(){html, version} (2213-2217/2665-2706), verbatim (Finding D); SSE: False / raise (the dispatch_mount refusal stays). (3) next_mount_version(html) — WS returns consumer._next_version(), the NO-ARM counter handle_mount uses (websocket.py:2746); crucially it does NOT call _next_version_armed / _arm_recovery (a mount ESTABLISHES the client VDOM baseline and has no prior frame to recover to — distinct from next_client_version, which arms for render-SEND frames), so _recovery_html stays None after a mount (Finding C / #1817); SSE: raw Rust version (placeholder, raises until 3.3a wires it). (4) on_mount_render_ready(view, html) — WS: sticky preservation (_find_sticky_slot_ids survivor scan + _register_child re-registration) + the sticky_hold frame emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returning html unchanged; SSE: returns html unchanged (Finding B residual). (5) finalize_mount_auth(view, verdict) — WS: the transport-level socket close(4403) the bespoke auth-finalization performs (websocket.py:2337-2401), GATED on not consumer._mounting_in_batch for the redirect verdicts so a batched login-required view does NOT drop the shared socket's sibling mounts (#291/#1780), unconditional for a permission-denial; SSE: no socket to drop → no-op (the runtime-sent error/navigate frame is the SSE finalization). DORMANT: dispatch_mount does NOT call any of these yet (Phase 3.3a wires them in) and the WS bespoke handle_mount / handle_mount_batch keep doing all of this inline (untouched until the Phase 3.3b flip); RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no production diff. New cases in python/djust/tests/test_transport_mount_hooks_1915.py (Test... MockTransport unit tests per hook + real-WebsocketCommunicator tests exercising the WS impls in isolation against a genuinely-mounted consumer — uses_actors_for_mount True for a use_actors view, next_mount_version returns the consumer counter WITHOUT arming recovery, finalize_mount_auth does NOT close when _mounting_in_batch=True) + DORMANT pins (dispatch_mount doesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts; handle_mount still does the work inline). All gate-off-verified (#1468): arming recovery in next_mount_version reds the 3 no-arm tests, removing the not _mounting_in_batch gate reds the in-batch tests, no-op'ing on_view_instantiated reds the stamp test. The anti-drift _WS_ONLY_MARKERS pin (test_transport_behavioral_parity.py) drops create_session_actor / _find_sticky_slot_ids / register_view (no longer WS-only — the dormant WS hooks now reference them in runtime.py), mirroring the Phase-3.1 state_snapshot_signed move.

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount STATE-RESTORE + on_mount hooks WebSocket handle_mount has, going LIVE for SSE mount (#1913, ADR-022 Iter 3 Phase 3.1). Second PR of the WS mount convergence (after Phase 3.0's cheap grows, #1911). Three ports, each gated on enable_state_snapshot (#1552) so default views are unaffected: (1) run_on_mount_hooks (websocket.py:2383-2401) runs the registered on_mount hooks after the pre-mount auth sequence + before mount(); a hook that returns a redirect URL emits a navigate frame, clears the unmounted view, and aborts — transport-agnostically (no socket close(); that belongs to the Phase 3.2/3.3a finalize_mount_auth hook, matching the runtime's existing auth-redirect handling in _check_auth). (2) Session-saved-state restore (websocket.py:2424-2474) reattaches the public + private state + per-process side-effect registrations (_restore_upload_configs / _restore_presence / _restore_listen_channels, hasattr-guarded) + component state the per-event session-save (#1466) wrote, on a plain reconnect-mount — in lieu of mount(). (3) The has_prerenderedskip_html_for_resume resume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new _mounted_from_restore framework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (the version still flows so patches stay in sync). _mounted_from_restore is initialized in LiveView.__init__ BEFORE the _framework_attrs snapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff, RUNTIME_OWNED_VERBS / handle_mount / handle_mount_batch are unchanged. The anti-drift _WS_ONLY_MARKERS pin drops state_snapshot_signed (no longer WS-only — now on the runtime too) and the live_view.py setattr-whitelist line numbers shift +11. New cases in python/djust/tests/test_runtime_mount_state_restore_1913.py (TestRuntimeSessionRestore, TestRuntimeOnMountHooks): an opt-in view's session-saved state restores on a runtime reconnect-mount while a default view ignores it (#1552 gate-off, RED when the gate is dropped); an on_mount redirect emits a navigate frame + aborts (RED when the redirect handling is gated off).

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount behaviors WebSocket handle_mount has, going LIVE for SSE mount (#1911, ADR-022 Iter 3 Phase 3.0). First PR of the WS mount convergence — grows the runtime mount path toward a functional superset of handle_mount over zero-WS-routing-risk PRs (the eventual flip is Phase 3.3b). Five grows, each ported from its WS site, gate-off-verified (#1468): (1) the _djust_mount_request / _djust_mount_kwargs stash (#1895, websocket.py:2596, placed after mount() + object-perm, before handle_params) — the runtime's OWN per-event session-save fallback (runtime.py:2030/2109) already READS this attr to discover the save session + liveview_{path} namespace, so the stash makes that fallback live on the converged path instead of silently degrading to the scope session; (2) _snapshot_user_private_attrs + _capture_dirty_baseline post-mount (websocket.py:2598-2603); (3) has_prerenderedskip_html_for_resume machinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the _mounted_from_restore flag defaults False, so HTML is always sent today); (4) optimistic_rules (DEP-002) + upload_configs on the mount frame (websocket.py:2823-2834, via a new runtime _extract_optimistic_rules mirror); (5) the mount-time _flush_push_events() + _dispatch_async_work(None) drain (websocket.py:2916, #1280/#1283) — ONLY those two queues, NOT the 8-queue _flush_all_pending the turn-end event path uses (mount establishes a baseline, it does not run a full event turn-end flush), with the #1391 source-grep pin MOVED to the runtime location in test_handle_mount_drains_queues.py. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff and RUNTIME_OWNED_VERBS is unchanged. Every grow has a gate-off witness in test_transport_behavioral_parity.py (7/7 verified RED). New cases in TestMountStashAndBaselines, TestMountAsyncAndPushDrain, TestMountFrameOptimisticAndUpload, TestMountFrameWireVersion.

  • THE FLIP: every WebSocket event now routes through ViewRuntime.dispatch_event — the bespoke _handle_event_inner is deleted (#1907, ADR-022 Iter 2 Phase 2.3b). The atomic moment of the event-path convergence (the #1646 cure: one event path, not two). "event" is added to RUNTIME_OWNED_VERBS (now {"url_change", "event"}), so receive() routes every WS event through the single ViewRuntime.dispatch_message chokepoint; handle_event becomes a thin shim over runtime.dispatch_event (mirroring handle_url_change); and the ~1170-line bespoke _handle_event_inner — the WS-only twin the runtime grew to a functional superset in Phase 2.3a (#1900/#1902/#1904/#1906) — is removed. The residual observability the bespoke handler owned is folded onto two new Transport hooks (SSE no-op): on_render_emitted carries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the _emit_full_html_update signal on the no-patch render branch, and on_handler_timing carries the record_handler_timing percentile telemetry; cache_request_id was already threaded through the runtime render path. The flip surfaced + fixed three parallel-path-drift regressions now that the runtime event path IS the WS event path: (1) ViewRuntime._flush_navigation is now await-ed (was fire-and-forget) and (2) the skip-render branch now calls _flush_all_pending, so a live_redirect() / navigation command queued by a state-unchanging handler still emits its navigation frame within the event turn (WS parity); and (3) the runtime's _dispatch_event_render now records a time-travel snapshot with error="permission_denied" / "validation_failed" on the security-rejected + validation-rejected early-return paths (record_event_start moved BEFORE the security check) — the bespoke _handle_event_inner recorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught by tests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBS contract, the event routing pin, the _handle_event_inner-deleted assertion) and the WS-source pins (1465 save-block, 1785 recovery-arming, 1788 wire-version count, 1802 sticky-child) redirected to the runtime where the behavior now lives. New TestResidualFoldObservability (DJE-053 + record_handler_timing survival, with reason/version gate-off siblings) and a WebsocketCommunicator regression for start_async / @background streaming its source="async" result over the runtime async path. Gate-off (#1468): removing "event" from RUNTIME_OWNED_VERBS makes all 11 test_ws_event_flip_parity_1896 behaviors fail with Unknown message type: event (the bespoke elif is gone) — proving the set membership is the only switch. The DEBUG-only debug-panel payload + cosmetic consumer attrs are deferred to #1908 (inert in production). Full suite green the way CI runs it (tests/ + python/tests/ = 4732 passed; python/djust/tests/ = 3750 passed; 0 failed, 21 skipped); the entire WS event regression net (reconnect-state #1465, sticky-child #1802/#1813, reauth #1777, send-version #1788, recovery-staleness #1817, url-change wire-version #1858, transport-hardening F21/F17, ratelimit-per-caller F27/F28) stays green.

  • ViewRuntime async-result frames now carry source="async", reconciling them with the WebSocket _run_async_work frames; and the dead use_binary framing path is confirmed + pinned (#1905, ADR-022 Iter 2 Phase 2.3a). Two folds finishing the 2.3a parity before the 2.3b WS-event flip. (1) async source="async" reconcileViewRuntime._render_async_result (the start_async / @background completion render shared by the success + error paths) emitted patch / html_update frames with NO source tag, while the WS _run_async_work tags all four of its frames source="async" (websocket.py:1166/1186/1223/1238). The client uses source to distinguish an out-of-band background-completion update from the in-turn source="event" response, so the runtime frames were the lone untagged twin — a #1646 parallel-path drift INSIDE the convergence target. Both runtime async-result branches now stamp source="async". LIVE for SSE + url_change async work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirmconsumer.use_binary is dead: initialized to False at websocket.py:580 ('MessagePack support TODO') and never set True anywhere in the package; the only honoring site is _send_update's binary branch (websocket.py:1391), which WSConsumerTransport.send does NOT traverse (it calls consumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test asserts WSConsumerTransport.send emits JSON via send_json (matching live WS), plus a source-grep pin that no production module assigns use_binary = True. No change to RUNTIME_OWNED_VERBS / WS routing; WS _handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b; websocket.py has no diff. New cases in TestAsyncSourceReconcile / TestBinaryFramingConfirm (python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (a start_async completion frame carries source="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing the source="async" tag makes the SSE end-to-end + unit assertions RED. test_async_integration + test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained the transport-agnostic {% dj_activity %} deferral WebSocket has — a defer-when-hidden gate + a lock-free deferred re-dispatcher — and it goes LIVE for SSE events (a parity improvement) (#1903, ADR-022 Iter 2 Phase 2.3a). The runtime event path lacked dj_activity deferral entirely: an event targeting a HIDDEN (non-eager) {% dj_activity %} region should be queued + acked with a no-op (no render) and replayed when the panel next shows, exactly as the bespoke WS _handle_event_inner does (websocket.py:3254-3273 gate + 4290-4294 flush). Two parts: (1) GateViewRuntime._dispatch_event_render (after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnostic ActivityMixin view methods (is_activity_visible / _is_activity_eager / _queue_deferred_activity_event); a hidden-region event is queued and answered with the runtime's self-describing noop (type/source/event_name/ref) and no render. (2) Flush + lock-free re-dispatcher (option (a)) — after a render that may flip visibility (BOTH the skip-render and render arms, mirroring the WS post-turn flush), ViewRuntime._flush_deferred_activity_events() hands the runtime ITSELF to the consumer-blind ActivityMixin._flush_deferred_activity_events as the _dispatch_single_event provider, so mixins/activity.py is UNCHANGED (the flush already accepts any object exposing that method). The new ViewRuntime._dispatch_single_event(target_view, event_name, params, event_ref=None) re-runs validate → handler → render for one queued event WITHOUT acquiring a lock and WITHOUT re-entering event_context — it already runs inside the borrowed context (which on WS holds the consumer _render_lock; re-acquiring the non-reentrant asyncio.Lock would deadlock, the websocket.py:1467 contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route through dispatch_event since Iter 1 (#1887), so SSE events now respect dj_activity deferral (the parity improvement); a no-op for SSE views with no activity region (zero-cost when unused). WS events are UNAFFECTED — the bespoke _handle_event_inner gate/flush stays until Phase 2.3b; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no diff. New suite python/djust/tests/test_runtime_dj_activity_1903.py — direct-runtime (MockTransport) + real-SSE end-to-end, each reproduce-first + gate-off (#1468): hidden-activity event → queued + noop (no render); flip-visible → the queued event drains in the same round-trip (2nd frame); no-activity view → renders normally; the re-dispatcher runs inside the borrowed context with no re-entry (no-deadlock proof, asserted via a re-entry-recording mock context); a denied queued event is re-validated + dropped; plus structural pins (gate lives in _dispatch_event_render; re-dispatcher body is lock-free; the flush passes the runtime as the dispatcher). Gate-off verified: disabling the gate makes the hidden-deferral + flip-drain tests RED; disabling the flush makes the flip-drain tests RED. The existing WS dj_activity behavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), and test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained an actor-event transport hook (transport.uses_actors() + transport.dispatch_actor_event()) so a use_actors view's events route through the per-session Rust actor on the runtime path too — DORMANT until the Phase 2.3b WS-event flip (#1901, ADR-022 Iter 2 Phase 2.3a). The load-bearing fold the WS-event flip sits on. ViewRuntime.dispatch_event had NO actor branch, while the use_actors guard lived ONLY in dispatch_mount (which refuses SSE outright). A WS view mounts in actor mode (use_actors=True + a created actor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hit dispatch_event with no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two new Transport hooks close the gap: (1) uses_actors(view)WSConsumerTransport returns consumer.use_actors and consumer.actor_handle is not None (the exact precondition of the bespoke WS actor block, websocket.py:3282), SSESessionTransport returns False (SSE has no bidirectional actor channel and dispatch_mount refuses use_actors mounts, runtime.py:602); (2) dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)WSConsumerTransport runs the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in a finally, the shared _validate_event_security + validate_handler_params checks, actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire version consumer._next_version() — the actor's internal result['version'] is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush), SSESessionTransport raises NotImplementedError (never called — uses_actors is False). Wired into _dispatch_event_inner BEFORE event_context (the actor block holds no render lock, matching WS), gated on uses_actors(view) AND the event NOT being routed to a sticky child — the WS not is_embedded_child_target mutual exclusion (websocket.py:3280-3282); per #1467 a component_id event does NOT reassign the target view and the WS actor block has no component handling, so a component_id event on a use_actors view goes through the actor (parity), and only a view_id resolving to a DIFFERENT child excludes it (_event_routes_to_sticky_child peeks at view_id WITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change: uses_actors is False for both live transports today (WS events still run on the bespoke _handle_event_inner; SSE refuses actor mounts), so no live event turn reaches the hook until 2.3b. WS routing (RUNTIME_OWNED_VERBS) + the WS _handle_event_inner actor block are UNTOUCHED (they stay until 2.3b); websocket.py has no diff. New direct-runtime suite python/djust/tests/test_transport_actor_event_1901.py (12 cases) builds a WSConsumerTransport over a fake consumer with use_actors=True + a fake actor_handle and asserts dispatch_event routes to dispatch_actor_event (the actor's .event() is called + the framed result is sent via _send_update with the consumer-owned wire version, NOT the in-process handler), uses_actors False for SSE + a WS consumer without actor_handle, a view_id-routed event skips the actor while a view_id-equals-top event still routes to it, and the SSE dispatch_actor_event raises; gate-off verified (#1468) — forcing uses_actors to always return False makes the actor-routing cases go RED (the event falls to the in-process render path). The existing #1896 actor-parity test (test_ws_event_flip_parity_1896.py, the bespoke WS path) + the #1899event_context suite stay green.

  • ViewRuntime now BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a new transport.event_context() hook, and the dead runtime-local _render_lock is deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold the dj_activity re-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1) ViewRuntime._render_lock was DEAD CODE — declared in __init__, never acquired anywhere — and is removed; the runtime CANNOT own the render lock, because render serialization is consumer-owned (LiveViewConsumer._render_lock, websocket.py:619) and SHARED with the WS-only _run_tick / server_push / db_notify render loops, so a runtime-local lock would be a different object and could not serialize against ticks (the #560 version-interleave bug). (2) So a new async-CM transport.event_context(view) on the Transport protocol + both adapters lets the runtime borrow the consumer's EXISTING lock: WSConsumerTransport.event_context on enter mirrors _handle_event_inner verbatim — await consumer._render_lock.acquire() (the existing object, not a new one), _processing_user_event = True, set the #1677 origin-channel contextvar to consumer.channel_name, start a PerformanceTracker + the SQL capture_for_event scope (websocket.py:3393-3400 / 3150-3154 / 3469-3475); on exit (finally) it resets the origin token, clears _processing_user_event, RELEASES the borrowed lock, and stops the SQL capture + tracker (websocket.py:4311-4313). SSESessionTransport.event_context is a no-op async CM (SSE runs single-threaded off the HTTP request — no concurrent tick/push loop to serialize against). The event handler+render body of _dispatch_event_inner is extracted into _dispatch_event_render and run inside async with self.transport.event_context(self.view_instance): (the view-mounted check stays OUTSIDE the context — a non-None view is needed to borrow its lock, matching WS, which acquires only after the view exists; a future actor-event branch will run OUTSIDE the context, matching WS where the actor block holds no lock). Zero WS-routing risk, no behavior change for current consumers: RUNTIME_OWNED_VERBS + _handle_event_inner are UNTOUCHED, and dispatch_url_change / _dispatch_url_change_inner are a SEPARATE path (untouched) — so this affects ONLY SSE events (the no-op context) and WS events (not routed through the runtime until the Phase 2.3b flip); url_change is unaffected. New direct-runtime suite python/djust/tests/test_transport_event_context_1899.py asserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception), _processing_user_event True-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op; ViewRuntime no longer owns a _render_lock; with a gate-off sibling (#1468 — a non-acquiring context makes the held-inside assertion go RED). The two existing source-grep pins (save-block gate, 5-grows enumeration) follow the body to _dispatch_event_render; four existing runtime transport mocks grow a no-op event_context.

  • The runtime event spine gained the three transport-agnostic per-event PERSISTENCE subsystems WebSocket has — time-travel record, session state-save (#1466), and sticky-child state-save (ADR-018) (#1894, ADR-022 Iter 2 Phase 2.2). Third PR of the 4-phase WS-event convergence split. ViewRuntime now records + persists per-event state the way the bespoke WS _handle_event_inner does, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel recordrecord_event_start / record_event_end wrap the handler call in the single-view, component, and sticky-child branches, scoped per #1467 (component records on the PARENT view since LiveComponents have no separate buffer; a sticky-child records on the CHILD), finalized in a finally so a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466ViewRuntime._persist_state_after_event mirrors the WS save (private attrs first, then public get_context_data(), then components), gated on top-level-view identity AND enable_state_snapshot (#1552 — default views MUST NOT persist, since unconditional saves left async session I/O in flight that a host snapshot captured unrecoverably) and bounded by a 150ms asyncio.wait_for (#1475); (3) sticky-child state-save ADR-018ViewRuntime._persist_sticky_child_after_event persists a view_id-routed child under its stable sticky key on the both-opt-in predicate (sticky_child_should_persist), with the one-shot opt-in-mismatch warning (warn_sticky_child_optin_skip) in the else-branch. New Transport hook on_event_recorded(view, snapshot) replaces the WS _maybe_push_tt_event direct send: WSConsumerTransport delegates to the consumer's existing _maybe_push_tt_event (single-sourcing the DEBUG-gated time_travel_event frame), SSESessionTransport no-ops (no SSE debug panel today). A runtime-side #1466 source-grep pin (test_runtime_save_block_present_and_gated) asserts the SAME gate / key-shape / 150ms-bound strings the WS pin asserts, so drift between the two save gates goes red on whichever lost the string. No behavior change for current WS consumers — the WS save-block source in websocket.py is UNTOUCHED (the #1466/#1552 grep-pins in test_ws_reconnect_state_1465.py:119/313/320 stay green; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suite python/djust/tests/test_runtime_state_save_tt_1894.py (12 cases) drives runtime.dispatch_event against a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing the enable_state_snapshot gate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes the on_event_recorded assertion fail (RED). Existing WS + runtime suites stay green (test_ws_reconnect_state_1465, test_sticky_child_recovery_1813, test_time_travel.py, test_time_travel_flow.py, test_runtime_child_routing_1892).

  • The runtime event spine gained the three transport-agnostic child-routing subsystems WebSocket has — component_id LiveComponent, view_id sticky-child, and embedded-child render (#1892, ADR-022 Iter 2 Phase 2.1). Second PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner now routes embedded children before the single-view path, mirroring the bespoke WS _handle_event_inner subsystems the runtime previously lacked entirely: (1) a view_id-targeted event resolves a sticky/embedded child via _get_all_child_views(), validates the handler against the CHILD, renders the child subtree, and emits a scoped embedded_update {view_id, html, event_name} frame — the client-supplied view_id is never echoed into the user-facing error (sanitize_for_log in the structured extra only, verbatim from WS); (2) a component_id-targeted event resolves a child LiveComponent via _components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters with component_id injected (ADR-002), and emits a parent-scoped full-HTML component_event frame — per #1467 it does NOT reassign the target view; (3) the embedded-child template render is single-sourced (the #1646 cure) — the pure render core, including the security-hardened escape + DEBUG-gate error path (CWE-79/CWE-209), is extracted verbatim into module-level websocket.render_embedded_child_html, the WS _render_embedded_child is now a thin delegating shim, and the runtime calls the same helper (one implementation, no parallel copy to drift). No behavior change for current WS consumers_handle_event_inner routing is untouched (WS events still flow through it; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE is a structural no-op for both checks (no components/sticky → falls through to the single-view path). New direct-runtime suite python/djust/tests/test_runtime_child_routing_1892.py drives runtime.dispatch_event against a MockTransport with a real parent LiveView + sticky child + LiveComponent (TestRuntimeStickyChildRouting, TestRuntimeComponentRouting, TestRuntimeEmbeddedRender); each security-critical guard (component-handler validation, view_id log-sanitization, embedded-error escape) has a reproduce-first + gate-off pair (#1468), all three verified to go RED when the guard is removed. The existing WS child-routing suites (test_sticky_child_event_noop_1802, test_sticky_child_recovery_1813, test_waiter_component_propagation, test_time_travel_flow) stay green — WS path unchanged.

  • The runtime event spine grew toward WebSocket parity — ref echo, source/event_name, _force_full_html, _notify_waiters, and the #700 push-only skip (#1889, ADR-022 Iter 2 Phase 2.0). First PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner / _render_and_send (the minimal SSE event spine, SSE's only event path post-Iter-1) gained the transport-agnostic shared behaviors the bespoke WS _handle_event_inner has but the runtime lacked: (1) the client ref (#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carries source="event" + event_name and the update frames carry source="event" for the client's #560 response-sequencing; (3) a handler that sets _force_full_html now defeats the auto-skip and sends a full html_update (patches discarded, flag consumed), mirroring websocket.py:4039-4040; (4) _notify_waiters (ADR-002 Phase 1b) runs after the handler so wait_for_event futures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (the id()-identity variant beyond the assigns-snapshot skip) is ported, so a push-events-only handler emits a noop instead of a wasted re-render. No behavior change for current WS consumerswebsocket.py is untouched (WS events still use _handle_event_inner; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560ref/source fields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho, TestEventSpineForceFullHtml, TestEventSpineNotifyWaiters, TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) in python/djust/tests/test_transport_behavioral_parity.py so a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParity in python/djust/tests/test_sse_runtime_convergence_1887.py, driving the /message/ endpoint which forwards the full ref-carrying envelope); and an extended RUNTIME_OWNED_VERBS contract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_owned in python/djust/tests/test_ws_receive_runtime_dispatch_1852.py) pinning the Phase-2.0 ↔ 2.3 boundary.

  • The SSE transport's mount + event now route through the shared ViewRuntime, retiring the legacy bespoke SSE copies (#1887, ADR-022 Iter 1). The SSE GET-stream mount and the legacy /event/ POST previously had their own hand-written mount/event/render/async helpers (_sse_mount_view, _sse_handle_event, _sse_handle_event_inner, _sse_run_async_work) — a fork of the same dispatch logic the WebSocket and ViewRuntime paths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch through session.runtime.dispatch_mount / dispatch_event — the SAME spine the SSE /message/ endpoint and the WS url_change shim already use — and the legacy helpers (plus their orphaned flush/async/cache sub-helpers) are deleted. No behavior change for SSE consumers: mount still renders against the real authenticated request, events still stream patch/html_update frames, object-permission denial still blocks the mount (now via dispatch_mount's Iter-0 check), and start_async/@background work still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop of start_async named-task work, since the legacy path only dispatched the never-set _async_pending format). SSE-specific behavior is preserved via two new SSESessionTransport hooks: build_request() (the runtime mounts against the real HTTP request, not a synthesized userless one) and on_view_mounted() (stamps _sse_session_id / _sse_session / session.view_instance). No websocket.py changes (WS convergence is Iter 2/3). New end-to-end integration suite python/djust/tests/test_sse_runtime_convergence_1887.py (mount / event / object-perm / start_async via the real endpoints, with gate-off witnesses, #1468); existing SSE + mount-chokepoint + has_ids-parity tests migrated to the converged path.

Performance

  • Keyed per-item loop render cache — large-list render_with_diff reorders re-render only changed items, flag-gated default-OFF (#1967).Node::For in the Rust template engine previously re-rendered every loop item from the AST on every render, so a pure reorder of a 50/500-item keyed list rebuilt all N item subtrees from scratch (~9 µs/item) even though their rendered bytes are byte-identical (only positions changed). A new persistent content-hash → rendered-fragment cache (crates/djust_templates/src/loop_cache.rs, a field on RustLiveView that survives across render_with_diff calls) reuses each unchanged item's fragment, turning the loop-RENDER phase from O(n) toward O(changed): a pure reorder is all cache HITS (0 re-renders), a content-change of K items costs K misses, an append costs 1. Correctness is paramount and proven: the cache is restricted to loop bodies whose rendered output is fully determined by the loop item(s), enforced by TWO gates. (1) Position-dependent bodies are non-cacheable — any {% if %} (dj-if marker carries the loop index, #1832), {% cycle %}, nested {% for %}, {{ forloop.* }} reference, or opaque Python/component tag (a content-hash cache there would emit stale positions). (2) Bodies that read ANY outer-context variable are non-cacheable (#1967 review) — the content hash covers only the loop item(s), but a body can also read outer context ({{ prefix }}, {% with label=flag %}, {% firstof flag x.name %}, settings.X); outer context is constant within a render but NOT across renders, and the cache is persistent across renders, so a reorder after an outer-var change would serve stale fragments. A body is therefore cacheable ONLY if every top-level variable it reads is one of the loop's bound name(s) (x.name/x.price resolve under loop var x → allowed; prefix/flag → non-cacheable; tuple-unpacking for k, v allows both k and v); the dep-subset test reuses the engine's existing partial-render dependency extractor (parser::body_root_var_names). Both gates are detected once per For-node and memoized. This narrows the cacheable surface to item-only bodies — the common data-list case ({{ item.field }} only) — while non-cacheable bodies fall back to normal per-item render (correct, no win). The cached fragment is the template-render output BEFORE dj-id assignment (dj-ids are assigned downstream in the html5ever parse phase), so the keyed VDOM diff (#1678/#1682) is unaffected — output is byte-identical with the cache on vs off, verified across initial render / reorder / content-change / append / remove on plain, forloop.counter, dj-if, {% cycle %}, nested, tuple-unpacking, outer-context ({{ prefix }} / {% with %} / {% firstof %}), and dj-key templates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable via LIVEVIEW_CONFIG['loop_render_cache_enabled'] = True. When off, the For-node path is byte-identical to before. Render-phase reorder bench (crates/djust_templates/benches/loop_render_cache.rs, criterion, item-only body): N=50 ~83 µs → ~50 µs (~1.7×), N=500 ~819 µs → ~515 µs (~1.6×) — the win survives for cacheable bodies. New TestOutputIdentity / TestCacheBehavior / TestLoopRenderCacheDefaults / TestOuterContextNonCacheable classes in python/djust/tests/test_loop_render_cache_1967.py (13 end-to-end via RustLiveView.render_with_diff) + crates/djust_templates/tests/test_loop_render_cache_1967.rs (17 Rust correctness cases incl. three gate-offs (#1468): the position guard, the cross-render persistence, and the outer-context dep-subset gate are each proven load-bearing). NOTE: the end-to-end render_with_diff win is bounded by the (uncached) html5ever-parse + VDOM-diff phases (Amdahl); this lever optimizes the render half cited as the dominant cost in #1967.

  • Parsed VNode subtree cache — reorders of unchanged loop items skip html5ever-PARSE too, not just render, flag-gated default-OFF (#1970). Extends the #1967/#1969 per-item RENDER cache to ALSO cache the PARSED VNode subtree per item, keyed by the SAME content-hash, under the SAME LIVEVIEW_CONFIG['loop_render_cache_enabled'] flag + the SAME two cacheability gates. The render cache cut the loop-render phase but the html5ever-parse + VDOM-build phases are ~60% of render_with_diff (#1969's render-only end-to-end win was Amdahl-bounded to ~6-11%); this reaches that bigger half. Mechanism:LoopRenderCache (crates/djust_templates/src/loop_cache.rs) gains a second map (content-hash u64 → parsed Vec<VNode>) + a per-render item manifest. For a parse-cache HIT on a foster-parenting-SAFE item (the item's rendered root tag is NOT a table/select-family element — tr/td/th/tbody/thead/tfoot/caption/colgroup/col/option/optgroup), the Node::For arm emits a tiny <dj-pc-<nonce> h=...> placeholder (a per-render random nonce in the tag name) instead of the item's HTML, so the assembled string html5ever parses is a SHORT reduced form; render_with_diff/render_binary_diff then splice the cached parsed subtrees back into the placeholders (djust_vdom::splice_loop_placeholders) and re-assign every dj-id by a pre-order re-walk. The dj-id hazard + strategy: dj-ids are purely positional (the parser assigns next_djust_id() pre-order), so a cached subtree's baked ids are position-WRONG when reused elsewhere — naive verbatim reuse duplicates ids ([0,1,2,3,4,1,2] for a 2-of-3 identical-content list). The fix re-walks the ASSEMBLED tree from the same id-counter base the full parse would use (0 for an initial parse_html, max(old_ids)+1 for a continuing parse_html_continue after the #1550/#1552 bump), reproducing a fresh full-parse's ids byte-for-byte — so the assembled VDOM, every patch (Insert/Replace embed the new node), and last_vdom are identical to the cache-OFF path. The foster-safe gate keeps <dj-pc> out of table/select containers (where html5ever foster-parents it out, destroying structure); foster-unsafe containers, multi-root items, and any splice anomaly (placeholder cache miss / found-count mismatch / a residual dj-pc-* sentinel) fall back to a full parse — always correct, no parse win for that render. Security (sentinel forgery, the adversarial-review 🔴): the placeholder sentinel tag carries a per-render random nonce (dj-pc-<nonce>) so a loop item that renders a literal unescaped <dj-pc ...> element via |safe/mark_safe — alongside a sibling that emitted a real placeholder — can neither be mistaken for a placeholder (which would strip it + corrupt the reconstructed HTML) nor splice a different cached item's subtree into its position via a crafted h= (content-confusion); reconstruction + splice match ONLY the current render's nonce tag, and parse-cache eligibility additionally refuses any item whose rendered HTML contains the literal sentinel prefix (belt-and-braces). Without the nonce, the bug stripped the user's <dj-pc> (cache-ON) while cache-OFF preserved it — a byte-identity violation for raw-HTML loops. VNode.attrs now serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plain HashMap serializes in nondeterministic bucket order, which the parse-cache path (assembling a node via a different parse than the cache-OFF full parse) would otherwise surface as an ON-vs-OFF patch-JSON diff. Default OFF (rides the #1967 flag, split-foundation #1122); when off, byte-identical to before. Per-phase reorder bench (median over 60 distinct shuffles, render_with_diff): N=50 parse 0.145→0.113 ms (-21.9%) / total 0.430→0.339 ms (-21.3%); N=500 parse 1.394→1.159 ms (-16.8%) / total 4.037→3.399 ms (-15.8%) — beating #1969's render-only win by also cutting the parse phase. Correctness proven: byte-identity (html + patches + version) cache ON == OFF across plain/keyed/dj-if/cycle/nested/tuple/div/table/select/multi-root templates × initial/reorder/change/append/remove for BOTH render_with_diff and render_binary_diff (the dj-key reorder round-trip — post-diff dj-ids/dj-keys match cache-off exactly — is the load-bearing case); a parse-count probe (loop_parse_cache_hits()/loop_parse_cache_misses()) asserts a reorder of N unchanged keyed items is N parse hits / 0 re-parses and an append re-parses only the new item; gate-off (#1468) confirms neutering the dj-id re-walk fails 6 byte-identity cases AND neutering the nonce (bare-prefix sentinel) fails the 3 sentinel-collision security cases. New cases in TestParseCacheByteIdentity1970 / TestParseCountProbe1970 / TestParseCacheSentinelCollision1970 (python/djust/tests/test_loop_render_cache_1967.py), the parse_cache_1970 module in crates/djust_templates/tests/test_loop_render_cache_1967.rs, and crates/djust_vdom/tests/test_loop_parse_cache_1970.rs (literal_unnonced_dj_pc_is_not_spliced + the bare-prefix gate-off).

Fixed

  • dj-virtual now ships a real layout contract and self-heals across server-driven re-renders — the windowed list scrolls inside a display:flex container and survives a live-changing {% for %} source (#1988, #1989). Two entangled dj-virtual gotchas hit in downstream production chat/feed builds. (#1988 — layout)setup() gave the injected shell position: relative (which does NOT remove it from flow — transforms are a paint-time effect per spec), so the shell's own rendered rows double-counted against the spacer and left ~400px of dead space past the last item (container.scrollHeight ≠ spacer height); and the spacer had no flex-shrink, so inside a display:flex container its explicit style.height was crushed to offsetHeight: 0 (default flex-shrink: 1) and the list silently never scrolled. The shell is now position: absolute; top/left/right: 0 (out of flow → only the spacer defines scroll height; translateY windowing preserved, container is made a positioned ancestor) and the spacer is flex-shrink: 0 (its height survives a flex parent). (#1989 — integration) A [dj-virtual] container had no reconcile path with normal server re-renders: the server always renders the full raw list (no notion of client virtualization), so a full re-render reverted the container's children back to the raw list and initVirtualLists no-op'd forever (it tracks setup state in a WeakMap keyed on the container, whose identity is unchanged) — permanent no-op, recoverable only by manual teardownVirtualList + re-init; and a single appended row landed as a loose child OUTSIDE the shell/spacer wrapper, leaking as a stray sibling whose finalize patch never applied (stuck stream). Both are now self-healing after every VDOM morph: initVirtualLists / refreshVirtualList DETECT a clobbered shell/spacer (detached or repurposed, marker attributes gone) and transparently re-virtualize against the fresh children (order-independent — whichever runs first heals), and loose element children are auto-absorbed into the item pool (at the tail) so they render inside the shell and receive subsequent patches. Absorb is append-only (correct for chat/feeds); keyed mid-list inserts/removals, differ-level dj-virtual awareness, out-of-window finalize-patch landing, and automatic stream_append__djVirtualItems wiring are deferred to follow-up #2017. Client-only change in 29-virtual-list.js (+231 B gzipped). 5 new regression cases in tests/js/virtual_list.test.js (shell/spacer style contract; full-revert self-heal via the reinit path AND via refreshVirtualList alone; loose-child absorb; intact-list no-op) — all gate-off verified. JSDOM has no layout engine, so these pin the CSS contract and reconcile behavior, not computed pixels; real-browser pixel verification (scrollHeight parity, spacer offsetHeight under flex) is a recommended manual follow-up.

  • The streaming-Markdown demo now actually streams, its Stop button works, and async background work runs on the converged WS path (#2001, #2002). Three entangled bugs in the framework's own shipped demo (examples/demo_project/djust_demos/views/markdown_stream_demo.py): (1) #2002 — mutate-and-return does not stream._stream_chars was a sync @background loop doing self.llm_output += ch; _run_async_work awaits the callback to completion and only re-renders AFTER it returns, so the client saw the whole reply in ONE frame — despite a comment claiming a VDOM patch per char. Rewrote it as an async def that pushes each token with await self.stream_to(..., html=render_markdown(...)), bracketed with stream_start/stream_done + a finally settle; the target <article> now carries dj-stream="md_stream" dj-update="ignore" so the stream ops and the event-completion render don't both write the region. (2) #2001 — cancel_async name mismatch + non-interruptible sync loop.reset() called cancel_async("md_stream") but @background registered the task under func.__name__ == "_stream_chars", a silent no-op; and a sync @background loop can't be interrupted mid-run. Now scheduled via start_async(self._stream_chars, name="md_stream") (names match) and async (the loop yields between tokens so reset flips streaming=False mid-stream). cancel_async's docstring now states the mismatched-name no-op and the sync-body limitation. (3) Framework fix (#1646 parallel-path drift): the LIVE WS-event async-work executor runtime.py:ViewRuntime._execute_async_task (post ADR-022 convergence, not the websocket.py:_run_async_work the issues cite) unconditionally wrapped callbacks in sync_to_async, raising TypeError for an async callback — so async @background/start_async silently failed on the converged path. Mirrored the consumer twin's iscoroutinefunction check (await async callbacks directly). Docs: streaming-markdown.md's example rewritten to explicit per-chunk stream_* + cross-linked to streaming.md; both guides now document the dj-update="ignore" rule for streamed targets and the stream_to()-without-html= full-template-re-render caveat. Tests in test_markdown_stream_demo_2001_2002.py drive a real WebsocketCommunicator + runtime path (#1650): the fixed pattern emits >1 content stream op, an in-suite gate-off sibling proves plain mutation emits 0, plus dj-update=ignore exclusion, cancel_async name-match semantics, and a source-pin on the shipped demo; gate-off (#1468) verified — reverting the runtime coroutine check makes the streaming tests RED.

  • dj-window-* / dj-document-* handlers on content that appears via a later patch now bind (they were silently dead) (#1996). A dj-window-keydown.escape="close" (or any dj-window-* / dj-document-* attribute) on an element that entered the DOM via a server-driven patch — e.g. inside a {% if %} that became true, such as a command palette or inline editor — never fired: no console warning, no exception, the handler was just dead. Root cause: _scanScopedElements() (the only code that populates the scoped-listener registry) was called exclusively from the one-shot _installScopedDelegation(), so after first mount nothing re-scanned; _sweepOrphanedScopedListeners() removed registry entries for elements that left the DOM but nothing symmetrically added entries for elements that just entered via a patch. bindLiveViewEvents() now calls _scanScopedElements() on every invocation (mirroring the per-bind rescan dj-shortcut / dj-click-away already do), while the window/document addEventListener install stays one-shot inside _installScopedDelegation() — so no duplicate listeners accumulate, and the per-element alreadyRegistered check prevents double-registration / double-fire. Moving the scan out of the one-shot install made the bundle 25 B smaller gzipped. 5 JS tests in tests/js/dj-window-rescan-1996.test.js (patch-in reproducer, dj-document-keydown patch-in, no-double-register across repeated binds, single-fire with no duplicate window listeners, cleanup-still-works for a patch-removed element); gate-off (neuter the rescan + rebuild) turns 4/5 RED including the primary reproducer.

  • Two form-field value-preservation gaps in the VDOM patch path, fixed together with two consistent, opposite-polarity declarative attributes (#1990, #1991). (1) dj-force-value — clear/overwrite a still-focused field (#1990).morphElement() (the real function; the issue cites the old name morphNode) skipped the server value sync for a focused input/select/textarea unless its name changed, so a handler that cleared a still-focused composer — the Enter-to-send path, where Enter never blurs — could never take effect (the sent text stayed in the box). A field carrying the opt-in dj-force-value attribute now applies the server value even while focused. The check is lazy (evaluated only when the field would otherwise be skipped) and conservative (fires only when the attribute is explicitly present, so every other focused field keeps its typing protection); covers INPUT/SELECT/TEXTAREA. (2) dj-update="ignore" — per-field opt-out from the broadcast textarea sweep (#1991). Every push_to_view broadcast unconditionally reset every<textarea>.value in the LiveView root (the #1601 sweep, scoped too broadly), so a peer's message in an unrelated conversation wiped a user's unsent draft. A textarea marked dj-update="ignore" (already the "client-owned, don't update" convention honored by the per-node morph) is now skipped by the sweep. Both broadcast-sweep call sites — 02-response-handler.js's applyPatches path and 12-vdom-patch.js's preserveFormValues innerHTML path — route through one shared syncBroadcastTextareas helper, so the opt-out lives in exactly one place (parallel-path-drift cure, #1646). +50 B gzipped; 11 JSDOM cases in tests/js/form_value_preservation_1990_1991.test.js (direct helper, the real production broadcast path via handleServerResponse, an anti-drift pin that both sweep sites route through the helper, and INPUT/TEXTAREA focus cases using real document.activeElement), gate-off verified (#1468) on both fixes. Documented in docs/website/guides/declarative-ux-attrs.md.

  • dj-input.debounce-N (and any .lazy / .debounce suffix on a non-dj-model directive) now warns in debug mode instead of silently never binding (#1999). Only dj-model parses the .lazy / .debounce-N in-name modifier from its attribute name; dj-input / dj-change / dj-click debounce via the separate standalone dj-debounce="N" attribute. Because a dot is a legal attribute-name character, dj-input.debounce-200="search" is one literal attribute that no [dj-input] selector matches — so the input never bound, with no console error and nothing pointing at the cause (the dj-model.debounce-300-works mental model made it read as a plain non-working feature). bindLiveViewEvents now runs a debug-gated scan (_warnUnrecognizedDjModifiers, zero cost outside window.djustDebug) that emits a console.warn naming the offending attribute and the standalone-dj-debounce fix. Deliberately scoped to the .lazy / .debounce modifiers on non-model directives — other legit dotted conventions (dj-keydown.enter, dj-window-keydown.escape, dj-loading.class / .show / .hide / .disable / .for) are untouched. The dj-model guide now documents the divergence side-by-side. +315 B gzipped; 7 JS tests in tests/js/dj-input-modifier-warning-1999.test.js (gate-off verified).

  • TenantMixin.set_tenant() lets a WebSocket event handler switch the current tenant from a fresh/default session, and the session resolver's WS-persistence semantics are now documented (#2003). Two undocumented frictions with the session tenant resolver: (1) SessionResolver.resolve() is read-only — it never writes request.session, and there was no set_tenant helper anywhere, so switching tenant over a WS event meant hand-writing request.session[...] with no built-in save guarantee (a LiveView event has no HTTP response for Django's SessionMiddleware to persist against); and (2) TENANT_REQUIRED (default True) is enforced in dispatch()/get()/post()beforemount(), so a fresh session 404s before mount() can resolve a default. Adds TenantMixin.set_tenant(tenant_id) (inherited by TenantScopedMixin): it updates the authoritative in-memory view state (self.tenant) and best-effort mirrors the id into request.session[TENANT_SESSION_KEY]only when a session resolver is configured (a no-op for subdomain/path/header/custom). The SessionResolver docstring + the Multi-Tenant guide now document that view state is the WS-lifecycle source of truth, the session write is a mirror only, and that tenant_required=False + manual resolution in mount() is the correct pattern when a fresh session has no tenant yet. 12 cases in TestSetTenant / TestTenantScopedMixinExposesSetTenant (test_tenant_set_tenant.py), incl. gate-off sentinels for the session-resolver mirror and the non-session no-op.

  • A private (_-prefixed) attr holding a Django model no longer comes back as a plain dict after a state round-trip — it re-hydrates as the model (#1994). A model cached on a private attr (e.g. self._workspace = Workspace.objects.get(...) in mount()) is persisted to the session so it survives the HTTP-POST-fallback restore path — which does NOT re-run mount(). That path ran normalize_django_value over private state (the client-facing serializer), turning the model into the lossy {"pk", "__str__", <fields>} dict, so on restore self._workspace was a dict and self._workspace.memberships raised AttributeError (the reported traceback at mixins/request.pypost()). Private state is server-side view cache (never sent to the client), so the fix encodes each model as a re-hydratable ref {"__djust_model_ref__": "<app>.<model>", "pk": ...} (recursing into nested dicts/lists) in _get_private_state(), and re-fetches it from the DB in _restore_private_state(). A ref whose row was deleted between save and restore re-hydrates to None with a warning (a stale cached model must not hard-crash a reconnect). 5 tests in test_private_model_roundtrip_1994.py: model-comes-back-as-model (gate-off sentinel), nested-in-dict, in-list, deleted→None, non-model attrs unaffected.

  • Docs: three copy-from-and-it-breaks documentation corrections (#2000, #2004). (a) PresenceMixin's module docstring showed a flattened presence record ({{ p.color }} / {{ p.name.0 }} / presence['name']), but every backend nests the caller-supplied meta under a "meta" key — the record is {"id", "joined_at", "meta": {...}}, so the correct access is p.meta.name / presence['meta']['name']. Following the docstring verbatim produced a KeyError / silently-empty output. Docstring corrected + the record shape documented (#2000). (b) {% djust_markdown %} requires DjustTemplateBackend (it registers only with djust's Rust engine, not Django's stock backend); a plain django-admin startprojectTEMPLATES setup raises TemplateSyntaxError: Invalid block tag 'djust_markdown' even with {% load live_tags %}. Now stated where readers copy the tag from (docs/website/guides/streaming-markdown.md). (c) dj-transition-group's "you author the CSS — this ships none" caveat (already noted for dj-transition) is now repeated in the dj-transition-group quick-start, where readers actually copy the class names from (docs/website/guides/declarative-ux-attrs.md) (#2004).

  • Two upload-config gotchas: LiveView runtime keys set in DJUST_CONFIG are now honored (not silently ignored), and the default upload chunk size no longer exceeds the default frame limit by 21 bytes (#1993). (1) LiveViewConfig._load_from_settings() (python/djust/config.py) only read LIVEVIEW_CONFIG, so a max_message_size / rate_limit / event_security set in the similarly-named DJUST_CONFIG dict (which already backs tenancy/presence/state-backend, and is easy to confuse with LIVEVIEW_CONFIG) was a silent no-op — e.g. raising the limit via DJUST_CONFIG = {"max_message_size": 262144} did nothing, no error, no warning. It now falls back to DJUST_CONFIG for keys that are genuine LiveView config keys (present in the defaults, so unrelated tenancy/presence keys aren't pulled in), with LIVEVIEW_CONFIG winning on a collision and a debug breadcrumb naming each adopted key. The misleading comment that implied DJUST_CONFIG was already handled here is corrected. (2) The upload client's DEFAULT_CHUNK_SIZE was 64 * 1024 — exactly the max_message_size default (65536) — and every chunk frame prepends a 21-byte binary header (buildFrame), so 65536 + 21 = 65557 > 65536: a brand-new project using only allow_upload(...) with default settings failed any upload past a fractional first chunk with Message too large (65557 bytes). Reduced to 63 * 1024 (64512 payload + 21 header = 64533 < 65536). 4 Python config tests (gate-off verified) + a JS source-invariant pin in tests/js/uploads.test.js (DEFAULT_CHUNK_SIZE + FRAME_HEADER_BYTES ≤ 65536).

  • {% djust_markdown %} on a code-only artifact (a complete fenced block with no trailing newline) no longer splits the closing ``` off as an escaped provisional paragraph (#1998). The provisional-line splitter (split_provisional, crates/djust_templates/src/markdown.rs) treats a trailing line with an ODD backtick count as an unterminated inline-code span. A lone closing ``` has 3 backticks (odd), so for a complete but newline-less fence — where inside_unclosed_fence correctly reports the fences as balanced — the closing ``` was split off and re-rendered as <p class="djust-md-provisional">`</p>` instead of completing the `<pre><code>` block. (This is why a chat transcript — prose + fence, always followed by more content or a trailing newline — highlighted code fine, while a "code artifact" panel rendering *just* the code body did not.) Fix: a one-line guard — a trailing line that is itself a ` fence delimiter completes a balanced fence (the count is even), so keep the whole block stable. Rust unit + render tests + a Python end-to-end test (test_markdown.py`); gate-off verified (reverting the guard reopens the provisional-paragraph split).

  • LiveView.set_changed_keys() now accepts a zero-arg form to force a re-render when a handler changed only external state (a DB row) and no public self.* attr (#1992). A handler that mutates only the database — e.g. msg.save(update_fields=["active_child_id"]) — and assigns no public attribute produced NO re-render: auto change-detection (_snapshot_assigns) saw nothing changed and auto-skipped the event, even though get_context_data() re-queries the DB and would render different HTML (the client kept showing stale content). set_changed_keys("attr") existed (#1981) but required naming a changed attr; there was no way to say "nothing on self changed, just re-render". Calling set_changed_keys() with no arguments now forces a full re-render via the existing _force_full_html bypass without naming a key. 4 tests in test_set_changed_keys_zero_arg_1992.py on the REAL ViewRuntime.dispatch_event path (the test client bypasses the pre==post skip and would hide the bug, #1650) — including a gate-off baseline (the same DB-only mutation minus the zero-arg call auto-skips → noop; adding the call renders), so the render is attributable to exactly the zero-arg hatch (#1468).

  • The redis / tenants-redis extras (and the dev group) now pin redis>=5.0.0,<8 — redis-py 8.x crashes the canonical channels_redis production setup (#1995).channels_redis's receive loop blocks on bzpopmin(timeout=5); redis-py 8.0 changed socket-read-timeout handling on that path, and the resulting redis.exceptions.TimeoutError is uncaught inside the ASGI consumer — so a djust deployment following the docs verbatim (channels_redis for CHANNEL_LAYERS, required for push_to_view/presence/cursor/cross-process) shows a flashing "reconnecting" banner every few seconds under multi-process load. The constraint was >=5.0.0,<9 in all three sites (pyproject.toml[redis] + [tenants-redis] extras + dev group), too permissive — it allowed 8.x. Pinned <8 (verified: redis>=5,<8 gives 0 errors in a soak that previously failed within seconds). Defensive tightening — the lock already resolved to 6.4.0, but <9 let a future re-lock drift to the crashing 8.x.

  • Nested dict/list access in templates ({{ block.content.text }} on a JSONField) no longer renders silently empty — Django _resolve_lookup parity (#1997).Context::resolve's lazy sidecar getattr walk (crates/djust_core/src/context.rs) did getattr ONLY at each segment, so a dict/list intermediate reached mid-path resolved to empty with no error: for {{ block.content.text }} where content is a JSONField (a plain dict), getattr(dict, "text") raises AttributeError and was swallowed → missing output, zero signal. Django's Variable._resolve_lookup tries dict item access → attribute → integer list-index at every segment; djust only did the middle step on the sidecar path (the eager Context::get path was already dict-aware — #1646 parallel-path drift). The walk now mirrors Django's order (get_itemgetattrget_item(int)). The #1986 serialization-floor proxies implement no __getitem__, so item access on them falls through to the floored getattr — verified no floor bypass. New test_nested_resolve_1997.py (dict value, list index, list→dict, dict-key-wins-over-attribute, missing-key-empty, floor-not-bypassed), gate-off verified.

  • In-place-mutation remedy advice was broken, and the #1678 kanban fixture's card-move step was a vacuous guard (#1981). The _snapshot_assigns list-≥100 and dict-≥50 fingerprint-truncation warnings told developers to call self.set_changed_keys({...}) — a method that did not exist — and the docstring pointed to self._changed_keys, which the pre/post skip renders ineffective. The method now exists (see Added) so the advice is accurate, and the docstring is corrected to point to it / an immutable update. Separately, the #1678 client-faithful VDOM fixture's step 2 (cross-column card move) captured 0 patches because KanbanTabsView.move_card mutated columns in place — a regression guard that exercised nothing. move_card now does an immutable update, so the step drives a real targeted diff (RemoveChild + InsertChild + count-badge SetText); the fixture was regenerated and the freshness gate (#1979) pins the meaningful output.

  • HTML preserve-block regexes now match end tags with trailing whitespace (</script >, </style\n>) — CodeQL py/bad-tag-filter#2482._strip_comments_and_whitespace() (mixins/template.py) masks <script>/<style>/<pre>/<code>/<textarea> raw-text blocks behind placeholders before the HTML-comment-strip + whitespace-collapse passes, so their bodies aren't corrupted. The end-tag patterns used a bare </tag>, but per the HTML5 tokenizer an end tag closes on </tag followed by whitespace, /, bogus attributes, or > — so </script >, </script\n>, and even </script bar> all close a <script> in a browser. The bare pattern missed those forms, so the block was NOT preserved, letting a comment-looking token inside the JS/CSS body (var s = '<!-- x -->') get stripped and the script corrupted. All five patterns now use </tag[^>]*> (CodeQL's recommended form, matching every close variant). New TestEndTagWhitespacePreservation (whitespace + newline + bogus-attribute cases, gate-off verified) in test_strip_whitespace.py.

  • _run_async_work no longer writes against a stale view on disconnect/re-mount mid-await (#1940).LiveViewConsumer._run_async_work runs as a detached ensure_future task that captures view = self.view_instance before its first await (the background callback). If a disconnect (which nulls view_instance) or a live_redirect / re-mount (which reassigns view_instance to a NEW view) interleaved during that await window, the completed task ran its handle_async_result + _sync_state_to_rust + render_with_diff + source="async" frame against the torn-down or replaced view — a pre-existing untested race (#245/#1198 TOCTOU class). Added an identity-guard after the callback await on both the success and error paths: if the consumer's live view is no longer the captured one, the stale re-render is dropped. Cancellation can't stop the in-flight worker thread (sync_to_async runs in a thread pool), so an identity-guard — not task cancellation — is the correct cure. The normal (no-teardown) async-work path is byte-identical. New cases in TestRunAsyncWorkTeardown (python/djust/tests/test_run_async_work_teardown_1940.py), gate-off verified.

  • TutorialMixin now initializes its four internal tutorial-signal attrs in __init__, not the tutorial_total_steps setter (#1952)._tutorial_active_target, _tutorial_active_class, _tutorial_skip_signal, and _tutorial_cancel_signal were previously initialized inside the tutorial_total_steps SETTER, so a TutorialMixin view that never set tutorial_total_steps (or read those attrs before the setter ran) hit AttributeError — e.g. _cleanup_active_step() (called from start_tutorial's finally block) reads _tutorial_active_target/_class, and skip_tutorial/cancel_tutorial read the skip/cancel signals once running. The four attrs now default to None in __init__ (placed alongside the existing _tutorial_running/_tutorial_current_step/_tutorial_total_steps inits); the setter keeps its sole job of updating _tutorial_total_steps. Surfaced by ADR-023 M4d typing (PR #1951), left untouched then per #1079 typing-PR scope. New regression cases in TestSignalAttrsInitializedInInit (read the four signals without invoking the setter; pre-fix raised AttributeError).

  • ComponentMixin.update_component no longer raises AttributeError on a LiveComponent (#1947).update_component(component_id, **props) calls component.update(**props), but LiveComponent (a subclass of ContextProviderMixin, NOT Component) had no update() method at runtime, so any LiveComponent that did not define its own update() raised AttributeError on that path (the latent bug annotated with a # type: ignore[attr-defined] in ADR-023 M4c, part 1). LiveComponent now has a base update(**kwargs) that sets each prop as an instance attribute (mirroring the Python/hybrid path of Component.update) and returns self for chaining — the same component.update(**props) API the component docs already document. Subclass update() overrides still take precedence. The stale # type: ignore[attr-defined] at the call site is removed. Regression coverage in TestUpdateComponentNoUpdateOverride (tests/unit/test_component_parent_communication.py): bare-LiveComponent update through update_component, base-update() chaining returns self, subclass-override-still-wins.

  • Dev-env: detect + recover the core.bare = true shared-config corruption that breaks worktree + main checkout (#1938). A linked git worktree shares one .git/config with the main checkout; if anything flips core.bare to true there (a build/PyO3-repoint step running git config core.bare true, an IDE/GitKraken integration, or a stray manual command — the #1804/#300 pattern), git status/git push break in BOTH trees (every tracked file shows as deleted). An exhaustive audit confirmed no djust pre-push hook, test, or script writes core.bare — every in-repo git operation is read-only (git status/diff/grep/ls-files/rev-parse) or scoped to an isolated tmp dir (test_run_with_venv_python.py, test_git_commit_with_precommit.py, test_deploy_cli.py's git init fixtures), and all three were verified empirically to leave core.bare unchanged — so the corruption is external, not a framework bug. New scripts/check-shared-git-config.sh reads core.bare from the SHARED config (resolved via --git-common-dir, works from any worktree), reports a leak (exit 1), and with --fix performs the documented recovery (core.bare false); it NEVER writes core.bare true. The worktree-subagent mitigation (push --no-verify; CI is the authoritative gate) plus the detector are documented in CONTRIBUTING.md "Working in a git worktree". Tested by 5 cases in tests/test_check_shared_git_config.py (build a throwaway main+worktree, simulate the leak in the throwaway shared config, assert detect + --fix-recover + the never-writes-true invariant; gate-off self-tested per #1468).

  • Real type gaps surfaced flipping management/checks/auth/templatetags + loose modules to strict (ADR-023 M4d, group 1). None changed runtime behavior; each removes a latent contract lie. Convergence dividend (one real annotation bug, fixed):mixins/request.py's _streaming_iter was annotated AsyncIterator[str] but yields the ChunkEmitter's bytes chunks (the emitter encode("utf-8")s every chunk before queueing, and StreamingHttpResponse is fed bytes) — the strict typing of http_streaming.ChunkEmitter._aiter_impl() -> AsyncIterator[bytes] exposed the mismatch in the (already-strict M4c) mixins/request.py; corrected to AsyncIterator[bytes]. Other gaps (annotation-only, no behavior change):checks/security.pycheck_security's S002 @csrf_exempt scan read node.body[0].value.value (an ast.Constant.value, a str | bytes | int | … union) and called .lower() on it — guarded with isinstance(doc, str) so a non-str first-statement constant can't AttributeError (it never matched "csrf" anyway); _decorator_callable_name typed Optional[str] so the _is_permission_required_decorator comparison stops leaking Any. auth/core.pycheck_view_auth's login_url (a getattr(...) or getattr(...) over unstubbed Django) cast to str for the _check_django_access_mixins(login_url: str) contract; check_redis (djust_doctor) returns Optional[_CheckResult] (it returns None to skip the non-Redis path). Plus bool(...)/str(...)/cast(...) boundary narrowing at the Django-untyped surface (user.has_perms(...), apps.is_installed(...), self.style.SUCCESS(...), json.loads(...), template.render(...), click.prompt(...)) and dict[str, Any]/list[CheckMessage] var annotations where mixed-type literals were inferred too narrowly. cleanup_liveview_sessions imports the session helpers from their canonical source (djust.session_utils) instead of the live_view re-export so the strict island resolves them (equivalently exported via live_view.__all__).

  • Real type gaps surfaced flipping the theming/ subpackage to strict (ADR-023 M4c, part 3). None changed runtime behavior; each removes a latent contract lie, verified rendering byte-identical. theming/manager.py: ThemeState.pack was annotated str with a None default (a dataclass field lying about nullability — get_state() returns pack=None when no pack is configured), which also made the ThemeState(pack=pack) construction an [arg-type] error against str | None; corrected to str | None = None. theming/_registry_accessor.py: the ThemeRegistry singleton's _presets/_themes/_packs/_manifests/_discovered were assigned only through a local inst in __new__, so mypy saw 25 [attr-defined]/[has-type] errors at every access in _registry_accessor + registry — declared them as class-level annotations (dict[str, Any] / bool), the canonical singleton-attr fix (the attrs are still populated once per process in __new__). theming/theme_css_generator.py + theming/pack_css_generator.py: self.ds / self.pack were DesignSystem | None / ThemePack | None (the get_design_system/get_theme_pack return type) but __init__raises when None, so every later self.ds.typography / self.pack.icon_style access was a union-attr error (14 in pack, 8 in theme) — narrowed by assigning the post-raise non-None value to a self.ds: DesignSystem / self.pack: ThemePack annotated attr. theming/manager.py: the two CompleteThemeCSSGenerator reassignments (in generate_critical/deferred_css_for_state) collided with the inner ThemePackCSSGeneratorgen var's inferred type ([assignment] + a phantom [attr-defined] on generate_critical_css) — renamed the inner var pack_gen. theming/palette.py: s_h/a_h mixed int (from hex_to_hsl) and float (from params[hue_offset] % 360, where _MODE_PARAMS is inferred dict[str, float] because it mixes sat_scale=0.85 with integer hue offsets) — wrapped the (always-integer) hue offsets in int(...) to keep s_h/a_hint (int(180) % 360 is identical). theming/build_themes.py: build_all declared -> Dict[str, str] but artifacts["individual_themes"] is a list[str] — the return type lied about the heterogeneous shape; corrected to Dict[str, Any] (+ the manifest/artifacts locals annotated). theming/accessibility.py: a float ** float returned Any ([no-any-return]) and a bool-or chain over unstubbed-attr comparisons returned Any — wrapped in float(...)/bool(...). theming/mixins.py: the conditional-import event_handler fallback was an unguarded [no-redef] (narrow # type: ignore[no-redef]), _theme_manager was ThemeManager with a None default (corrected to ThemeManager | None), and the four event handlers gained if self._theme_manager is None: return guards so the _theme_manager.set_mode(...) accesses type-check (no-ops post-mount, matching _setup_theme_context's existing guard). Plus several mark_safe(...)/config-.get(...)/cookie-.get(...)Any-leaks narrowed at the boundary (cast(str, ...) / str(...)).

  • Real type gaps surfaced flipping the admin_ext/ subpackage to strict (ADR-023 M4c, part 2). None changed runtime behavior; each removes a latent contract lie, verified behavior-identical by the admin test suite. admin_ext/views.py: LoginView.update_username / update_password had implicit-Optional defaults (field: str = None) that PEP 484 prohibits — corrected to Optional[str]; and ModelCreateView.mount overrode ModelDetailView.mount(self, request, object_id=None, ...) with a narrower mount(self, request, **kwargs) signature (an LSP [override] violation) — restored the object_id parameter (still forced to None internally, so the create view's "always start with no object" behavior is unchanged) so the override is contract-compatible. admin_ext/options.py: several warn_return_any leaks at the Django-untyped boundary narrowed at the return site — _widget_has_permission returns bool(user.has_perms(...)), get_form pins modelform_factory(...) to a typed local, and get_field_display_name wraps the verbose_name/short_description reads in str(...). admin_ext/plugins.py: NavItem.has_permission / AdminWidget.has_permission / AdminWidget.render similarly narrowed (bool(request.user.has_perm(...)), str(render_to_string(...))). admin_ext/__init__.pyautodiscover and admin_ext/apps.pyDjustAdminConfig.ready gained explicit -> None returns.

  • Real type gaps surfaced flipping the mixins/ subpackage to strict (ADR-023 M4c, part 1). None changed runtime behavior; each removes a latent contract lie or surfaces a latent bug for follow-up. Latent bug (annotated, NOT fixed — out of scope #1079):mixins/components.pyComponentMixin.update_component calls component.update(**props) after isinstance(component, LiveComponent), but LiveComponent (which subclasses ContextProviderMixin, NOT Component) has no update method at runtime — confirmed via the live MRO (Component.update exists; LiveComponent.update does not). So update_component() would raise AttributeError if ever invoked with a LiveComponent. The strict flip carries a narrow # type: ignore[attr-defined] with a comment at the call site; the fix (move update onto LiveComponent, or change the routing) is left for a dedicated bugfix PR since mixins/ M4c(1) is annotation-only. Other gaps (annotation-only, no behavior change):mixins/page_metadata.py_pending_page_metadata / _drain_page_metadata were List[Dict] (incomplete generic) → List[Dict[str, str]]. mixins/model_binding.pyallowed_model_fields class attr was inferred None (from = None) → annotated Optional[List[str]] (the true subclass-override contract); _dj_model_fields bare frozensetfrozenset[str]. mixins/rust_bridge.pyrendered_context = {} was inferred Dict[str, dict[str, Any]] from its first (dict-valued) assignment, breaking later primitive/str assignments → annotated Dict[str, Any] (no logic change; the change-detection path is byte-identical). mixins/template.pypos = open_pos + 4 mixed the float("inf") sentinel into an int accumulator → narrowed int(open_pos) in the branch where open_pos < close_pos guarantees a real int; _current_html_size/_previous_html_size declared Optional[int] to match the getattr(..., None) first-render seed. mixins/jit.py_variable_extraction_cache was Dict[str, dict] but stores Optional[dict]Dict[str, Optional[dict]]; the if not extract_template_variables truthy-function check (a function is always truthy) → is None.

  • Real type gaps surfaced flipping the FINAL components/ modules to strict (ADR-023 M4b, part 3). None changed runtime behavior; each removes a latent contract lie, verified rendering byte-identical. components/components/button.py + components/ui/list_group_simple.py: Dict[str, any] (the builtin any function used as a type — a typo) corrected to Dict[str, Any]. components/ui/modal_simple.py: Modal._render_custom read self.show but __init__ never assigned it (the show= kwarg was passed to super().__init__ but not re-set as an instance attr like its siblings body/title/etc.) — [attr-defined] against the RustModal-instance path; added self.show = show to match the established pattern (byte-identical: in the Python-fallback render path the base already set it via its kwargs loop). components/components/prompt_editor.py: self.template reads were typed Optional[str] (the base Component.template: Optional[str] class attr) while the subclass always sets a str — narrowed via a template = self.template or "" local at the top of _render_custom. components/ui/navbar_simple.py: the nav-items param was annotated List[Dict[str, Union[str, bool, List[...]]]] which mis-typed the nested-dropdown access as non-iterable (union-attr on .get/__iter__) — widened to List[Dict[str, Any]] (the honest contract for heterogeneous dynamically-accessed dicts, #1108). Float/int local-init mismatches in chart/heatmap/pivot renderers (total = 00.0, y = ...y: float = ..., row_total/col_totals/grand_total → float) where a numeric accumulator was seeded int then +='d a float (output via :.1f/_format_val is identical). components/gallery/registry.py: cat = info.get("category", "misc") was object-typed (from the heterogeneous EXAMPLES literal) so cat.title() was attr-defined/call-overload — coerced cat = str(...) (category is always a str). Numerous list/dict locals across data_table/templatetags annotated to fix mixed-element var-annotated (e.g. pages: list mixing page ints and "...", col_items: list[list[Any]]).

  • Real type gaps surfaced flipping the components/ UI catalog to strict (ADR-023 M4b, part 2). None changed runtime behavior; each removes a latent contract lie. components/mixins/accordion.py + components/descriptors/accordion.py: AccordionState.active (and the descriptor's nested State.active) was annotated str, but in multiple=True mode it holds a list of open item ids — so actives.remove(value) / actives.append(value) / state.active = [value] were [attr-defined]/[assignment] errors against the declared str. Corrected to Union[str, List[str]] (the true runtime contract — single id when single, list when multiple), narrowing the list branch with cast(List[str], inst.active) (mixin, guarded by inst.multiple) / the existing isinstance(actives, list) (descriptor). The 8 deprecated state mixins (tooltip/tabs/sheet/modal/dropdown/collapsible/carousel/accordion) had component_id = self._resolve_component_id(component_id) reassign an Optional[str] return onto a now-str-typed param — coalesced to ... or "" (behavior-identical: _get_typed_instance("") and _get_typed_instance(None) both miss the instance dict and hit the inst is None guard). Typed all *_instances class vars (Optional[Dict[str, XState]]) and the descriptor _handle_event(self, state: "State", ...) params (the nested-State-subclass forward-ref, not the base TypedState, so state.is_visible/.active/etc. resolve).

  • Real type gaps surfaced flipping the components/ machinery to strict (ADR-023 M4b, part 1). None changed runtime behavior; each removes a latent contract lie. components/server_event_toast.py: ServerEventToastMixin.push_toast calls self.push_event(...), a method supplied by the host LiveView (via PushEventsMixin) and absent from the standalone mixin — mypy flagged [attr-defined]; declared the cooperating method under if TYPE_CHECKING: (the canonical djust mixin pattern, mirrors streaming.py), no runtime change. components/function_component.py: {% call %} dispatch set instance._slots / instance._children on a LiveComponent (per-invocation template-render attrs, distinct from the class-level slots declaration list) — narrow # type: ignore[attr-defined] with an explanatory comment; the @component decorator's _djust_* metadata stamps on a plain Callable likewise narrowed. components/presets.py: _BUTTON_PRESETS (heterogeneous str/bool values) was inferred dict[str, object], making the built-in register_preset(...) registration loop an [arg-type] error — annotated Dict[str, Dict[str, Any]]. components/mixins/base.py: TypedState.__init__ called default.fget(self) where property.fget is Optional — added the fget is not None guard (behavior-preserving for every real _make_property-built property). components/utils.py + components/icons.py + components/suspense.py + components/templatetags/_registry.py: several Any-leaks at Django boundaries (col.get(...)/value.strftime(...)/mark_safe(...)/conditional_escape(...)/render_to_string(...)) narrowed to str at the boundary so warn_return_any is satisfied without an Any escape.

  • Real type bugs surfaced flipping the loose top-level modules to strict (ADR-023 M4a). None changed runtime behavior; each removes a latent contract lie. react.py: ReactComponentRegistry._component_modules was annotated Dict[str, str] but every register() stores a nested {"module": ..., "export": ...} dict — the field annotation contradicted the (correct) return types of get_module_info() / get_all_modules(); corrected to Dict[str, Dict[str, str]]. presence.py: broadcast_to_presence(event, payload: Dict[str, Any] = None) declared a non-Optional param with a None default (the body already coalesces payload = {}) — corrected to Optional[Dict[str, Any]] = None. testing.py: assert_routed_views_allowed imported _routed_liveview_classes from djust.checks, where it is not re-exported (it lives in djust.checks.components) — corrected to import from the defining submodule (verified importable). performance.py: PerformanceTracker.root_node/current_node were inferred None-only from __init__ then reassigned TimingNode — declared Optional[TimingNode], and the _find_parent_node call now guards root_node (was guarding only current_node, but both are None/set together).

  • Real type gaps surfaced flipping the dispatch/runtime core to strict (ADR-023 M3). None changed runtime behavior; each makes the spine type-check clean and removes a latent contract lie. sse.py: SSESession._request was assigned (DjustSSEStreamView.get) and read (runtime.SSESessionTransport.build_request) but never declared in __init__ — added the Optional[Any] declaration alongside _event_request. runtime.py: _instantiate_error_frame was first-assigned None then a dict (mypy inferred None-only, so the dict assignments were errors) — declared Optional[Dict[str, Any]] in __init__; _instantiate_view called Optional[type]() ("None not callable") — added the ViewResolution.__bool__-implied view_class is None guard; the dormant actor-mount path called Optional[create_session_actor] — added the actor-availability guard. websocket.py: _recovery_html was str-typed from its first assignment but cleared to None on one-time use — annotated Optional[str]. live_view.pyi: the M2-island stub omitted the module-level _FRAMEWORK_INTERNAL_ATTRS that websocket._snapshot_assigns imports — added it (a stub-completeness gap the M3 flip surfaced because websocket now resolves the import against the strict stub). Several Any-leaks at Django/PyO3 boundaries narrowed at the boundary (bool()/str()/int() wraps on validate_host, child-render output, and the version helpers).

  • Real type bugs surfaced while building the mypy strict islands (ADR-023).security/attribute_guard.py: DANGEROUS_ATTRIBUTES was annotated Set[str] (mutable) but holds a frozenset — the annotation lied about mutability for a membership-only, never-mutated security denylist; corrected to frozenset[str], matching the immutable-denylist intent. security/log_sanitizer.py: sanitize_dict_for_log's result dict holds heterogeneous values (redacted strings, nested sanitized dicts, sanitized item lists) under an inferred dict[str, str] — annotated dict[str, Any]. security/state_snapshot.py (sign_snapshot) and permissions.py (dump_starter_document): [no-any-return] from untyped-dependency calls (TimestampSigner.sign, yaml.safe_dump) narrowed to str at the boundary. Plus annotation gaps closed in rate_limit, _context_provider, schema, permissions, and test_isolation (missing return/param annotations + var-annotated hints). None changed runtime behavior; they make the cited security/validation modules type-check clean under strict rules.

  • Real type bugs surfaced flipping the public-API quartet to strict (ADR-023 M2).decorators.event_handler: the untyped dual-call API (@event_handler bare vs @event_handler(...)) reported [arg-type] + "Self argument missing" at every bare-decorator call site (e.g. FormMixin.validate_field / submit_form) — the exact consumer-facing liability ADR-023 names; fixed with @overload so both forms type correctly for downstream consumers. live_view._is_serializable: _non_serializable was fixed to a 3-element tuple by inference, so the appended _thread.LockType (4th element) silently fell outside the declared type and the lock branch was effectively untyped — annotated tuple[type, ...]. live_view.pyi: the stream stub was missing the limit param present on StreamsMixin.stream (stub-vs-source signature drift, the #1646 class) — added. mixins/handlers.py: _handler_metadata had no base annotation, so its inferred non-optional dict conflicted with LiveView.__init__'s None init — annotated Optional[Dict[str, Dict[str, Any]]] to match the runtime contract (the is not None cache guard). decorators._ComputedProperty: custom metadata attrs (_is_computed, _computed_name, _computed_deps) were assigned but undeclared — declared as class annotations. None changed runtime behavior.

  • live_redirect to a non-LiveView path now falls back to a full-page navigation instead of stranding the page (#1934). With auto_navigate defaulting ON in v1.1, a live_redirect whose target is a plain Django view (e.g. a TemplateView) left the URL bar on the new path while the previous LiveView stayed mounted — the URL led the DOM with no swap. Two coupled client bugs in handleLiveRedirect (python/djust/static/djust/src/18-navigation.js): (1) the pushState fired BEFORE the view resolution, so the URL changed for a target that never got a DOM swap; and (2) — the load-bearing root cause found by symptom-up tracing, NOT the issue's cited "resolveViewPath returns falsy" — the resolution used resolveViewPath(), which has a container fallback that returns the CURRENT [dj-view]'s class on a route-map miss. That fallback is documented "only works for live_patch, not cross-view navigation", so for a cross-view live_redirect to a non-LiveView it returned the SOURCE view (truthy) and the client SPA-mounted the OLD view under the NEW URL — the exact reported symptom (URL /onboarding/, but the jira view mounts). The server's #1647_resolve_view_path_from_url guard also returns None for a non-LiveView URL and keeps the stale client-supplied view, so the client must make the full-nav decision. Fix: a new STRICT resolveLiveViewPath() (route map ONLY, no container fallback) drives the cross-view decision; the pushState + URL-dependent side effects (updateAriaCurrent, scroll, before-navigate) are DEFERRED into the LiveView-resolved + WS-connected branch, so the URL never leads the DOM. A non-LiveView target (or a disconnected WS) does a full-page navigation validated through window.djust.safeNavigationTarget (mirroring the existing cross-origin branch, with the safeNavigationTarget open-redirect/javascript: guard). The popstate back-nav redirect (the #1646 twin) also switched to the strict resolver, so a back-nav to a non-LiveView reloads correctly instead of re-mounting the source view. The served minified bundle (client.min.js + .gz/.br/.map) was rebuilt to carry the fix. Reproduce-first + gate-off (#1468) verified: new cases in describe('issue #1934 …') (tests/js/navigation.test.js) — non-LiveView target: full-page nav, NO pushState, NO WS mount (strand-free), positive case: a LiveView target still SPA-mounts, and LiveView target but WS not connected: full-page nav — go RED when EITHER half of the fix is reverted (the strict-resolver call → the SPA branch fires safeNavigationTarget never called; the pushState-first order → the strand pushState assertion fails). Full JS suite green (1746 passed).

  • De-flaked TestMountAsyncAndPushDrain::test_mount_dispatches_async_work under parallel -n auto by OWNING THE COMPLETION SIGNAL instead of bounded-polling the scheduler (#1931; the async-dispatch sibling of #1930). The test mounts a view that schedules a background callback via start_async() in mount(), then asserts the callback ran (view.value == 42). The runtime dispatches that callback FIRE-AND-FORGET via asyncio.ensure_future(self._execute_async_task(...)) inside ViewRuntime._dispatch_async_work (python/djust/runtime.py:4444), and _execute_async_task ITSELF awaits a sync_to_async(callback) thread-pool round-trip before setting value=42. The original test waited for that to land via a BOUNDED poll — for _ in range(10): await asyncio.sleep(0) — a wall-clock-fragile race: under a CPU-saturated parallel loop the asyncio scheduler can fail to run the spawned task (which also competes for the thread pool) within 10 yields, so the assertion fired while value was still 0 (flaked 1/4 runs in the #1930 worktree; passed 3/3 in isolation). This is the CLAUDE.md bounded-poll-racing-a-real-scheduler class (#1830/#1815 family), NOT a code regression — the mount async-dispatch feature is correct and lands every time given enough scheduler turns. Fix (in python/djust/tests/test_transport_behavioral_parity.py, test-only — production unchanged): wrap the runtime's asyncio.ensure_future seam during dispatch_mount to capture the EXACT _execute_async_task task handle it spawns (the _flush_push_events fire-and-forget send, which uses the same primitive, is excluded by coroutine name so the gate-off stays sharp), then await asyncio.gather(*async_work_tasks) — a deterministic completion signal, no timing bound. Reproduce-first verified: shrinking the poll bound to 0–1 yields makes the OLD form fail 25/25 (proving the margin is razor-thin), while the new form passes 0/15 failures under 8-way CPU saturation. Gate-off (#1468) verified: disabling the _dispatch_async_work(None) call in dispatch_mount spawns no _execute_async_taskassert async_work_tasks fails (empty list), so the test is load-bearing on the actual async-dispatch path. 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8604 passed each). New behavior in test_mount_dispatches_async_work.

  • De-flaked the six rate-limit burst-exhaustion tests under -n auto by OWNING THE CLOCK — test_ping_flood_triggers_disconnect no longer flakes on a wall-clock token refill (#1930).TestRateLimiter + TestGlobalRateLimit (tests/unit/test_event_security.py) build test-local TokenBucket / ConnectionRateLimiter instances and assert burst exhaustion (e.g. rate=100, burst=2 → the 3rd check() must be False). TokenBucket read time.monotonic() directly, so under CPU-saturated parallel make test real wall-clock elapsed between consume() calls and the refill math tokens + elapsed * rate (rate=100 = 1 token / 10ms) added a token back — flipping a "burst exhausted → False" assertion non-deterministically to True. This is the CLAUDE.md flaky-timing class (never gate pass/fail on wall-clock; #1830/#1815 family), NOT the #1883 shared-global pollution class the issue hypothesized — every limiter here is test-local and reads no leaked global. Fix: a _monotonic = time.monotonic module-level seam in python/djust/rate_limit.py (production behavior identical; one indirection) routes TokenBucket.__init__ + consume() through a patchable name WITHOUT patching the global time module; a FakeClock + frozen_clock pytest fixture monkeypatches djust.rate_limit._monotonic so the five burst-exhaustion tests run on a FROZEN clock (elapsed == 0 → no refill → deterministic), and test_token_bucket_refills replaces time.sleep(0.05) with frozen_clock.advance(0.05) (deterministic, instant, still genuinely exercises the refill path). Reproduce-first verified: advancing the clock 15ms between burst checks flips the 3rd ping check False → True. Gate-off (#1468) verified: with an advancing clock the rate=100 tests go RED at a 15ms stall and the slower rate=10/rate=1 tests go RED at a 2s stall (frozen clock load-bearing for all six), and the refill advance() is load-bearing (without it the drained token stays unavailable). 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8603 passed each, the unrelated pre-existing async-timing flake #1931 deselected). Fixture applies to the burst tests in TestRateLimiter and TestGlobalRateLimit.

  • An inline <script> (or <style>) inside the dj-root is no longer silently neutered by whitespace collapse, so its page JS actually runs on mount (#1927; the live-morph twin of #1848/#1871).TemplateMixin._strip_comments_and_whitespace — the single normalizer every render path runs (HTTP GET, WS mount, SSE/runtime, streaming) to match the Rust VDOM parser's whitespace pass — preserved whitespace only for <pre>/<code>/<textarea>, but the Rust parser ALSO preserves <script>/<style> (crates/djust_vdom/src/parser.rs:475). So the re.sub(r"\s+", " ") pass collapsed every newline inside an inline <script> onto ONE line; a leading // line comment then commented out the entire body, so the script's addEventListener / init never ran — with NO console error. This is why #1871's window.djust._runInsertedScripts mount-morph re-execution could not cure the symptom: the script was already neutered at render, before any morph re-execution. The fix adds <script>/<style> to the preserved-block set (the #1646 parallel-path-drift cure: the Python normalizer now matches the Rust parser's preserve set exactly), and — CRITICAL ORDERING — extracts the raw-text <script>/<style> blocks BEFORE the HTML-comment strip so an HTML-comment-looking token inside a JS/CSS body (var s = '<!-- x -->') is not mistaken for markup and stripped. Non-script/style whitespace collapse is unchanged. Diagnosed by driving the demo /demos/browser-smoke/ page in a real browser (the inline tab-toggle script's __smokeTabsWired stayed undefined until this fix); validated end-to-end in-browser (both the HTTP-GET parse AND the #1610 WS-mount morph now run the script, tab toggle works, no console error). New cases in TestStripCommentsAndWhitespace (python/djust/tests/test_strip_whitespace.py): the exact #1927//-comment-led-body trigger, multi-script/style preservation, the comment-inside-script ordering guard, and a "non-script whitespace still collapses" non-regression. Gate-off (#1468) verified: reverting the <script>/<style> preservation collapses the body to one line and reds the comment-not-swallowed assertion. The now-blocking browser-smoke CI job (this PR) is the end-to-end validator.

  • A batched object/permission-denied mount no longer closes the SHARED WebSocket socket, killing the sibling mounts (#1922, #291-consistency).WSConsumerTransport.finalize_mount_auth closed the socket with code 4403 UNCONDITIONALLY on the permission_denied verdict, while gating the redirect verdicts (login-required / on_mount redirect) on not mounting_in_batch (the #291/#1780 multiplexed-path rule). Inside a mount_batch the socket is SHARED across sibling mounts, so a single object-level- or permission-denied view dropped the shared socket and collaterally killed the survivor mounts (the #291 failure class; pre-existing parity with the old bespoke handle_mount which also closed unconditionally). The permission_denied close is now gated on not self.mounting_in_batch too, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends the error (permission_denied) frame and clears view_instance BEFORE finalize_mount_auth runs; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports in failed[] exactly as the redirect case already reports in navigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes 4403 (mounting_in_batch is False outside a batch). New cases in test_ws_auth_close_socket.py (real WebsocketCommunicator, mirroring the #291 batch harness): test_mount_batch_with_objperm_denied_view_does_not_close_shared_socket (denied view → failed[], public sibling mounts, shared socket pongs = open) and test_single_objperm_denied_mount_still_closes_socket (over-gating guard). Gate-off (#1468) verified: reinstating the unconditional permission_denied close makes the batched-denial test go RED (the ping openness probe receives websocket.close instead of pong); the redirect-verdict gate and the single-mount close are unchanged.

  • Post-mount-flip cleanup — the DEBUG event-render residuals THE FLIP scoped out are now folded onto the runtime path, and the dead _extract_* consumer copies are removed (#1908, #1921). Two post-convergence cleanups from the WS event/mount flips (#1907/#1919), both inert in PRODUCTION. (#1908) DEBUG residuals: the deleted bespoke _send_update attached three things a runtime-routed WS event (which sends via transport.send directly) dropped — (1) the per-event _debug debug-panel payload (_attach_debug_payload, DEBUG + _debug_panel_active gated) plus the top-level timing / performance fields (gated on _should_expose_timing() = DEBUG or DJUST_EXPOSE_TIMING); (2) the no_patchescontext_snapshot the bespoke path passed to _emit_full_html_update; and (3) the cosmetic _current_event_name / _current_event_ref consumer attrs. A new Transport.on_event_frame(view, frame, *, event_name, event_ref) hook (SSE no-op) — called by _render_and_send in-place just before every patch / html_update event frame — attaches (1) via the consumer's existing _attach_debug_payload + _should_expose_timing (verbatim bespoke gate; performance from the event_context-borrowed PerformanceTracker; timing.render from a render-duration measured per event) and stamps (3); on_render_emitted grew a context param so the no_patches branch threads get_context_data() back into the snapshot (2), re-captured only under DEBUG so PRODUCTION never double-calls it. PRODUCTION byte-identical: every attached field is DEBUG/timing-gated, so a prod-mode WS event frame is unchanged (both were also absent in prod on the bespoke path); the internal _timing_render_ms marker is always popped before send and never reaches the wire. (#1921) dead code: the LiveViewConsumer._extract_cache_config / _extract_optimistic_rules copies had ZERO callers after the mount flip deleted the handle_mount body that called them (orphan-grep confirmed across python/ + tests/); ViewRuntime owns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror of LiveViewConsumer._extract_*" refs are corrected. No change to RUNTIME_OWNED_VERBS / routing; SSE unaffected. New cases in TestResidualFoldObservability + TestDebugResidualOnEventFrame (python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicator DEBUG-vs-PRODUCTION parity (a DEBUG event frame carries _debug, timing under expose-timing; a prod frame carries NEITHER _debug/timing/performance nor the internal marker) + direct-hook unit pins for the context snapshot, the consumer-attr stamp, the panel-closed/best-effort gates, and the #1921 deletion. Gate-off (#1468) verified: gating the on_event_frame fold + the context threading off makes the 8 behavior-meaningful tests RED.

  • The SSE /event/ alias now forwards the client-sent ref so the #560 ref echo works on BOTH SSE endpoints (#1891). The /message/ endpoint forwards the raw body verbatim to runtime.dispatch_message, so a client-supplied top-level ref reached dispatch_event and was echoed on the noop / update frame (#560, ADR-022 Iter 2 Phase 2.0). The legacy /event/ alias instead REBUILT the dispatch dict as {type, event, params} and DROPPED ref — so the runtime's _dispatch_event_render (which reads ref from the top level of the data dict) saw None and echoed nothing, leaving the end-to-end ref echo exercised only via /message/. DjustSSEEventView.post now carries ref through into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed. params already carried _cacheRequestId / component_id / view_id (SSE has neither component nor sticky-child routing), so ref was the only dropped field. New cases in TestSSEEventAliasRefEcho (python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the /event/ alias) and TestDjustSSEEventViewPost::test_forwards_ref_to_dispatch_event (python/tests/test_sse.py, the dispatch-dict pin). Gate-off (#1468) verified: reverting the rebuild to the pre-fix {type, event, params} shape makes the two echo tests RED while the gate-off witness (which re-drops ref to confirm absence) stays green.

  • component_id-routed WebSocket events now re-render the parent and emit html_update instead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke _handle_event_innercomponent_id branch resolved + ran the LiveComponent handler but never re-rendered the parent view: html stayed None, the html_update fallback stripped None and raised TypeError, and handle_exception turned it into an error frame — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route through ViewRuntime.dispatch_event, the runtime's _dispatch_component_event (the Phase-2.1 port) re-renders the parent (component VDOM is separate from the parent's), emits a parent-scoped html_update carrying the parent's updated state (e.g. values pushed up via send_parent), and echoes the event ref. The #1896 parity net's component_id test is updated errorhtml_update (the single intended behavioral change of the flip); its gate-off sibling (a bogus component_id still errors Component not found at resolution) stays green, proving the positive test genuinely resolves a real component.

  • ViewRuntime now drains all 8 flush queues like the WebSocket path, fixing flash/page-metadata/layout/a11y/i18n silently dropped on SPA navigation (#1885 / #1646, ADR-022 Iter 0). The runtime drained only 3 of WebSocket _flush_all_pending's 8 turn-end queues (push_events / navigation / deferred), so its one production user — url_change (dj-patch click / popstate SPA navigation) — silently dropped flash messages, page-metadata (title/meta) updates, set_layout swaps, accessibility announcements, and i18n commands queued during handle_params() (a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single _flush_all_pending that drains all 8 queues in WebSocket's exact canonical order (mirrors websocket.py:888), called from both turn-end sites (event render + url_change) so a future queue addition cannot be wired on one path and not the other. New behavioral-parity nets (TestFlushQueueParity, TestWireVersionParity, TestWsOnlyBehaviorEnumeration in python/djust/tests/test_transport_behavioral_parity.py) AST-pin the WS↔runtime flush-queue set + order, the wire-version stamping (#1858), and the known WS-only mount/event behaviors so future ViewRuntime-convergence drift re-forks RED. Reproduce-first + gate-off (#1468) verified: removing the 5 added flush lines reproduces the pre-fix 3-of-8 state and the parity net detects exactly the missing {flash, page_metadata, pending_layout, accessibility, i18n}.

  • Systemic test-isolation: one autouse fixture resets djust's process-globals between tests, retiring the shared-global flaky class (#1883, #1882). Three shared-process-global test-pollution flakes in two milestones were all the SAME class — a process-global left dirty across tests in an xdist worker: #1862 (ROOT_URLCONF leak, PR #1874), #1875 (djust_hotreload channel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a stray djust_hotreload frame on the cached InMemoryChannelLayer re-renders on a later consumer and bumps its per-connection _next_version() counter, so test_time_travel_jump_recovery_version_is_current saw the jump land at version 4 instead of 3 under -n auto). Each was whack-a-moled per-test. The systemic cure is a new shared helper djust.test_isolation.reset_djust_globals() (DRY, #1646) called by an autouse _reset_djust_globals fixture in BOTH test roots (tests/conftest.py, mirroring cleanup_session_cache; and python/djust/tests/conftest.py) that resets djust's leak-prone process-globals BEFORE each test: the Channels layer manager (channel_layers.backends.clear() — the #1875/#1882 class), Django's URLconf caches (clear_url_caches() + set_urlconf(None) — the #1862 class), djust's route-map cache (_reset_route_map_cache()), and the module-level itertools.count id counters (mixins.sticky._view_id_counter, components.templatetags.djust_components._tooltip_id_counter). It is deliberately conservative (runs on every test): it resets ONLY state that genuinely leaks and is lazily re-derived, with lazy imports wrapped so a missing optional dep (Channels) never errors the fixture; it does NOT touch state_backend (already isolated by cleanup_session_cache), the keyed self-invalidating _jit_serializer_cache, the one-shot _CUSTOM_FILTERS_BRIDGED bootstrap, or per-instance StickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) in python/djust/tests/test_global_isolation_1883.py: a stale-layer sibling group_send reproduces the exact got 4 drift WITHOUT the reset and the clean 1 -> 2 -> 3 chain WITH it, plus per-global unit pins (neutering reset_djust_globals fails 5/8 cases). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 (plus × 3 bonus) all clean, 8163 passed / 0 failed each run — the fixture breaks no existing test.

  • De-flaked the 17 #1721 theme-tag tests under -n auto — the systemic #1883 fixture now re-asserts the ready()-time Rust tag handlers (#1928, #1883-class).python/djust/tests/test_theme_tags_rust_engine_1721.py flaked under full -n auto: has_tag_handler("theme_panel") returned False and all 17 tests 500'd with Unsupported template tag '{% theme_panel %}'. Root cause is the same shared-process-global class as #1883: the process-global Rust tag-handler registry (crates/djust_templates/src/registry.rs) is shared across an xdist worker, and DjustThemingConfig.ready() / DjustComponentsConfig.ready() register the {% theme_X %} / {% render_slot %} handlers only ONCE per process. tests/benchmarks/test_tag_registry.py::TestRustPythonInterop clears the registry (clear_tag_handlers()) and its restore_registry fixture restores ONLY the djust.template_tags built-ins — not the app-registered theme/component handlers — so once it runs in a worker the theme handlers stay gone for every later test (also reproducible by any test that django.setup()s without djust.theming). This is the exact #1771 bug fixed only in tests/unit/test_tag_registry.py (parallel-path drift, #1646); the benchmark twin was uncovered. Systemic cure: reset_djust_globals() (python/djust/test_isolation.py) grows _reset_rust_tag_handlers(), which re-runs both ready()-time registrars BEFORE every test in both test roots — idempotent (theming guards on has_tag_handler, component overwrites) and a no-op without the Rust extension, so it is cheap. Retires the whole flaky class regardless of which polluter ran, rather than patching the one benchmark file. New cases in python/djust/tests/test_global_isolation_1883.py: test_reset_reasserts_theme_and_component_tag_handlers_1928 (clear → prove gone → reset → prove restored) + test_gate_off_clear_without_reset_loses_theme_handler_1928 (gate-off sibling proving the bare clear loses the handler, non-tautological per #1468). Reproduce-first verified: the benchmark-polluter-then-theme order failed 17/18 pre-fix and passes 18/18 post-fix; gate-off (#1468) verified (neutering _reset_rust_tag_handlers() re-reds both the repro order and the new pin). 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8604 passed / 0 failed each).

  • De-flaked test_mount_batch_with_login_view_does_not_close_shared_socket under -n auto (#1875). The #291 regression test (a login-redirecting view in a mount_batch must NOT close() the shared socket) was order-fragile under full -n auto saturation — it failed 1 of 3 full runs, passed in isolation. Two independent races, both fixed without weakening the guard: (1) the consumer joins the process-global djust_hotreload channel-layer group on connect, so a sibling test's group_send("djust_hotreload", ...) could deliver a stray frame into the test's receive_nothing window — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpolluted InMemoryChannelLayer; (2) the receive_nothing(timeout=0.5) "no mid-batch close" check raced a wall-clock window (flaky under CPU saturation per the #1830/#1795 flaky-timing canon) — replaced with a deterministic pingpong openness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the _mounting_in_batch close-suppression guard makes the test fail (Expected type 'websocket.send', but was 'websocket.close'). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 all clean.

  • V004 no longer false-fires on framework-invoked lifecycle hooks (#1684). The V004 system check ("public method looks like an event handler but is missing @event_handler") flagged user overrides of hooks the framework calls directly (self.X() / getattr / hasattr) rather than through the user-event router — these must NOT carry @event_handler, but their names match the event-handler-like regex and were absent from the V004 lifecycle-skip set in checks/components.py. Canonical symptom: handle_presence_leave (bit djust-org/djust-start#5). Added the 8 framework-invoked hooks (handle_presence_join/handle_presence_leave/handle_cursor_move/handle_tick/handle_async_result/handle_component_event/handle_info/on_wizard_complete) to the skip set. The fix originally landed on the 1.1 branch (#1685) against the pre-#1822-split checks.py; it was never ported to main's split checks/ (so the false-positive was live through 1.0.8) — this lands it on main. New regression TestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684 (gate-off verified, #1468).

  • djust new scaffold's settings.py template now reads DJUST_SQLITE_PATH for the SQLite NAME, falling back to BASE_DIR / "db.sqlite3". A scaffolded app's default SQLite database lived under BASE_DIR, which is read-only on a typical PaaS app rootfs (e.g. djustlive) — the first write 500'd in production. Hosts that mount a writable path now export it as DJUST_SQLITE_PATH and the scaffold picks it up automatically; local development (no env var set) is unaffected.

All releases · Atom feed