djust 1.3.0rc16

Pre-release Security Released
Install
pip install djust==1.3.0rc16

This is a pre-release. djust 1.3.0 has shipped since.

Part of djust 1.3 — read the 1.3 release notes.

Before you upgrade, read the upgrade guide.

Added

  • LIVEVIEW_CONFIG["http_status_of_state"] sets the not-found/gone status map for the whole project, and V025 warns about a get_http_status() override that would replace it (#3706). http_status_of_state (#3688) was a class attribute only, so a project with several base views repeated the map on each. A view that declares none now reads the config key; the view's own attribute wins, then the config, and the two are never merged (a view that declares {} opts out of the project's map; neither set answers 200 as before). LiveView.http_status_of_state is now an empty read-only mapping that compares equal to {}, so a view's own declaration (even {}) is told from "declared nothing" by identity. The key is validated like the form-data limits: when the config loads and on every config.set() / config.update(), it must map state names (str) to statuses (int, 100 to 599); anything else is logged and replaced by no map. V025 (Warning, manage.py check) reports a class that defines get_http_status() while a project-wide map is configured, once per defining class, so per-view copies of the logic do not creep back; suppress it with DJUST_CONFIG = {"suppress_checks": ["V025"]} for an override that calls super(). The other items of #3706 (V023 params_form check, the test client driving handle_params, the reserved names) shipped in #3722. Not included: letting mount() raise Http404 / PermissionDenied and render the view's own template under that status on every transport, and a per-field fallback for handle_invalid_params (#3874).
  • self.server_push(), self.live_transport and tick_interval over SSE (#3809). A view can now push from its own code on any live transport: await self.server_push("handle_x", {...}) runs the same push/server_push turn the WebSocket runs for push_to_view() (turn chain, render lock, busy deferral in arrival order, one render, a source="broadcast" frame) on a WebSocket or an SSE session, and is a documented no-op returning False on the HTTP render, an HTTP fallback POST's fresh instance, LiveViewTestClient, and a closed or replaced view. self.live_transport ("websocket", "sse" or None) answers "is a live transport attached?" and is already set inside mount(). tick_interval + handle_tick() now tick over SSE (they stood still), stopped by a close, a navigation or a failed mount. push_to_view() groups, push scopes and db_notify stay WebSocket-only over SSE; documented in server-push.md and the sse.py limits. live_transport is a reserved name (a read-only property; assigning it in a view raises AttributeError). Over SSE a tick or push re-checks reauth_on_event against the stored session and user, and server-driven frames are bounded (a tick is skipped and a push deferred while 128 frames are queued unread). Slice 1 of #3809.
  • params_form_fallback = "fields" keeps the fields that validated when params_form rejects the URL (#3874). The default handle_invalid_params() hands handle_params() the initial value of every field, so ?page=abc&sort=name lost the valid sort=name with the invalid page. A view that sets params_form_fallback = "fields" keeps every field that validated (it is not in form.errors and is in form.cleaned_data, filled from initial where the URL left it out, exactly as on a valid URL) and replaces only the failed ones with their initial value; a failed field's own entry is always its initial value ("initial" is the mode that never hands over a raw request value; a clean() that copies raw request data into a kept field hands it over through that field, as on a valid URL for a field the URL supplied). A form-level error (clean() raising without naming a field) and a URL past the form-data bounds keep nothing, because the fields that passed one by one are the combination the form refused (a clean() that names the field with add_error() is a field error and keeps the siblings, provided the merged result still passes clean(): it is run once more on a copy of the form with the kept values next to the replaced fields' initial values, and the view gets the plain initial values if clean() rejects it, so the result is always a combination clean() accepts, or the plain initials; the fields' own clean_<field>() validators are not run again on the merged values). params_errors is unchanged. The default does not change: "initial" stays what a view gets unless it opts in. A value other than "initial" or "fields" raises ImproperlyConfigured on every request of a view that declares params_form, and manage.py check reports it as V023, so a typo such as "field" cannot silently mean the default. Guide: Navigation, "Keeping what validated". clean() must be idempotent to use "fields": it is re-run on values it already cleaned (a clean() that multiplies a price by 100 hands handle_params() 50000 where a valid URL hands 500), and its output is adopted only for the fields that were kept, so a replaced field is always its initial value.
  • scripts/gate_off_batch.py / make gate-off-batch: a gate-off over many sites in one full test run per batch, not per site (#3905). It applies every mutation of a batch at once, runs the whole test command once, and attributes each failing test to a mutation by the text of its failure (a declared names substring or a test_ids regex). A test claimed by two mutations, a failure nobody claims, a batch that does not collect, hangs or runs away is never guessed onto a member: the affected mutations are re-run alone. It reuses scripts/gate_off.py for the bounded run (HUNG and RUNAWAY-MEMORY are CAUGHT with the measurement), the directory lock and journal, the SHA-256-verified restore and pytest classification (errors counted; a module that did not import is INVALID), and adds a clean-tree guard, --survivors-only, --verify-survivors and --jobs 2. Documented in docs/testing-strategy.md; the tool is itself gate-off'd in scripts/gate-off-batch-selfcheck.json.

Changed

  • A Django Form, BoundField or formset crossing to the Rust engine as an object is no longer rendered until the template prints it (#3769). The conversion of a form object called str(form) (the as_div render of every field) and str(bf) for every field a loop produced, on every render, for every form in the context, whether or not the template read it; a template that never touched the form paid 4-5x the cost of the flattened dict it replaced (#3755). Encoded.display is now a DisplayText: eager for every other object, and for a form / BoundField / formset an object that is asked for str() the first time a sink reads it ({{ form }}, a string filter, |json_script, a loop-cache hash), with the answer shared by every clone of the value. A form printed N times is rendered N times, as under Django; the eager conversion rendered it once before the template printed anything (through DjustTemplateBackend.from_string(..).render({"form": f}): hello called str(form) once and {{ form }} twice, now 0 and 1) and once more per occurrence. display_safe, str_raised and (for every opaque carrier, where it was a copy of display) json are derived from that one reading and are methods (Encoded::display(), display_safe(), str_raised(), json()), so a new reader of the text cannot get a half-built answer; the walk floor is unchanged (the asked object is still the floored handle), and the teardown that clears a view's live handles (clear_live_handles) releases the text's own handle too, without running the object's __str__: a text nothing had asked keeps its repr as the stand-in, so a render of the state AFTER that teardown prints the repr where the eager text printed the form (the teardown runs at disconnect and a reconnect re-converts from Python first, so no connected view renders through it; pinned by test_a_render_after_the_clear_shows_the_stand_in_not_the_form), while a text that was asked keeps its answer. A form whose __str__ raises is now an ordinary carrier whose text raises at {{ form }} and nowhere else, as under Django. Behaviour change, towards Django: str(form) validates the form (full_clean()), so reading it at conversion validated every form the engine was handed. Spellings that depend on that accident now print what Django prints for a form nothing validated: {{ form.cleaned_data }} is empty (it was the cleaned data), {{ form|pprint }} says valid=Unknown (it said valid=True / False), and on a bound ModelForm {{ form.instance.x }} is the unmodified instance (it carried the submitted value). A view that calls form.is_valid(), or a template that reads form.errors / form.is_valid, is unaffected. Cost, measured on a release build (CPU time per render, DjustTemplateBackend, a bound invalid form of N CharField(help_text=...)): a template that never reads the form 734 -> 33 us at N=4, 3,240 -> 39 us at N=20, 30,035 -> 97 us at N=200; {{ form.f0 }} 796 -> 131 / 3,401 -> 141 / 30,021 -> 312 us; {{ form }} is now Django's own cost (1.9x faster than before, the same as Django's engine). Not removed: a loop over a large form still pays, per BoundField, the walk floor's re-derivation of the form's password-widget fields (serialization._model_form_floor), which is O(fields) and so O(fields^2) over a loop; it is floor code and was left as it is. Known, never worse than before: a form (or a list/dict of forms) passed through |join, |unordered_list, |safeseq, forms|join or {% regroup forms %} is still read eagerly where Django does not read it; those five spellings render the form's text, as they did.

  • A template path asks "is this a password field?" by NAME, so a loop over a wide form is no longer quadratic (#3816). The password-widget floor of #3798 built the form's whole set of password names, scanning every field, for every BoundField a template walked: a {% for f in form %} over a 100-field form was 100 walked fields x 100 scanned fields per pass, +35-50% of a loop-heavy render (5 fields +5-10%, 30 fields +2-7%). A bound field of a plain form is now decided by _password_name_held(fields, name), which is one dict lookup per way of splitting the name into <field><suffix> (so a MultiWidget's pair_0 / pair_1 keys are still held), and never builds the form's floor; a ModelForm's floor carries the names as a read-through view of the form's live fields instead of an eager set. Same answers (a randomized sweep against the scan covers plain, subclass, drawn-as-password and MultiWidget forms with indexed, keyed and empty-keyed parts), with nothing remembered: it is not a memo, so a widget swapped on a live form (text -> password -> text), a field added, replaced or removed, or a widget's input_type changed in place is followed on the very next step, and no answer can be shared between forms, requests or threads. Release build, loop-heavy templates, interleaved A/B (minimum of 4 rounds each): 100 fields 30.3 -> 17.4 ms (one loop) and 89.2 -> 37.7 ms (three loops), 30 fields 6.5 -> 5.4 ms and 15.9 -> 11.8 ms, 5 fields 1.15 -> 1.13 ms and 2.50 -> 2.40 ms (within noise). A formset's floor, which unions the password names of every form it built, is unchanged (20/100/300 forms showed no measurable difference).

    Long keys. form.data is keyed by what the client posted, and the by-name check is a lookup per way of splitting the name, so on its own it is quadratic in a key's length (and cubic when form.data is filtered, which asks about every --delimited tail of every key): an 8 KB key of dashes pinned a worker for about 30 s. Two guards keep a key's length from deciding how often the form is asked: _without_floor_keys reads the names as a SET once per mapping (as before this change, linear in the key), and _password_name_held answers a name past 256 characters with one scan of the form. Measured on a bound UserChangeForm, {{ form.data.username }} with one extra POST key (release): "-" * 3000 6.2 ms (1,726 ms without the guards, 6.5 ms before #3816); "-" * 8000 20.9 ms (29,576 ms; 20.7 ms); "a-" * 4000 12.3 ms (15,038 ms; 12.2 ms).

    Regression cases in TestAKeyAClientChoseCostsLinearInItsLength (a count of lookups and scans on 10,000-character plain, dashed and prefixed keys, with exact agreement with the old set), TestAStepDoesNotScanTheForm (a fields mapping that counts every whole-form read: a step of a 100-field form scans nothing, and the scans of a native loop are the same for 10 fields and for 100), TestAWidgetSwapOnALiveFormIsFollowed, TestNothingIsSharedBetweenForms and TestSameAnswerAsTheScan in python/tests/test_password_names_by_name_3816.py.

  • The form-data size limit (max_form_data_size) is now exact, no member is free, and an event weighs the form's stored data in C passes rather than a Python step per member (#3841). The size is the characters of the keys and values the data would hold after the event, with no extra character for the data itself, and a size equal to the limit is accepted (before, each check added one for its own container, so an event could count two too many and a payload of exactly the limit was refused when nothing else was kept). Behaviour change: an empty string, bytes or bytearray now counts as one character, not zero. Before, [""] * 15000 (about 60 KB of JSON, under the default frame limit) weighed a single character, so 150 such validate_field events stored 2,250,000 empty members and made every later event take over a second; now the size limit bounds how many are kept (a form that declares N fields holds N more characters for their empty initial values). A flat list of containers is no longer pulled whole before it is charged. With the form filled to the cap an event cost 174-279 ms to weigh; it is now 4-30 ms (a list of 320,000 integers: 148 ms to 5 ms; 300,000 integer-valued fields: 200 ms to 13 ms), and an event that replaces a field no longer copies the rest of the data to find out what is left. The verdict is unchanged for every other input (checked against the previous walker on generated values and event sequences).

  • A native component handler uses an explicitly passed argument as given, even when it is empty. data_table (rows, columns), breadcrumb, stepper, dj_select, dj_radio, autocomplete and dependent_select now let an explicit argument win over a context variable of the same name; the render context is consulted only when the argument is absent (the handlers' documented convenience, unchanged). An argument that resolves to nothing counts as passed: {% breadcrumb items=nope %} draws empty, as the Python tag does, where it used to fall back to breadcrumb_items. A structural test scans the handler modules for the previous shape.

Fixed

  • The native handlers of nine component tags dropped or invented dj-* event attributes the Python tag emits (#3669). Under the socket render a click or input handler wired by the first, Django-engine render was missing or renamed. The nine tags, in six families: dj_button; color_picker; pagination; data_table; nav_item; filter_bar with filter_select, filter_date_range and filter_search. dj_button kept dj-click only while enabled, never emitted dj-confirm, and ignored preset= (a preset can carry event / confirm); color_picker ignored swatches=; pagination drew no numbered page buttons (each fires next_event with the page as data-value); data_table ignored row_click_event and the prev_event / next_event pager shown when total_pages > 1 without paginate; nav_item with an event and a text body became a dropdown with no dj-click; filter_bar took each control's own event and never drew the "Clear filters" button. The Python tag is the reference. For filter_bar, a native block handler is handed only the rendered children, so each filter_* child now leaves its arguments in a comment that the bar reads, removes and draws with the Python tag's own renderer (render_filter_bar), which owns event and clear_event; a child outside a bar leaves an invisible comment where Django renders nothing; the default id stays filter-bar rather than a per-render random one. Whether a value wires an event is decided on the VALUE, not on its escaped text, so confirm=None / False / 0 / [] and row_click_event=None wire nothing, as in Python. python/djust/tests/test_component_dj_attr_parity_3669.py renders about 95 argument combinations of the twelve tags in the issue through both engines and compares the (element, dj-attribute, value) multiset, plus every falsy shape (None, False, 0, 0.0, "", [], {}) for every event-bearing parameter, a hostile event-name sweep (quotes, <, &, </script>, U+2028) and a strict-CSP check on both engines. combobox and toast_container already agreed and are now pinned. Behaviour that stays different, and why: tree_view / kanban_board differ only for a SafeString operand (native escapes it, the safe side); the native data_table also wires about a dozen features the Python template never renders; the native bar and nav item see rendered children, so controls and sub-items inside {% if %} / {% for %}, a bar nested in a bar, and other content in a bar are drawn natively (as before) where the Python tag drops them; the string "0" and a lone space reach a handler as the number 0 and nothing (the engine hands handlers text); a block tag's unresolved-variable argument; a nav_item nested three levels deep; and the Python data_table template's own sortable: False quirk. Markup outside dj-* (classes, text, ids, ARIA) is #3663.

  • The Rust Avatar read a URL with a leading control character as having no scheme (#3748). url_scheme() in crates/djust_components/src/simple/avatar.rs removed only tab, CR and LF before it looked for the scheme, so "\x01blob:...", "\x00file:..." and "\x1fintent:..." failed the leading-letter test and reached <img src> as written, where url_attr returns #. It now strips leading C0 controls and space first, as Python's urlsplit and a browser do. The recorded gap (KNOWN_RUST_MIRROR_GAPS) in the sweep test is gone, the sweep corpus gained leading-control blob: / file: / intent: payloads, and python/tests/test_avatar_rust_scheme_parity_3748.py compares the Rust avatar with url_attr over every C0 control in every position and a seeded random sweep. The markdown renderer's three-scheme denylist is unchanged; the measurement is on the issue.

  • A Django Form / BoundField bound by {% for %} or {% with %} renders under the Rust engine as it does under Django (#3755). {% for f in form %}{% field_error field=f %}{% endfor %}, {% with f=form.name %}{% field_error field=f %}{% endwith %} and {% for f in form %}{{ f.errors }}{% endfor %} rendered nothing under the Rust engine and the errors under Django. The plain-backend render path flattened a form to {name: SafeString(widget HTML)} before the engine saw it, which keeps {{ form.name }} working and loses what a form is: iterating it yielded the field names ({{ f.label }} was empty), f.errors / f.field.required read attributes a string does not have, form|length was the number of fields (Django: 0) and {{ form }} printed a dict. With for_render=True the form now crosses as itself (template.serialization.serialize_value); the JSON-state paths (normalize_django_value, the LiveView bridge) still flatten. Four engine rules came with it, each measured against Django: (1) a bridged Python tag receives a live BoundField although a BoundField is iterable (context::live_handle_is_form_object, decided by djust.serialization._is_form_object). This is a new spelling of a reach that already existed, not a new reach: a bridged tag already got the live form under its own name, and the bridge has no serialization floor, so {% some_bridged_tag x=f.form.instance.password %} now prints what x=form.instance.password already printed (#3771; the PR's first version of this line said "adds no exposure", which was wrong for json_viewer, since fixed by keeping it on the floored native path, #3756). (2) A list subclass that overrides __str__ / __repr__ (Django's ErrorList) crosses as the carrier whose {{ v }} is str(o) (list_subclass_spells_itself, the list half of #2899), so {{ f.errors }} is <ul class="errorlist">…</ul> and not ['This field is required.']; for |json_script the carrier is the array, as the dict-subclass one is the object (Encoded::live_sequence_value), so {{ form.errors|json_script:'e' }} is the errors as JSON. (3) A top-level ErrorList / ErrorDict in the context is no longer rebuilt as a plain container by serialize_value (limited to subclasses that define __html__; a top-level OrderedDict is rebuilt as before). (4) An object whose str() / iter() raised at conversion stays iterable by TYPE, so {% for f in form %} over a form with one broken widget renders the loop and the real exception appears where the broken field is rendered, as in Django, instead of 'Form' object is not iterable. A Form / BoundField is also carried like a one-shot iterator (handle live, items read at the sink), which removes the eager subwidget enumeration. Behaviour change: {{ errors }} for a top-level ErrorList / ErrorDict, and any list subclass with its own __str__ / __repr__ reached through an object, now print what Django prints (the errorlist markup) instead of a list / dict display. Cost: a form in the context of a template rendered through DjustTemplateBackend costs more per render than the flattened dict did (0.86 ms against 0.31 ms for four fields, 3.5 ms against 1.3 ms for twenty, release build), because the engine converts an object eagerly, str(form) included; the LiveView path is unaffected; tracked at #3769. Not covered here: a form held as LiveView state still reached the template as {name: widget HTML}, because LiveView state is JSON (fixed in #3767).

  • {% json_viewer data=v %} renders a datetime / date / Decimal as Django does (#3756). The Python tag dispatches on isinstance(value, ...) and spells every type it does not know as str(value) with no type modifier (<span class="dj-json__value">2024-03-15</span>); the native handler was handed the engine's stand-in for a value it cannot carry (str(datetime), a float for a Decimal) and dispatched on that, printing dj-json__value--string with quotes and --number. The same went for a UUID, an Enum, a Path, bytes, a set and a tuple, and for a datetime / date / Decimal / UUID nested in a dict or a list. A handler can now declare TYPED_ARGS = True (rust_handlers.TYPED_ARGUMENT_TAGS, read by registry.rs at registration): the engine resolves each key=<expression> operand through the same floored walk {{ }} uses (get_value, which re-protects every segment of a dotted path) and hands the typed Python value to the handler in the context under a reserved key; json_viewer reads its data from there, with the operand's runtime-safe flag ({% with p=x|safe %}, a mark_safed value) carried through the channel so a safe operand is raw as in Django (typed_operands uses get_value_safe; without it the flag was dropped and the operand over-escaped; a hostile unsafe value stays escaped, tree and raw <script> copy, byte for byte against Django, python/tests/test_typed_args_provenance_3770.py). It deliberately stays native. A first version of this change moved it onto the {% load %} bridge so Django's own node could dispatch on the type, and the bridge walks raw objects with no serialization floor: {% json_viewer data=form.instance.password %}, data=f.form.instance.password in a form loop, data=form.fields.owner.queryset.first.password, data=obj.user.password, data=obj.users.first.password and data=view.user.get_session_auth_hash all printed the hash, which {{ }} and the native handler refuse. Every one of those is a test now (python/tests/test_typed_args_floor_3756.py). The tags that depend on a value's type are measured, not guessed (scripts/lib/template_library_parity/typed_values.py, 208 tags x 15 types): the Python tag is run on a value and on the stand-in the engine would hand a handler, and a tag whose output differs depends on the type. Of the 781 type-sensitive cells, the tags still wrong for a type a template realistically passes (datetime, date, time, timedelta, Decimal, Enum, Path) are four, all a date or path in a CSS-class / URL slot (theme_mode_toggle, theme_preset_selector, theme_switcher, map_picker); they are recorded in KNOWN_TYPE_LOSS, which the test compares with the measurement in both directions (an entry that stops being wrong fails; a new loss fails until it is looked at), and the remaining isolated cells (a tuple, UUID, bytes or set in a text or number slot, and a tuple holding an OrderedDict) are tracked at #3768. A tag rendered through the bridge still has no floor (#3771).

  • A Django Form held as LiveView state reaches the template as the form, not as {field_name: widget HTML} (#3767). LiveView state is JSON, so _sync_state_to_rust flattened a BaseForm held on the view to {name: SafeString(str(form[name]))} (serialization.render_form_value) and left it out of the raw-Python sidecar. That keeps {{ form.name }} and loses what a form is: {{ form.name.errors }}, {% field_error field=form.name %} and {% for f in form %}{% field_error field=f %}{% endfor %} rendered nothing, {% for f in form %}{{ f.label }} iterated the field names, {{ form.is_bound }} and {{ form|length }} read a dict, and {{ form }} printed it, while DjustTemplateBackend (since #3755) and Django rendered all of them. The cure is not one of the three options the issue lists, and it needs no Rust change. All three assume the form must also be in the Rust state (flattened, or as a handle-bearing value that is diffed, re-sent and serialised on every event). The engine already has a channel for a live object that is not state: the raw-Python sidecar, resolved lazily at the lookup, per segment, under the serialization floor. So a context name for which djust.serialization.holds_form is true — a Form, a BoundField, a formset, or a dict / list / tuple holding one (up to four containers deep: a wizard's steps, {"login": ..., "signup": ...}) — is kept out of the state and rides the sidecar only, so the value stack has nothing to answer first. The same decision is read at every entry that hands the engine a state dict and the raw objects separately: _sync_state_to_rust (HTTP GET, WebSocket mount and event, SSE and the runtime all call it), the page shell (render_full_template, both branches) and a template_name component. The set of update_state call sites is pinned from the source. Cost, measured on a release build (an event that assigns a new form and renders; min of 5 batches of 150; the figures are for 4, 20 and 60 fields): a template that reads a field ({{ form.f0 }}, {{ form.f0.errors }}) goes from 1.2 / 2.1 / 4.6 ms to 1.0 / 1.2 / 1.4 ms, and one that never touches the form from 1.2 / 2.3 / 4.9 ms to 0.9 / 1.0 / 1.4 ms, because only what the template touches is converted and the per-field flatten is gone; an event that does not change the form costs the same as before (0.7-0.9 ms either way) for those templates only. The one thing that got slower is the whole-form spelling: {% for f in form %}{{ f }}{% endfor %} goes from 1.1 / 2.1 / 4.5 ms to 2.0 / 6.0 / 15.2 ms and {{ form }} from 1.1 / 2.3 / 5.1 ms to 1.7 / 4.5 / 10.4 ms. That is the engine's eager conversion of a form object (str(form), the as_div render), the cost #3769 measures on DjustTemplateBackend (6.3 ms for the loop at 20 fields), and those spellings printed the wrong thing before (the loop yielded the field names, {{ form }} a dict). It is also paid by an event that does NOT change the form: the form is a sidecar object, so the engine cannot see that it is the same one and converts it again on every render that evaluates the whole-form node (measured by the review: {% for f in form %}{{ f }} 0.8 / 0.8 / 0.9 ms before against 1.8 / 5.6 / 15.9 ms after at 4 / 20 / 60 fields, {{ form }} 0.8 / 0.9 / 0.9 against 1.4 / 3.4 / 8.1; a probe of mine through the partial-render entry (render_with_patches) did not show it, a full render() did, so it depends on whether the node is re-evaluated). #3769 (a lazy Encoded.display, its PR in progress) is the cure and is unchanged by this fix. The floor holds on every route: the form crosses as the already-floored proxy (#3790, #3798), and python/tests/test_form_floor_differential_3790.py now runs its whole grammar (4998 templates in 213 groups) against a root form held as an attribute of a LiveView, asserting both that no part of the secret appears and that the render equals the plain backend's. That also closes a flattened-copy exposure the old shape had for the DJUST_SENSITIVE_FIELDS text field: {{ form|pprint }} and {% for k, x in form.items %}{{ x }}{% endfor %} printed the control's value="…" for a held form and a held formset, which the plain backend never did. What does not change: JSON state. get_state(), the session save and the signed snapshot use normalize_django_value, which still flattens (state_roundtrip=True) and still treats a form attribute as the non-serialisable value it always was; a form restored from the session (enable_state_snapshot, the HTTP POST fallback) is therefore still the JSON dict. Behaviour change: a view that keeps formset.management_form directly as an attribute used to flatten it (no data readable); it now reaches the template as the form, a plain Form with no pinned floor (#3799), so {{ mf.data }} prints the formset's whole submitted data, as on the plain backend (docs/SECURE_DEFAULTS.md, pinned by a test). Not covered: a form nested more than four containers deep, or held by a container that is not a dict / list / tuple (a deque, a set, a QuerySet), stays flattened as before; the Tokio-actor transport's MOUNT frame hands the engine str(form) (identical before this change; the render after an event is correct), pinned as a strict xfail.

  • A native component tag gets a tuple, a UUID and a Path as the value it is, not the engine's stand-in (#3768). The Rust engine hands a native handler its operands as text or JSON, so {% approval_gate reject_label=v %} over v = (1, 2) printed [1, 2] where Django's tag prints (1, 2), and {% live_counter value=v %} over a UUID showed 0 where Django counts int(uuid). scripts/lib/template_library_parity/typed_values.py measured 495 such cells (cells where the Python tag renders a value differently from its stand-in, and the engines agree on the stand-in) over 208 tags, 15 value types and 21,390 cells; the count is now 45, 450 fewer. Over ALL 21,390 cells (Rust output equal to Django's, exact status and text, base against this change) 517 more now agree, 0 newly disagree, 15,341 agreed before and still do, and 5,532 still differ for reasons unrelated to a stand-in. Every inline and block component handler now declares TYPED_ARGS (one wrapper at registration, not a class attribute on ~180 of them) and _parse_args, the one place they read their operands, takes back a UUID, a Path and a tuple. A tuple comes back as TupleValue, a list subclass that prints like the tuple: ~55 handlers read a list slot with isinstance(x, list), and a real tuple turned rows=my_tuple_of_dicts into an empty table (the first version of this fix did that to dependent_select; a measurement over 13 probe shapes found it). Both the conversion and the printing walk an explicit stack: a tuple nested 500 deep rendered before this change, and a recursive walk raised RecursionError for it. (Past ~1000 levels the engine's own context serializer raises on the unchanged code, before any handler runs.) The block tag needed the engine's twin of the inline channel (add_typed_operands, the one function both call sites use). A wrapped handler also declares TYPED_ARGS_STANDINS_ONLY, so the engine hands over only a tuple or an object operand and a large list or dict is not materialised a second time as Python objects. Behaviour change: a UUID or Path given to a parameter that takes a list or text used to render (the handler iterated the value's text as characters) and now raises TypeError, as Django's tag does, for animated_number separator=, carousel images=, data_grid columns= / rows=, multi_select options= / selected=, notification_center notifications=, table_of_contents items=, tag_input suggestions= / tags= and tree_view nodes= (11 parameters of 8 tags, each for both types; test_both_engines_raise_the_same_error). Cost, release build, base against this change, one process per case, a loaded machine: a 100,000-row data_table 2,301 / 2,273 ms to 2,311 / 2,319 ms (+0.4% / +2.0%), peak RSS 746 / 747 MB to 772 / 772 MB (+3.5%); a 1,000-point bar_chart +0.8% / +1.5%, RSS unchanged; a scalar tag about +3 to +5 µs per call (badge 33 / 34 µs to 36 µs, modal 38 µs to 41 / 43 µs). A 2,000,000-element tuple operand, the case the recovery exists for, is materialised as Python objects again: 5,027 / 5,022 ms to 5,409 / 5,416 ms (+7.6% / +7.8%), peak RSS 1,122 / 1,235 MB to 2,220 / 1,670 MB (the two rounds disagree; linear in the tuple). The first version handed every operand over and cost +25% RSS on the 100,000-row table; the narrow ask removed it. Recorded, not fixed, and pinned exactly so the number cannot grow (RESIDUAL_STANDIN_LOSS): a tuple in a slot that takes a list (the native handler stays more lenient than Django's strict tag), set / frozenset / bytes (an iterable crosses to a handler as the engine's wrapper, so its type cannot be recovered), a date in the theme_* CSS-class slots, and a UUID in an argument notification_center ignores. python/tests/test_standin_types_3768.py, gate-off verified.

  • {% for g in u.groups.all %}{{ g.name }}{% endfor %} over a QuerySet renders the related rows on a LiveView and under DjustTemplateBackend, as Django and _rust.render_template do (#3777). The Rust queryset serializer (serialize_object_with_paths in crates/djust_live/src/lib.rs) walked the extracted paths groups.all / groups.all.name with getattr: groups is the related manager and all is its bound method, which has no name, so it shipped {"groups": {"all": {}}} and the loop ran over an empty dict. The Python codegen twin (python/djust/optimization/codegen.py) has always iterated .all(); the Rust one never did, and the key-count fallback to that codegen cannot fire because the groups key is present. Only a QuerySet was affected, so a list[Model] of the same rows rendered the names. Sibling spellings of the same missing step were wrong too and are fixed with it: {{ u.groups.all|length }} counted the characters of the QuerySet's repr (41 for a two-row relation), {% if u.groups.all %} was true for an empty relation, and u.groups.all.0.name was empty. This holds for a custom QuerySet class (SomeQS.as_manager(), which a related manager's all() hands back) as well as a plain one: the check is an isinstance, not a class-name test. Every row reached through .all goes through the serialization floor exactly as a row reached any other way does (password, is_superuser, is_staff, a model's djust_exclude_fields and djust_serializable_fields), at every depth of a chain. A called all still goes through the one template_call_guard (#3772), so a related manager whose all is marked alters_data or do_not_call_in_templates is never run and its name is omitted. A raising all() is no longer swallowed: only the two exceptions the codegen catches (TypeError, AttributeError) become an empty list; a database error propagates to the caller's existing [JIT ERROR] handler, which logs it and falls back, instead of rendering a user with no groups. Rows that are plain dict / list / tuple data (a model method named all) keep their data. Pinned through DjustTemplateBackend, a LiveView's HTTP GET, its WebSocket mount and the serializer itself, with Django's engine as the control and real rows (python/tests/test_queryset_all_traversal_3777.py, python/tests/test_queryset_all_floor_and_errors_3777.py). Same-symptom paths this does not reach (a QuerySet nested in a dict value, .all.count / .all.first, no row cap) are tracked at #3788.

  • A QuerySet inside a dict or a list of dicts renders its related rows on a LiveView, {{ u.groups.all.count }} / .all.exists / .all.first.name render instead of empty, and a called all has a row cap (#3788). Three same-symptom paths #3785 (the #3777 fix) did not reach, each re-measured against the real render path before the fix. (1) {"d": {"users": qs}} with {% for g in x.groups.all %} rendered empty while {{ x.username }} worked: get_context_data only read the template when a TOP-LEVEL value was a Model / QuerySet, and the JIT looked its paths up by the dotted name d.users, which the extraction map (keyed by root name) does not have, so the value fell to the flat field-dict serializer. The template source is now read on the first nested DB value (a context with none pays one type() lookup per value, not a template read), a dotted name resolves to its root's paths without the leading segments (_paths_for_variable, one resolver for the QuerySet, model and model-list producers), a list of dicts is walked like a dict, and the generated serializer's function name is sanitised (serialize_d.users_… did not compile). The same lookup fixed a Model and a model list inside a dict beside a top-level QuerySet, which rendered {{ d.user.username }} empty from the identity-only map. (2) all read only through QuerySet methods is shipped as {"count": n, "exists": b, "first": {...}, "last": {...}}, each method run on the QuerySet all() returned (COUNT, EXISTS, LIMIT 1) under the one call guard, a first / last row through the serialization floor (and, when the row is also read whole, {{ u.groups.all.first }} beside .first.name, carrying the __str__ a serialized model carries, so it prints its text rather than a dict). The tree cannot tell u.groups.all.count from {% for i in o.items.all %}{{ i.count }}, so the method reading is used only when the template does not read the relation as rows (a whole-path mark, with_whole_marks / ALL_WHOLE_KEY), every child is a method name, and the row model has no attribute of that name. Rust (serialize_all_methods) and the Python codegen twin (all_methods_value) are compared in test_rust_and_codegen_ship_the_same_methods; the codegen all step now also honours alters_data / do_not_call_in_templates, as the Rust one does. (3) LIVEVIEW_CONFIG["queryset_all_row_cap"] (default 100,000; 0, or any value of 2**62 or more, removes it; None is the same as unset, i.e. the default; a non-integer is logged and replaced by the default) bounds the rows read through a called all (qs[:cap + 1]: a SQL LIMIT on an unevaluated relation) in both serializers, logs a warning when a relation is cut, and never applies to .count / .exists / .first / .last. A container is searched four containers deep; below that the QuerySet is left as the view built it, a flat field dict, so its relations render empty with no error. The cap bounds the work the serializer does, not memory: the optimizer still prefetches the whole relation first. Not fixed, tracked at #3801: a template that reads the same .all as rows AND through a method still renders the method empty on a LiveView (a loop next to .all.count, {{ u.groups.all|length }}/{{ u.groups.all.count }}, {% if u.groups.all %}{{ u.groups.all.count }}, {% with gs=u.groups.all %}{{ gs.count }}, |slice plus .count); a model with a count / exists / first / last field keeps all a list of rows, so .all.count stays empty there, and on a list[Model] the Python serializer drops a row's integer count / exists field (pre-existing); {{ g }} beside {{ g.name }} inside a loop over .all prints a dict (pre-existing, the loop's iterable cannot be told from a whole read); the extractor erases the index of .all.0.name; the relation prefetch loads all rows regardless of what the serializer ships; and a leaf-only all runs str(row) for every row. Pinned through DjustTemplateBackend, a LiveView mount, an event re-render, an HTTP GET and a WebSocket mount, with Django's engine as the control (python/tests/test_queryset_all_followups_3788.py).

  • A relation read as rows AND through a QuerySet method renders the method on a LiveView, a row's integer count field survives the Python codegen, and a Model past the nesting bound is reported (#3801). (1) {% for g in u.groups.all %} beside {{ u.groups.all.count }} rendered [:staff,ops,] where Django renders [2:staff,ops,]: both extract to one groups.all node and one serialized value cannot be both the list the loop iterates and the object .count reads. The same held for |length plus .count, {% if %} plus .count, {% with gs=u.groups.all %}{{ gs.count }}, |slice plus .count, .first / .last / .exists beside a loop, and a row model with a count / first field. The evaluated rows of exactly such a name (a top-level QuerySet or list[Model]; codegen.all_methods_need_rows) now ride the render sidecar and the engine's guarded getattr walk answers the method from them, the walk DjustTemplateBackend already used: a method marked alters_data or do_not_call_in_templates is still never called, and the serialization floor still governs every row. A plain loop and a method-only read keep no rows. (4) The Python codegen's leaf branch called a leaf named count / exists / all as a method even when it was an integer field, so a list[Model] row lost its count; only a callable is called now. (7) A Model or QuerySet directly inside the first container past _NESTED_DB_DEPTH is left as a flat field dict (relations render empty); that is now logged once per process, when the container is reached from a list-rooted value (a dict-rooted shape is not reported). Not done, filed as #3862: the erased index of .all.0.name and the relation prefetch (2), str(row) per row in the leaf shape (3), {{ g }} beside {{ g.name }} printing a dict (6), and the mixed read on a QuerySet nested in a dict.

  • _rust.serialize_context no longer overflows the stack on a symmetrical self-referential relation, a container that holds itself, or a very long chain of related rows (#3814). With Person.friends = ManyToManyField("self") and A <-> B, _rust.serialize_context({"p": a}) ended the interpreter with Fatal Python error: Segmentation fault: the all-fields model arm builds a model's dict with model_to_dict and recurses into every value, and a relation hands back a FRESH instance of the row it came from, so no identity check ended it. guard_panic cannot catch that (it is the process's stack, not a Rust panic), and a list or dict that contains itself did the same. Every recursive step now goes through one function, serialize_nested, which fails CLOSED to str(obj): into a container or a model row (class + primary key) the walk is already inside of, or past a depth ceiling (128 levels, for a chain of DISTINCT rows). The row check runs before model_to_dict, so the query that would fetch the next ring of the cycle is not made. A row reached from two different parents is not a cycle: the path is popped on the way out. Before, in a bounded child process, the model, list and dict cases exit with SIGSEGV and the non-str render() raises; after, all exit normally with bounded output.

    A render() that returns a non-str (an int, None) no longer raises TypeError out of serialize_context; it falls back to str(obj) exactly as a render() that raises does.

    The production serializers were checked for the same shape and do not have it, so none was changed. serialize_object_with_paths / serialize_queryset walk the template's field-path tree, which is finite whatever the data is (and queryset_template_value carries its own 64-level cap), and the Python encoder is capped at serialization_max_depth. Run against the same relation through serialize_queryset, the DjustTemplateBackend ({{ p.friends.all.0.friends.all.0.friends.all.0.name }}, a {% for %} over p.friends.all, a bare {{ p }}), a LiveView attribute and the encoder, each exits normally on the unchanged build as well. serialize_context has no production caller (#3778).

    Behaviour changes to know about.

    • Nesting past 128 levels now crosses as str(). A level is a container, a model, or a model's dict, so a chain of related rows is cut after about 42 rows (three levels per row) and a list nested 129 deep is cut at the 129th. Before, such a structure was returned whole up to the Python recursion limit (or crashed the process if it was cyclic or long enough). The ceiling is nesting, not width: a flat list of any length is untouched.
    • The fallback is str(obj), so an object whose __str__ raises now makes serialize_context raise at a cycle or at the ceiling (before: a segfault), and a __str__ that embeds a field value puts that value in the output. A model's __str__ is public text by contract (#3806), as it is everywhere else it is rendered.
    • Stack: the worst chain (a model per three levels, up to the ceiling) needs about 112 KB of native stack in a release build (measured in the review of this change, where 96 KB crashed with SIGBUS). That is a large margin on the default thread stacks (8-16 MiB), but a thread started with a very small stack would have less.

    Regression cases in python/tests/test_serialize_context_cycles_3814.py, each run in a child process watched on the clock and on memory (a segfault is exit -11), with a control that proves the harness sees a crash. The row check is pinned to run before model_to_dict by a query count (6 for A <-> B, 9 if the third row were read), a flat list of 1,000 and two classes sharing a primary key pin that the ceiling and the row key are not over-eager. Each mechanism (container check, model-row check, ordering before model_to_dict, depth ceiling and its decrement, the (class, pk) key, the shared chokepoint, the pop, the non-str fallback) is gate-off tested separately. Tuples are not tracked (a tuple cannot hold itself without a list or dict between, and those are).

  • A dict lazy= on {% live_render %} works under the native (Rust) engine (#3838). {% live_render "app.views.Child" lazy=cfg %} with cfg = {"placeholder": ..., "trigger": "visible"} rendered under the Django engine but raised TemplateSyntaxError: lazy= accepts True, 'visible', or a dict - got '{"placeholder":...}' under the native one, because the engine hands a handler a structured operand as a JSON string. LiveRenderTagHandler now declares TYPED_ARGS (the channel json_viewer uses, #3756) and reads lazy as the value the engine resolved, not the string decoded back: a string cannot carry SafeString-ness, and the #3819 placeholder contract depends on it. A mark_safe placeholder is markup and a plain str is text on both engines, byte for byte. The same read means a string variable holding the text 'visible' (quotes inside) is refused as Django refuses it; the native handler used to strip the quotes and accept it. Only lazy is read this way; the other keyword operands are the child's mount() kwargs and cross as they always did. TYPED_ARGS_KEY now lives in djust.template_tags (re-exported by djust.components.rust_handlers). tests/unit/test_native_live_render_lazy_dict_3838.py, gate-off verified (6 mutations).

  • A namedtuple that cannot be rebuilt for rendering is logged through log_failure (#3849). The debug log passed the user's exception to the logger directly, which test_log_exposure_pin forbids (ADR-038); the exception text now rides exc_info and only where diagnostics are allowed.

  • The engine remembers a namedtuple shape collections.namedtuple() refused, and "cached per shape" is now tested (#3849). Follow-up to #3856 from its independent review. The engine rebuilds a namedtuple's class for a native tag with collections.namedtuple(name, fields) (named_tuple_class, crates/djust_core/src/lib.rs) and cached only a shape it BUILT: a shape the factory refused (a rename=True duplicate, a leading underscore, a keyword, a non-identifier name) re-ran the factory, an exec that raises, on every conversion (115 ms against 92 ms per 20,000 refused rows). A refusal (ValueError, or the SyntaxError namedtuple() raises for fields equal only after NFKC normalisation, which was not cached either and escaped the Python shape gate, failing the render) is a fact about the shape and is cached as such; no shape raises TypeError, so it is no longer caught; any other failure (MemoryError, an exception from a patched factory) is not, and is tried again; the value still falls back to the plain tuple either way. The lru_cache on the Python shape gate (_namedtuple_shape_is_buildable_cached) had no test; both caches are now pinned by COUNTED work, the number of times the factory runs for a shape over 25 conversions (python/tests/test_namedtuple_shape_cache_3849.py), not a duration. Recorded, not changed, with the measurement that decided each: a namedtuple (or a plain tuple, UUID, Path) INSIDE a list or dict operand is still its JSON text ([[1, 2]] where Django prints [P(x=1, y=2)]; handing over a container that holds one keeps the whole operand in memory a second time, +24% peak RSS on a 100,000-row list, time unchanged), pinned by strict xfails in python/tests/test_namedtuple_in_container_operand_3849.py. The custom-__repr__ and tuple-depth items of the review stay recorded as in #3856.

  • A native component tag gets a namedtuple as the namedtuple it is, and recovering a large tuple does less counted work (#3849). Follow-ups to #3843 (#3768). (1) {% approval_gate reject_label=v %} over P(1, 2) printed (1, 2) on the Django-backend engine ([1, 2] before #3843; Django prints P(x=1, y=2)), and seven cells flipped from their default to (1, 2): dashboard_grid gap / row_height, map_picker height, resizable_panel initial_size / min_size / max_size, sortable_grid gap. Two causes, one per layer: serialize_value(for_render=True) rebuilt every tuple as tuple(items) (a namedtuple is now rebuilt positionally and keeps its type, so {{ v }} prints P(x=1, y=2) as Django does), and the engine's narrow ask (TYPED_ARGS_STANDINS_ONLY) handed over a Value::Tuple but not a Value::NamedTuple (it does now). _parse_args returns a NamedTupleValue, a TupleValue that is a list (the isinstance(x, list) slots keep working) and prints P(x=1, y=2); a namedtuple nested in a tuple is converted too. A namedtuple the engine cannot rebuild never fails a render: collections.namedtuple refuses some shapes a real class can have (a rename=True duplicate is ('a', '_1', 'b') and a leading underscore is refused; a keyword or non-identifier field; a class whose name is not an identifier), and the engine's conversion of the context back to Python for a native tag raised for ANY tag in a template that merely held such a value ({% static %}, {% now %}, {% badge %}), operand or not. serialize_value keeps the type only when namedtuple(name, fields) accepts the shape (the real constructor is the test, cached per shape), and the engine falls back to the plain tuple when it cannot build the class (named_tuple_or_tuple, crates/djust_core/src/lib.rs), for a value that reaches it without passing serialize_value (RustLiveView.update_state). The rebuild runs the class's own __new__ on already-serialized values (a lazy string is a str by then), so ANY exception it raises degrades to the plain tuple, logged at debug. Pinned for every shape on both native entry points (the Django-backend engine and RustLiveView) against Django's own tag: plain, namedtuple (one field, none), tuple in namedtuple and the reverse, nested, empty, one-element and a tuple subclass (python/tests/test_typed_tuple_shapes_3849.py), and for each refused shape (python/tests/test_namedtuple_refused_shapes_3849.py). Recorded, not changed: a tuple subclass that overrides __repr__ and a namedtuple with a custom __repr__ print the default (the engine keeps no class); a tuple held as LiveView state is a list before the engine (normalize_django_value); {{ rows|dictsort:'x' }} / |dictsortreversed over a list of namedtuples still render EMPTY natively where Django sorts them (unchanged). (2) The review of #3843 measured a 2,000,000-element tuple at +34% time and +39% RSS, more than #3843 stated. The avoidable work is gone and pinned by COUNTED work, not a duration: _parse_args no longer decodes the JSON text of an operand the typed value replaces (the test asserts json.loads never sees it), and _from_typed copies a tuple with no tuple member in one C-level pass instead of a Python loop per element (the test asserts the executed source lines are the same for 10 and 20,000 elements). The wall-clock effect is small and noisy: an independent run through approval_gate measured about 2 to 4% faster with the same peak RSS, a bare-handler run about 10% faster and 3% less RSS; do not read more into either. What remains is the engine's own: the operand is serialised once as the string argument, resolved a second time as a typed value and built once as Python objects. A lazy sequence view was considered and not done: ~55 handlers read a list slot with isinstance(x, list). Not addressed: the tuple-depth label difference at 1,100 and 3,000 levels (the render raises RecursionError in serialize_value first, unchanged) and the run-to-run variation of the reproducible cell counts (the reviewer's own measurement note).

  • The safe-markup guard no longer trusts a class attribute the class body may not define, and checks where safe_url output lands in the string-building forms it knows (#3850). Soundness follow-ups from two reviews of #3848. In the 322-function review corpus, the fuzz programs run (4,800 here, 12,800 in the review) and the real tree the guard clears nothing new (the audit baseline is unchanged except the corrected JSChain.__html__ reason); that is measured, not proved. A class attribute defined only inside an if/try/loop, or deleted by del C.X / del X, is trusted only when the class has no __getattr__ (on it, on a subclass in the file, or assigned from outside), no base other than object and no class keyword; the same holds for a method. The position check on safe_url / url_attr / safe_css_* (text or a quoted value, never an unquoted attribute or a tag name, including a trailing <) and escape_json_for_script (script text only) now covers + chains, % and str.format (a prefix held in a name, returned by a helper of the file or passed as a literal argument counts), and a string built from such an output ('' + safe_url(x), '%s' % ..., a helper that returns one, a join or container of it) is judged again where it is placed unless it is a balanced attribute fragment of its own (placed unchecked, see #3864); a bare output where nobody checked its position is not clean. Not covered, listed in the guard's docstring and #3864: an unreadable prefix is assumed position-neutral, a function that takes the output as a parameter and builds the tag, a lambda, a metaclass descriptor, hostile monkeypatching. A + chain longer than the depth bound is flagged instead of crashing the scan. Directed rows pin every rule.

  • A formset whose form adds fields in __init__ can mark rows for deletion (#3861). remove_row exempted from max_form_fields only the fields a form class declares (form.base_fields) and an inline formset's foreign key, so a form that adds d1, d2, d3 in __init__ counted all of their seeded row keys: with 600 rows (1,800 such keys) none of the 600 deletes was stored. The exempt names now come from formset_cls().empty_form.fields (the unbound form, so they are the class's and never the client's), read once per formset class per process (a form whose fields depend on state that changes later keeps the first answer) and cached weakly, over an empty queryset for a model formset so no rows are read; a form that cannot be built without arguments keeps the declared fields exempt as before and logs the fallback once. Tests added for the inline foreign key (a real ContentType / Permission inline formset) and for the TOTAL_FORMS clamp's max against min. #3867 adds tests only (no behaviour change): the kept-rows sidecar entry never replaces an explicit entry, never ships for a name outside the context, and nothing is kept for a QuerySet reached through a container.

  • The Python data_table template rendered an empty cell for every cell of a dict row, an empty data-value on every clickable row, and read sortable: False as True (#3870). table.html read a cell as {{ row|dictsort:col.key|first }}, but dictsort over a dict iterates its KEYS and resolves the column key against each, so the lookup was ""; the same expression gave data-value (row_click_value_key), data-href (row_url) and the href of a link column (col.link). {% if col.sortable|default:True %} turned False into True, so an unsortable column still had dj-click on its header. Cells now come from a table_cell filter (a dict row's value; "" for a missing key or a row that is not a dict; written with str() like the native table, so a number is not localised and 1000 is not 1,000; a mark_safe string is kept, as every Python tag does), and sortable is default_if_none:True. The two URL sinks the fix makes live go through table_href (url_attr): javascript:, data: and vbscript: become #, the value is escaped, and a quote cannot end the attribute. The native table reads sortable: None as "not given" too, as the template does. table.html now begins with {% load djust_components %}, so a test that renders it with a bare Engine() registers the library (the three that did are updated). The native table's data-value and cells are pinned equal to the Python template's for dict rows with a missing key, a None, falsy values, a key named items, numbers and a hostile payload sweep. Rows that are not dicts still render nothing natively and an empty cell under Python, as before. A row's data-value is force-escaped, so a mark_safe row value cannot leave the attribute.

  • A native BLOCK tag's argument naming an unresolved variable became that variable's NAME (#3871). {% nav_menu toggle_event=nope %} with nope undefined rendered dj-click="nope" under the socket render (an event name the view may well have), and {% card title=nope %} printed nope; the Django tag renders both empty, and an inline tag's argument ({% dj_button event=nope %}) already did on both engines. The engine resolves a block tag's arguments through a channel that keeps the raw token when a name does not resolve, so the handler was handed text it could not tell from an operand that resolved TO nope (a lookup against the context, the context.get(val, val) in _parse_args, answers a different question). Block handlers now declare TYPED_ARGS without TYPED_ARGS_STANDINS_ONLY (_TypedBlockOperands): the engine says which operands resolved, and the wrapper empties exactly the ones that did not, so a block handler sees what an inline handler sees. A quoted literal, a number, True / False / None, a dotted lookup, a falsy variable (0, "", None, False) and a value that merely reads like an unresolved name (shadow = "nope") are untouched. Still different, and pinned in test_a_value_that_is_also_a_variable_name_is_resolved_a_second_time: that same context.get(val, val) fallback looks a RESOLVED value up a second time, so ev = "name" prints whatever the variable name holds natively; it stays because about 400 tests call a handler's render with a raw token and rely on it (filed separately). python/djust/tests/test_component_arg_handling_3870_3871_3872.py renders 24 argument shapes through nav_menu, nav_item, filter_bar, card, dj_button and badge on both engines. The {% call %} / {% component %} handlers keep their own argument reader, whose operands arrive as raw tokens by declaration (RESOLVE_ARG_POSITIONS), so a lookup there is the resolution, not a second one. Behaviour change to know: an unresolved operand on a block tag (title=view.page_title, a namedtuple attribute pt.x, both of which resolve on neither engine) now renders empty instead of its own text.

  • Native component handlers stripped the whitespace of a variable-valued argument; the Django tags keep it (#3872). {% dj_button event=ev %} with ev = "x " (or "x\n", "x ", " x") rendered dj-click="x" natively and dj-click="x " under Django, and {% badge label=ev %} lost the same padding from visible text. _parse_args ran val.strip() on the argument text, but the engine splits a tag on whitespace, so a source token never carries any: only a RESOLVED value was ever stripped. The text is now read as it arrives; a padded text is never read as a number (int(" 5 ") is 5) or as True / None, and a lone quote character is no longer read as the empty literal. A lone space now wires on both engines. Still different, and pinned in test_the_text_channel_cannot_tell_the_string_zero_from_zero: the string "0" reaches an inline handler as the number 0 (the engine hands handlers text, and a falsy count must wire nothing). Behaviour change to know: a resolved value that carries leading or trailing whitespace is no longer stripped natively, as the Django tag never stripped it, so dj-click="go " no longer dispatches, and pagination page=" 2 ", which rendered natively before, now raises TypeError as the Django tag does.

  • {% include obj %} in a child view's template called a render() marked alters_data or do_not_call_in_templates (#3880). #3879 refused it on five entries (DjustTemplateBackend, _rust.render_template, RustLiveView, a LiveView's WebSocket and HTTP GET renders), but embedded and sticky child views (websocket.py::render_embedded_child_html, templatetags/live_tags.py::_render_sticky_child_html) render through Django's stock engine, which called it. The engine is unchanged (no change to filters, tags or escaping): the render is now made inside include_guard_scope (python/djust/_include_guard.py) and a patched IncludeNode.render, inert outside that scope, applies the same refused_marker check as the five entries, so a flagged render is not called, the include renders the empty string and one WARNING is logged per type. The operand is resolved once and the checked bound method is the one that runs. The same scope now wraps the other places a view's or component's context reaches Django's engine: the two remaining {% live_render %} branches (legacy non-sticky and lazy), streamed fragments, pending layouts, the native renderer, dj_suspense fallbacks, the component gallery, a component's Django-engine fallback and {% extends %} path (both component kinds), the template of an inclusion tag bridged into a LiveView render, and LiveViewTestClient.render(engine="django"). A render no djust view or component is making (your own page, the admin) is still stock Django. Regression cases in python/tests/test_render_call_guard_3815.py (the two child-view tests that pinned the old behaviour are flipped).

  • The native data_table drew no row_url navigation and no link columns, so they vanished when the socket opened (#3887). The Python table.html (the first render, under Django's engine) draws row_url="key" as data-href, role="button", tabindex="0", style="cursor:pointer" and the data-table-row-clickable class on each <tr>, loads data-table-row-click.js (which does the navigating), and draws a column with "link": "key" as <a href> (with link_class); the native handler implemented none of it, and its row-click rows lacked the role, tabindex and the script too. The native handler now writes the same markup, with two exceptions: a row_url variable holding the text "0", "None" or "False" is a clickable row in the Python tag but not natively, because the native handler re-types the text it is handed (#3882, #3883), and a row whose URL value is None still draws <a href="None"> on both engines (#3895). Every URL goes through the same url_attr allowlist as the Python tag (javascript:, data: and the other non-navigation schemes, case-folded, whitespace-, control-character-, entity- and percent-obfuscated, become #; the value is escaped), row_click_event still wins over row_url, and a falsy row_url / link (None, "", 0, False) draws nothing, decided on the value. test_data_table_row_url_link_3887.py renders every shape through both engines (the sweep's 60 URL payloads and 17 more, three placements, 15 row-value shapes, 27 link-column forms, 27 parameter shapes, callables behind the call guard, marked-safe and __html__ values called through the handler directly) and compares what a browser acts on; the parity baseline moves data_table from 118 to 120 readable parameters and 20 to 16 tags / other-attrs differences. A mark_safe URL is still kept raw by the Python tag (#3894); the other residuals are listed in #3895.

  • A relation read inside a filtered loop over a list of models renders empty on the Django-backend engine (#3888). {% for u in users|slice:':2' %}{% for g in u.groups.all %}{{ g.name }}{% endfor %}{% endfor %} over a list / tuple of models (or a QuerySet at a nested name such as page.users) rendered nothing where Django's own engine prints the groups; so did {{ u.groups.all.count }}, {{ u.groups.count }}, a *_id attname such as {{ p.content_type_id }}, and the same reads behind |first, |dictsort and {% with s=users|slice %}. A model that reaches the engine inside a container used to be a dict of its fields, and anything the dict lacked was read through a loop alias that a filtered operand never registers. Such a model is now a floored live handle: the row carries its own handle through any filter, and the handle is the floor proxy the template sidecar already used, so every attribute read, relation traversals included, is checked against the serialization floor (the fields, methods, per-model rules and field types it withheld before stay withheld, the live handle refuses underscore-prefixed names for any reader, and anything a read returns is plain data or another floored handle (a model, manager or queryset becomes a proxy; a callable becomes a wrapper whose result is floored again; a class or module is refused, so {{ u.DoesNotExist }} renders empty; the wrappers report a class with no Python-level methods and close routes to Python-defined objects and namespaces: a UUID is its text, plain data is matched by exact type, a dict's keys are floored like its values, and a datetime or time with a Python-defined tzinfo carries a floor tzinfo that answers the same offset, DST and name questions), and alters_data methods are still never called). The rows a relation read returns (u.groups.all, u.groups.first) are handles too, so a second relation hop works. {% if u == rows.0 %} / u in rows compare models by class and primary key, |dictsort:'field' (dotted keys included) reads the key off the row once per row, {{ u }} is str(u), and a model written as JSON for a tag argument or to a state snapshot is the same floored dict it always was. Scope: only a model that reaches the engine inside a container; a top-level single model is still serialised first and keeps the dict form on a LiveView (#3936) — DjustTemplateBackend carries it as a handle too since #3902 — so on a LiveView {{ u|pprint }} of a top-level model dumps its fields while {{ rows|first|pprint }} prints its name. Behaviour changes for a template that depended on the old dict: a model in a container is no longer a mapping, so {% for k, v in u.items %} renders empty, as on Django; a bare FileField prints its stored name, as on Django ({{ p.image.url }} is the URL on both); a custom Python filter or simple tag that receives such a model gets the floor proxy (attribute reads work; row["field"] does not) where it got the dict, while a top-level model on a LiveView or a top-level queryset loop still gets the dict; and {{ rows|json_script:"x" }}, {{ rows|pprint }} and a data-dump tag such as {% json_viewer %} spell each model in a container as its str() (["u0", "u1"]) where they dumped its fields (Django raises for the json_script case), so code that reads such data in JavaScript must build the data explicitly. A bridged or native tag that takes a list of models as an argument ({% data_table rows=rows %}) still receives dict rows. LiveView state, the client payload and the msgpack snapshot are untouched. Not in this change: dictsort over a field the template reads nowhere else, on a QuerySet or in a LiveView, still renders empty (#3892).

  • params_form_fallback = "fields" hands handle_params() the value the valid path would for a kept field the URL left out or empty, when that field has an initial (#3891). On a valid URL the form's clean() runs first and such a field then takes its initial, so clean()'s write to it is overwritten. The "fields" fallback filled the initial before its extra clean() check and then adopted clean()'s output, so clean()'s write survived: a clean() that copies a request value into another field handed a value that had failed validation to handle_params() through a field the URL never named. The initial fill is now applied again to the check's output, with the helper the valid path uses. The check itself still runs on the combination that would be handed over, so the result stays a combination clean() accepts, or the plain initials. Behaviour changes: a clean() that sets a default into such a field now loses to the field's initial (as on a valid URL), and the fallback hands over a copy of a mutable initial it fills in (the valid path and "initial" mode hand over the object itself, so a handle_params() that mutates it there changes the form class's default for later requests: #3926). Not covered, because the valid path shares it: a field with no initial, a clean() that also writes the field into self.data, and a multi-select field the URL left out (#3922). A field the URL supplied still keeps clean()'s output on both paths, a failed field is still its initial, and "initial" mode is unchanged. Guide: Navigation, "Keeping what validated".

  • {% for u in users|dictsort:'username' %}{{ u.pk }}{% endfor %} over a QuerySet, or a QuerySet / list[Model] on a LiveView, no longer renders an empty loop when the template reads username nowhere else (#3892). A path-JIT row holds only the fields the template reads, and the template-variable extractor (extract_template_variables, crates/djust_templates/src/parser.rs) attributed u.<field> reads in a loop body and nothing else, so a field named only in a filter ARGUMENT was never in the row; Django's dictsort answers '' for a missing key rather than raising, so the loop was silently empty. The quoted argument of dictsort / dictsortreversed (a table of the built-in filters that read a named attribute of each item, measured against Django: exactly these two) and the by attribute of {% regroup %} (which grouped by None on a LiveView) are now filed on the list beside the fields the body reads, through a loop, a {% with %} alias, a {{ }} or an {% if %} operand, and a nested path (page.users). One extractor serves DjustTemplateBackend and the LiveView, so both are fixed together. The key is filed only when the items are also read by field: with nothing else singled out the row is serialized whole, which already carries the key and the __str__ a bare {{ u }} prints, so {% for u in users|dictsort:'name' %}{{ u }} keeps working. A key that is not a plain path (empty segment, a segment starting with _, as Django refuses) is ignored, and the serialization floor applies to a sort key as it does to {{ u.password }}. Not fixed here and filed: a context-variable key (dictsort:key, #3910), a dotted key the Rust dictsort cannot sort by (#3912), and a loop variable read whole next to a field (#3911). New cases in python/tests/test_item_key_filters_3892.py; the strict xfails of TestDictsortOverJitRows in python/tests/test_sliced_list_of_models_3888.py are removed.

  • A Component's hybrid template now applies the attribute rule of the Rust instance and _render_custom() to its attribute values (#3900). Component.render draws a class that has a template through it when the crate constructor refuses a value (a lazy string, gettext_lazy / format_lazy, is one; so is any object that is not plain text) and always when the class has no crate counterpart (ButtonGroup, Checkbox, Input, Offcanvas). That path wrote {{ src }} into an attribute with only autoescape and no URL check, so Avatar(src=<lazy "javascript:alert(1)">) printed a live script URL where the plain string printed <img src="#">, and a lazy size / status could leave its attribute. Now every {{ ... }} in the value of an attribute of a start tag is escaped whatever the value is marked as, however it yields its text (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable the engine calls), also under {% autoescape off %}; and in a URL attribute (href, src, action ..., and the dj-navigate / dj-patch / dj-patch-reload directives) the value is judged with the scheme allowlist the other paths use (url_allowed, data_url_attr, srcset_attr), so any scheme outside the allowlist (javascript:, data: for a link, sms:, magnet: ...) becomes #. The template is read once per template string (a bounded LRU cache) with the HTML attribute grammar (whitespace around =, quoted and unquoted values), so it covers the 19 shipped hybrid classes and a template you write yourself. Behaviour changes for your own hybrid components: a marked-safe value in an attribute is escaped again (mark_safe("a &amp; b") prints a &amp;amp; b; pass plain text); in an attribute a number prints in its canonical form (33.5, not a localized 33,5) and a date in its ISO form (2026-01-02, not a localized Jan. 2, 2026), for a plain name, a dotted path and a filtered expression alike; a callable that needs arguments prints nothing, as in Django; and the hybrid path is now stricter than _render_custom() for a scalar argument in a non-URL attribute. A URL is judged as the WHOLE value in every way it can come out: a literal inside {% if %} / {% for %} may be skipped, so it never fixes the value (each branch is a path of its own, and a good value that follows a block-added literal is judged as if the block were skipped), and a literal is read the way a browser reads it (character references decoded, tab and newline dropped). A slot that can still choose the scheme (first in the value, or only scheme characters before it) is judged together with the literal text and the other slots; a slot after a top-level literal that already fixes the value (href="/search?q={{ q }}", href="http://{{ host }}/", href="{% url 'x' %}?q={{ q }}") is escaped, not judged, so foo:bar and host:port are kept. A URL value that can choose the scheme but whose slot is a dotted path, a filtered expression, a {% for %} / {% with %} variable, sits beside a tag that prints text this module does not follow ({% trans %}), has more than 64 paths, or has an attribute NAME built by tags, is escaped but not judged; a srcset is judged only when the slot is the whole value; an unquoted value is escaped but cannot be made safe. Each of these is named by one warning per component class and set of attributes, which carries the class name and the tag / attribute names only, never template text. A value whose str() raises raises in a URL slot as the engine does; in a plain slot the Rust engine's force_escape prints the escaped default repr where Django's raises (the crate's filter, unchanged). {% verbatim %} and {% comment %} blocks, {{ }} in an attribute name and text between tags are left as written. The sweep in test_marked_safe_attribute_sweep_3898.py no longer sets the hybrid cells aside: it drives every template class through the template and through Component.render, with a claim for each kind of value, and its reach test asks per claim kind.

  • A filtered rebinding of a top-level single model reads its relation empty through DjustTemplateBackend (#3902). {% with q=user|default:user %}{{ q.groups.count }}{% endwith %} over ONE model passed as a top-level context variable rendered nothing where Django's own engine prints the count; so did q.groups.exists, q.groups.first.name, {% for g in q.groups.all %}, a model method that returns a queryset, a method call on the model through the rebinding ({% with q=obj|default:obj %}{{ q.describe }}{% endwith %}), a *_id attname, and {% if user in users %}. DjustTemplate.render replaced the model with a dict of its fields before the engine saw it, so a filtered operand (which registers no loop alias) had no manager and no relation to read. A plain render is not state, so the model now reaches the engine whole, as a list row has since #3888: a floored live handle, checked against the serialization floor on every attribute read, with the same refusals (underscore-prefixed names, Python-defined classes and modules, a function's __globals__, a UUID is its text, a Python-defined tzinfo, a dict's model keys). A model reached through a context processor (request.user as user, a lazy object) takes the same route. serialize_value(for_render=True) also stops treating a model as a file: it asked hasattr(value, "url") and hasattr(value, "name"), which a model with a name field and a url field satisfies, so {{ link }} over a list of such rows rendered the row's URL instead of its str(), and a row whose url property raised anything but AttributeError failed the whole render. Behaviour changes for a template that depended on the old dict (top-level model, DjustTemplateBackend only; each was run against the old and the new code): a bare FileField now prints its stored NAME, as on Django, not its URL ({{ doc.attach }} printed the URL and now prints the stored name, which has no leading slash; {{ doc.attach.url }} prints the URL on both), so <img src="{{ profile.avatar }}"> written against the old dict must become <img src="{{ profile.avatar.url }}">; the model is no longer a mapping or an iterable of field names, so {% for f in user %} and {{ user|unordered_list }} now fail the render (a RuntimeError; Django raises TypeError) where they iterated the field names, {{ user|join:"," }} is str(user) where it was id,pk,__str__,..., {{ user|pluralize }} is empty where it was s, {% for k, v in u.items %} renders empty and {% if 'username' in u %} is false, as on Django; {{ u|pprint }} and {{ u|json_script:"x" }} print the model's str() where they dumped its fields (Django's json_script raises for a model), so code that reads such data in JavaScript must build the data explicitly; and a custom Python FILTER that receives the model gets the floor proxy (attribute reads work, row["field"] does not) where it got the dict (a simple_tag already received the proxy, so nothing moves for tags), and a filter that copy.copys, copy.deepcopys or pickles the model now gets copy.Error / pickle.PicklingError. A bridged or native tag argument is still the same denylist dict text, byte for byte. Not in this change: a LiveView holds the model as state (ADR-027: client payload, msgpack, change detection), so the same template still renders empty there (#3936).

  • An item value that is marked safe is escaped in the attributes the template tags write it to, for the list parameters the sweep derives (#3904, follow-up to #3898). Behaviour change: an entry of a list parameter, or a value read out of an item dict, that is marked safe (mark_safe, a lazy one, a subclass, a str with __html__, or an object whose text form is marked safe) used to be written as it is into an attribute (value=, data-*, id=, class=, aria-label=, fill=, ...) by 43 tag parameters, and by a form field's choice value in dj_form (also through the native engine, which hands the form to the same tag) and the column keys and filter options of model_table: audit_log (entries, columns), autocomplete, collab_selection, combobox (options, selected), cursors, dashboard_grid, data_grid (columns, rows), dependent_select, dj_select, export_dialog (columns, formats), fab, file_tree, gantt_chart, kanban_board (a column and the cards inside it), line_chart, masonry_grid, meter, model_selector, multi_select, notification_center, reactions, scroll_spy, sortable_list, split_button, tag_input (tags, suggestions), toast_container, toggle_group, tour, tree_view, wizard, theme_radio / theme_select of djust_theming, and further parameters treated the same way. It is now escaped there exactly as a plain string is (attr_text in the Python tags, |force_escape in the three templates); text slots keep their markup. A scalar tag argument is unchanged. The template protection lives in the three shipped templates: a per-theme override or a project copy made from the old toast.html, select.html or radio.html must add |force_escape to the attribute values it prints from an item. test_marked_safe_attribute_sweep_3898.py now drives each of the 43 parameters (a cell each: the attributes the item reaches are pinned, and every kind of marked-safe value must leave them as a plain string does), derives the list parameters of the swept tag libraries at run time and tries each with the item's keys read from the tag, its other lists filled, its flags on and off, the scalars that select a branch set, nested items, pairs and an empty block body; a planted-leak test proves the derivation can report a leak, and a static hunt over the tag modules, the structural check of the two, finds an escaped item written to a quoted attribute (an f-string, a concatenation, % or .format; not an unquoted attribute, format_html, or a helper that escapes a value it is handed) without enumeration. Its list of known leaks is empty.

  • scripts/gate_off_batch.py no longer leaves its test command running after a hard kill (#3905). The test command runs in its own session, so when the tool (or the pytest worker that was running it) was SIGKILLed, nothing could kill the command's process group and a hung child spun as an orphan until someone found it. The command now runs under a small watchdog that kills its whole process group as soon as the tool that started it is gone (the deadline, RSS, SIGINT and SIGTERM kills are unchanged), and every busy loop in the tool's own test fixtures ends by itself after 45 s. Six tests assert that no descendant, a spawned grandchild included, is alive after a HUNG run, an import-time hang, a RUNAWAY-MEMORY run, SIGINT, SIGTERM and a SIGKILLed tool; the self-check list gained three mutations for the group kill.

  • The inline template of a LiveComponent and of a class-level (BoundComponent) component gets the attribute rule of the hybrid Component template (#3913). All three draw their template string through one helper, _render_inline_template (components/base.py), which applies djust.components._hybrid_attrs.harden_context before the engine; an AST census of the package fails when another site renders a component's inline template (under any spelling: an alias, another module, a direct Template(...).render(...)). So every {{ ... }} in the value of an attribute is escaped whatever the value is marked as (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable the engine calls; also |safe and under {% autoescape off %}), a URL attribute (href, src, action ..., and the dj-navigate / dj-patch / dj-patch-reload directives) is judged with the scheme allowlist on the whole value as it is assembled, and the text of <textarea>, <title> and HTML comments is escaped (#3914). Behaviour change for existing inline templates, measured over a corpus of 9,950 renders (118 differ): (1) a scheme outside the allowlist in a plain-name URL slot becomes # (href="{{ url }}" with javascript:alert(1), and sms:, whatsapp:, zoommtg:, webcal:, geo:, slack:, silently; the no-op href="javascript:void(0)" / javascript:; is kept, in an href only, so in an <img src> or dj-navigate it becomes #); (2) a marked-safe value, or a plain value written with |safe, in an attribute, in <textarea>, in <title> or in a comment is escaped (title="{{ v|safe }}" with mark_safe('{"a": "b"}') printed the quotes raw, ending the attribute, and now prints &quot;; mark_safe("a &amp; b") prints a &amp;amp; b, so pass plain text); (3) a number or date in an attribute prints in its canonical form (2026-01-02, 33.5, not Jan. 2, 2026 / 33,5). Not changed: <script>, <style>, <xmp>, <noscript> ... keep the author's markup (<script>var cfg = {{ cfg|safe }};</script> stays JSON; use json_script or a data-* attribute to put data in a page); relative, http(s):, mailto:, tel:, fragment and query URLs, ids, component_id and every ordinary value render byte for byte as before; a text slot keeps a marked-safe value's markup. Also fixed: {% blocktranslate %} / {% blocktrans %} in an attribute (aria-label="{% blocktranslate %}Delete {{ item }}{% endblocktranslate %}" printed Delete because the rewrite renamed the catalog-keyed variable; the mechanism came in with #3915 for the hybrid Component). A translation block's body is now never rewritten, and what it prints is escaped once (a variable it reads from the context is escaped around it, a with argument gets |force_escape, a count argument is left alone); it works on both engines, for plural, with, trimmed, context and asvar, in an attribute and in <textarea> / <title> / a comment. A block in a URL attribute is escaped, not scheme-checked. What is escaped but not scheme-checked (a URL whose slot is a dotted path, a filtered expression or a {% for %} / {% with %} variable, a translation block; an unquoted value), the slots of an {% include %}d partial (neither escaped nor checked), a tag that prints text inside <textarea> / <title>, and shapes the scanner cannot follow like a browser does are named by one warning per component class (class, tag/attribute/element names, shape names and a count; never an attribute value or anything from the context; an attribute name is template text and is logged, so keep names static in a template built per instance). An {% include %} of a literal name that the loaders find and that holds nothing to harden is no longer reported. Two behaviours: a callable in a URL slot is called when the context is prepared, even if its branch does not render (it must be judged first), where a callable in <textarea> / <title> text is called only where the engine reaches it. Not changed: a template_name file (loaded by the project's loaders; #3933) and a LiveView's own template. See the components guide.

  • A value written into <textarea>, <title> or an HTML comment of a hybrid component template is escaped even when it is marked safe (#3914). The content of <textarea> and <title> is text in which character references are decoded, ended by the first matching end tag, and --> ends a comment the same way, so markup in a value there can end the element. Component.render draws a class through its template when the crate constructor refuses a value (a lazy string is one) and always for a class with no crate counterpart, and that path left a marked-safe value alone: TextArea(value=<lazy marked-safe text that ends the textarea>) ended the element and put live markup after it. Now a {{ }} in that text is escaped whatever the value is marked as (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable returning one; also {{ v|safe }} and under {% autoescape off %}). Before/after: <textarea>{{ v }}</textarea> with v = mark_safe("<b>x</b>") printed <b>x</b> and now prints &lt;b&gt;x&lt;/b&gt;; the same for <title> and for <!-- {{ v }} -->. An ordinary value prints exactly as before: a plain name keeps the engine's own formatting (a date localized, a number formatted), checked by rendering a corpus (strings, numbers, decimals, dates, datetimes, None, booleans, lists, dicts) before and after; a dotted path or a filtered expression ends in |force_escape, so a date or number there prints in its canonical form (2026-01-02). A callable in such a slot is called where the engine reaches the slot, not before, so one in a branch that does not render is not called. Unchanged on purpose: <script>, <style>, <xmp>, <iframe>, <noembed>, <noframes>, <plaintext> and <noscript> keep the author's markup. Entities are not decoded in them, so HTML escaping is the wrong encoding (<script>var cfg = {{ cfg|safe }};</script> must stay JSON); use {{ data|json_script:"id" }} or a data-* attribute to put data in a page. The scanner still reads the first seven as raw text, so an <a href="{{ u }}"> inside <xmp> is not an attribute. Every other text slot keeps a marked-safe value's markup, as before; the remaining difference to the Rust and custom paths for ordinary text slots is documented in the components guide and tracked in #3932. A {% blocktranslate %} block in an attribute value or in that text is no longer broken (see the #3913 fragment); a tag that prints text inside <textarea> / <title> / a comment ({% firstof %}, {% cycle %}), shapes the scanner cannot follow like a browser does (an element name built by a tag, an end tag in another block than its start) and an {% include %}d partial that holds slots are named by the existing once-per-class warning (class, tag/attribute/element names, shape names and a count; never a value). The scanner also reads a raw-text end tag the way the HTML tokenizer does (ASCII case and whitespace; </textarea-x> is not an end tag) and follows an element opened inside {% verbatim %}.

  • An option whose value contains &, <, >, " or ' is drawn as selected (#3927). {% combobox %} decided "selected" by comparing the HTML-escaped option value with the raw selected value, in single mode (value=) and in multi mode (selected=), so a value such as a&b was never drawn as selected (no combobox-option-selected class, aria-selected="false") while ab was. Both modes now compare the raw text of the option value and of the selection (as str, so an integer 1 still matches "1" and None still matches None), and escape only what they write; a&amp;b as a raw value stays a different value from a&b, and a marked-safe or lazy value is compared by its text, not by its marker. The same escaped-versus-raw comparison was in three other places, all fixed: the native dj_select handler (selected), the native dj_radio handler (checked; the Django tag already compared raw, so the two engines disagreed), and the class-based DependentSelect. dependent_select, the data-table select filter and dj_select on the Django engine compare two values escaped the same way, or two raw ones, and are unchanged.

  • The server-function and sticky-LiveView demo pages printed their own template comments. Five multi-line {# #} comments in examples/ (Django's {# #} is single-line only, so they rendered as text) are now {% comment %} blocks. scripts/check-template-comments.py scanned only python/djust; it now scans examples/ too (--path is repeatable) and skips staticfiles/, node_modules/ and virtualenvs.

  • Per-model serialization settings are honoured for lazily wrapped model instances. djust_exclude_fields, djust_serializable_fields, djust_serialize_sensitive_fields, to_dict() and the column-type rule are read from the model class a wrapper reports through __class__, on the native engine (a bare read, a dotted filter, a filtered rebinding), the {% load %} bridge, the eager serializer, the JIT attribute gate (which decides by name) and a ModelForm over a wrapped instance. A wrapper that cannot be evaluated raises. A model that declares none of these settings renders as before. Regression cases in python/tests/test_per_model_rules_lazy_wrapped_model.py.

  • {% theme_icon %} no longer raises for a long digit string on an interpreter whose decimal is the pure-Python _pydecimal (follow-up to #3766). theme_icon is documented to warn and never raise for a hostile size, but a size of 5000 digits raised ValueError: Exceeds the limit (4300 digits) for integer string conversion on the Python 3.15.1 CI cell (python/tests/test_theme_icon_size_3766.py, the very-long rows) while passing on 3.12. The size text matched the validation pattern (it is all digits) and Decimal(<the digits>) was then built outside the try; CPython's C _decimal parses digits itself, but _pydecimal builds the value with int(), which is subject to sys.get_int_max_str_digits(). The whole-number part of the size is now length-checked (leading zeros dropped, so "0016" is still 16) before any Decimal is built from it, and a failure from Decimal anyway falls back to the default size under the existing guard. The same class in decode_state_roundtrip (python/djust/serialization.py): a user dict {"__djust_decimal__": "<5000 digits>"} that collides with the state tag now stays a dict, as its docstring promises, instead of raising ValueError on a _pydecimal build. The new tests (python/tests/test_theme_icon_int_limit.py) force the pure-Python Decimal and the digit limit, so they fail without the fix on every interpreter.

  • URLs are normalised the way a browser reads them before the same-site test. The data table's row navigation (data-table-row-click.js), the map picker's tile and asset URL (map_picker._plain_url, map-picker.js), validate_mount_url and same_origin_target now apply the browser's own URL normalisation before deciding whether a URL stays on the site, through one definition (djust.security.urls.url_as_parsed and url_is_protocol_relative, mirrored in the two static scripts). same_origin_target also answers "/" for a URL its parser refuses, where it raised. url_allowed / url_attr are unchanged: they are a scheme allowlist, and their docstring now says so. Absolute http(s) row URLs are still followed. A differential over about 120,000 generated URLs checks the helper, the unchanged JavaScript guard and the Python decisions against the browser's URL parser and Django's url_has_allowed_host_and_scheme.

Security

  • FormMixin does not pre-fill password and other credential fields from the stored value. A field is withheld when it is on the serialization floor and looks secret-like: it has a password widget; or its name contains password, passwd, secret, token or apikey, or one of key, hash, salt, pwd, passphrase, otp, signature, pin, credential, credentials as a whole word (api_key and pinCode match, privatekey and pw do not); or its column is of a type the type floor excludes (a sensitive_field_types entry, BinaryField, an encrypted field type). A floor field that does not look secret-like (for example email listed in DJUST_SENSITIVE_FIELDS) and a custom column that is not on the floor (api_key with the default configuration; list it in DJUST_SENSITIVE_FIELDS to include it) are seeded as before. A withheld field is seeded with "" on mount, reset_form, a wizard step and after a valid submit, in form_data and in as_live(), as_live_field(), {% live_field %} and get_field_value(). {{ form }} and {% dj_form %} render as before.

    Behaviour. On an edit form an absent, empty, whitespace-only, None, 0, False, [] or {} submission of a withheld field keeps the stored column. A withheld password-widget field writes a typed value; every other withheld field is read-only (disabled, as before) and ignores typed input. A PasswordInput (including render_value=True) is no longer echoed by the live renderers, and a hash-widget field that was not disabled becomes read-only (a one-time warning names the field). After a valid UserCreationForm submit password1 and password2 are "" in the form state, and the UserChangeForm password input is empty. A secret-like field with an initial on a create form is not pre-filled in the live renderers. A validation message that carries a withheld field's stored value is replaced by "Enter a valid value."; other messages, such as a failed login or a password-policy message, are unchanged. A FormMixin view with no form class gets "" for secret-like keys of its hand-managed form_data when state is restored. Saved form state from earlier versions is cleaned when it is restored. Regression cases in python/tests/test_form_data_secret_seed.py.

  • pulldown-cmark is pinned to >= 0.13.6, past RUSTSEC-2026-0414 and RUSTSEC-2026-0433 (#3741, #3742). Both advisories (an exponential event blow-up from [[a|]], and quadratic work on nested ![[ wikilinks) affect pulldown-cmark 0.13.0 through 0.13.5 and only with Options::ENABLE_WIKILINKS. v1.3.0rc14 shipped 0.13.4 in its Cargo.lock and SBOM; v1.3.0rc15 and main already lock 0.13.6, so the weekly scan clears once it next reads a newer tag. djust is not reachable by either advisory: crates/djust_templates/src/markdown.rs::to_pulldown starts from Options::empty() and sets only tables, strikethrough and task lists, never wikilinks. This change raises the Cargo.toml requirement from 0.13 to 0.13.6, so a lockfile regeneration cannot resolve below the patched version, and adds a Rust test that Options::ENABLE_WIKILINKS is unset and wikilink syntax stays literal text under every RenderOpts combination. One behaviour difference between 0.13.4 and 0.13.6 reaches render_markdown: a link reference definition followed by a whitespace-only line ([b]: /u\n\t\n) no longer renders an empty <p></p>. A differential of the two versions over the 401 markdown files in this repository (zero differences) and 200,000 generated inputs under all eight option combinations found that one class and nothing else. Regression case wikilink_syntax_is_never_a_link in crates/djust_templates/src/markdown.rs.

  • djust_theming judged the URL a template prints, not the value it was handed (#3749). A theme_nav / theme_nav_group / theme_breadcrumb / theme_sidebar_nav item whose url is a callable (def url(self): return "javascript:alert(1)") printed href="javascript:...": the check ran before the template engine called the callable, judged "<bound method ...>", and the engine then printed what the method returned. The item's value is now looked up and resolved the way the engine does it: item["url"] first and the attribute second (for any object with a __getitem__, not only a Mapping), then called once by the engine's own rules (do_not_call_in_templates, alters_data, a call that needs arguments), and the item is rewritten with a value the engine will not call or convert again; a callable that returns a callable is written back as the text the engine would have printed for it. A callable items on a sidebar section is resolved the same way. A value is converted to text once, and that text is what is judged and printed (an object whose __str__ answers differently the second time can no longer pass the check and print something else). A lazy value that prints as marked-safe text (lazy(lambda: mark_safe(...), str)()) is plain text like a SafeString.

    The **attrs pass-through of theme_button / theme_input / theme_select / theme_progress judged only href / src / action / formaction. These are now also judged by djust.components.utils.url_allowed: ping, srcset (entry by entry, so one bad entry does not drop the good ones), poster, data, background, and the framework's own dj-navigate / dj-patch / dj-patch-reload. A rejected scheme renders # and logs a warning, and these never raise (only the four that always did still do). The set is an explicit list decided name by name, not a pattern: cite, longdesc, manifest, codebase, usemap, icon, profile, archive, classid, itemid, itemtype (no browser follows them on the elements these tags print, or they hold text such as icon="mdi:home") and every author-defined data-* / dj-* name (data-url, dj-action, data_location="Paris: France") are printed as written. One visible consequence: data="x:y", data being the <object> fetch attribute the issue names, renders # like href="x:y".

    What this covers, and what it does not: the tags' own arguments and the pinned pass-through names. A project template override of one of the theming component templates (the badge's, say) that prints an attrs.<name> into a URL attribute is outside it, because a tag cannot see which key an override reads; no template in this repository does (the set of such reads is derived from the templates and pinned in TestAttrsReadsInUrlPositionsArePinned). Regression cases in TestCallableUrl, TestEveryPassthroughTagAndAttribute, TestThePassthroughSetIsPinned in python/tests/test_theming_url_hardening_3749.py and in python/tests/test_theming_url_review_3765.py; the sweep (scripts/template-library-url-sweep.py) drives every URL attribute the scanner reads through every tag that takes **attrs, so a new pass-through sink fails test_url_scheme_sweep_3665.py until it is classified.

  • {% theme_icon %}, {% theme_form %}, {% theme_form_errors %} and the inline theme switcher no longer write a raw value into markup they mark safe (#3766).

    theme_icon(name, size=20) built each icon with an f-string that interpolated size and returned it under mark_safe, the banned mark_safe(f'...') with an interpolated value: {% theme_icon 'check' size=u %} with u = '1" onload="alert(1)' printed <svg width="1" onload="alert(1)" ...>, an attribute injection whenever size holds user-influenced data (a literal size=16 was inert, which is why nothing showed it). size is now validated and the markup is built with format_html. A number above zero and at most 1000, with at most three decimals, optionally followed by px, em, rem, ex, pt or % in any case, is accepted; an int, float or Decimal is rounded to three decimals first (so a computed 14 * 1.1 prints 15.4), any other type is read by its text. Anything else renders the default size 20 and logs one WARNING per call (the value's type, never its content; an icon rendered in a loop logs once per icon), and does not raise: that includes a value whose __str__ raises and an int over Python's 4300-digit limit, both of which used to propagate. size=None means "not given" and renders the default silently. theme_icon has no Rust tag handler of its own (djust.theming.rust_handlers registers the theme_tags tags only), so both engines run this one function, and the tests drive it through both.

    What changes for a size that worked before: it prints what it printed when it is an int, or text of the form 16 / 1.5 / 24px / 1.5rem with a lower-case unit. Deliberate differences: the number is re-printed from its parsed value (16.0 prints 16, "016" prints 16, "1.50" prints 1.5); a unit is lower-cased (16PX prints 16px); a float or Decimal is rounded to three decimals (Decimal("1E+2") prints 100, 0.1 + 0.2 prints 0.3); and a size outside the range now renders 20 with a warning (a number over 1000, such as 1001 or 1234.5; zero or less; a float that rounds to zero, such as 0.0001; a type whose text is not a plain number, such as Fraction(3, 2)). Everything else a hostile or malformed value can be (a quote, >, an entity, whitespace, a sign, an exponent, a bool, a very long string) renders 20 too.

    The same class, found in review where the first guard could not see it, because the value never sat inside mark_safe(<f-string>): theme_form and theme_form_errors (theme_form_tags.py) appended f-string lines holding the configured css_prefix and BoundField.id_for_label (built from the FIELD NAME, the form prefix and auto_id) to a list and marked the join safe, so a form with a field named a" onfocus="alert(1)" x=" printed <label for="id_a" onfocus="alert(1)" x="" class="theme-label">; every line is now a constant or format_html. The {{ theme_switcher }} chunk (context_processors._render_theme_switcher, marked safe by LazyThemeHTML) printed preset["name"] and preset["display_name"] into <option value="..."> unescaped; they are escaped now (&, <, > and "; the apostrophe is left alone, because both places sit between double quotes or in text and the built-in preset Synthwave '84 keeps its bytes, so a name containing & prints &amp;, which a browser shows unchanged). _passthrough_attrs joined name="value" parts built with an f-string; name is validated first so it was inert, and it is format_html now with the same output. The gallery tooltip example (python/djust/theming/gallery/context.py, a constant from a literal tuple) is format_html. A benign form renders byte for byte what it did (40 renderings across prefix, layout and form, captured before the change, are pinned in python/tests/test_theme_markup_escaping_3766.py).

    The guard is scripts/lib/safe_markup_taint.py, tested in python/tests/test_safe_markup_guard_3766.py. It is name-based and single-function: it reads the syntax tree one function (or the module body) at a time, does no type inference, and never follows a call into another function. It follows a value through local variables, tuple unpacking, :=, containers (d[k] = ..., .append, .extend, .insert, += [...]), list / dict / generator comprehensions, +=, join, %, +, .format, .format_map and Template.substitute, to a mark_safe / SafeString / SafeText call under any alias, or to the format string of format_html. What counts as an escaper is decided by where the name comes from, not how it is spelled: escape, conditional_escape, format_html and format_html_join imported from django.utils.html, html.escape, and int / float / bool / len clean a value; re.escape, shlex.quote, an escape imported from anywhere else, and a locally defined def escape(v): return v do not. min / max / round / abs / str of clean arguments, uuid.uuid4().hex, secrets.token_hex, an f-string field with a numeric format spec ({n:.2f}), an enumerate index and .render(...) output (trusted by method name) are clean. It flags the pre-fix theme_icon, theme_form, theme_form_errors and _passthrough_attrs, which the earlier literal-shape check did not, and flags nothing under djust/theming now (the audited allowlist is empty).

    What it does not see, probed and pinned in KNOWN_MISSES of its test: anything that crosses a function (a string built in one function and marked safe in another, a module global appended to in one function and joined in another, a value stored on self, a helper that returns an interpolated string); mark_safe(param); a sink reached without a call to it by name (partial(mark_safe, ...), map(mark_safe, ...), getattr(safestring, "mark_safe")(...)); functools.reduce over raw items; and any builder not listed above. In particular the inline theme switcher (preset_options += f"..." inside _render_theme_switcher, marked safe by LazyThemeHTML elsewhere) is protected by its own tests in python/tests/test_theme_markup_escaping_3766.py, not by the guard. build_theme_head_context builds the <style> / <link> blocks that ThemeMixin marks safe; that was read, not machine-checked.

  • A tag rendered through the {% load %} bridge no longer walks raw objects past the serialization floor (#3771). {{ obj.user.password }} has always been empty under the Rust engine, because the engine's walk re-protects after every segment of a path. A bridged tag (relative_time, avatar_group and the other object-taking djust component tags, every third-party simple_tag / inclusion_tag / raw @register.tag, {% blocktranslate %}, {% cache %}) resolves its operands in Django's own node with Variable._resolve_lookup over the raw objects of the handler dict, and nothing re-protected those steps: {% relative_time datetime=obj.user.password %} printed the password hash, and so did form.instance.password, form.fields.owner.queryset.first.password, obj.users.first.password and, since #3770 hands a bridged tag a live BoundField, {% for f in form %}…f.form.instance.password. The leaf floor only wrapped a Model / Manager / QuerySet that was itself the root of the path, so a Form, a BoundField or a plain object holding a model passed through.

    The node stays Django's. What changes is the walk: while a bridged node renders, every Variable it resolves against the Context the bridge built (FilterExpression operands, |filter:argument, and the template.Variable a raw tag constructs itself, at compile or at render time) is resolved by Django's own _resolve_lookup over a context whose lookups return floor-checked wrappers, and the next step is re-protected after every segment, including after a floor proxy: a stock queryset method or a model property that returns a plain container of models (qs.in_bulk.<pk>.password, obj.user.friends_prop.0.password, obj.user.get_friends.0.password) hands Django a container, and the model inside it is protected on the step that reaches it. This is the re-protection Context::walk_live does for {{ }}, applied to the steps Django's resolver takes; it is not a second implementation of that walk, and the two can differ for an exotic object (see below). The value the walk ends on is released: a wrapper is unwrapped; a model reached on the way is the floor-enforcing proxy; a container that holds a model is re-protected like a top-level container; anything else, including a container with no model in it, arrives as the caller's own object. The refused path is "missing", as in {{ }}: an unresolved operand is the empty string and a filter argument that does not resolve raises VariableDoesNotExist, exactly as Django treats any missing variable. The gate is exact (the Context registered by the bridge for that node, per thread and task), so a Django engine in the same process, another thread, and the Context.new() an inclusion tag renders its own template on are untouched. Benign output is unchanged (the walker is transparent to callable, do_not_call_in_templates, alters_data, silent_variable_failure, signature binding and a property that raises AttributeError).

    Behaviour change. A model reached by a path now arrives in a tag as the floor proxy (as a top-level {% tag user=user %} always did) rather than the raw instance. So isinstance(operand, User), operand._meta, operand.save() and an ORM filter on the instance no longer work on a path-spelled operand, exactly as they do not on a top-level one. Equality did flip silently ({% t obj.user obj.user %} and {% t request.user obj.user %} compared two distinct proxies and went from equal to unequal); the proxy's __eq__ / __hash__ now delegate to the model behind it, which reveals identity and no field, so those comparisons are as they were. isinstance cannot be restored without making the proxy a Model, which would let _protect_sidecar_value treat it as a model to wrap again, so it is not attempted.

    What this does not floor (the template author's reach through the tag author's Python): a generic accessor tag that takes a path as a string ({% getattr obj "user.password" %}, get_item, call_method) receives a released plain object and walks it in its own code; a takes_context tag reading context["obj"] itself; a tag function's body, which receives its released operands; an inclusion tag's own template, rendered on Context.new(); a node that never uses Variable; and an operand released as a non-list / tuple / dict / set iterable (a dict view, a generator, a RawQuerySet), which arrives as the raw object. Whether a particular third-party tag is one of the unfloored shapes is the tag author's code, not something the engine can see.

    Regression cases in python/tests/test_bridge_floor_3771.py (the five spellings from the issue and their neighbours, under ten operand shapes, for an inline tag, a raw tag that builds its own Variable, a raw-block tag and a cache vary-on operand, through DjustTemplateBackend, LiveViewTestClient, an HTTP GET and a WebSocket mount); each mechanism is gate-off tested separately. The code is python/djust/template_bridge_floor.py; the three places the bridge renders a node (_render_node, the raw-block handler, the cache operand) enter its scope. install() is one assignment of a constant and reads nothing back, so it cannot lose a race.

  • A template that named a mutating callable ran it: {{ users.delete }} over a QuerySet deleted every row under DjustTemplateBackend and in a LiveView (#3772). Django refuses, because Model.delete and QuerySet.delete carry alters_data = True and the engine never calls such a callable. The refusal existed where the Rust engine walks an object (Context::maybe_call), and _rust.render_template, which has no queryset step, was never affected. The call was made one step earlier. The Rust queryset serializer (serialize_object_with_paths in crates/djust_live/src/lib.rs, behind DjustTemplateBackend.render and the LiveView queryset JIT) walks the paths a template names and called every callable it found there with no alters_data / do_not_call_in_templates check; the Python codegen twin already had the check. So {{ users.delete }}, {% for u in users %}{{ u.delete }}{% endfor %}, {{ u.save }}, {{ u.groups.clear }}, {{ u.groups.create }}, {{ g.user_set.clear }} and {{ g.user_set.create }} each ran against the real rows. The reachable shapes are a QuerySet's rows and the related managers reached through them. A single model ({{ user.delete }}), a list[Model] and a top-level manager were already inert and stay so; they are kept in the test matrix as controls.

    The rule is now stated once, in djust_core::template_call_guard (do_not_call_in_templates first, then alters_data, Django's order), and both walkers ask it: the sidecar walk and the queryset serializer. A refused name is left out of the serialized row, exactly as a name the serialization floor denies is, so the template renders empty. The serializer's .all() call on a to-many attribute goes through the same guard. A callable with neither marker is still called ({{ u.get_username }}, {{ u.groups.count }}), as Django calls it. The order of the two reads is Django's: a callable carrying both markers is used as it is and prints as its repr, it is not refused as alters_data; that order is pinned in crates/djust_core/tests/test_template_call_guard_3772.rs and in TestTheMarkerOrderIsDjangos3772.

    Not changed: the fixed-name calls in serialize_python_value (render() on a component, four get_* names on a model), which a template author does not choose. Regression cases in python/tests/test_alters_data_toplevel_3772.py: every shape runs against a real table through DjustTemplateBackend, a LiveView and _rust.render_template, with Django's own engine as the control and a check that the detector sees a direct mutation.

  • A context key named __djust_typed_args__ no longer reaches a template tag handler (#3773). json_viewer (and any handler that declares TYPED_ARGS = True) is handed its resolved operands under that reserved key. The engine put the key in only when the tag had a resolved operand, so for a tag whose operands were all quoted literals ({% json_viewer data="x" class="plain" %}) a top-level context key of that name, supplied by the application, stood in for the engine's value and replaced data and class. The output stayed escaped, so this was not an XSS; it was an application value overriding an engine-to-handler channel. The key is now the engine's alone: it is removed from the flat context map, from the raw-Python objects laid over it, and from the live frames a bridged tag reads, before any handler is called, and the engine puts its own value back only for a handler that declares TYPED_ARGS. A user value of any shape (a dict, a string, a list, a number, None, an object that cannot cross) no longer reaches an inline, block, assign or bridged handler. Regression cases in python/tests/test_typed_args_reserved_key_3773.py; each of the four removals is gate-off tested separately.

  • An HTML attribute NAME built from a template argument could carry markup (#3774). djust._html.build_tag escaped every attribute value but wrote the name as given, so {% live_input "text" handler="h" event=var %} with var = "dj-input onfocus=alert(1) autofocus y" rendered dj-input onfocus=alert(1) autofocus y="h". Names are now checked against the HTML rule (no whitespace, control, quote, <, >, / or =) by the new djust._html.attr_name, which build_tag and FormMixin.as_live's BaseAdapter._build_tag share; an invalid name raises ValueError. The writers that did not go through build_tag now call it too: render_icon's extra attributes (the name was only html.escaped, which cannot make one safe), the data-* keys of Button, Card and PaginationComponent, and the trigger and dj-value-* keys of LiveComponent.event_attrs. Every real name (dj-input, aria-label, @click, dj-keydown.enter) renders exactly as before. Found by auditing the 125 mark_safe sites the safe-markup guard flags outside djust.theming: none of those was vulnerable.

  • _rust.serialize_context ran render() on any object and four get_* methods on any model, with no call guard, and shipped password, is_superuser and is_staff of a model (#3778). The Rust function (serialize_python_value in crates/djust_live/src/lib.rs) is a public #[pyfunction] that no production path calls: a git grep over python/djust, examples, benchmarks and scripts finds only the Python helper of the same name in djust.template.serialization (a different function), every importer of djust._rust.serialize_context is a test, and a spy on it records zero calls across a DjustTemplateBackend render and a LiveView mount. So the exposure was a caller who hands it an object, not a template author. Reproduced first against the real call path: a render carrying alters_data still ran, a get_username marked alters_data deleted the row (User.objects.count() 1 -> 0), get_absolute_url marked do_not_call_in_templates ran, and the model's dict contained password, is_superuser and is_staff because the arm built it with model_to_dict and never asked the serialization gate (the one serialize_queryset asks since #2685). The issue named the calls; the floor bypass was found by running it.

    Both arms now follow the rule the queryset serializer follows. Every call goes through djust_core::template_call_guard (#3772): a render or get_* that carries do_not_call_in_templates or alters_data is not called, a refused render falls back to str(obj) and a refused get_* is left out. Every model field and method name goes through the serialization gate (djust.optimization.codegen.emittable_names), so the floor, DJUST_SENSITIVE_FIELDS and the per-model allow and deny lists apply, and a name it refuses is dropped before the method behind it can be called. If the gate is missing or raises the model is str(obj); if the gate fails internally it permits no names and the model ships as an empty dict. It is never an ungated dict. The gate call is now shared with the queryset serializer (permitted_among), not copied. A callable with neither marker is still called, so the output of an ordinary model and component is unchanged.

    Decision: guarded, not deleted. The function is listed in _rust.pyi and docs/TYPE_STUBS.md, so removing it is a public-surface call for a maintainer; with no production caller, deleting it later is a pure removal and is tracked as the follow-up on #3778. The stub also declared a field_paths argument the function never took; it now matches the signature and describes the guard. Regression cases in python/tests/test_serialize_context_guards_3778.py: a component whose render deletes rows, each of the four get_* names under each marker, the floor fields, a configured sensitive field, a per-model allowlist and exclusion, a nested model at every container depth, and a gate that raises.

  • LIVEVIEW_CONFIG['theme']['css_prefix'] is validated when the config is read; a prefix that holds markup can no longer break out of the theme <style> blocks (#3779).

    get_css_prefix() returned the raw config string. It reached generate_component_css, generate_css_for_state and the critical-CSS split, which print it into .{prefix}btn { ... } inside <style> blocks that python/djust/theming/templates/djust_theming/theme_head.html renders under {% autoescape off %}. LIVEVIEW_CONFIG = {"theme": {"css_prefix": "x</style><img src=x onerror=alert(1)>"}} printed a parsed <img onerror> element in {% theme_head %}, {% theme_css %} and {{ theme_head }} (the context-processor chunk). This is settings-level, not request-reachable: it needs a hostile or careless value in settings, which is why nothing had shown it. cookie_namespace was regex-validated and direction allow-listed; css_prefix was neither.

    get_theme_config() now raises ImproperlyConfigured for a css_prefix that is not a string of ASCII letters, digits, underscores and hyphens ([A-Za-z0-9_-]*), the way it already does for cookie_namespace. "" (the default) and None still load. Every reader goes through that function (theme_head, theme_css, the context processor, the CSS views, ThemeMixin, theme_form / theme_form_errors, the component and page tags, get_css_prefix), so one check covers them all, in both the Django and the Rust template engines. It is an exception at load rather than only a system check because manage.py check does not run under gunicorn or uvicorn: a check-only gate would leave the injection live in production. The three generators that also take a prefix as an ARGUMENT, bypassing the config (generate_component_css, CompleteThemeCSSGenerator, DesignSystemCSSGenerator), run the same validator, so generate_css_for_state(state, css_prefix=...), generate_critical_css_for_state, generate_deferred_css_for_state and generate_theme_css are covered through them.

    What changes: a project with a prefix outside that alphabet now fails at the first theme render with a message that names the setting (previously it rendered a broken or hostile stylesheet). No documented or tested prefix is affected: dj-, dj, djt-, myapp- and "" render byte for byte what they did (43 renderings across prefix, tag and engine, captured before the change, are pinned in python/tests/test_theme_css_prefix_validation_3779.py).

    Related, same file: check_css_prefix (djust_theming.E004 / W002) used re.match(r"^...$"), and $ also matches before a trailing newline, so a prefix of "dj-\n" passed the check; it now uses fullmatch. The check reads the unvalidated config so it can still REPORT a prefix the loader refuses (E004) instead of dying in get_theme_config(). E004 stays stricter than the loader (it also wants a leading letter): the loader draws the line at "cannot carry markup", the check at "is a valid class prefix".

  • A model form's values are held to the serialization floor under the native engine (#3790). BREAKING for a template that reads a floor field's value back from a form. ModelForm.initial is model_to_dict(instance), a plain dict of strings by the time a template reads it, so the engine's walk never met a Model and the model proxy had nothing to refuse. On a UserChangeForm bound to a User, {{ form.initial.password }}, {{ form.password.value }} and {{ form.password.initial }} printed the password hash while {{ form.instance.password }} was empty: the same secret by a different spelling. So did form.cleaned_data.password (a disabled field validates to its initial), form.data.password (and a prefixed form's u-password), form.password.data, form.password.subwidgets.0.data.value (and form.password.0, and iterating the field), the as_text / as_textarea / as_hidden renderers (they swap in a widget that prints the value), {% for f in form %}{{ f.value }}, form.visible_fields, form.get_context.fields…, form.password.form.initial.password, a {% with %} alias of any of these, the data / cleaned_data / initial of a formset over a model (and fs.management_form.data, which holds the formset's whole submitted data), and the same paths as the operand of a {% load %}-bridged tag. djust's own dj_form, combobox and rich_select tags drew a UserChangeForm's hash into an <input value="…">. The floor is the model's, not the word password: is_superuser, is_staff, DJUST_SENSITIVE_FIELDS, a per-model djust_exclude_fields and djust_serializable_fields, the djust_serialize_sensitive_fields opt-out, and the field TYPE floor (sensitive_field_types, #1987) are all consulted through the one authority the model proxy uses (_attr_is_serializable).

    Migration (read this if a template stopped showing a checkbox, a flag or a value). A floor field's value is now missing from a template path, exactly as {% if user.is_staff %} already was on the model. {% if form.is_staff.value %}checked{% endif %} renders unchecked, and re-posting that page would then demote the user. Render the control with {{ form.is_staff }} (the widget the form's author chose, unchanged), or read the value in the view and pass it in get_context_data(), or opt the field in with djust_serialize_sensitive_fields on the model. The first refused read of each field is logged once as a warning naming the field (User.is_staff … via .value), so the break is found in the log and not by a user losing a flag.

    The cure is at the chokepoint where the engine reads a path, not at spellings. After the root and after every segment of a path, the engine now calls djust.serialization._protect_sidecar_walk, which is _protect_sidecar_value plus three arms: a ModelForm becomes a floor proxy whose initial / data / cleaned_data are COPIES without the model's floor fields (data is matched through its prefix: u-password, form-0-password; the form itself is never mutated: initial is what a disabled field validates against, and a rendered form still saves without clearing the user's password); a formset over a model gets the same for the lists it holds, and its management_form (a plain Form that carries the formset's whole QueryDict) is proxied with the formset's floor; and the BoundField of a floor field becomes a proxy that refuses value, initial, data, subwidgets (and indexing or iterating the field) and the three renderers that swap in a value-printing widget. A refused name is a missing name: it renders empty, as form.instance.password does. Every other BoundField, and every field of a form that holds no floor field, is the real object, byte-identical to before (checked against Django's output for the benign paths). template_bridge_floor walks a bridged tag's operand paths through the same function.

    The invariant, and where it is enforced. A template walk never receives a real BoundField of a floor field, nor the real form (or management form) behind one. A path segment is floored by the walk function. Every OTHER route by which a form or field becomes a value the template can use (a {% for f in form %} loop variable, visible_fields, hidden_fields, get_context().fields, a |slice, |first / |last, a {% with %} alias, a loop over a formset, bound_field.form, management_form.<field>.form) goes through ONE conversion, and that conversion now stores the floored object as the value's live handle (floored_form_handle, crates/djust_core/src/lib.rs); every sink that reads the handle afterwards (iteration, in, an index or a slice, ==, the walk of a loop variable) therefore reads the floor. A formset pins its floor on its management form, which is a plain Form holding the formset's whole submitted data. A consequence, wanted: {% if form.is_staff in form %}, {% if f == form.is_staff %} for a loop variable f, and == between a path and an alias all compare as Django does. python/tests/test_form_floor_differential_3790.py generates the cross product (root form x producer x sink) and asserts the full secret and its last twelve characters never appear, with a Django-engine control per group.

    Policy, stated plainly. Django itself prints all of these. The floor is the stricter policy djust adds (SECURE_DEFAULTS Pattern 1), and the Django template engine is unchanged by this fix; only the native engine and the bridge's operand walk apply it.

    The limit: widgets are not blanked. The floor guards what a template can READ BACK, not what a form DRAWS. {{ form.password }}, {{ form.as_p }}, {{ form }} and a loop's {{ x }} render the widget the view author chose: Django's ReadOnlyPasswordHashWidget (what UserChangeForm uses) shows a summary of the hash and never the hash, but a TextInput over the password column, or over a DJUST_SENSITIVE_FIELDS field, prints value="…". Blanking an editable control would clear it on the next save (measured: a blank or an omitted text control clears the column and an unchecked checkbox demotes the user), so it is not done; tracked in #3797.

    Components: a disabled floor field is drawn without its value, an editable one is not. dj_form, combobox and rich_select read bound_field.value() in Python (through one function, _bound_field_value). A disabled floor field (Django ignores what is posted for it: UserChangeForm.password) is now drawn without its value. An editable floor field (is_staff, a DJUST_SENSITIVE_FIELDS text field the form posts back) is still drawn with its value, because the alternative is silent damage on the next save; the component logs once, naming the field, that it did. Declare the form field disabled=True, or leave it out, to draw it without the value.

    What a tag is handed. A bridged tag is the application's own Python (djust's component readers; a third-party form tag), so it is handed the real form and the real BoundField, never the proxy. Only a template PATH is floored, so {% tag x=form.initial.password %} receives nothing and {% tag form.password %} receives the field. The engine therefore has two entry points: _protect_sidecar_walk for a path the template spells (Context::walk_live, the sidecar walk, the bridge's operand walk) and _protect_sidecar_value for an object handed to a tag (value_into_handler_pyobject, the handler dict). The Rust side looks the walk function up by name and falls back to the leaf floor when it is absent. The legacy sidecar walk used to hand the raw object on when its floor raised; it now drops the value (the cell renders empty). An extension that predates this fix keeps asking the leaf floor and would lose the form arms silently, so DjustConfig.ready() logs an error when djust._rust.WALK_FLOOR_COVERS_FORMS is missing: rebuild or reinstall the extension (make dev-build).

    Reach, measured. A LiveView's own top-level self.form attribute was not reachable by these paths when this was written (mixins/rust_bridge.py flattened a BaseForm into the state and left it out of the raw-object sidecar), so there it was not a leak; since #3767 it is a sidecar value like any other object, and python/tests/test_form_floor_differential_3790.py runs the whole grammar against a form held as view state. The exposed shapes were a standalone render (DjustTemplateBackend, render_template) with a form in the context, a form held by another object in a LiveView (page.form.initial.password), and the component tags.

    What this does not floor. A plain Form (no model behind it: #3799; this includes a plain Form that carries self.instance = <Model>, and a management form handed to the template directly as a context variable, since only a formset's own management_form is pinned); a typed PasswordInput value that is not a model column, such as UserCreationForm.password1 (held from #3798 on, by widget type); form.fields.<name>.initial (a declared or model-default value, never the instance's); a tag author's own Python and a takes_context tag reading the context itself; the form's own widget rendering (#3797); an editable floor field in a component; and the Django template engine.

    Regression cases in python/tests/test_form_initial_floor_3790.py and python/tests/test_form_floor_followups_3790.py (the spelling table for an unbound and a bound UserChangeForm, each with a Django-engine control that prints the hash so the comparison is never two empty strings; prefixed, QueryDict and formset data; the components; what posting a rendered page back does; what widgets do; the real object handed to a tag; LiveViewTestClient, HTTP GET and WebSocket mount). Each mechanism is gate-off tested separately. The code is _protect_sidecar_walk, _SidecarFormProxy, _SidecarFormSetProxy and _SidecarBoundFieldProxy in python/djust/serialization.py, _bound_field_value in python/djust/components/templatetags/_forms.py, protect_sidecar_walk_strict in crates/djust_core/src/context.rs, and release in python/djust/template_bridge_floor.py. Requires the matching compiled extension (rebuild with make dev-build).

  • A SECRET floor field of a form is never rendered with its stored value, and the column is protected from being rewritten by the empty control (#3797). BREAKING for a template that relied on a secret floor field echoing its value. #3796/#3798 refused every way a template could READ a model form's floor-field value back, but {{ form.password }}, {{ form.as_p }}, {{ form }} and {% for x in form %}{{ x }}{% endfor %} still printed value="..." for a floor field with a plain widget (the password column over a TextInput, a declared api_token), the body of a Textarea, the selected option of a Select, the checked of a checkbox and the parts of a MultiWidget; {{ formset.as_p }}, as_widget, as_field_group, BoundField.render(), the {name: html} snapshot of a form kept in LiveView state and the BoundField handed to a bridged {% load %} tag or filter ({% render_field form.password %}, |add_class) did the same. Such a field's control is now drawn the way Django draws a field that has no value, by Django's own widget on a copy of the BoundField (value() and initial answer None; its own posted keys, including Django 5's hidden-initial value, are removed from a copy of the form; its validation messages that interpolate the value, such as a disabled ChoiceField's invalid_choice, are replaced by a generic one): no value=, an empty <textarea>, no selected, an unchecked box, empty MultiWidget parts, with no cooperation from a custom widget. Only SECRET-LIKE floor fields are blanked: a password widget, a name with a password / secret / token / key / hash / salt / pwd / otp word (api_token, secret_key), or a sensitive_field_types column. Privilege flags (is_staff, is_superuser), selects and ordinary declared-sensitive text (email, first_name) keep drawing their real state, because an empty control is posted back empty (a blank text control clears the column, an unselected <select> posts its first option, an unchecked box posts nothing and a classic POST of the stock UserChangeForm would demote a superuser). The column is protected where Django can protect it, on the FORM FIELD: FormMixin.get_form() (so ModelFormMixin) marks every blanked field disabled=True on a form that edits an existing row, so Django ignores the posted value and keeps the stored one; HTML disabled / readonly and leaving the control out still save "". A plain ModelForm in a classic view cannot be protected at render time: the new system check djust.S014 reports an editable secret floor field in a ModelForm with the fix. djust's component tags draw an editable secret floor field empty too; another editable floor field keeps its value (as before). Kept by their own contract: Django's ReadOnlyPasswordHashWidget (exact type; a masked summary) and PasswordInput(render_value=True) (the author's explicit echo); the model's djust_serialize_sensitive_fields takes a field off the floor entirely. A value the project's own code puts elsewhere (widget.attrs, help_text, a closure holding the form) and a bridged tag that walks the real FORM it is handed are not covered. A field the protection makes read-only is logged once per (model, field) with the ways out (djust_serialize_sensitive_fields, a different field name, or disabled=True yourself): it reaches fields that only look secret (sort_key named in djust_exclude_fields) and a "set a new password" box on an existing row. The name heuristic has known limits (pass, passcode, pw, ssn, cvv, card_number, auth_code, bearer, jwt, totp_seed, recovery_code are not matched); a get_form() override that does not call super() is not protected; djust.S014 also reports a form that only a FormMixin view uses.

  • What a person typed into a password input can no longer be read back by a template under the native engine, on any form (#3798). Narrowly BREAKING for a template that branches on, or prints, such a value. A bound UserCreationForm holds the typed password under password1 / password2, which are no columns of User, so the model floor of #3790 (keyed on the model's field names) did not name them: {{ form.password1.value }}, {{ form.data.password1 }} and {{ form.cleaned_data.password1 }} printed the plaintext, as did every sibling spelling (.data, .initial, form.data / form.initial / form.cleaned_data as a whole or by iteration, a prefixed form's u-password1, {% for f in form %}{{ f.value }}, visible_fields, a {% with %} alias, a formset's data / cleaned_data / initial). A plain Form has no model floor at all (#3799), so the same read worked on any form with a password field.

    The floor is now also keyed on the WIDGET. A field whose widget is a password input (PasswordInput or a subclass, any widget with input_type == "password", or a MultiWidget with such a part, whose per-part keys pair_0 / pair_1 are named too) is a floor field by type, whatever its name and whatever the form is (a ModelForm or a plain Form). It sits at the chokepoint #3796 built (_model_form_floor -> _floor_field_readable), so the form, bound-field and formset proxies and the engine's value conversion all hold it with no per-spelling code, and the Rust extension is unchanged. The test is one function, djust.serialization.field_has_password_widget, which djust.forms.is_sensitive_form_field (persisted_form_input) now asks too, so the two cannot disagree about what a password field is. A formset's own lists are read from the forms it built as well as the form class, so a field added in a form's __init__ is held. djust's own dj_form, combobox and rich_select draw a password field blank whatever its widget class is called: a PasswordInput subclass under a name _get_field_type does not know fell to the text branch and printed value="…".

    What is NOT lost (measured). Django's PasswordInput never writes a value back into the control (render_value=False), so {{ form.password1 }}, {{ form }}, {{ form.as_p }} and the errors of a typed-but-invalid form are byte-identical to Django's with and without this change, and a re-save posts what the person types now, not what was typed before. The view still reads cleaned_data["password1"] and form.save() still hashes it; the proxy replaces nothing on the form. Migration: a template that printed the typed value, or branched on it ({% if form.password1.value %}), now sees nothing and takes the else branch. Compute what the template needs in get_context_data(). The first refused read of each field is logged once, naming the field and never the value.

    Not floored, by decision. A plain Form field that is merely NAMED password over a text widget, and a plain Form handed initial=model_to_dict(user), are #3799 (a name-based floor for every form would blank a login form's typed password; that is a product decision). PasswordInput(render_value=True) writes the value into the control on purpose and still does; the template's reads of it are refused. A view's own dict (form_data) is not a form. The Django template engine is unchanged.

    Regression cases in python/tests/test_password_widget_floor_3798.py and, as roots of the existing grammar, python/tests/test_form_floor_differential_3790.py (a bound UserCreationForm, a bound model formset over it, a bound and an unbound plain form with a MultiWidget and a late-added password field, and a plain-form formset, each x every producer x every sink, each with a Django-engine control that prints the secret). Each mechanism is gate-off tested separately.

    Also stricter: persisted_form_input. A field the new widget test classes as sensitive (a TextInput subclass with input_type = "password", TextInput(attrs={"type": "password"}), a MultiWidget password part) is now refused by persisted_form_input(...) at view-class definition with ExposureConfigurationError. Before, such a typed value could be persisted to the server session. A view that declared one of these as persisted now fails at import: remove it from persisted_form_input, or give the field a real PasswordInput.

  • {% include obj %} and the component tags called a render() / get_full_name() marked alters_data or do_not_call_in_templates (#3815). render_template_object (python/djust/template/operands.py) called obj.render() on a context object named by {% include obj %} with no look at either marker; the component tags avatar_group, AvatarGroup, LiveIndicator and PresenceAvatars called user.get_full_name() the same way. Stock Django does call such a render, so this was Django parity; djust is now stricter than Django here on purpose, on the rule the serializers already apply through djust_core::template_call_guard (#3772, #3778). The guard covers the five entries DjustTemplateBackend, _rust.render_template, RustLiveView, a LiveView's WebSocket render and a LiveView's HTTP GET render. A render whose marker is truthy is not called and the include renders the empty string; a flagged get_full_name is not called and the avatar falls back to str(user), the branch a blank name already takes. Neither raises; each logs one WARNING per type and method per process. A marker read that raises anything but AttributeError refuses too (fail closed), and the method is looked up once so the object checked is the object called. An unmarked render() / get_full_name() is called exactly as before. Not covered: the templates of {% live_render %} / sticky and embedded child views are rendered by Django's stock engine and still call a marked render; a test pins that so a later fix flips it on purpose. {% extends obj %} was never affected (it reads a compiled template's source and does not call render). Regression cases in python/tests/test_render_call_guard_3815.py count the calls on a real payload, with Django's own engine as the control.

  • Attributes passed through to a tag now follow one policy: no inline event handlers, no script URLs, no type that submits (#3818). {% live_input %} forwards every keyword it does not know as an HTML attribute, and FormMixin.as_live forwards a Django widget's attrs and render_icon its extras. build_tag escapes the value and (since #3774) checks the name, but neither says what the attribute does: live_input("text", handler="h", formaction="javascript:alert(1)"), onfocus=... and type="image" src="javascript:..." rendered as given on both engines (the native engine bridges to the same tag), while djust.theming's _passthrough_attrs already refused them. djust._attr_policy.passthrough_attrs is now the one place that decides, called by live_input's collector, BaseAdapter._merge_widget_attrs (and _get_field_type, where a widget's attrs={"type": ...} becomes the tag's own type) and render_icon (and with it the native {% icon %} handler). Only an attribute NAME raises: an event-handler attribute (on*, and the @, x-on, v-on and hx-on spellings) raises ValueError. A VALUE never raises, because a template can take it from data (formaction=profile.website) and an error page that a stored hostile value triggers for every viewer would be an availability hole of its own: a URL whose scheme the shared allowlist rejects (javascript:, vbscript:, data:, blob:, file:, custom; read the way a browser reads it) renders # and is logged by attribute name in href, src, action, formaction, poster, srcset, ping, data, background, xlink:href, dj-navigate and dj-patch (a srcset / ping list is judged entry by entry), and a type value of image, submit, reset or button is left out so the field keeps its own type. Everything else passes through byte-identically, including other type values (date, color ...), aria-*, data-*, style, x-* and hx-*. Behaviour change: a project that passes onclick= / onchange= through live_input, widget attrs or an icon now gets a ValueError (use a dj-* binding); a javascript: URL in formaction / href now renders # instead of a live script URL. djust.theming's own implementation is unchanged (it still raises for a script-bearing href / src / action / formaction); python/tests/test_passthrough_attr_policy_3818.py runs both against the same payloads, asserts the documented differences and nothing else, and pins the URL attribute sets equal. Regression cases there cover both engines, the widget route, the icon route, theming parity and a tripwire over the set of passthrough writers; gate-off verified.

  • A {% live_render %} lazy placeholder is escaped unless it is marked safe, and the JSON inlined in a <script> is script-escaped (#3819). lazy={"placeholder": ...} was spliced into <dj-lazy-slot> as raw HTML with no stated contract, so a placeholder built from data injected markup. It now follows Django's own rule (conditional_escape): a marked-safe value (mark_safe, format_html, anything with __html__, a lazy marked-safe string) is markup and is spliced in as written, a plain str is text and is escaped. Behaviour change: a project that passed "<div>Loading...</div>" must wrap it in mark_safe(...); the contract is documented in the streaming-render guide. routing.get_route_map_script put json.dumps(routes) inside a <script> with mark_safe, on the argument that URL config cannot hold </script>; json.dumps leaves <, > and & raw and nothing enforced that argument, so it now passes the JSON through escape_json_for_script (the helper the debug panel and the PWA tags use). The same one-line fix applies to the two other developer-config blobs inlined in a script: the handler-metadata script (window.handlerMetadata) and window.DJUST_LOADING_GROUPING_CLASSES. Output is byte-identical for any value with none of <, >, &, U+2028, U+2029. python/tests/test_raw_slots_3819.py and the payload sweep (live_render is now given a hostile plain-str placeholder), gate-off verified.

  • The safe-markup guard (scripts/lib/safe_markup_taint.py) now follows a name's flow, narrows on membership tests, trusts the project's own escapers by origin and in the position they are clean in, and sees the two mark_safe wrappers _safe and safe_html; the #3774 audit list shrinks from 199 flagged sinks to 55 (#3820).

    Over python/djust the guard flagged 125 sinks (#3774 audited and kept 96 of them in 93 functions, mostly its own limits). It also could not see _safe(...) (about 190 call sites in components/rust_handlers.py) or djust._html.safe_html(...), so a new def f(v): return safe_html(f"<div>{v}</div>") passed the guard, the audited baseline and the payload sweep alike. Both are sinks now, and so is any function the guard finds, by data flow, to mark one of its parameters safe (def _ms(v): x = v.strip(); return mark_safe(x), a lambda, a partial, str(v), *args), and a test names every such wrapper in the package. With the wrappers the first guard flagged 199 sinks; the guard as it is now flags 55 in 51 functions. 144 of the 199 are proved by the guard and their audit entries are deleted rather than kept; 15 sinks in 14 functions were read by a person and are recorded in scripts/lib/safe_markup_audit.py with a reason and evidence (verdict S: read and traced, not proved). This is a statement about what the guard can prove, not a claim about the sinks it cannot: the guard has known false negatives, listed in its docstring and in one issue.

    What the guard learned, each rule shown by a dirty shape and its clean twin in python/tests/test_safe_markup_guard_3820.py and test_safe_markup_guard_3848.py: flow (width = int(width) leaves f"{width}" clean; a coercion in only one branch, past a continue / break, in a loop that comes back round, in a try body whose handler runs, in an earlier except* handler, in a with that can swallow, after a += / append, in a closure, global, nonlocal, :=, finally, import / match rebinding or beside locals() does not); narrowing (x in CONSTANT, x == "a" and isinstance(x, int) clear x on the branch where they hold, from either edge; x == other narrows nothing, and neither does a collection that anything in the file changes in place (.append, .update, .setdefault, D[k] = v, |=, __setitem__, through self.X, cls.X or an alias), declares global, or hands on (a = D, f(D), return D), the wrong edge, or assert); the project's escapers, trusted by the module that DEFINES them, never a re-export or a same-named twin or an import the file rebinds (safe_url, url_attr, srcset_attr; safe_css_color / _length / _ratio / _number when their default is clean and no *args / **kw hides it; the record builders upload_attrs, voice_extras, map_picker_attrs, pad_extras, cropper_extras, menu_attrs, clamp_columns, clamp_cells, rich_html, render_markdown), each pinned by a hostile-payload test of its contract. A value-escaper's output is clean in element text or a QUOTED attribute value and not in an unquoted attribute (<a href={safe_url(x)}> keeps its spaces); escape_json_for_script is clean only for json.dumps output in <script> text (it leaves " alone); attr_name is not cleared at all (onmouseover is a legal name); class tables and constants (self.ICONS.get(k, "")) are trusted only when nothing writes them: not under setattr, __dict__, __setattr__, vars, globals() anywhere in the file, not in a class that inherits (across the package) from one that writes its constructor keywords with setattr (Component), not when the table is changed through self / cls / an alias, not in a dataclass-style annotated field, and not when a class of the same file overrides them; d.get(k, DEFAULT), calls to a helper the file defines (judged by what it returns, with its parameters raw; not when global or setattr can replace it, and a bare name in a method is the module's, not the class's), sum / max of clean values and comprehensions of them, a Rust handler's block content, which the engine hands over as a SafeString (checked against the compiled engine), and a depth bound that FLAGS a chain it cannot follow instead of clearing it.

    Found while teaching it: TABLE.get(key, default) was judged by the table only, so a raw default came out clean (no site in the tree used it). Fixed, and pinned with the rule switched off. Three tag modules (_advanced.py, djust_components.py, _forms.py) imported safe_url, _dev_tools.py imported conditional_escape and three call sites imported escape_json_for_script through a re-export, which the guard rightly did not trust (the #3774 lesson); they import from the defining module now, and TestNoEscaperIsImportedThroughAReExport is the lint that finds the class without running the guard.

    The flow analysis is checked against real execution: a differential fuzz generates random functions, runs them with a marker as the raw value, and fails when a mark_safe that received the marker was not flagged (no false negative in 40 000 programs across 16 seeds). 57 unsafe functions from an independent review's corpus are RUN (each must emit the payload raw) and flagged; 27 of them the earlier head of this change had stopped flagging and are regression-tested by name. Twenty-two deliberately broken analyses (a loop that forgets its break / continue states, an if that follows one branch, a merge that keeps the first path, narrowing on the wrong edge or against any expression, an assert that narrows, an exception that sees only the state before the try, a helper that is always clean, hazards that are not found, a read from another scope that is always vouched for, a class body visible to its methods, a depth bound that fails open, ...) are each caught by the fuzz or a directed row. The payload sweep now also drives the Rust-native handlers and parses what they print. The safe-markup baseline check is on make merge-gates (test_safe_markup_gate_3848.py), because two PRs can break it together.

  • A context value reaching a {% djust_pwa_head %}, {% djust_pwa_manifest %}, {% djust_sw_register %} or {% djust_offline_indicator %} argument can no longer write template code or markup into the page under the native engine (#3824). The four handlers in template_tags/pwa.py bridged to the Django tags by writing the tag into Django template SOURCE as key="<resolved value>" and compiling it, so a value carrying a quote and %} ended the tag early and everything after it was compiled as template text: {% djust_pwa_head name=evil %} with evil = 'a" %}<script>alert(1)</script>{% djust_pwa_head name="b' rendered a live <script>alert(1)</script>. All 16 keywords the four tags take through this bridge were reachable, through a plain variable and through a dotted one (u.name); a tenant-configurable app name was enough. The Django engine resolves name=var as a value and was never affected. The handler now builds its source from constants only (a tag name and keyword names checked against _TAG_PARAMS, then one context-variable reference per keyword, name=kw_name) and passes every value in the render context, so Django resolves it as data and the tag's own escaping applies. icons (a list of dicts, which this bridge can only ever pass as a string) is no longer accepted: {% djust_pwa_manifest icons=... %} under the native engine now renders the failure comment instead of a silently odd manifest. A keyword the tag does not take, or a tag name outside the table, raises into the existing marked <!-- djust: ... render failed --> diagnostic instead of reaching the source. Two consequences to know: a quote-bearing value used to fail with that diagnostic and now renders, escaped; and an unquoted-safe literal such as theme_color="Tom & Jerry's" is now escaped in <meta content> (Tom &amp; Jerry&#x27;s) because the value is a plain string rather than a SafeString literal. Ordinary values render the same bytes (a differential over 48 ordinary cells: no difference), and for all 208 tag x keyword x payload cells the native engine now renders what the Django engine renders (89 differed before). theme_color/background_color that are not colour-shaped and a sw_url carrying a javascript:/data:/vbscript:/blob: scheme or a control character are refused in the shared Django tag functions (the default is used and the parameter name is logged), so both engines agree. python/tests/test_pwa_failure_diagnostic_2434.py and the _ARG_VECTORS row in test_custom_tag_return_escape_2379.py used a double-quote-bearing value as their only way to reach the diagnostic; both now use an unsupported keyword. Regression cases in python/tests/test_pwa_tag_template_injection_3824.py, gate-off verified (ten mutations, each caught).

  • A POST key of any length now costs linear work to classify against the form floor (#3830). _floor_data_key_readable asks the floor about a form.data key and about every --delimited tail of it, and it sliced and hashed every tail in full, so the cost was quadratic in the key's length. A client could POST a key of dashes to any view whose template reads form.data on a bound ModelForm (or a formset, or management_form.data) and hold the worker's GIL: a 32 KB key cost ~0.7 s and a key at Django's 2.5 MB DATA_UPLOAD_MAX_MEMORY_SIZE extrapolated to tens of minutes (5,000 keys of 500 dashes: ~11 s). A tail longer than the longest name the floor treats specially (the form's password names, the model's denied / allowlist / opt-out sets, the sensitive model methods and the model's own columns) is now asked in the form of its first bound + 1 characters, and the keys of a mapping share one set-up (_DataKeyReader). The verdict is unchanged for every key, including a tail the model arm refuses for starting with _ or for being absent from an allowlist, which is why the shortcut of skipping long tails was not taken. Counted on the real path (child process, release-independent, pure Python): a 32,000-dash key went from 32,004 questions and 512,016,016 characters to 28 questions and 32,292 characters; a 2,500,000-dash key now takes 0.48 s. The reference function is kept as the oracle in python/tests/test_floor_data_key_linear_3830.py, which holds the new one to it over 38,494 generated keys on ten floor shapes, and counts questions and characters, not time.

  • A client can no longer grow a form's submitted data without bound over a WebSocket, and a template that reads form.data many times no longer pays for it each time (#3834). max_message_size bounds one frame, but FormMixin.form_data outlives it (one client-named key per validate_field event, every key of a submit_form event, all bound to the form on every event), so LIVEVIEW_CONFIG["max_form_fields"] (default 1000, counted beyond the fields the form declares) and ["max_form_data_size"] (default 2,621,440 characters, every string, bytes and nested list or mapping at any depth) now hold what FormMixin, WizardMixin, the admin form view and a params_form keep, with Django's own DATA_UPLOAD_MAX_* defaults; an event past them is refused whole, logged without the keys, and reported in form_errors / wizard_step_errors["__all__"] / params_errors["__all__"] (the message is cleared by the next accepted event; 0 removes a limit; a value that is not a non-negative integer is the default, never unlimited). Behaviour change: more than 1,000 undeclared fields, or 2.5 MB, sent over the socket is now refused as it is over HTTP; set max_form_fields / max_form_data_size higher (or 0) for a form that needs more. Separately, the serialization floor's filter of form.data is kept on the form and reused only while the mapping has the same keys under the same floor (a widget swapped on a live form, the model's floor sets, the model's columns, sensitive_field_types and DJUST_SENSITIVE_FIELDS are all followed; values are read fresh on every read), so ten reads of 100,000 dashed keys filter once (~1 s) instead of ten times.

  • A form's stored data is now bounded by how many members it holds, not only how many characters (#3844). max_form_data_size bounds characters, and characters do not bound time: every event hands ALL of the stored form data to the template engine (a Python pass per member, then the extension's update_state, about 0.7 microseconds a member), so 174 validate_field events of [""] * 15000 stored 2.61 million empty members under the 2.6 million limit and made each later event take 0.03 s growing to 1.25 s ([[]] * 7500: up to 3.9 s) over a connection's own state. LIVEVIEW_CONFIG["max_form_members"] (default 100,000; 0 removes it, a value that is not a non-negative integer is the default, never unlimited) now counts every key, every value and every list, tuple, set or mapping inside them as one, however long a string is, and is judged by the same walk, early stops, nesting bound and replaced-entry arithmetic as the size limit, on every path that keeps a form's data (FormMixin, WizardMixin, the admin form view, a params_form); an event past it is refused whole and reported like the others, in form_errors / wizard_step_errors["__all__"] / params_errors["__all__"], without the keys. Behaviour change: form data of more than 100,000 members over the socket is refused (a form with a multi-select of tens of thousands of options needs max_form_members raised, or 0); the state sync itself still walks everything that is stored, so the cost of an event at the cap is bounded rather than gone: about 70 ms for flat strings or numbers, and roughly 0.2 to 0.4 s for container-heavy data (a list or mapping weighs one member, the same as a leaf; measured on a loaded machine). Each field counts as two members (its key and its value), so raising max_form_fields above about 50,000 needs max_form_members raised too. A wizard's member and character limits are the TOTAL across its steps, not each step's (#3858). Also: the exact-limit case inside a bulk container with pending non-leaf members is now pinned for both limits (the > to >= mutation in _charge_rest survived #3842's tests), and the lazy-provenance benchmark targets are 10x their measured medians, not 5x.

  • A wizard's data limits are a total across its steps, remove_row asks the form-data bound before it stores a client-named key, and the three limits are validated when written in code (#3858). The review of #3857 found that WizardMixin bounded each step's data alone, so a four-step wizard of required=False fields kept about four times max_form_members (360,000 members against 100,000) and handed every one of them to the template engine on every event; max_form_data_size and max_form_members now bound every step together (max_form_fields stays per step, a step being a form). FormSetHelpersMixin.remove_row and remove_row() stored <prefix>-DELETE for a client-named prefix with no check (3,000 calls reached 3,000 keys against max_form_fields = 1000); past a limit the call is now a logged no-op, and only the formset's management keys and the real fields of its rows (below TOTAL_FORMS, plus DELETE, ORDER and id, indexed in canonical ASCII digits) are exempt from max_form_fields: any other key under its prefix, an invented suffix under a real row or a zero-padded or non-ASCII index included, counts. config.set() / config.update() of max_form_fields, max_form_data_size or max_form_members skipped the validation done at load, so 'abc' made every form event raise TypeError, -5 refused everything and 3.7 / True were accepted: a value that is not a non-negative integer is now the default, as at load. The refusal messages and the production "no limit" warning name the setting (and which one is 0). Behaviour change: a wizard whose steps together hold more than the limit is refused (raise max_form_members / max_form_data_size for a long wizard).

  • The Python data_table template no longer trusts a marked-safe row value in an attribute (#3894). A row URL (row_url, a link column), a link column's link_class, the row id of the selection checkbox, a column key and a column width are checked and escaped whatever the value is marked as (mark_safe, a lazy or __html__ value), for any kind of column list; a marked-safe URL that was already HTML-escaped is now escaped once more (&amp; becomes &amp;amp;, so pass the plain URL). A marked-safe value in a TEXT cell still renders its markup.

  • Marked-safe values are no longer exempt from the URL scheme check; the no-op javascript:void(0) / javascript:; hrefs are still accepted (#3898, #3894). Behaviour change: a mark_safe (also lazy, or __html__) value used as a URL anywhere a component prints one (the class-based components, the native handlers, the template tags' item data and their own keywords such as {% avatar src=... %}) is now checked against the scheme allowlist and escaped like any other string (an already-escaped &amp; is escaped once more, so pass the plain URL). An ITEM value (an entry of a list of items, or an item dict) that lands in an attribute is escaped whatever it is marked as in every class-based component and in the template tags this change edits (avatar_group, presence_avatars, chat_bubble, mentions_input, sortable_grid, rich_select, live_indicator, activity_feed, data_card_grid, org_chart, carousel, lightbox, model_table's row id and filter value); 34 other tag parameters still leak an item value and are listed, and pinned exactly, in #3904. A scalar argument (a constructor argument or a tag keyword) in an attribute other than a URL is the developer's own and is unchanged. Text slots keep their markup. The literal no-op hrefs javascript:void(0) and javascript:; (exact text, href only, never an image source) are accepted marked or not, so a dropdown toggle needs no mark_safe. URL helpers convert values to the exact str type before checking them. test_marked_safe_attribute_sweep_3898.py derives the classes, parameters, tags and item keys from the code and fails on any URL call that leaves honor_safe at its default.

  • {% modal %} finds its {% modal_description %} block by a marker escaped variables cannot spell. The marker is now <!--dj-modal-description-->, no marker is left in the rendered output, and both engines share the one render_modal path.

Documentation

  • Audit of the 125 mark_safe sites the taint guard flags outside djust.theming (#3774). docs/internal/mark-safe-audit-3774.md records the verdict and the evidence for each, and scripts/lib/safe_markup_audit.py is the machine-checked baseline (python/tests/test_safe_markup_audit_3774.py): a flagged site with no entry, a sink added to an audited function, a stale entry, or evidence that is not in the audited function's own code fails. A payload sweep drives the audited sinks with a hostile value (a markup payload and a javascript: URL payload), parses the output, and a line-coverage test fails when an audited sink never ran. _advanced.py, _charts.py and _forms.py now import conditional_escape from django.utils.html instead of through ._registry, which removes 29 of the 125 sites from the guard's output without a behaviour change. The wrappers _safe(...) and safe_html(...) are outside the guard (#3820).
  • A row's __str__ is documented as public text (#3806). djust_exclude_fields, djust_serializable_fields and the serialization floor decide which fields of a row a template can read; they do not decide what str(row) says, because "__str__" is an identity key every serialized model carries (and Django's own engine prints the same text). A model whose __str__ prints a field the floor withholds therefore shows that field's value in {{ row }} while {{ row.that_field }} is empty. docs/SECURE_DEFAULTS.md (section 1) and docs/SECURITY_GUIDELINES.md (Common Vulnerabilities 8, and the review checklist) now state the paths that ship it (a top-level loop over a QuerySet, a model in the context, a foreign key read whole, a row reached through .all, and on the DjustTemplateBackend a QuerySet's .first / .last), that the exclusion and allowlist rules do not constrain it, the Django parity, and the author guidance: build __str__ from public fields only. No behaviour changes. Pinned, with Django's engine as the control, in python/tests/test_row_str_is_public_text_3806.py (which also records, as a strict xfail, that a LiveView attribute that is a top-level QuerySet renders {{ rows.first }} as nothing: it withholds more than documented, never less).
  • The kept-rows cost and the sliced-name gap of the QuerySet .all method read are measured and recorded (#3867). Items 3 and 4 of the #3863 review. (3) A mixed read (rows and .count on one .all) through {% for u in users|slice:':2' %} still prints no count where Django prints one, on a QuerySet and a list[Model], through the Django-backend engine and a LiveView: the kept rows are reached by the loop variable's alias for users.<N>, a filtered operand registers none, and the serialized rows carry no handle of their own. Closing it means shipping handle-bearing rows, a change to what the serializer ships, not a contained fix in the extractor or the sidecar, so it is declined and pinned by 16 strict xfail cells (12 are this gap; the 4 list[Model] cells on the Django-backend engine are attributed to #3888, where the sliced loop is empty whatever is read) plus controls that show the gap is exactly the mixed read (python/tests/test_queryset_all_slice_3867.py). (4) The kept rows measured at 20,000 users, release extension: render 1.19 to 1.34 s without them, 1.96 to 2.00 s with them (+48 to +69%), peak RSS 296 MB to 358 to 360 MB (about 3.2 KB a row); no cheap, obviously correct saving exists (a lazy proxy changes how the serialization floor is built), so the figure is in docs/JIT_SERIALIZATION_PATTERN.md instead. A different finding made on the way is filed as #3888 (a relation read inside a sliced loop over a list[Model] is empty on the Django-backend engine, with no kept rows involved).
  • Documented the merge-queue ruleset (how it is set up, how to roll it back) and the gh run view --log 64 KiB truncation gotcha in docs/testing-strategy.md.
  • Recorded in docs/testing-strategy.md what the first real merge-queue run confirmed (base_sha, base_ref, scope classification) and the review and push settings on main.

All releases · Atom feed