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-scopeduserhas 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 RustFromPyObject__dict__bulk-dump (crates/djust_core/src/lib.rs), which filtered only_-prefixed keys, so it dumpedpasswordfor 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 rawdict/tuplerows 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 rawlist/tupleof models reached via a non-model intermediary —{% for x in presenter.items %}{{ x.password }}— whose elements reach the RustFromPyObjectVec<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_serializableand the same sensitive-method set (extracted to shared_SENSITIVE_MODEL_METHODS/_SENSITIVE_MODEL_METHOD_PREFIXESconstants 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 (vianormalize_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 inget_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 singleprotect_sidecarchokepoint (crates/djust_core/src/context.rs) that routes every just-materialized value — after bothgetattrand 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 throughnormalize_django_value(the denylist serializer) instead of the__dict__bulk-dump, so a raw model reaching aValuevia a list/tuple/dict container is floor-filtered too (vector 7). These are the two durable chokepoints — the getattr-walkprotect_sidecarand 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 thetemplate_auto_callkill-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 intest_template_auto_call_1985.py(TestSidecarSerializationFloorcovers 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 Rustprotect_sidecarchokepoint, or theFromPyObjectmodel routing makes the corresponding leak test RED). Field-type-based exclusion (always-dropBinaryField, encrypted-field types) is a follow-up hardening of both paths (#1987).TYPE-based serialization floor — always-drop
BinaryField+ encrypted-field types + a configurablesensitive_field_typeslist, 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-modeldjust_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 containingencrypted/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 newLIVEVIEW_CONFIG['sensitive_field_types'](a project-configurable list, empty by default; case-exact).FileField/ImageFieldare 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 inpython/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 theBinaryField-leak test RED). See SECURE_DEFAULTS Pattern 1.ViewRuntime.dispatch_mountgained 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 ofmount(); the payload is a server-signedTimestampSignerblob (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_mountnow ports the WS restore VERBATIM (websocket.py:2491-2587): the sameunsign_snapshot(blob, slug=view_path, sid=session_key)HMAC binding (a snapshot signed for view A / session S1 / older thanDJUST_STATE_SNAPSHOT_MAX_AGEdoes NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, theDJUST_STATE_SNAPSHOT_ENABLEDoperator master-switch, and the_should_restore_snapshot(request)view-level veto. The session key for thesidbinding is sourced fromrequest.sessionand 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_snapshoton the mount frame,websocket.py:2754-2792) is also ported, opt-in only. Gatedenable_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 UNTOUCHED —handle_mountkeeps its own copy until the Phase 3.3b flip;RUNTIME_OWNED_VERBS/ WS routing /handle_mount_batchare unchanged (websocket.pyhas no diff). New suitepython/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 themount()default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap inunsign_snapshotmakes 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.ViewRuntimegained atransport.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 WShandle_eventalready re-checks per-event auth whenLIVEVIEW_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. NewTransport.recheck_event_auth(view) -> bool(default-True = no re-check) wired intoViewRuntime._dispatch_event_innerat the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler.WSConsumerTransportreplays the WS bespoke logic verbatim (re-resolve the user from the scope session viachannels.auth.get_user, reflect ontoview.request.user, re-runcheck_view_auth_lightweight; on failurenavigateto the login url +close(4403)).SSESessionTransportre-checks against the LIVE event-POST request (session._event_request, stamped by the/event/+/message/endpoints just before dispatch — the current POSTer'srequest.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 onreauth_on_event+login_required/permission_required(default views pay nothing). #291 multiplexed-path care: the runtime clearsview_instanceUNCONDITIONALLY on aFalsereturn (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_batchis mount-only — but the close stays gateable if events are ever collected, matching the WS bespokeview_instance = Noneafter 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 suitepython/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_instancecleared; 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_mountbefore it could go live (#1885, ADR-022 Iter 0). The WebSockethandle_mountenforces the ADR-017 post-mount object-permission check (check_object_permission), butViewRuntime.dispatch_mountdid not — so a view whosehas_object_permission()returnsFalse(or whoseget_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_mounthas 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 sharedenforce_object_permissionchokepoint the other transports use (runtime.py, mirroringwebsocket.py:2554-2573), placed AFTERmount()(soget_object()can read URL-derived attrs) and BEFOREhandle_params+ render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a customget_object(behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only apermission_deniederror frame after. New cases inTestDispatchMountObjectPermission(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 + explicitget_*), 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 theFromPyObjectstr()catch-all. The walk now implements Django's exactVariable._resolve_lookupsemantics at every segment (root, mid-path, final): no-argcall0();do_not_call_in_templates→ used as-is (Model classes,Choicesenums);alters_data→ never called, renders empty (the data-destruction guard —{{ user.delete }}cannot destroy data);TypeErrorfrom the call runs theinspect.signature(...).bind()probe (args-required → empty; internalTypeErrorpropagates); 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 aManager/QuerySet— in a LiveView that is a DB query per re-render (per WebSocket event), so precompute inget_context_data()on hot paths. Kill-switch:LIVEVIEW_CONFIG["template_auto_call"](defaultTrue);Falserestores the pre-ADR no-call walk. 16 doc-claim-verbatim tests inpython/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). Seedocs/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 thancopy.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 instate-primitives.md). The_snapshot_assignsfingerprint-truncation warnings and docstring already advised callingself.set_changed_keys({...}), but no such method existed (it wouldAttributeError). This adds the method toRustBridgeMixin(inherited byLiveView): it marks the given keys changed and sets_force_full_htmlto force the re-render the auto-skip would otherwise drop._changed_keysand_force_full_htmlare now in_FRAMEWORK_INTERNAL_ATTRS(excluded from the assigns snapshot), so assigningself._changed_keysdirectly 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_htmlskip-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_keysforces a full re-render. Verified on the productionViewRuntime.dispatch_eventpath (not justLiveViewTestClient, which bypasses the skip); gate-off (#1468): neutering the method turns the in-place-mutation render test RED. Seedocs/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/blockrender()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]]withignore_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 thatcasts Django's@keep_lazy-decorated (untyped →Any)mark_safetostr, absorbing the ~200-strongno-any-returncascade across every handler return without ignores; (b) inlinecast(...)/str(...)(runtime no-ops) at eachint()/float()/dict-key/attribute site of thekw.get(...) -> objectcascade, plus a handful of explicitvar: 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 (thecast/strcoercions are runtime no-ops; the onlystr()wraps that touch lookup keys were converted tocastto guarantee key identity).mypy python/djuststays GREEN (822 files) withdjust.components.rust_handlersstrict; gate-off-verified (#1468) — a wrong-typedintreturn injected into a handler (ModalHandler.render, declaredstr) 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. Seedocs/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]]withignore_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-enginetemplatetags/package already flipped in M4d group 1); and the theme-gallery / component-storybook surface (theming/gallery/—viewsthe gallery/editor/diff + storybook DEBUG/staff-gated views,contextthe example-context + token-serialization builders,component_registry,urls,storybook). None of the three subpackages has atests/dir, so the ratchet completes each in one PR with no test sub-package to defer. Annotated with real types (params + returns — notAnycosmetics):HttpRequest/HttpResponseon the gallery views,list[dict[str, Any]]on the example builders,Callable[[Type[TagHandler]], Type[TagHandler]]on the@registerdecorator factory. Render output is byte-identical — the SafeString/HTML boundaries (format_htmlinflash,escapeinmarkdown,Template.renderinpwa,reverseinurl,staticinstatic,_client_config_htmlinclient_config, the dynamic component.render()incomponent_registry) returnAnyunder the lenient global config (Django + the cross-islandlive_tags._client_config_htmlare seen as untyped), so each is coerced withstr(...)at the boundary to satisfywarn_return_anyWITHOUT changing the returned (already-safe) HTML. One real type fix:scaffolding/generator.pylist_display_fieldsannotatedlist[str](was an un-annotated[]flaggedvar-annotated).mypy python/djuststays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn injected intotemplate_tags/url.UrlTagHandler.render(declaredstr) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Seedocs/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 thedjust.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-levelColorScale/ThemeTokens/ThemePreset/DesignSystem/ThemePackliterals, zero functions), the dependency-free re-export hub_base, the package__init__(pure re-exports), and the deprecated_legacymodule (theTheme/THEMESdataclass 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 untypeddictoverrides (__getitem__/__contains__/get/items/keys/values/__iter__/__len__) — annotated to match thedict[str, Theme]superclass signatures (the three view methods declare-> Anyfor the un-nameable concretedict_items/dict_keys/dict_valuesreturn 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/djuststays GREEN (822 files) withtheming/themes/*strict; gate-off-verified (#1468) two ways — a wrong-typedstrreturn on_legacy._DeprecatedThemesDict.__len__(declaredint) 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-leveltheming/modules are already strict (M4c part 3), but mypy'sdjust.theming.*glob matches only direct children, not the deeperdjust.theming.themes.Xsubmodules, so this subpackage needed its own override entry. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the loose top-level modules (appsDjustConfig,audit_astAST security-audit walker,audit_liveruntime auditor,bug_captureharness,checks_css_proposalproposed CSS system-checks,hookslifecycle registry,hot_view_replacementHVR engine,state_backend/template_backendback-compat re-export shims,template_filtershelpers,time_travelrecorder,utilsshared 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 — notAnycosmetics):db/decorators.notify_on_save.decoratetypedtype[models.Model]so_meta/labelresolve, with narrow# type: ignore[attr-defined]s on the dynamic_djust_notify_channel/_djust_notify_receiversintrospection attrs stashed on/deleted from the decorated model class; the signal receivers_on_save/_on_deleteannotated(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_backendnowcast(PresenceBackend, _registry.get())mirroring the already-strictstate_backends.registrypattern (the generic registry returnsAny);backends.redis.RedisPresenceBackend.countwraps the untypedzcountAny-return inint(...);db.notifications._import_psycopggained its-> tuple[Any, Any]return; anddb.notifications._dsn_from_url's URL-field loop variable was renamed (val→dsn_val) to stop colliding with the earlierstr-typedparse_qslloop var so the mixedstr | int | Nonefield tuple type-checks.mypy python/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typedintreturn inbackends.registry.get_presence_backend(declaredPresenceBackend) 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. Seedocs/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_componentswas 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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the four template-tag modules (theme_components— the ~26 themed component tagstheme_button/theme_card/theme_alert/theme_input/theme_modal/theme_table/theme_nav/etc.;theme_pages— the auth/error/utility page-fragment tagstheme_login_page/theme_404_page/theme_maintenance_page/etc.;theme_tags— thetheme_head/theme_css/theme_switcher/theme_preset/theme_modeaccessors + the sharedbuild_theme_head_contextbuilder;theme_form_tags—theme_form/theme_form_errors/get_css_prefix) and thedjust_thememanagement 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 theirmark_safe/format_htmlreturn values are annotatedSafeString(the HTML-safe boundary) andcontext/request/formparams getContext/HttpRequest | None/BaseForm; output is byte-identical. The management command uses the establishedCommandParser/*args: Any, **options: Anyshape mirroringdjust_setup_css/djust_doctor. Four real type fixes to clean the islands (the kind strict-flips surface, ADR-023):theme_components.theme_progressannotatespercentage: float(themin(100, (int(value)/int(max))*100)reassignment is afloat; the= 0seed inferredint→[assignment]);theme_tags.theme_framework_overridesnarrows theformat_htmlresult through astrlocal at the unstubbed-django boundary ([no-any-return]); the three_css_prefix()helpers +theme_pages._csrf_token_valuewrap the untypedget_theme_config().get(...)/get_token(...)boundary instr(...); anddjust_theme.handle_marketplace_inforeads the required-positionalmp_theme_namevia subscript (not.get()) so it stays non-Optionalfor thethemes_dir / theme_namePath division +get_component_coverage(str, ...)call ([operator]/[arg-type]).mypy python/djuststays GREEN (822 files) with all 8 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn intheme_pages._css_prefix(declaredstr) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + thestr(...)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 optionaltheming/gallerysubpackage, which remains for a continuation batch. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the PWA layer (mixinsPWAMixin/OfflineMixin/SyncMixin,storageoffline backends +OfflineAction/SyncQueue,syncSyncManager/ConflictResolver,manifest,service_worker,utils), the optimization layer (fingerprintStateFingerprint/SectionCache/IncrementalStateSync,codegenserializer code-gen,query_optimizerselect/prefetch analysis,cacheSerializerCache,__init__), the multi-tenant layer (resolvers,managersTenantManager/TenantQuerySet,backendsredis/memory presence,middlewareContextVar tenant binding,mixinTenantMixin/TenantScopedMixin,audit,security,models,__init__— annotations only; tenant-isolation logic byte-identical), and the observability layer (viewslocalhost-gated endpoints,middlewarelocalhost gate,sql/timings/log_handler/tracebackscapture buffers,dry_runside-effect blocker,registry,urls,__init__). Annotated with real types (params + returns — notAnycosmetics), using the established mixin-collaborator pattern (# type: ignore[misc]on cooperativesuper().get_context_data()/dispatch()calls mirroringwizard.py/tenants;TYPE_CHECKING-onlypush_event/sync_queuestubs on the PWA mixins documenting the co-mixed-LiveViewcontract) and a narrow# type: ignore[import-untyped]ondry_run's lazyimport requests(a known-stub package mypy won't silence viaignore_missing_imports). Two real bugs fixed to clean the islands (the kind strict-flips surface, ADR-023):pwa.storage.OfflineAction.idwidened toUnion[str, int](callers forward an int model pk asobj_id; theSyncQueueaction-id params widened to match), andpwa.mixins.delete_offlinenow passes the requiredOfflineAction(data={})— omitting it raisedTypeErrorat runtime on every call (a guaranteed crash in an untested path).mypy python/djuststays GREEN (822 files) with all 31 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedstrreturn inoptimization.fingerprint.StateFingerprint.version(declaredint) 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: thepwa/{templatetags,management}-style leaf packages do not exist for these four subpackages, so M4d(2) completes their non-test surface. Seedocs/adr/023-incremental-type-enforcement.md.Strict type enforcement on the
tutorials/,api/,template/, andstate_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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the declarative guided-tour state machine (tutorials/— theTutorialStepdataclass +TutorialMixinasync tour loop, withif 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_functiondispatch views, the pluggableBaseAuth/SessionAuthcontract, 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_contentsloaders, theDjustTemplaterendering pipeline incl. the{% extends %}/{% block %}parser +{% url %}resolver, and theserialize_value→JSONValueserializer), and the LiveView state-persistence backends (state_backends/— theStateBackendABC, the in-memory + Redis backends, and the registry).api/andstate_backends/are security/correctness-relevant — annotations only, logic byte-identical: the_snapshot_assigns/_compute_changed_keysdiff, 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.pyiwire-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-JITDjangoJSONEncoder = None/_get_model_hash = Noneimport fallbacks intemplate/rendering.py; the transientNone-view health-check probe entry instate_backends/memory.py);cast(...)is used at the Django/zstd/Rust unstubbed-boundaryAnyleaks, andassert ... is not Nonenarrows already-guarded optionals (thenext_start.end()block-parser sites, the_get_compressor()compress path gated by_compression_enabled).mypy python/djuststays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn instate_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 atests/subdir, so the ratchet completes each in a single PR (no test sub-package to defer). Seedocs/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]]withignore_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_introspecthelper; the full Django system-check family (configuration/security/templates/quality/components/integrations/accessibility+ the sharedutils); the auth layer (thecheck_view_auth/run_pre_mount_auth/enforce_object_permissionsecurity core, theLoginRequiredLiveViewMixin/PermissionRequiredLiveViewMixin, thesocial_auth_providerscontext processor, the signup/loginviews+forms, and thedjust_adminplugin + itsOAuthProvidersView/SocialAccountsViewLiveView pages); all five template-tag modules (live_tags— the big one with{% live_render %}/{% colocated_hook %}/{% dj_activity %}+ the lazy-thunk emitter, plusdjust_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 — notAnycosmetics):SafeStringat themark_safe/format_htmlboundary;CheckMessagefor system-checkerrorslists + returns;argparse.Namespace/CommandParserfor the management commands;ast.*node types (ast.ClassDef/ast.Call/ast.expr/ast.Module) for the AST-based checks;AsyncIterator[bytes]for theChunkEmitterstreaming surface. The only narrow coded# type: ignores are at genuine dynamic edges: thedjust.checkssetattrre-export (_root.*— the patch-by-path contract from the #1822 monolith split), thedjust-adminoptional-dependency fallback class (no-redef/assignment), the optional_rustversionexport (not in the.pyi), the auth-mixin cooperativesuper().dispatch(provided by the combined View), and the Djangomodel._metaaccess (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/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return in a flipped module (templatetags/djust_flash.dj_flash→int) turns the gate RED ([no-any-return]), reverting restores GREEN. Full suite 8604 passed / 0 failed.requests(consumed bydeploy_cli) joinsyamlin the untyped-third-party override. Remaining for a continuation batch: themanagement/templatetags-adjacent long tail is already covered; thetenants/,backends/, andstate_backends/subpackages + the last few loose modules remain. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the registry layer (_registry_accessorsingleton +registrydiscovery wiring), theThemeManager+ThemeStatestate/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,mixinsThemeMixin,components,formsrenderer), the build/adapters/tooling (build_themes,shadcn,tailwind,inspector,checks,manifest,loaders,theme_packs,compat,contracts), theappsAppConfig,views,urls, and the leaf_config/_constants/_types/_builtin_presetsmodules. Annotated with real types (params + returns — notAnycosmetics), using thecast(str, mark_safe(html))boundary pattern for the theme-component renderers (django'ssafestringis unstubbed, somark_safereturnsAny;SafeStringitself resolves toAnywithout django-stubs, so astrcast is the honest no-Any-leak shape).mypy python/djuststays GREEN (822 files) with all 39 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn inmanager.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 defensiveif self._theme_manager is None: returnguards in the fourThemeMixinevent handlers (no-ops on the real post-mount path, matching the existing_setup_theme_contextguard). Full suite 8604 passed / 0 failed; 1863 theming tests pass (incl. the previously-flakytest_theme_tags_rust_engine_1721, green via #1929's fixture). Remaining theming/ for a continuation batch: thetheming/{templatetags,management,gallery}subpackages (the templatetag modules are the heaviest —theme_components~51 errors — so they're a separate batch). Seedocs/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]]withignore_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 aTYPE_CHECKING-guarded import so the optionalmcpdep is never imported at module load,_ensure_django() -> bool,main() -> None, and the observability-tool returns); the binary-WebSocket-frame upload system (uploads/__init__— theUploadWriterbase +BufferedUploadWriter+UploadConfig+UploadManager,uploads/resumable— the resumable chunk protocol,uploads/storage— the in-memory + RedisUploadStateStoreimpls,uploads/views— theUploadStatusViewHTTP endpoint withHttpRequest/JsonResponseannotations); 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 atests/dir, so the ratchet completes in one PR with no test sub-package to defer. Annotated with real types (params + returns — notAnycosmetics); the only narrow coded edges are:# type: ignore[override]on the legacywrite_chunk(self, chunk)adapters (BufferedUploadWriter,GCSMultipartWriter,AzureBlockBlobWriter) — the dropped trailingchunk_indexdefault is an INTENTIONAL, runtime-dispatched part of theUploadWritercontract (_writer_accepts_chunk_indexintrospects the signature; documented on the base method), andcast(...)narrows at the untyped boundaries (json.loadsinuploads/storage,boto3.generate_presigned_urlins3_presigned,requests.Response.text+session.session_keyinmcp/server/uploads/views). Twomcp/serverobservability-toolparamsdicts inferred homogeneous-then-mutated-with-the-other-type were annotateddict[str, object]. Third-partyrequests(consumed bymcp/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/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return injected intouploads/storage.deleteturns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed (413 upload/mcp-related tests pass). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans every non-testadmin_ext/module: theDjustAdminSite(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+ theadmin_login_requiredwrapper and the_VIEW_REGISTRYplumbing), theAdminFormMixin(FK/M2M option loading, date/time field detection, readonly handling, real-time field validation), the bulk-action progress widget +@admin_action_with_progressdecorator, theAdminTailwindAdapteradmin CSS-framework adapter, theregister/action/displaydecorators, theDjustAdminConfigAppConfig, the autodiscover package__init__, and the admin template-tag helpers (get_item/get_field/concat/admin_url). Excludesadmin_ext/tests/, which stays on the lenient global default. Annotated with real types (params + returns — notAnycosmetics):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: Anyannotation-only attrs onAdminBaseMixin+AdminFormMixindocumenting the co-mixed-LiveViewcontract, plus a# type: ignore[misc]on the cooperativesuper().as_view()mirroringwizard.py); decorator function-attribute stamping (wrapper.short_description = ...) carries narrow# type: ignore[attr-defined]at the genuine dynamic edge.mypy python/djuststays 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_adapters→int) 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. Seedocs/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 entiremixins/subpackage — the LiveView mixin layer that composes the publicLiveViewclass — from the lenient mypy default to strict islands ([[tool.mypy.overrides]]withignore_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_work—start_async/defer/assign_async,waiters—wait_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 (context—get_context_data/_apply_context_processors/_deep_serialize_dict,jit—_jit_serialize_queryset/_jit_serialize_model/_get_template_content), the HTTPrequestmixin (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 largetemplaterendering mixin (render/render_full_template/render_with_diff/arender_chunks+ the HTML extraction/stripping helpers). Annotated with real types (params + returns — notAnycosmetics), using the establishedif TYPE_CHECKING:host-attribute-declaration pattern (mirroringstreaming.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-RustRustLiveView = None/extract_template_variables = Noneimport fallbacks; theevent_handlerdirect-file-import fallback shim; the dynamiccomponent_id/_auto_idattribute sets on theComponent | LiveComponentunion).rust_bridge/jitchange-detection is byte-identical — annotations are runtime no-ops; the_sync_state_to_rustchange-detection, the_framework_attrs-class filter conventions, and all id()/value comparison logic are UNTOUCHED (no comparison or filter expression was altered).mypy python/djuststays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict mixin (template.get_template→int) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Themixins/ratchet completes in a single PR (nomixins/tests/sub-package exists to defer). Seedocs/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]]withignore_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 — everyNode.render(self, args/content, context) -> SafeString,do_*(parser, token) -> template.Node, and@register.simple_tag/inclusion_tagfunction, with inclusion_tags correctly typed-> dict[str, Any]since they return a context dict, not HTML), the per-widgetmixins/data_table(theDataTableMixin— its ~21on_table_*event handlers,handle_*override hooks,get_*/_apply_*pipeline, and the safe-arithmetic expression parser), the gallery LiveView surface (gallery/live_views—GalleryCategoryMixin+ 9 category views, with thetemplate_nameLiskov conflict resolved by aTYPE_CHECKING-onlyLiveViewbase alias;views,examples,registry,context_processors, and thecomponent_gallerymanagement command), the ~24 remainingcomponents/components/*widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/_eval_expression/etc.), thelayout/tabs/data/pagination/ttyd/terminalleaves, and theui/*_simplestateless widgets +ui/dropdown(the over-narrow nav-item dict widened to the honestAnycontract per #1108; the optional-Rust import shims —from djust._rust import RustX/RustX = Nonefallbacks 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 themark_safe(...) -> SafeStringboundary pattern (noAnyleak).rust_handlersis deliberately left LENIENT — it is a genuinely-dynamic Rust-bridge registry whose 193 handlers parse untyped Rust-engine arg lists intodict[str, object](thekw.get() -> objectcascade), so strict typing surfaces 344 errors (203no-any-return+ 91call-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/djuststays GREEN (823 files) with all 68 strict andrust_handlerslenient; gate-off-verified (#1468) — a wrong-typed return inmixins/data_table([return-value]) and a dropped annotation intemplatetags/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 documentedrust_handlersexception remains lenient within components/). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the fullcomponents/components/widget catalog (146 modules — alert, badge, card, spinner, kanban-adjacent leaves, charts, etc.), theui/stateless widgets (8 — spinner, modal, alert, progress, badge, button, card, list_group), thedata//forms//layout//gallery//management//ttyd/leaf packages, the descriptor-based components (descriptors/*— the DEP-002Accordion/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 — notAnycosmetics): typed*_instancesclass vars (Optional[Dict[str, XState]]),instance_id: str/component_id: str/is_open: boolparams,get_*_ctx(...) -> Dict[str, Any]accessors, andrender() -> SafeString(mirroring themarkdown.pyisland —mark_safe(...)returnsAnyunder django's unstubbedsafestring, soSafeStringis the correct str-compatible annotation that cleanly absorbs theAnywithout 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/djuststays 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-errormark_safe/kw.get()-objecticeberg, a separate decision), the per-widgetmixins/data_table, the big templatetag modules (templatetags/djust_components/_advanced/_forms/_charts), thegallery/live_views/views/examplesLiveViews, the ~24components/components/*widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/etc.), and the union-typedui/*_simplewidgets +ui/navbar_simple/modal/dropdown(over-narrow dict inference + the declared-but-unbuiltRustNavBar/Rust*fallback imports). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any):components.__init__,components.apps,components.registry(theLiveComponentname registry),components.assigns,components.dependencies(theDependencyManagerCSS/JS asset registry),components.function_component(the@componentdecorator +{% 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(sharedformat_cell/interpolate_color/interpolate_color_gradient+CURRENCY_SYMBOLS),components.mixins.base(the per-component interactive mixin base —ComponentMixin+ theTypedStatedict subclass),components.templatetags._registry(the sharedtemplate.Library+ the security-sensitivesafe_urlscheme-validator +_resolve/_parse_kv_args), andcomponents.templatetags._dev_tools(the Terminal/MarkdownEditor/JsonViewer/LogViewer/FileTree dev-tool template tags). Annotated with real types (params + returns — notAnycosmetics); the only narrow coded# type: ignore[attr-defined]are at genuine dynamic edges (the@componentdecorator stamping_djust_*metadata onto a plainCallable; the per-invocation_slots/_childrenattached to aLiveComponentinstance for template render). Themark_safe-returns-Anyboundary is handled with typed-local narrowing (a small_safe(html: str) -> strwrapper in_dev_tools,str-typed locals elsewhere) — noAnyleak.mypy python/djuststays 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-> stris added — themark_safe/kw.get()-objecticeberg), the per-widgetmixins.data_table, and the big templatetag modules (djust_components/_advanced/_forms/_charts), plus the UI catalog (ui/,data/,forms/,gallery/, charts UI). Seedocs/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]]withignore_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(thelive_sessionURLconf walk + auth-filtered route-map emit),formsets,simple_live_view,testing(the publicLiveViewTestClient+SnapshotTestMixin+LiveViewSmokeTestfuzz/smoke harness),react,rust_components,frameworks(the CSS framework adapters),js(theJScommand-chain builder),wizard(WizardMixin),performance,profiler, andpresence(PresenceMixin+LiveCursorMixin). Annotated with real types (params + returns — notAnycosmetics); the only# type: ignore[misc]are at genuine mixinsuper()-delegation edges (wizardmount/get_context_data, which the LiveView MRO supplies at runtime).mypy python/djuststays 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). Seedocs/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/ViewRuntimedispatch spine (every mount + event flows through them) are now mypy strict islands ([[tool.mypy.overrides]]withignore_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 — notAnycosmetics):runtime.py(42 strict errors —ViewRuntimedispatch helpers,_build_request/_check_auth/_extract_*, the actor-mount path,_tenant_context),websocket.py(73 —LiveViewConsumerlifecycleconnect/disconnect/receive, thehandle_*verb handlers, the Channels event handlersserver_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 — theDjustSSE*Viewget/postHTTP handlers, the owner-binding helpers, the SSE event-stream async generator),streaming.py(6 —StreamingMixin, withTYPE_CHECKINGhost-class attribute declarations), andwebsocket_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-binaryreceive()text frame).mypy python/djuststays 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). Seedocs/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 thatpy.typedexposes to downstream consumers are now mypy strict islands ([[tool.mypy.overrides]]withignore_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_viewdecorator + private-state helpers) and its PEP 561 stublive_view.pyi;components/base.py(theComponent+LiveComponentpublic bases — descriptor protocol, render waterfall, event-handler factory);decorators.py(@event_handler,@action,@server_function,@reactive,@state,@computed,@optimistic,@background+ their nested wrappers/descriptors); andforms.py(FormMixin+LiveViewForm).mypy python/djuststays 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). Seedocs/adr/023-incremental-type-enforcement.md.Enforced incremental type-checking — a mypy merge gate + strict islands + the
_rust.pyiboundary (ADR-023). djust shipspy.typed(PEP 561 — downstream consumers type-check against djust's hints), andpyproject.tomldeclared a strict[tool.mypy]config — but mypy was invoked nowhere (CI / Makefile / pre-commit), so the strict config was dead andmypy python/djustreported 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]]withignore_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), plusrate_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-errormypystep in thepython-testsCI job (a new MERGE GATE — #1236 governance — wired into thetest-summaryAND-condition; ships gating because it is green by construction, per #1534), amake typechecktarget (inmake check), and a scoped pre-commit hook onpython/djust/**.py{,i}changes. The_rust.pyistub'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) sincepy.typedexposes it. Seedocs/adr/023-incremental-type-enforcement.md.Mount-spine parity nets + 6 real-
WebsocketCommunicatorflip 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.pycharacterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespokehandle_mountover a real channelsWebsocketCommunicator(each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (ause_actorsview renders an actor-backed mount frame, NOT the SSE refusal — Finding D),sticky_hold-before-mount-frame ORDERING vialive_redirect_mount(Finding B), Channelsgroup_addserver-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (asource="tick"frame arrives with no client event),optimistic_rules+upload_configson 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 nullsself.view_instancebefore re-mounting, and a naive flip that forgets to also resetruntime.view_instancewould silently no-op the re-mount sincedispatch_mountearly-returns whenview_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling.python/djust/tests/test_transport_behavioral_parity.pygrows 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_pendingsource pin) and extends_WS_ONLY_MARKERSwith 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"}) andhandle_mount/handle_mount_batchare 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 (viaengine.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 viaLIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases intest_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-023TYPE_CHECKINGstub idiom). The strict-mypy ratchet addedif 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'spy/ineffectual-statementflags 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'squery-filtersso it doesn't recur as the type-checking blocks grow. Also converted acast("Any", …)string forward-ref to a directcast(Any, …)incomponents/rust_handlers.pyso CodeQL sees theAnyimport 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.gotoflake on the BLOCKING browser-smoke gate (#1943). The blocking browser-smoke gate (#1869) intermittently red-barred unrelated PRs withPage.goto: Timeout 30000ms exceedednavigating to/demos/browser-smoke/: on a cold-cache run that compiles thedjust_componentsRust crate from scratch, the demo uvicorn server wasn't ready within the fixed 30spage.gotodeadline (it cleared on a warm-cache re-run — not a real break). The.github/actions/djust-playwright-serverreadiness 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/, usingcurl -fsSso a half-initialized app's 5xx does NOT count as ready — this warms the route (first-hit lazy import/compilation) DURING the bounded loop sopage.gotolands on an already-warm route instead of racing its own deadline.tests/playwright/test_browser_smoke.py'spage.gotoalso 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.logdump) 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-blockingplaywright-testsleg into its OWN dedicatedbrowser-smokeCI job (nocontinue-on-error) and wired into thetest-summaryaggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors thedemo-checksblocking-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-blockingplaywright-testsleg — 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 viawindow.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"inRUNTIME_OWNED_VERBS, soreceive()routes every WS mount frame through the singledispatch_message→dispatch_mountchokepoint, and the ~870-line bespokehandle_mountbody is DELETED — reduced to a THIN SHIM overdispatch_mount(mirroring the event flip #1907 andhandle_url_change). Phases 3.0-3.3a had already growndispatch_mountinto a functional superset (F22 view resolver,run_pre_mount_authpre-mount auth+tenant via_check_auth,on_mounthooks, session + signed-snapshot state restore, post-mount object-permission,handle_params, actor mount, no-arm mount wire version, thesticky_holdpre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) viaWSConsumerTransporthooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the_dispatch_runtime_ownedmount arm,disconnect, and thelive_redirectteardown all nullruntime.view_instanceBEFORE dispatch, so a reconnect /live_redirectre-mount is never silently no-op'd bydispatch_mount'sif view_instance is not Noneearly-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads backself.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_notifygroup_add, the periodictick_intervaltask, theuse_actorsflag, the real-scope_websocket_path/_websocket_query_stringstamps, the_sticky_auto_reattachedreset) is folded into the now-LIVE WSon_view_mountedtransport hook — madeasynctoawaitgroup_add— Finding B residual; (C) mount wire version via thenext_mount_versionhook (the no-arm consumer counter). The object-perm denial now closes the socket viafinalize_mount_auth(Finding E — the bespoke unconditionalclose(4403)had no runtime equivalent). Amount_batchbug the flip surfaced is also fixed:ViewRuntime._instantiate_viewfire-and-forgot its error frame viaasyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor tofailed[]); it now stashes the frame anddispatch_mountawait-sends it inside the correct_mount_onewindow.handle_mount_batch/_mount_onestay WS-only (the collector contract is unchanged;finalize_mount_authstill gates the redirect-verdict close onnot _mounting_in_batchper #291/#1780). Boundary pins updated to the post-flip reality: theRUNTIME_OWNED_VERBScontract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth/ object-perm /validated_host_from_scopeconverged ontoruntime.py),_WS_ONLY_MARKERS(group_add/channel_layer/tick_intervalmoved to the runtime hook), and thehandle_mountsource-grep pins (snapshot sign/unsign, skip-html,_ensure_tenant-before-restore,has_ids, mount-url validation, next-version) moved todispatch_mount; the fake consumers intest_sw_advanced.py/test_sw_advanced_flow.pygained a permissive_rate_limiterso they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes thelive_redirectre-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering theon_view_mountedfold makes thegroup_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 WShandle_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_VERBSis UNCHANGED ({"url_change", "event"}),handle_mount/handle_mount_batchare UNTOUCHED (websocket.pyhas no diff) — but the dormant hooks are now called bydispatch_mountat their WS-faithful positions (read offhandle_mount): (1)on_view_instantiated(view)right after instantiation (WS stamps_ws_consumer/_push_events_flush_callback/ observabilityregister_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 WSuse_actorsview now RENDERS through the actor system at the render step (verbatimhandle_mountordering — after auth +mount()+handle_params, html sent without strip/extract,websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount→ False, so the structureduse_actors is not supported over SSEenvelope 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 (WSconsumer._next_version()— establishes the baseline, does NOT armrequest_htmlrecovery so_recovery_htmlstaysNone; SSE returns the raw Rustrender_with_diff()version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to(html, rust_version=1)mirroringnext_client_versionso 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 ARMINGnext_client_versionthe event path uses. (4)on_mount_render_ready(view, html)(Finding B residual) runs after render, before the mount frame (WS sticky preservation + thesticky_holdframe emitted BEFORE the mount frame; SSE returnshtmlunchanged). (5)finalize_mount_auth(view, verdict)(Finding E) on the three auth-block verdicts (_check_authpermission_denied + redirect;dispatch_mountrun_on_mount_hooksredirect) — the runtime already sent the verdict frame + clearedview_instance, so the hook adds ONLY the transport-levelclose(4403)(WS unconditional for permission-denial, gated onnot _mounting_in_batchfor 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_mountis now a clean superset (Findings A/B prep) — the idempotency guard +view_instanceownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (theruntime.view_instancereset + read-back) only. New cases inTestRuntimeBasicMountParity,TestRuntimeActorMountParity,TestRuntimeNoArmVersionWiring,TestRuntimeAuthBlockFinalize,TestRuntimeStateRestoreParity(python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drivesdispatch_mountover a REALWSConsumerTransport(direct-call shim, NOT viaRUNTIME_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, thenext_mount_versionwiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins inpython/djust/tests/test_transport_mount_hooks_1915.pyare INVERTED to load-bearing WIRED pins (each hook is now referenced indispatch_mount/ the auth helper; SSEnext_mount_versionreturnsrust_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 theTransportprotocol (behavior-preserving no-op / refuse defaults),WSConsumerTransport(the real WS impl, each encapsulating the verbatim bespokehandle_mountlogic for its cited site), andSSESessionTransport(no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1)on_view_instantiated(view)— WS stampsview._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 (thedispatch_mountrefusal stays). (3)next_mount_version(html)— WS returnsconsumer._next_version(), the NO-ARM counterhandle_mountuses (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 fromnext_client_version, which arms for render-SEND frames), so_recovery_htmlstaysNoneafter 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_idssurvivor scan +_register_childre-registration) + thesticky_holdframe emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returninghtmlunchanged; SSE: returnshtmlunchanged (Finding B residual). (5)finalize_mount_auth(view, verdict)— WS: the transport-level socketclose(4403)the bespoke auth-finalization performs (websocket.py:2337-2401), GATED onnot consumer._mounting_in_batchfor 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_mountdoes NOT call any of these yet (Phase 3.3a wires them in) and the WS bespokehandle_mount/handle_mount_batchkeep doing all of this inline (untouched until the Phase 3.3b flip);RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no production diff. New cases inpython/djust/tests/test_transport_mount_hooks_1915.py(Test...MockTransport unit tests per hook + real-WebsocketCommunicatortests exercising the WS impls in isolation against a genuinely-mounted consumer —uses_actors_for_mountTrue for ause_actorsview,next_mount_versionreturns the consumer counter WITHOUT arming recovery,finalize_mount_authdoes NOT close when_mounting_in_batch=True) + DORMANT pins (dispatch_mountdoesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts;handle_mountstill does the work inline). All gate-off-verified (#1468): arming recovery innext_mount_versionreds the 3 no-arm tests, removing thenot _mounting_in_batchgate reds the in-batch tests, no-op'ingon_view_instantiatedreds the stamp test. The anti-drift_WS_ONLY_MARKERSpin (test_transport_behavioral_parity.py) dropscreate_session_actor/_find_sticky_slot_ids/register_view(no longer WS-only — the dormant WS hooks now reference them inruntime.py), mirroring the Phase-3.1state_snapshot_signedmove.ViewRuntime.dispatch_mountgrew the transport-agnostic mount STATE-RESTORE +on_mounthooks WebSockethandle_mounthas, 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 onenable_state_snapshot(#1552) so default views are unaffected: (1)run_on_mount_hooks(websocket.py:2383-2401) runs the registeredon_mounthooks after the pre-mount auth sequence + beforemount(); a hook that returns a redirect URL emits anavigateframe, clears the unmounted view, and aborts — transport-agnostically (no socketclose(); that belongs to the Phase 3.2/3.3afinalize_mount_authhook, 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 ofmount(). (3) Thehas_prerendered→skip_html_for_resumeresume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new_mounted_from_restoreframework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (theversionstill flows so patches stay in sync)._mounted_from_restoreis initialized inLiveView.__init__BEFORE the_framework_attrssnapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff,RUNTIME_OWNED_VERBS/handle_mount/handle_mount_batchare unchanged. The anti-drift_WS_ONLY_MARKERSpin dropsstate_snapshot_signed(no longer WS-only — now on the runtime too) and thelive_view.pysetattr-whitelist line numbers shift +11. New cases inpython/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); anon_mountredirect emits anavigateframe + aborts (RED when the redirect handling is gated off).ViewRuntime.dispatch_mountgrew the transport-agnostic mount behaviors WebSockethandle_mounthas, 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 ofhandle_mountover 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_kwargsstash (#1895,websocket.py:2596, placed aftermount()+ object-perm, beforehandle_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_baselinepost-mount (websocket.py:2598-2603); (3)has_prerendered→skip_html_for_resumemachinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the_mounted_from_restoreflag defaultsFalse, so HTML is always sent today); (4)optimistic_rules(DEP-002) +upload_configson the mount frame (websocket.py:2823-2834, via a new runtime_extract_optimistic_rulesmirror); (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_pendingthe 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 intest_handle_mount_drains_queues.py. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff andRUNTIME_OWNED_VERBSis unchanged. Every grow has a gate-off witness intest_transport_behavioral_parity.py(7/7 verified RED). New cases inTestMountStashAndBaselines,TestMountAsyncAndPushDrain,TestMountFrameOptimisticAndUpload,TestMountFrameWireVersion.THE FLIP: every WebSocket event now routes through
ViewRuntime.dispatch_event— the bespoke_handle_event_inneris 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 toRUNTIME_OWNED_VERBS(now{"url_change", "event"}), soreceive()routes every WS event through the singleViewRuntime.dispatch_messagechokepoint;handle_eventbecomes a thin shim overruntime.dispatch_event(mirroringhandle_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 newTransporthooks (SSE no-op):on_render_emittedcarries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the_emit_full_html_updatesignal on the no-patch render branch, andon_handler_timingcarries therecord_handler_timingpercentile telemetry;cache_request_idwas 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_navigationis nowawait-ed (was fire-and-forget) and (2) the skip-render branch now calls_flush_all_pending, so alive_redirect()/ navigation command queued by a state-unchanging handler still emits itsnavigationframe within the event turn (WS parity); and (3) the runtime's_dispatch_event_rendernow records a time-travel snapshot witherror="permission_denied"/"validation_failed"on the security-rejected + validation-rejected early-return paths (record_event_startmoved BEFORE the security check) — the bespoke_handle_event_innerrecorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught bytests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBScontract, 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. NewTestResidualFoldObservability(DJE-053 +record_handler_timingsurvival, with reason/version gate-off siblings) and aWebsocketCommunicatorregression forstart_async/@backgroundstreaming itssource="async"result over the runtime async path. Gate-off (#1468): removing"event"fromRUNTIME_OWNED_VERBSmakes all 11test_ws_event_flip_parity_1896behaviors fail withUnknown message type: event(the bespokeelifis 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.ViewRuntimeasync-result frames now carrysource="async", reconciling them with the WebSocket_run_async_workframes; and the deaduse_binaryframing 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) asyncsource="async"reconcile —ViewRuntime._render_async_result(thestart_async/@backgroundcompletion render shared by the success + error paths) emittedpatch/html_updateframes with NOsourcetag, while the WS_run_async_worktags all four of its framessource="async"(websocket.py:1166/1186/1223/1238). The client usessourceto distinguish an out-of-band background-completion update from the in-turnsource="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 stampsource="async". LIVE for SSE +url_changeasync work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirm —consumer.use_binaryis dead: initialized toFalseatwebsocket.py:580('MessagePack support TODO') and never setTrueanywhere in the package; the only honoring site is_send_update's binary branch (websocket.py:1391), whichWSConsumerTransport.senddoes NOT traverse (it callsconsumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test assertsWSConsumerTransport.sendemits JSON viasend_json(matching live WS), plus a source-grep pin that no production module assignsuse_binary = True. No change toRUNTIME_OWNED_VERBS/ WS routing; WS_handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b;websocket.pyhas no diff. New cases inTestAsyncSourceReconcile/TestBinaryFramingConfirm(python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (astart_asynccompletion frame carriessource="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing thesource="async"tag makes the SSE end-to-end + unit assertions RED.test_async_integration+test_sse_runtime_convergence_1887stay green.ViewRuntimegained 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 lackeddj_activitydeferral 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_innerdoes (websocket.py:3254-3273gate +4290-4294flush). Two parts: (1) Gate —ViewRuntime._dispatch_event_render(after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnosticActivityMixinview 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-blindActivityMixin._flush_deferred_activity_eventsas the_dispatch_single_eventprovider, somixins/activity.pyis UNCHANGED (the flush already accepts any object exposing that method). The newViewRuntime._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-enteringevent_context— it already runs inside the borrowed context (which on WS holds the consumer_render_lock; re-acquiring the non-reentrantasyncio.Lockwould deadlock, thewebsocket.py:1467contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route throughdispatch_eventsince Iter 1 (#1887), so SSE events now respectdj_activitydeferral (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_innergate/flush stays until Phase 2.3b;RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no diff. New suitepython/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 WSdj_activitybehavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), andtest_sse_runtime_convergence_1887stay green.ViewRuntimegained an actor-event transport hook (transport.uses_actors()+transport.dispatch_actor_event()) so ause_actorsview'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_eventhad NO actor branch, while theuse_actorsguard lived ONLY indispatch_mount(which refuses SSE outright). A WS view mounts in actor mode (use_actors=True+ a createdactor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hitdispatch_eventwith no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two newTransporthooks close the gap: (1)uses_actors(view)—WSConsumerTransportreturnsconsumer.use_actors and consumer.actor_handle is not None(the exact precondition of the bespoke WS actor block,websocket.py:3282),SSESessionTransportreturnsFalse(SSE has no bidirectional actor channel anddispatch_mountrefusesuse_actorsmounts,runtime.py:602); (2)dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)—WSConsumerTransportruns the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in afinally, the shared_validate_event_security+validate_handler_paramschecks,actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire versionconsumer._next_version()— the actor's internalresult['version']is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush),SSESessionTransportraisesNotImplementedError(never called —uses_actorsisFalse). Wired into_dispatch_event_innerBEFOREevent_context(the actor block holds no render lock, matching WS), gated onuses_actors(view)AND the event NOT being routed to a sticky child — the WSnot is_embedded_child_targetmutual exclusion (websocket.py:3280-3282); per #1467 acomponent_idevent does NOT reassign the target view and the WS actor block has no component handling, so acomponent_idevent on ause_actorsview goes through the actor (parity), and only aview_idresolving to a DIFFERENT child excludes it (_event_routes_to_sticky_childpeeks atview_idWITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change:uses_actorsisFalsefor 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_inneractor block are UNTOUCHED (they stay until 2.3b);websocket.pyhas no diff. New direct-runtime suitepython/djust/tests/test_transport_actor_event_1901.py(12 cases) builds aWSConsumerTransportover a fake consumer withuse_actors=True+ a fakeactor_handleand assertsdispatch_eventroutes todispatch_actor_event(the actor's.event()is called + the framed result is sent via_send_updatewith the consumer-owned wire version, NOT the in-process handler),uses_actorsFalse for SSE + a WS consumer withoutactor_handle, aview_id-routed event skips the actor while aview_id-equals-top event still routes to it, and the SSEdispatch_actor_eventraises; gate-off verified (#1468) — forcinguses_actorsto always returnFalsemakes 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_contextsuite stay green.ViewRuntimenow BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a newtransport.event_context()hook, and the dead runtime-local_render_lockis deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold thedj_activityre-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1)ViewRuntime._render_lockwas 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_notifyrender 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-CMtransport.event_context(view)on theTransportprotocol + both adapters lets the runtime borrow the consumer's EXISTING lock:WSConsumerTransport.event_contexton enter mirrors_handle_event_innerverbatim —await consumer._render_lock.acquire()(the existing object, not a new one),_processing_user_event = True, set the #1677 origin-channel contextvar toconsumer.channel_name, start aPerformanceTracker+ the SQLcapture_for_eventscope (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_contextis 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_inneris extracted into_dispatch_event_renderand run insideasync 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_innerare UNTOUCHED, anddispatch_url_change/_dispatch_url_change_innerare 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_changeis unaffected. New direct-runtime suitepython/djust/tests/test_transport_event_context_1899.pyasserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception),_processing_user_eventTrue-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op;ViewRuntimeno 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-opevent_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.
ViewRuntimenow records + persists per-event state the way the bespoke WS_handle_event_innerdoes, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel record —record_event_start/record_event_endwrap 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 afinallyso a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466 —ViewRuntime._persist_state_after_eventmirrors the WS save (private attrs first, then publicget_context_data(), then components), gated on top-level-view identity ANDenable_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 150msasyncio.wait_for(#1475); (3) sticky-child state-save ADR-018 —ViewRuntime._persist_sticky_child_after_eventpersists aview_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 hookon_event_recorded(view, snapshot)replaces the WS_maybe_push_tt_eventdirect send:WSConsumerTransportdelegates to the consumer's existing_maybe_push_tt_event(single-sourcing the DEBUG-gatedtime_travel_eventframe),SSESessionTransportno-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 inwebsocket.pyis UNTOUCHED (the #1466/#1552 grep-pins intest_ws_reconnect_state_1465.py:119/313/320stay green;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suitepython/djust/tests/test_runtime_state_save_tt_1894.py(12 cases) drivesruntime.dispatch_eventagainst a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing theenable_state_snapshotgate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes theon_event_recordedassertion 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_idLiveComponent,view_idsticky-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_innernow routes embedded children before the single-view path, mirroring the bespoke WS_handle_event_innersubsystems the runtime previously lacked entirely: (1) aview_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 scopedembedded_update {view_id, html, event_name}frame — the client-suppliedview_idis never echoed into the user-facing error (sanitize_for_login the structuredextraonly, verbatim from WS); (2) acomponent_id-targeted event resolves a child LiveComponent via_components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters withcomponent_idinjected (ADR-002), and emits a parent-scoped full-HTMLcomponent_eventframe — 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-levelwebsocket.render_embedded_child_html, the WS_render_embedded_childis 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_innerrouting is untouched (WS events still flow through it;eventstays out ofRUNTIME_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 suitepython/djust/tests/test_runtime_child_routing_1892.pydrivesruntime.dispatch_eventagainst 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 —
refecho,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_innerhas but the runtime lacked: (1) the clientref(#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carriessource="event"+event_nameand the update frames carrysource="event"for the client's #560 response-sequencing; (3) a handler that sets_force_full_htmlnow defeats the auto-skip and sends a fullhtml_update(patches discarded, flag consumed), mirroringwebsocket.py:4039-4040; (4)_notify_waiters(ADR-002 Phase 1b) runs after the handler sowait_for_eventfutures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (theid()-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 consumers —websocket.pyis untouched (WS events still use_handle_event_inner;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560ref/sourcefields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho,TestEventSpineForceFullHtml,TestEventSpineNotifyWaiters,TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) inpython/djust/tests/test_transport_behavioral_parity.pyso a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParityinpython/djust/tests/test_sse_runtime_convergence_1887.py, driving the/message/endpoint which forwards the fullref-carrying envelope); and an extendedRUNTIME_OWNED_VERBScontract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_ownedinpython/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 andViewRuntimepaths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch throughsession.runtime.dispatch_mount/dispatch_event— the SAME spine the SSE/message/endpoint and the WSurl_changeshim 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 streampatch/html_updateframes, object-permission denial still blocks the mount (now viadispatch_mount's Iter-0 check), andstart_async/@backgroundwork still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop ofstart_asyncnamed-task work, since the legacy path only dispatched the never-set_async_pendingformat). SSE-specific behavior is preserved via two newSSESessionTransporthooks:build_request()(the runtime mounts against the real HTTP request, not a synthesized userless one) andon_view_mounted()(stamps_sse_session_id/_sse_session/session.view_instance). Nowebsocket.pychanges (WS convergence is Iter 2/3). New end-to-end integration suitepython/djust/tests/test_sse_runtime_convergence_1887.py(mount / event / object-perm /start_asyncvia 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_diffreorders re-render only changed items, flag-gated default-OFF (#1967).Node::Forin 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 onRustLiveViewthat survives acrossrender_with_diffcalls) 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.priceresolve under loop varx→ allowed;prefix/flag→ non-cacheable; tuple-unpackingfor k, vallows bothkandv); 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 %}), anddj-keytemplates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable viaLIVEVIEW_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. NewTestOutputIdentity/TestCacheBehavior/TestLoopRenderCacheDefaults/TestOuterContextNonCacheableclasses inpython/djust/tests/test_loop_render_cache_1967.py(13 end-to-end viaRustLiveView.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-endrender_with_diffwin 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% ofrender_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 → parsedVec<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), theNode::Forarm 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_diffthen 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 assignsnext_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 (0for an initialparse_html,max(old_ids)+1for a continuingparse_html_continueafter 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), andlast_vdomare 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 residualdj-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 craftedh=(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.attrsnow serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plainHashMapserializes 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 BOTHrender_with_diffandrender_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 inTestParseCacheByteIdentity1970/TestParseCountProbe1970/TestParseCacheSentinelCollision1970(python/djust/tests/test_loop_render_cache_1967.py), theparse_cache_1970module incrates/djust_templates/tests/test_loop_render_cache_1967.rs, andcrates/djust_vdom/tests/test_loop_parse_cache_1970.rs(literal_unnonced_dj_pc_is_not_spliced+ the bare-prefix gate-off).
Fixed
dj-virtualnow ships a real layout contract and self-heals across server-driven re-renders — the windowed list scrolls inside adisplay:flexcontainer and survives a live-changing{% for %}source (#1988, #1989). Two entangleddj-virtualgotchas hit in downstream production chat/feed builds. (#1988 — layout)setup()gave the injected shellposition: 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 noflex-shrink, so inside adisplay:flexcontainer its explicitstyle.heightwas crushed tooffsetHeight: 0(defaultflex-shrink: 1) and the list silently never scrolled. The shell is nowposition: 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 isflex-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 andinitVirtualListsno-op'd forever (it tracks setup state in aWeakMapkeyed on the container, whose identity is unchanged) — permanent no-op, recoverable only by manualteardownVirtualList+ 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/refreshVirtualListDETECT 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-leveldj-virtualawareness, out-of-window finalize-patch landing, and automaticstream_append→__djVirtualItemswiring are deferred to follow-up #2017. Client-only change in29-virtual-list.js(+231 B gzipped). 5 new regression cases intests/js/virtual_list.test.js(shell/spacer style contract; full-revert self-heal via the reinit path AND viarefreshVirtualListalone; 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, spaceroffsetHeightunder 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_charswas a sync@backgroundloop doingself.llm_output += ch;_run_async_workawaits 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 anasync defthat pushes each token withawait self.stream_to(..., html=render_markdown(...)), bracketed withstream_start/stream_done+ afinallysettle; the target<article>now carriesdj-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()calledcancel_async("md_stream")but@backgroundregistered the task underfunc.__name__=="_stream_chars", a silent no-op; and a sync@backgroundloop can't be interrupted mid-run. Now scheduled viastart_async(self._stream_chars, name="md_stream")(names match) and async (the loop yields between tokens soresetflipsstreaming=Falsemid-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 executorruntime.py:ViewRuntime._execute_async_task(post ADR-022 convergence, not thewebsocket.py:_run_async_workthe issues cite) unconditionally wrapped callbacks insync_to_async, raisingTypeErrorfor an async callback — so async@background/start_asyncsilently failed on the converged path. Mirrored the consumer twin'siscoroutinefunctioncheck (await async callbacks directly). Docs:streaming-markdown.md's example rewritten to explicit per-chunkstream_*+ cross-linked tostreaming.md; both guides now document thedj-update="ignore"rule for streamed targets and thestream_to()-without-html=full-template-re-render caveat. Tests intest_markdown_stream_demo_2001_2002.pydrive a realWebsocketCommunicator+ runtime path (#1650): the fixed pattern emits >1 content stream op, an in-suite gate-off sibling proves plain mutation emits 0, plusdj-update=ignoreexclusion,cancel_asyncname-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). Adj-window-keydown.escape="close"(or anydj-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 rescandj-shortcut/dj-click-awayalready do), while the window/documentaddEventListenerinstall stays one-shot inside_installScopedDelegation()— so no duplicate listeners accumulate, and the per-elementalreadyRegisteredcheck prevents double-registration / double-fire. Moving the scan out of the one-shot install made the bundle 25 B smaller gzipped. 5 JS tests intests/js/dj-window-rescan-1996.test.js(patch-in reproducer,dj-document-keydownpatch-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 namemorphNode) skipped the server value sync for a focused input/select/textarea unless itsnamechanged, 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-indj-force-valueattribute 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). Everypush_to_viewbroadcast unconditionally reset every<textarea>.valuein 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 markeddj-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'sapplyPatchespath and12-vdom-patch.js'spreserveFormValuesinnerHTML path — route through one sharedsyncBroadcastTextareashelper, so the opt-out lives in exactly one place (parallel-path-drift cure, #1646). +50 B gzipped; 11 JSDOM cases intests/js/form_value_preservation_1990_1991.test.js(direct helper, the real production broadcast path viahandleServerResponse, an anti-drift pin that both sweep sites route through the helper, and INPUT/TEXTAREA focus cases using realdocument.activeElement), gate-off verified (#1468) on both fixes. Documented indocs/website/guides/declarative-ux-attrs.md.dj-input.debounce-N(and any.lazy/.debouncesuffix on a non-dj-modeldirective) now warns in debug mode instead of silently never binding (#1999). Onlydj-modelparses the.lazy/.debounce-Nin-name modifier from its attribute name;dj-input/dj-change/dj-clickdebounce via the separate standalonedj-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 (thedj-model.debounce-300-works mental model made it read as a plain non-working feature).bindLiveViewEventsnow runs a debug-gated scan (_warnUnrecognizedDjModifiers, zero cost outsidewindow.djustDebug) that emits aconsole.warnnaming the offending attribute and the standalone-dj-debouncefix. Deliberately scoped to the.lazy/.debouncemodifiers on non-modeldirectives — other legit dotted conventions (dj-keydown.enter,dj-window-keydown.escape,dj-loading.class/.show/.hide/.disable/.for) are untouched. Thedj-modelguide now documents the divergence side-by-side. +315 B gzipped; 7 JS tests intests/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 thesessiontenant resolver: (1)SessionResolver.resolve()is read-only — it never writesrequest.session, and there was noset_tenanthelper anywhere, so switching tenant over a WS event meant hand-writingrequest.session[...]with no built-in save guarantee (a LiveView event has no HTTP response for Django'sSessionMiddlewareto persist against); and (2)TENANT_REQUIRED(defaultTrue) is enforced indispatch()/get()/post()beforemount(), so a fresh session 404s beforemount()can resolve a default. AddsTenantMixin.set_tenant(tenant_id)(inherited byTenantScopedMixin): it updates the authoritative in-memory view state (self.tenant) and best-effort mirrors the id intorequest.session[TENANT_SESSION_KEY]only when a session resolver is configured (a no-op for subdomain/path/header/custom). TheSessionResolverdocstring + the Multi-Tenant guide now document that view state is the WS-lifecycle source of truth, the session write is a mirror only, and thattenant_required=False+ manual resolution inmount()is the correct pattern when a fresh session has no tenant yet. 12 cases inTestSetTenant/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 plaindictafter 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(...)inmount()) is persisted to the session so it survives the HTTP-POST-fallback restore path — which does NOT re-runmount(). That path rannormalize_django_valueover private state (the client-facing serializer), turning the model into the lossy{"pk", "__str__", <fields>}dict, so on restoreself._workspacewas adictandself._workspace.membershipsraisedAttributeError(the reported traceback atmixins/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 toNonewith a warning (a stale cached model must not hard-crash a reconnect). 5 tests intest_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 isp.meta.name/presence['meta']['name']. Following the docstring verbatim produced aKeyError/ silently-empty output. Docstring corrected + the record shape documented (#2000). (b){% djust_markdown %}requiresDjustTemplateBackend(it registers only with djust's Rust engine, not Django's stock backend); a plaindjango-admin startprojectTEMPLATESsetup raisesTemplateSyntaxError: 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 fordj-transition) is now repeated in thedj-transition-groupquick-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_CONFIGare 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 readLIVEVIEW_CONFIG, so amax_message_size/rate_limit/event_securityset in the similarly-namedDJUST_CONFIGdict (which already backs tenancy/presence/state-backend, and is easy to confuse withLIVEVIEW_CONFIG) was a silent no-op — e.g. raising the limit viaDJUST_CONFIG = {"max_message_size": 262144}did nothing, no error, no warning. It now falls back toDJUST_CONFIGfor keys that are genuine LiveView config keys (present in the defaults, so unrelated tenancy/presence keys aren't pulled in), withLIVEVIEW_CONFIGwinning on a collision and a debug breadcrumb naming each adopted key. The misleading comment that impliedDJUST_CONFIGwas already handled here is corrected. (2) The upload client'sDEFAULT_CHUNK_SIZEwas64 * 1024— exactly themax_message_sizedefault (65536) — and every chunk frame prepends a 21-byte binary header (buildFrame), so65536 + 21 = 65557 > 65536: a brand-new project using onlyallow_upload(...)with default settings failed any upload past a fractional first chunk withMessage too large (65557 bytes). Reduced to63 * 1024(64512 payload + 21 header = 64533 < 65536). 4 Python config tests (gate-off verified) + a JS source-invariant pin intests/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 — whereinside_unclosed_fencecorrectly 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 publicself.*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 thoughget_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 onselfchanged, just re-render". Callingset_changed_keys()with no arguments now forces a full re-render via the existing_force_full_htmlbypass without naming a key. 4 tests intest_set_changed_keys_zero_arg_1992.pyon the REALViewRuntime.dispatch_eventpath (the test client bypasses thepre==postskip 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-redisextras (and the dev group) now pinredis>=5.0.0,<8— redis-py 8.x crashes the canonicalchannels_redisproduction setup (#1995).channels_redis's receive loop blocks onbzpopmin(timeout=5); redis-py 8.0 changed socket-read-timeout handling on that path, and the resultingredis.exceptions.TimeoutErroris uncaught inside the ASGI consumer — so a djust deployment following the docs verbatim (channels_redisforCHANNEL_LAYERS, required forpush_to_view/presence/cursor/cross-process) shows a flashing "reconnecting" banner every few seconds under multi-process load. The constraint was>=5.0.0,<9in all three sites (pyproject.toml[redis]+[tenants-redis]extras + dev group), too permissive — it allowed 8.x. Pinned<8(verified:redis>=5,<8gives 0 errors in a soak that previously failed within seconds). Defensive tightening — the lock already resolved to 6.4.0, but<9let a future re-lock drift to the crashing 8.x.Nested dict/list access in templates (
{{ block.content.text }}on aJSONField) no longer renders silently empty — Django_resolve_lookupparity (#1997).Context::resolve's lazy sidecar getattr walk (crates/djust_core/src/context.rs) didgetattrONLY at each segment, so a dict/list intermediate reached mid-path resolved to empty with no error: for{{ block.content.text }}wherecontentis aJSONField(a plain dict),getattr(dict, "text")raisesAttributeErrorand was swallowed → missing output, zero signal. Django'sVariable._resolve_lookuptries dict item access → attribute → integer list-index at every segment; djust only did the middle step on the sidecar path (the eagerContext::getpath was already dict-aware — #1646 parallel-path drift). The walk now mirrors Django's order (get_item→getattr→get_item(int)). The #1986 serialization-floor proxies implement no__getitem__, so item access on them falls through to the flooredgetattr— verified no floor bypass. Newtest_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
#1678kanban fixture's card-move step was a vacuous guard (#1981). The_snapshot_assignslist-≥100 and dict-≥50 fingerprint-truncation warnings told developers to callself.set_changed_keys({...})— a method that did not exist — and the docstring pointed toself._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#1678client-faithful VDOM fixture's step 2 (cross-column card move) captured 0 patches becauseKanbanTabsView.move_cardmutatedcolumnsin place — a regression guard that exercised nothing.move_cardnow 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>) — CodeQLpy/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</tagfollowed 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). NewTestEndTagWhitespacePreservation(whitespace + newline + bogus-attribute cases, gate-off verified) intest_strip_whitespace.py._run_async_workno longer writes against a stale view on disconnect/re-mount mid-await (#1940).LiveViewConsumer._run_async_workruns as a detachedensure_futuretask that capturesview = self.view_instancebefore its firstawait(the background callback). If adisconnect(which nullsview_instance) or alive_redirect/ re-mount (which reassignsview_instanceto a NEW view) interleaved during that await window, the completed task ran itshandle_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_asyncruns 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 inTestRunAsyncWorkTeardown(python/djust/tests/test_run_async_work_teardown_1940.py), gate-off verified.TutorialMixinnow initializes its four internal tutorial-signal attrs in__init__, not thetutorial_total_stepssetter (#1952)._tutorial_active_target,_tutorial_active_class,_tutorial_skip_signal, and_tutorial_cancel_signalwere previously initialized inside thetutorial_total_stepsSETTER, so aTutorialMixinview that never settutorial_total_steps(or read those attrs before the setter ran) hitAttributeError— e.g._cleanup_active_step()(called fromstart_tutorial'sfinallyblock) reads_tutorial_active_target/_class, andskip_tutorial/cancel_tutorialread the skip/cancel signals once running. The four attrs now default toNonein__init__(placed alongside the existing_tutorial_running/_tutorial_current_step/_tutorial_total_stepsinits); 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 inTestSignalAttrsInitializedInInit(read the four signals without invoking the setter; pre-fix raisedAttributeError).ComponentMixin.update_componentno longer raisesAttributeErroron aLiveComponent(#1947).update_component(component_id, **props)callscomponent.update(**props), butLiveComponent(a subclass ofContextProviderMixin, NOTComponent) had noupdate()method at runtime, so anyLiveComponentthat did not define its ownupdate()raisedAttributeErroron that path (the latent bug annotated with a# type: ignore[attr-defined]in ADR-023 M4c, part 1).LiveComponentnow has a baseupdate(**kwargs)that sets each prop as an instance attribute (mirroring the Python/hybrid path ofComponent.update) and returnsselffor chaining — the samecomponent.update(**props)API the component docs already document. Subclassupdate()overrides still take precedence. The stale# type: ignore[attr-defined]at the call site is removed. Regression coverage inTestUpdateComponentNoUpdateOverride(tests/unit/test_component_parent_communication.py): bare-LiveComponent update throughupdate_component, base-update()chaining returns self, subclass-override-still-wins.Dev-env: detect + recover the
core.bare = trueshared-config corruption that breaks worktree + main checkout (#1938). A linkedgit worktreeshares one.git/configwith the main checkout; if anything flipscore.baretotruethere (a build/PyO3-repoint step runninggit config core.bare true, an IDE/GitKraken integration, or a stray manual command — the #1804/#300 pattern),git status/git pushbreak in BOTH trees (every tracked file shows as deleted). An exhaustive audit confirmed no djust pre-push hook, test, or script writescore.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'sgit initfixtures), and all three were verified empirically to leavecore.bareunchanged — so the corruption is external, not a framework bug. Newscripts/check-shared-git-config.shreadscore.barefrom the SHARED config (resolved via--git-common-dir, works from any worktree), reports a leak (exit 1), and with--fixperforms the documented recovery (core.bare false); it NEVER writescore.bare true. The worktree-subagent mitigation (push--no-verify; CI is the authoritative gate) plus the detector are documented in CONTRIBUTING.md "Working in agit worktree". Tested by 5 cases intests/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_iterwas annotatedAsyncIterator[str]but yields theChunkEmitter'sbyteschunks (the emitterencode("utf-8")s every chunk before queueing, andStreamingHttpResponseis fed bytes) — the strict typing ofhttp_streaming.ChunkEmitter._aiter_impl() -> AsyncIterator[bytes]exposed the mismatch in the (already-strict M4c)mixins/request.py; corrected toAsyncIterator[bytes]. Other gaps (annotation-only, no behavior change):checks/security.pycheck_security's S002@csrf_exemptscan readnode.body[0].value.value(anast.Constant.value, astr | bytes | int | …union) and called.lower()on it — guarded withisinstance(doc, str)so a non-str first-statement constant can'tAttributeError(it never matched"csrf"anyway);_decorator_callable_nametypedOptional[str]so the_is_permission_required_decoratorcomparison stops leakingAny.auth/core.pycheck_view_auth'slogin_url(agetattr(...) or getattr(...)over unstubbed Django)casttostrfor the_check_django_access_mixins(login_url: str)contract;check_redis(djust_doctor) returnsOptional[_CheckResult](it returnsNoneto skip the non-Redis path). Plusbool(...)/str(...)/cast(...)boundary narrowing at the Django-untyped surface (user.has_perms(...),apps.is_installed(...),self.style.SUCCESS(...),json.loads(...),template.render(...),click.prompt(...)) anddict[str, Any]/list[CheckMessage]var annotations where mixed-type literals were inferred too narrowly.cleanup_liveview_sessionsimports the session helpers from their canonical source (djust.session_utils) instead of thelive_viewre-export so the strict island resolves them (equivalently exported vialive_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.packwas annotatedstrwith aNonedefault (a dataclass field lying about nullability —get_state()returnspack=Nonewhen no pack is configured), which also made theThemeState(pack=pack)construction an[arg-type]error againststr | None; corrected tostr | None = None.theming/_registry_accessor.py: theThemeRegistrysingleton's_presets/_themes/_packs/_manifests/_discoveredwere assigned only through a localinstin__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.packwereDesignSystem | None/ThemePack | None(theget_design_system/get_theme_packreturn type) but__init__raises when None, so every laterself.ds.typography/self.pack.icon_styleaccess was aunion-attrerror (14 in pack, 8 in theme) — narrowed by assigning the post-raisenon-None value to aself.ds: DesignSystem/self.pack: ThemePackannotated attr.theming/manager.py: the twoCompleteThemeCSSGeneratorreassignments (ingenerate_critical/deferred_css_for_state) collided with the innerThemePackCSSGeneratorgenvar's inferred type ([assignment]+ a phantom[attr-defined]ongenerate_critical_css) — renamed the inner varpack_gen.theming/palette.py:s_h/a_hmixedint(fromhex_to_hsl) andfloat(fromparams[hue_offset] % 360, where_MODE_PARAMSis inferreddict[str, float]because it mixessat_scale=0.85with integer hue offsets) — wrapped the (always-integer) hue offsets inint(...)to keeps_h/a_hint(int(180) % 360is identical).theming/build_themes.py:build_alldeclared-> Dict[str, str]butartifacts["individual_themes"]is alist[str]— the return type lied about the heterogeneous shape; corrected toDict[str, Any](+ themanifest/artifactslocals annotated).theming/accessibility.py: afloat ** floatreturnedAny([no-any-return]) and abool-orchain over unstubbed-attr comparisons returnedAny— wrapped infloat(...)/bool(...).theming/mixins.py: the conditional-importevent_handlerfallback was an unguarded[no-redef](narrow# type: ignore[no-redef]),_theme_managerwasThemeManagerwith aNonedefault (corrected toThemeManager | None), and the four event handlers gainedif self._theme_manager is None: returnguards so the_theme_manager.set_mode(...)accesses type-check (no-ops post-mount, matching_setup_theme_context's existing guard). Plus severalmark_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_passwordhad implicit-Optional defaults (field: str = None) that PEP 484 prohibits — corrected toOptional[str]; andModelCreateView.mountoverrodeModelDetailView.mount(self, request, object_id=None, ...)with a narrowermount(self, request, **kwargs)signature (an LSP[override]violation) — restored theobject_idparameter (still forced toNoneinternally, so the create view's "always start with no object" behavior is unchanged) so the override is contract-compatible.admin_ext/options.py: severalwarn_return_anyleaks at the Django-untyped boundary narrowed at the return site —_widget_has_permissionreturnsbool(user.has_perms(...)),get_formpinsmodelform_factory(...)to a typed local, andget_field_display_namewraps theverbose_name/short_descriptionreads instr(...).admin_ext/plugins.py:NavItem.has_permission/AdminWidget.has_permission/AdminWidget.rendersimilarly narrowed (bool(request.user.has_perm(...)),str(render_to_string(...))).admin_ext/__init__.pyautodiscoverandadmin_ext/apps.pyDjustAdminConfig.readygained explicit-> Nonereturns.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_componentcallscomponent.update(**props)afterisinstance(component, LiveComponent), butLiveComponent(which subclassesContextProviderMixin, NOTComponent) has noupdatemethod at runtime — confirmed via the live MRO (Component.updateexists;LiveComponent.updatedoes not). Soupdate_component()would raiseAttributeErrorif ever invoked with aLiveComponent. The strict flip carries a narrow# type: ignore[attr-defined]with a comment at the call site; the fix (moveupdateontoLiveComponent, or change the routing) is left for a dedicated bugfix PR sincemixins/M4c(1) is annotation-only. Other gaps (annotation-only, no behavior change):mixins/page_metadata.py_pending_page_metadata/_drain_page_metadatawereList[Dict](incomplete generic) →List[Dict[str, str]].mixins/model_binding.pyallowed_model_fieldsclass attr was inferredNone(from= None) → annotatedOptional[List[str]](the true subclass-override contract);_dj_model_fieldsbarefrozenset→frozenset[str].mixins/rust_bridge.pyrendered_context = {}was inferredDict[str, dict[str, Any]]from its first (dict-valued) assignment, breaking later primitive/str assignments → annotatedDict[str, Any](no logic change; the change-detection path is byte-identical).mixins/template.pypos = open_pos + 4mixed thefloat("inf")sentinel into anintaccumulator → narrowedint(open_pos)in the branch whereopen_pos < close_posguarantees a real int;_current_html_size/_previous_html_sizedeclaredOptional[int]to match thegetattr(..., None)first-render seed.mixins/jit.py_variable_extraction_cachewasDict[str, dict]but storesOptional[dict]→Dict[str, Optional[dict]]; theif not extract_template_variablestruthy-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 builtinanyfunction used as a type — a typo) corrected toDict[str, Any].components/ui/modal_simple.py:Modal._render_customreadself.showbut__init__never assigned it (theshow=kwarg was passed tosuper().__init__but not re-set as an instance attr like its siblingsbody/title/etc.) —[attr-defined]against theRustModal-instance path; addedself.show = showto 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.templatereads were typedOptional[str](the baseComponent.template: Optional[str]class attr) while the subclass always sets astr— narrowed via atemplate = self.template or ""local at the top of_render_custom.components/ui/navbar_simple.py: the nav-itemsparam was annotatedList[Dict[str, Union[str, bool, List[...]]]]which mis-typed the nested-dropdownaccess as non-iterable (union-attron.get/__iter__) — widened toList[Dict[str, Any]](the honest contract for heterogeneous dynamically-accessed dicts, #1108). Float/int local-init mismatches in chart/heatmap/pivot renderers (total = 0→0.0,y = ...→y: float = ...,row_total/col_totals/grand_total→ float) where a numeric accumulator was seededintthen+='d a float (output via:.1f/_format_valis identical).components/gallery/registry.py:cat = info.get("category", "misc")wasobject-typed (from the heterogeneous EXAMPLES literal) socat.title()wasattr-defined/call-overload— coercedcat = str(...)(category is always a str). Numerous list/dict locals across data_table/templatetags annotated to fix mixed-elementvar-annotated(e.g.pages: listmixing 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 nestedState.active) was annotatedstr, but inmultiple=Truemode it holds a list of open item ids — soactives.remove(value)/actives.append(value)/state.active = [value]were[attr-defined]/[assignment]errors against the declaredstr. Corrected toUnion[str, List[str]](the true runtime contract — single id when single, list when multiple), narrowing the list branch withcast(List[str], inst.active)(mixin, guarded byinst.multiple) / the existingisinstance(actives, list)(descriptor). The 8 deprecated state mixins (tooltip/tabs/sheet/modal/dropdown/collapsible/carousel/accordion) hadcomponent_id = self._resolve_component_id(component_id)reassign anOptional[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 theinst is Noneguard). Typed all*_instancesclass vars (Optional[Dict[str, XState]]) and the descriptor_handle_event(self, state: "State", ...)params (the nested-State-subclass forward-ref, not the baseTypedState, sostate.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_toastcallsself.push_event(...), a method supplied by the hostLiveView(viaPushEventsMixin) and absent from the standalone mixin — mypy flagged[attr-defined]; declared the cooperating method underif TYPE_CHECKING:(the canonical djust mixin pattern, mirrorsstreaming.py), no runtime change.components/function_component.py:{% call %}dispatch setinstance._slots/instance._childrenon aLiveComponent(per-invocation template-render attrs, distinct from the class-levelslotsdeclaration list) — narrow# type: ignore[attr-defined]with an explanatory comment; the@componentdecorator's_djust_*metadata stamps on a plainCallablelikewise narrowed.components/presets.py:_BUTTON_PRESETS(heterogeneousstr/boolvalues) was inferreddict[str, object], making the built-inregister_preset(...)registration loop an[arg-type]error — annotatedDict[str, Dict[str, Any]].components/mixins/base.py:TypedState.__init__calleddefault.fget(self)whereproperty.fgetisOptional— added thefget is not Noneguard (behavior-preserving for every real_make_property-built property).components/utils.py+components/icons.py+components/suspense.py+components/templatetags/_registry.py: severalAny-leaks at Django boundaries (col.get(...)/value.strftime(...)/mark_safe(...)/conditional_escape(...)/render_to_string(...)) narrowed tostrat the boundary sowarn_return_anyis satisfied without anAnyescape.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_moduleswas annotatedDict[str, str]but everyregister()stores a nested{"module": ..., "export": ...}dict — the field annotation contradicted the (correct) return types ofget_module_info()/get_all_modules(); corrected toDict[str, Dict[str, str]].presence.py:broadcast_to_presence(event, payload: Dict[str, Any] = None)declared a non-Optionalparam with aNonedefault (the body already coalescespayload = {}) — corrected toOptional[Dict[str, Any]] = None.testing.py:assert_routed_views_allowedimported_routed_liveview_classesfromdjust.checks, where it is not re-exported (it lives indjust.checks.components) — corrected to import from the defining submodule (verified importable).performance.py:PerformanceTracker.root_node/current_nodewere inferredNone-only from__init__then reassignedTimingNode— declaredOptional[TimingNode], and the_find_parent_nodecall now guardsroot_node(was guarding onlycurrent_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._requestwas assigned (DjustSSEStreamView.get) and read (runtime.SSESessionTransport.build_request) but never declared in__init__— added theOptional[Any]declaration alongside_event_request.runtime.py:_instantiate_error_framewas first-assignedNonethen a dict (mypy inferredNone-only, so the dict assignments were errors) — declaredOptional[Dict[str, Any]]in__init__;_instantiate_viewcalledOptional[type]()("None not callable") — added theViewResolution.__bool__-impliedview_class is Noneguard; the dormant actor-mount path calledOptional[create_session_actor]— added the actor-availability guard.websocket.py:_recovery_htmlwasstr-typed from its first assignment but cleared toNoneon one-time use — annotatedOptional[str].live_view.pyi: the M2-island stub omitted the module-level_FRAMEWORK_INTERNAL_ATTRSthatwebsocket._snapshot_assignsimports — added it (a stub-completeness gap the M3 flip surfaced because websocket now resolves the import against the strict stub). SeveralAny-leaks at Django/PyO3 boundaries narrowed at the boundary (bool()/str()/int()wraps onvalidate_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_ATTRIBUTESwas annotatedSet[str](mutable) but holds afrozenset— the annotation lied about mutability for a membership-only, never-mutated security denylist; corrected tofrozenset[str], matching the immutable-denylist intent.security/log_sanitizer.py:sanitize_dict_for_log'sresultdict holds heterogeneous values (redacted strings, nested sanitized dicts, sanitized item lists) under an inferreddict[str, str]— annotateddict[str, Any].security/state_snapshot.py(sign_snapshot) andpermissions.py(dump_starter_document):[no-any-return]from untyped-dependency calls (TimestampSigner.sign,yaml.safe_dump) narrowed tostrat the boundary. Plus annotation gaps closed inrate_limit,_context_provider,schema,permissions, andtest_isolation(missing return/param annotations +var-annotatedhints). 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_handlerbare 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@overloadso both forms type correctly for downstream consumers.live_view._is_serializable:_non_serializablewas 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 — annotatedtuple[type, ...].live_view.pyi: thestreamstub was missing thelimitparam present onStreamsMixin.stream(stub-vs-source signature drift, the #1646 class) — added.mixins/handlers.py:_handler_metadatahad no base annotation, so its inferred non-optionaldictconflicted withLiveView.__init__'sNoneinit — annotatedOptional[Dict[str, Dict[str, Any]]]to match the runtime contract (theis not Nonecache 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_redirectto a non-LiveView path now falls back to a full-page navigation instead of stranding the page (#1934). Withauto_navigatedefaulting ON in v1.1, alive_redirectwhose target is a plain Django view (e.g. aTemplateView) 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 inhandleLiveRedirect(python/djust/static/djust/src/18-navigation.js): (1) thepushStatefired 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 usedresolveViewPath(), 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-viewlive_redirectto 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_urlguard also returnsNonefor a non-LiveView URL and keeps the stale client-supplied view, so the client must make the full-nav decision. Fix: a new STRICTresolveLiveViewPath()(route map ONLY, no container fallback) drives the cross-view decision; thepushState+ 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 throughwindow.djust.safeNavigationTarget(mirroring the existing cross-origin branch, with thesafeNavigationTargetopen-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 indescribe('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, andLiveView 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_workunder parallel-n autoby 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 viastart_async()inmount(), then asserts the callback ran (view.value == 42). The runtime dispatches that callback FIRE-AND-FORGET viaasyncio.ensure_future(self._execute_async_task(...))insideViewRuntime._dispatch_async_work(python/djust/runtime.py:4444), and_execute_async_taskITSELF awaits async_to_async(callback)thread-pool round-trip before settingvalue=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 whilevaluewas 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 (inpython/djust/tests/test_transport_behavioral_parity.py, test-only — production unchanged): wrap the runtime'sasyncio.ensure_futureseam duringdispatch_mountto capture the EXACT_execute_async_tasktask handle it spawns (the_flush_push_eventsfire-and-forget send, which uses the same primitive, is excluded by coroutine name so the gate-off stays sharp), thenawait 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 indispatch_mountspawns no_execute_async_task→assert async_work_tasksfails (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 intest_mount_dispatches_async_work.De-flaked the six rate-limit burst-exhaustion tests under
-n autoby OWNING THE CLOCK —test_ping_flood_triggers_disconnectno longer flakes on a wall-clock token refill (#1930).TestRateLimiter+TestGlobalRateLimit(tests/unit/test_event_security.py) build test-localTokenBucket/ConnectionRateLimiterinstances and assert burst exhaustion (e.g.rate=100, burst=2→ the 3rdcheck()must beFalse).TokenBucketreadtime.monotonic()directly, so under CPU-saturated parallelmake testreal wall-clock elapsed betweenconsume()calls and the refill mathtokens + elapsed * rate(rate=100 = 1 token / 10ms) added a token back — flipping a "burst exhausted → False" assertion non-deterministically toTrue. 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.monotonicmodule-level seam inpython/djust/rate_limit.py(production behavior identical; one indirection) routesTokenBucket.__init__+consume()through a patchable name WITHOUT patching the globaltimemodule; aFakeClock+frozen_clockpytest fixture monkeypatchesdjust.rate_limit._monotonicso the five burst-exhaustion tests run on a FROZEN clock (elapsed == 0→ no refill → deterministic), andtest_token_bucket_refillsreplacestime.sleep(0.05)withfrozen_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 checkFalse → 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 refilladvance()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 inTestRateLimiterandTestGlobalRateLimit.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 there.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'saddEventListener/ init never ran — with NO console error. This is why #1871'swindow.djust._runInsertedScriptsmount-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__smokeTabsWiredstayedundefineduntil 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 inTestStripCommentsAndWhitespace(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-blockingbrowser-smokeCI 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_authclosed the socket with code4403UNCONDITIONALLY on thepermission_deniedverdict, while gating the redirect verdicts (login-required /on_mountredirect) onnot mounting_in_batch(the #291/#1780 multiplexed-path rule). Inside amount_batchthe 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 bespokehandle_mountwhich also closed unconditionally). Thepermission_deniedclose is now gated onnot self.mounting_in_batchtoo, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends theerror(permission_denied) frame and clearsview_instanceBEFOREfinalize_mount_authruns; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports infailed[]exactly as the redirect case already reports innavigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes4403(mounting_in_batchisFalseoutside a batch). New cases intest_ws_auth_close_socket.py(realWebsocketCommunicator, 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) andtest_single_objperm_denied_mount_still_closes_socket(over-gating guard). Gate-off (#1468) verified: reinstating the unconditionalpermission_deniedclose makes the batched-denial test go RED (the ping openness probe receiveswebsocket.closeinstead ofpong); 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_updateattached three things a runtime-routed WS event (which sends viatransport.senddirectly) dropped — (1) the per-event_debugdebug-panel payload (_attach_debug_payload, DEBUG +_debug_panel_activegated) plus the top-leveltiming/performancefields (gated on_should_expose_timing()= DEBUG orDJUST_EXPOSE_TIMING); (2) theno_patchescontext_snapshotthe bespoke path passed to_emit_full_html_update; and (3) the cosmetic_current_event_name/_current_event_refconsumer attrs. A newTransport.on_event_frame(view, frame, *, event_name, event_ref)hook (SSE no-op) — called by_render_and_sendin-place just before everypatch/html_updateevent frame — attaches (1) via the consumer's existing_attach_debug_payload+_should_expose_timing(verbatim bespoke gate;performancefrom theevent_context-borrowedPerformanceTracker;timing.renderfrom a render-duration measured per event) and stamps (3);on_render_emittedgrew acontextparam so theno_patchesbranch threadsget_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_msmarker is always popped before send and never reaches the wire. (#1921) dead code: theLiveViewConsumer._extract_cache_config/_extract_optimistic_rulescopies had ZERO callers after the mount flip deleted thehandle_mountbody that called them (orphan-grep confirmed acrosspython/+tests/);ViewRuntimeowns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror ofLiveViewConsumer._extract_*" refs are corrected. No change toRUNTIME_OWNED_VERBS/ routing; SSE unaffected. New cases inTestResidualFoldObservability+TestDebugResidualOnEventFrame(python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicatorDEBUG-vs-PRODUCTION parity (a DEBUG event frame carries_debug,timingunder expose-timing; a prod frame carries NEITHER_debug/timing/performancenor 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 theon_event_framefold + the context threading off makes the 8 behavior-meaningful tests RED.The SSE
/event/alias now forwards the client-sentrefso the #560 ref echo works on BOTH SSE endpoints (#1891). The/message/endpoint forwards the raw body verbatim toruntime.dispatch_message, so a client-supplied top-levelrefreacheddispatch_eventand 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 DROPPEDref— so the runtime's_dispatch_event_render(which readsreffrom the top level of the data dict) sawNoneand echoed nothing, leaving the end-to-end ref echo exercised only via/message/.DjustSSEEventView.postnow carriesrefthrough into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed.paramsalready carried_cacheRequestId/component_id/view_id(SSE has neither component nor sticky-child routing), sorefwas the only dropped field. New cases inTestSSEEventAliasRefEcho(python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the/event/alias) andTestDjustSSEEventViewPost::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-dropsrefto confirm absence) stays green.component_id-routed WebSocket events now re-render the parent and emithtml_updateinstead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke_handle_event_innercomponent_idbranch resolved + ran the LiveComponent handler but never re-rendered the parent view:htmlstayedNone, the html_update fallback strippedNoneand raisedTypeError, andhandle_exceptionturned it into anerrorframe — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route throughViewRuntime.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-scopedhtml_updatecarrying the parent's updated state (e.g. values pushed up viasend_parent), and echoes the eventref. The#1896parity net'scomponent_idtest is updatederror→html_update(the single intended behavioral change of the flip); its gate-off sibling (a boguscomponent_idstill errorsComponent not foundat 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_layoutswaps, accessibility announcements, and i18n commands queued duringhandle_params()(a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single_flush_all_pendingthat drains all 8 queues in WebSocket's exact canonical order (mirrorswebsocket.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,TestWsOnlyBehaviorEnumerationinpython/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_URLCONFleak, PR #1874), #1875 (djust_hotreloadchannel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a straydjust_hotreloadframe on the cachedInMemoryChannelLayerre-renders on a later consumer and bumps its per-connection_next_version()counter, sotest_time_travel_jump_recovery_version_is_currentsaw 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 helperdjust.test_isolation.reset_djust_globals()(DRY, #1646) called by an autouse_reset_djust_globalsfixture in BOTH test roots (tests/conftest.py, mirroringcleanup_session_cache; andpython/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-levelitertools.countid 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 touchstate_backend(already isolated bycleanup_session_cache), the keyed self-invalidating_jit_serializer_cache, the one-shot_CUSTOM_FILTERS_BRIDGEDbootstrap, or per-instanceStickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) inpython/djust/tests/test_global_isolation_1883.py: a stale-layer siblinggroup_sendreproduces the exactgot 4drift WITHOUT the reset and the clean1 -> 2 -> 3chain WITH it, plus per-global unit pins (neuteringreset_djust_globalsfails 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
#1721theme-tag tests under-n auto— the systemic#1883fixture now re-asserts theready()-time Rust tag handlers (#1928, #1883-class).python/djust/tests/test_theme_tags_rust_engine_1721.pyflaked under full-n auto:has_tag_handler("theme_panel")returnedFalseand all 17 tests 500'd withUnsupported 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, andDjustThemingConfig.ready()/DjustComponentsConfig.ready()register the{% theme_X %}/{% render_slot %}handlers only ONCE per process.tests/benchmarks/test_tag_registry.py::TestRustPythonInteropclears the registry (clear_tag_handlers()) and itsrestore_registryfixture restores ONLY thedjust.template_tagsbuilt-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 thatdjango.setup()s withoutdjust.theming). This is the exact #1771 bug fixed only intests/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 bothready()-time registrars BEFORE every test in both test roots — idempotent (theming guards onhas_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 inpython/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_socketunder-n auto(#1875). The #291 regression test (a login-redirecting view in amount_batchmust NOTclose()the shared socket) was order-fragile under full-n autosaturation — 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-globaldjust_hotreloadchannel-layer group on connect, so a sibling test'sgroup_send("djust_hotreload", ...)could deliver a stray frame into the test'sreceive_nothingwindow — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpollutedInMemoryChannelLayer; (2) thereceive_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 deterministicping→pongopenness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the_mounting_in_batchclose-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.V004no longer false-fires on framework-invoked lifecycle hooks (#1684). TheV004system 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 theV004lifecycle-skip set inchecks/components.py. Canonical symptom:handle_presence_leave(bitdjust-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 the1.1branch (#1685) against the pre-#1822-splitchecks.py; it was never ported tomain's splitchecks/(so the false-positive was live through 1.0.8) — this lands it onmain. New regressionTestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684(gate-off verified, #1468).djust newscaffold'ssettings.pytemplate now readsDJUST_SQLITE_PATHfor the SQLiteNAME, falling back toBASE_DIR / "db.sqlite3". A scaffolded app's default SQLite database lived underBASE_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 asDJUST_SQLITE_PATHand the scaffold picks it up automatically; local development (no env var set) is unaffected.