djust 1.2.0rc2

Pre-releaseSecurityReleased
Install
pip install djust==1.2.0rc2

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

Part of djust 1.2 — read the 1.2 release notes.

Added

  • {% autoescape on|off %} is implemented on the Rust engine (#2556, PR A of the six-tag bucket). Django's AutoEscapeControlNode, as a Context::autoescape flag mirroring emit_dj_if_markers (render-time, per-context, never on the cached Template), a Node::AutoEscape parser arm carrying Django's two error messages verbatim, and a render arm that flips the flag on a per-block clone, so nesting restores the outer setting for free. The flag is an emit-time term and the needs_autoescape argument — never a safety grant: it does not enter filter_output_is_safe / filter_output_items_are_safe / SAFE_OUTPUT_FILTERS / input_was_safe, the only production writers of set_autoescape are the render arm and the {% include … only %} copy (pinned by a source grep), no pyfunction exposes it, and a context key spelled autoescape has no effect. Where {% autoescape off %} changes behaviour: the {{ }} / inline-if / {% cycle %} / {% firstof %} emits go raw; the five needs_autoescape built-ins (linebreaks, linebreaksbr, linenumbers, urlize, urlizetrunc) compute Django's autoescape and not isinstance(value, SafeData) instead of a pinned first term; join takes Django's raw arg.join(value) branch (items and separator unescaped); unordered_list's escaper becomes the identity; the custom-filter bridge passes the real block policy as the autoescape= kwarg instead of a literal true; escape_handler_return (custom tag and block handlers) honours Django's if context.autoescape: guard; and an {% include … only %} fresh context inherits the flag, as Django's context.new() copies it. Unchanged on purpose: {% csrf_token %} (Django's node is format_html, always escaped) and escapeseq (unconditional). Two structural fixes rode along: the {% extends %} block walkers now descend into the new node, so a parent's {% autoescape off %}{% block %} governs the child's override, and the backend-list generator's closer set learned endautoescape. The {{ x|f:"…" }} filter-argument literal now goes through Django's unescape_string_literal the way the tag-operand path already did — a #1646 twin the family surfaced (autoescape-tag08, and the one out-of-family mover, filter_syntax10, FAIL → OK). Measured on this branch alone against Django 5.2.16's template_tests: the 79 Unsupported template tag '{% autoescape …' cells go 71 OK / 6 FAIL / 2 ERROR; the 8 left are owned elsewhere — test_invalid_arg / test_no_arg need typed TemplateSyntaxError (#2549), include13 / include14 need string_if_invalid (#2518 / #2550), cycle27 needs PR C's cycle state, and urlize01 / urlize09 / urlizetrunc01 are the pre-existing urlize href defect (#2582, the same bytes urlize02 / urlizetrunc02 fail with on main). Scoreboard: 44.03% → 50.91% of engine cells (461 → 533 of 1047), 59.75% → 64.70% whole label, 0 regressions. python/tests/test_autoescape_tag_2556.py, 179 cases: one Django-parity cell per sink row under off and on across the plain, DjustTemplateBackend and RustLiveView paths, nesting / include only / extends, a 400-case randomized grammar differential against Django, the security pins above, and a count pin that Node::AutoEscape joins every children-walker Node::Spaceless does. Gate-off per mechanism: removing the render arm's set_autoescape reddens every reached row; removing the include only copy reddens only that row; passing true to the bridge again reddens only the custom needs_autoescape row. djust_audit's X007 still flags {% autoescape off %} for human review.

  • The five remaining Django built-in tags the Rust parser did not know — filter, resetcycle (with a rewrite of cycle's state model), lorem, debug, querystring — as one conformance family (#2556 PRs B–E). {% filter f1|f2:arg %}…{% endfilter %} renders its body, binds it as the SAFE variable var (Django's NodeList.render is a SafeString) and runs var|f1|f2:arg through the one pipe loop {{ }}, {% firstof %} and {% cycle %} share; the block's output is the chain's output as-is — measured against Django, a FilterNode's return is never re-escaped, so {% filter upper %}{{ p }}{% endfilter %} is &LT;SCRIPT…, and cycle21's force_escape double-escapes exactly as Django. safe / escape in the chain are refused with Django's message. <!--dj-if--> markers are off inside the body (marker bytes through upper are not a VDOM boundary; documented). {% cycle %} now has Django's state model — per NODE, per RENDER, on a store shared by every derived Context (for, with, include, include … only, the way Django's render_context is shallow-copied) instead of the per-{% for %}-iteration counter (__djust_cycle_counter, deleted): named definitions (as name [silent]), references ({% cycle name %} advances the SAME iterator and shares its silent flag), one advance per render with the emitted value bound ({% cycle 'a' 'b' as x %}{{ x }} is aa), and {% resetcycle [name] %} rewinding the named or the last-DEFINED cycle. Behaviour change: a single-operand {% cycle x %} is now Django's reference form and raises No named cycles in template. 'x' is not defined where it used to render x's first value; two cycles outside a loop render ab where they rendered aa; and a cycle inside nested loops keeps ONE iterator across the outer iterations (A12B31), where the old counter restarted it (A12B12) — all three are Django's bytes. {% lorem %} and {% debug %} are Python handlers that call Django's own lorem_ipsum.words / paragraphs and DebugNode's escape(pformat(…)) verbatim, so lorem is byte-identical under a shared random seed and debug renders "" unless settings.DEBUG — with djust's serialization floor and sidecar proxies already in front of the handler, so a User's password hash is absent from the dump (pinned). {% querystring %} (Django ≥ 5.1, version-gated registration) is defaulttags.querystring called through Django's own parse_bits / compile_filter, escaped by the registry like a simple_tag; the as var form is refused until inline handlers can bind context (#2591). Two plumbing fixes it needed: the plain backend now carries a RequestContext's request (flatten() drops .request — the request slice of #2550), and a QueryDict / MultiValueDict keeps its type through serialize_value, normalize_django_value and the render sidecar (it was rebuilt as a plain dict, losing .urlencode() and every repeated key). Suite cells, branch alone: filter 0 → 4 (+cycle21), cycle 13 previously-FAILing shapes → OK, resetcycle 0 → 7, lorem 0 → 5, debug 0 → 4, querystring 0 → 13 — 44.03% → 48.52% (461 → 508 of 1047), 0 regressions, nothing outside the family moved. Still not OK, by owner: the TemplateSyntaxError-typed cells (#2549; Django's exact messages are emitted), cycle27 (autoescape, PR A), cycle29/30 (ifchanged), test_non_ascii (#2590), the AttributeError passthrough cell. The GET page-shell LiveView path carries no request for {% querystring %} (pre-existing, #2589). Covered by TestFilterTag (200-case random chain sweep), TestCycleStateModel (300 random cycle programs), TestLoremTag (336-case seeded byte-exact differential), TestDebugTag, TestQuerystringTag (220 randomized query shapes with the #1825 encoded variants, RequestContext and explicit-QueryDict shapes), TestQuerystringVersionGate and the children-walker count pin, on both the plain backend and the LiveView entry — 97 tests in python/tests/test_remaining_builtin_tags_2556.py.

  • {% load app_tags %} imports the Django template library and bridges its tags and filters into the Rust engine (#2547). The parser's load arm now calls a Python loader (djust.template_libraries, installed through the new _rust.register_library_loader) at the sink — every parse, primary or {% include %}d or inside a {% block %} of an extended template — which resolves the name the way Django's load tag does (the engine's library map via get_installed_libraries() plus OPTIONS['libraries'], or load_from_library for {% load x from lib %}), imports it with Django's own import_library, and bridges every entry: filters through the #1121 register_django_filter bridge (the #2548 input-gated is_safe rule unchanged), tags through ONE generic handler per tag that calls the library's own compile function on a synthetic Parser/Token and renders the Django node it returns — simple_tag (every argument shape, takes_context, as var), simple_block_tag (custom end_name, nesting, as var), inclusion_tag (its template resolves through the enclosing backend), and raw @register.tag functions that build a node from their own token. Byte-equal to Django by construction: parse_bits, SimpleNode, SimpleBlockNode and InclusionNode are Django's. DjustTemplateBackend accepts OPTIONS['libraries'] and OPTIONS['builtins'] with Django's meaning. An unknown library raises Django's exact TemplateSyntaxError ('x' is not a registered tag library. Must be one of:…); a library's own exception crosses whole with its type.

    Two new contract points on the Rust side. A handler that declares RETURNS_BINDINGS = True returns (output, bindings) — the diff of ctx.dicts[-1] after node.render() — so a context write made by Django's node (as var in any position, a get_*-style tag writing several keys) reaches the siblings that follow, and its Python exceptions cross as PythonException rather than a flattened string. The block registry now honours RESOLVE_ARG_POSITIONS like the other two (the #1646 drift the plan measured: {% div id=name %} handed Django's parser the resolved value). A bare numeric filter argument is Django's literal ({{ s|trim:5 }} hands the filter int 5, which also flipped test_for_tag_filter_ws and test_widthratio15). The renderer's marked-safe paths are re-minted on the handler's context dict (Context::safe_key_paths), so a mark_safed operand reaches the tag as SafeData exactly as on Django. Every node output crosses back mark_safed — Django never re-escapes a node's return; the grant is Django's own stance on author content and does not extend to anything data-derived, which the node escaped itself.

    Refused loudly, per tag: a raw @register.tag whose compile function consumes a body (parser.parse(...), next_token) cannot be bridged; the parser raises Django's TemplateSyntaxError naming the tag, the library and #2558 (the raw-body registration kind) the moment a template uses it, while the rest of that library bridges normally (the first cut refused the whole library at {% load %} — that took djust's own live_tags down with colocated_hook). Django's own libraries (i18n, l10n, tz, cache, static) and djust's own djust.templatetags.* resolve but are deliberately not bridged: separate rows, native handlers. Registration is process-global (ADR-001), so once any parse loads a library every later parse on either engine path sees its tags — and the Rust TEMPLATE_CACHE is keyed by source, so a test suite that clears the Rust registries must call djust.template_libraries.reassert() (wired into djust.test_isolation) or a cached template's tags resolve to nothing.

    Scoreboard (#2517): template_tests/test_custom.py 7 → 23 OK; 34 cells improved across test_custom, test_load, test_simple_tag, test_extends, test_include, tests.TemplateTests; engine subset 44.03% → 47.28% (whole label 59.75% → 62.09%), 0 regressions. The remaining test_custom cells are the 11 TemplateSyntaxError-at-from_string cells (#2549), the two autoescape_off cells, test_15070_use_l10n's second assertion (#2586) and test_no_render_side_effect (#2587, Django internals, won't-fix). Tests: 138 cases in python/tests/test_load_imports_django_libraries_2547.py over a scratch library with every registration shape, on the plain backend AND the real LiveView entry, each byte-compared to live Django, plus a seeded randomized differential of 300+ unique cases; four gate-offs, one per mechanism, each red on exactly its own rows.

  • Django's own template test suite now runs against DjustTemplateBackend, as the conformance scoreboard for the v1.2.0 template arc (#2517). make django-template-suite clones django/django at the tag matching the installed Django into a gitignored .django-src/ cache, runs tests/template_tests through Django's own runtests.py, and prints OK / FAIL / ERROR plus a percentage. First baseline, Django 5.2.16: 43.55% of the tests that reach the engine pass (456 OK / 227 FAIL / 364 ERROR of 1047); across the whole label, 59.41% (865 of 1456, 14 skipped). The engine subset is the headline because 409 tests never reach any engine (test_parser, test_context, ...) and measure Django against itself: a whole-label headline would be padded and every later gain would read smaller than it is. ERROR (an unsupported tag, a crash) and FAIL (wrong output) are counted separately because they are different work. The seam is an Engine subclass that keeps the real constructor and overrides only from_string and get_template, installed into django.template after django.template.backends.django has bound the real class, so the TEMPLATES backend, the admin checks and the default Template("...") engine stay on Django; nothing in Django's checkout is edited. Seven template_tests cases segfault the interpreter (a DjustTemplate or a type object in the context, the #2516 class), so the child records one flushed JSON line per test and the outer loop marks the in-flight test ERROR: process crashed, skips it plus every finished test, and relaunches; a crash with no test in flight (class or module setup) cannot be attributed and stops the run with the finished count.

    The percentage moves only through scripts/django-template-suite-baseline.json via --write-baseline, and the figure in docs/TEMPLATE_BACKEND.md is pinned to that file by a test. The CI job is non-gating (continue-on-error, informational compare) until it has been runner-green (#1534); promotion to a blocking ratchet is #2522. Review hardening: --django-tag is validated before any filesystem access (no /, .., whitespace or a leading -), the checkout paths are asserted to lie inside --cache-dir, labels starting with - are refused and every label is passed after a literal --, and the checkout root is normalised out of recorded messages so runs from different machines diff cleanly. The first run filed #2518 (the backend silently drops every OPTIONS key except context_processors), #2520 (as var tags never write back into the caller's Context), #2521 (recursive {% extends %} across loaders hits the depth limit) and the <!--dj-if--> marker leaking into plain-backend output, attached to #2519. Covered by TestSummaryArithmetic, TestRecordingResult, TestEmpiricalCanary, TestCrashIsolation, TestArgvHardening, TestRatchetCompare, TestAdapterInSubprocess, TestDocClaimMatchesBaseline and TestAgainstRealDjangoCheckout (the last skipped unless a checkout is present).

  • The djust_templates engine gains a liveview cargo feature, on by default, that separates the LiveView half of the engine from the plain Django-backend half (#2519). It gates the three things only a LiveView consumes: the djust_vdom::VNode loop parse cache (the parsed storage, its hit/miss counters and the get_parsed / insert_parsed accessors that render_with_diff splices into the diff baseline), the <!--dj-if--> boundary-marker emission in the Node::If arm, and the djust_components renderer path (render_rust_component and the Node::RustComponent arm, whose feature-off twin returns a TemplateError naming the feature). With --no-default-features both djust_vdom and djust_components leave the dependency graph. Deliberately NOT gated, each decided and written down in docs/TEMPLATE_BACKEND.md: the PyO3-backed tag and filter registries (a plain Django project needs {% url %} and @register.filter more than LiveView does, so the engine without the feature is still a PyO3 crate; cutting that is a different, larger change), the parser's marker ids (a harmless Option<String>, and template_hash_hex doubles as the Redis cache key), the loop-cache manifest and dj-pc placeholder (string-only, inert without an installed guard), and markdown / timezone / inheritance, which both paths use. djust_live needs no change; it builds with default features. CI's rust job now runs cargo check and cargo test on the engine without the feature plus a load-bearing cargo tree pin (#1859) that fails if either crate stops being optional; make check-no-default-features runs the same three steps locally and is chained from make test-rust. Rust coverage in crates/djust_templates/tests/test_dj_if_markers_off_2519.rs has three halves: the switch (a Context with emit_dj_if_markers off emits no marker form, including through {% include %} and {% include … only %}, and the flag survives Clone), the LiveView pin (a default Context with the feature on is byte-identical to before), and the feature-off build (a default Context emits nothing and a <RustButton /> is an error). Python coverage in python/tests/test_dj_if_marker_plain_backend_2519.py: TestIssueReproducer, TestPlainBackendMatchesDjango (a real DjangoTemplates-versus-DjustTemplateBackend differential), TestEachPlainEntry (one test per plain entry, #1104), TestLiveViewPathStillEmitsMarkers and TestSourcePins (the #1125 count pins: both djust_live plain entries set the switch, and the regex strip is gone everywhere).

  • docs/TEMPLATE_BACKEND.md's supported and unsupported tag and filter lists are now generated from the engine's own registries and checked in pre-commit and CI (#2533). The hand-written lists had drifted: escapejs was listed as unsupported with a json.dumps workaround and a todo while {{ s|escapejs }} already matched django.utils.html.escapejs, and the troubleshooting entry misquoted the engine's error text. scripts/generate-template-backend-lists.py reads the ARITY filter table in crates/djust_templates/src/filter_arity.rs and the parser's tag match arms from the working tree, the python/djust/template_tags/ handler registry, and Django's defaultfilters, defaulttags and loader_tags plus the i18n, l10n, tz, static and cache libraries at the installed version, and writes one block between <!-- generated:template-backend-lists --> markers, the way client-sizes.json is emitted and checked (#2138). Check mode is the default, wired as a pre-commit hook scoped to the doc and its sources and as a CI step after the doc-snippet check; it exits 1 with a diff when the committed block is stale. --write regenerates it (a same-directory temp file plus os.replace, keeping the doc's mode, refusing a read-only doc with a clean ERROR: and exit 2). --cross-check reconciles the unsupported set with a #2517 scoreboard run. The doc now says, on Django 5.2.16: built-in filters 57 of 57 supported; built-in tags 18 of 25 (16 native Rust plus regroup and url via Python handlers; unsupported autoescape, debug, filter, ifchanged, lorem, querystring, resetcycle); library tags 1 of 18 (static); library filters 0 native, 9 bridged, 0 unsupported.

    Bridging is detected at generation time rather than asserted. The first version called the nine library filters unsupported, which is false under the doc's own recommended TEMPLATES: with a DjangoTemplates fallback engine the filter bridge (#1121) forwards them into the Rust engine and they resolve. The generator now configures both engines, runs bootstrap_django_filters and reads the Rust registry back, and the block says a djust-only TEMPLATES raises Unknown filter for each. A direction check renders every listed tag through DjustTemplateBackend and asserts Unsupported template tag for every generated-unsupported name and its absence for every supported one, so neither list can flip direction without a test going red. The scoreboard cross-check agrees on 20 of 20 Django tag names; four tz and l10n tags (get_current_timezone, localize, localtime, timezone) never reach template_tests as a tag error and are listed on the generator's authority, with a test pinning that these four are the whole never-exercised set. Every emitted name is validated against ^[a-z_][a-z0-9_]*$ (an ExtractionError, exit 2), so a mis-parsed source line cannot land in the doc as a tag. Filed from this work: #2540 (three other hand-written copies of the same sets have drifted: checks/templates.py, system-checks.md and _BUILTIN_NAMES) and #2541 (the bridged tz filters resolve but render empty output). Covered by TestExtractionMatchesTheRegistries, TestCheckMode, TestWriteMode, TestScoreboardParity (skipped unless .django-src/last-run.txt is present), TestCrossCheckDetectsDisagreement, TestDocAndWiring, TestGeneratedTagBucketsMatchTheEngine and TestBridgedLibraryFilters.

  • A model-backed render benchmark: the five-bucket boundary profile ADR-027 is scored against (#2532). tests/benchmarks/test_model_backed_render_2532.py (make benchmark-model; release build required) drives a 50-row × 6-column list of Django models through the real WebSocket mount and event paths (WebsocketCommunicatorLiveViewConsumerViewRuntime.dispatch_mount / dispatch_event): seven variants — list_control (the select_related control), list_property (a @property column), list_reverse ({{ row.comments.count }}), list_fk_nosel (an FK without select_related), presenter_control and presenter_reverse (the same rows behind a plain Page(rows) object), and snapshot (list_control with enable_state_snapshot = True) — three events each (a label outside the loop → the fragment fast path; a class on a <tr> → full parse + diff; a persisted views += 1 on row 7 → the text-region fast path). Each phase is split into the five buckets PERFORMANCE_BRAINSTORM.md §7 asked for: (1) Rust render proper, the differ's render_ms minus the crossings' Python time; (2) direct Rust-origin boundary crossings, counted by caller-classified monkeypatches of _protect_sidecar_value / normalize_django_value gated by a thread-local flag set around render_with_diff, with transitive proxy re-wraps and pre-render Python calls reported apart; (3) ORM, via an execute_wrappers hook installed on the consumer's worker thread; (4) state serialization — _sync_state_to_rust minus get_context_data, plus ViewRuntime._persist_state_after_event; (5) parse + diff + serialize, tagged by which parse-skipping path fired. For (5) RenderTiming gains a fast_path key (0 full parse, 1 fragment fast path, 2 text-region fast path), exposed through get_render_timing(); the binary render's in-place update calls the whole-HTML byte diff and so reports 2, not 1 (it was labelled as the fragment path in the first cut). The table prints from pytest_terminal_summary; DJUST_BENCH_TABLE_JSON=<path> dumps it. Assertions are on counts and flags only, never a duration (the v1.0.5-4 rule — a wall-clock threshold is flaky under load, and timing stays non-gating until runner-stable, #1534): the five list-shaped variants make 0 crossings in every phase and presenter_reverse > 0 on every full render; fast_path is set for the two text events and clear for the attribute event in every variant, and agrees with the pre-#2532 diff_ms == 0 ∧ all-SetText inference so the flag is load-bearing (#1859) and the inference cannot drift silently; presenter_reverse issues more queries per full render than presenter_control (asserted as >, not a number); row 7's variant cell is present in the mount HTML; only snapshot persists, exactly once per event, and the 50 normalised rows read back from the session store.

    Measured (release build, medians; the table is in PERFORMANCE_BRAINSTORM.md §7.1): the issue's fixture table was wrong. A @property column and a reverse-relation call on a list[Model] row never reach the Rust sidecar — ContextMixin.get_context_data JIT-serialises the list in Python by template-extracted paths and rust_bridge.py never admits a list to the sidecar — so all four list_* variants cross the boundary zero times; that zero is the invariant the ADR-027 flip (#2539) is held to. The sidecar path is the presenter shape: {{ row.comments.count }} on Page(rows) costs 302 direct crossings (+950 transitive re-wraps) and 50 COUNT(*) queries per full render, an N+1 on every attribute-change event, ~6 ms in the Rust walk; the presenter control still pays 52 (+850), ~5 ms of Python, for the one-off Value extraction of the row list. The JIT re-serialises every row on every event (~497 serializer calls and one query per event, even for a one-label change) — filed as #2536, not fixed here. A list mount is state sync, not render: ~4.3 ms in _sync_state_to_rust against ~0.4 ms of Rust render on a quiet machine. snapshot adds 1.8–2.3 ms per event of signed-snapshot session write and issues 4–5 queries where the other list variants issue 1–2. The #2539 planning probe against the same real entry filed #2542. Review items closed: the plan's snapshot variant restored with a presence count on _persist_state_after_event (it had been dropped, leaving the persist column dead on every variant); the ORM hook removed in the driver's finally on the thread that installed it, since left in place it would time every ORM statement of every later test in the process; the binary in-place fast path relabelled to text-region with the second Rust site covered (#1104); the no-threshold pin widened to any receiver expression and every *_ms attribute, exempting only comparisons with literal zero. make benchmark-model runs the seven benchmarks serially. Covered by test_model_backed_render_profile (one benchmark per variant) and test_query_hook_is_removed_from_the_worker_connection_on_teardown; by TestCrossingClassifier, TestPhaseRowBuckets, TestSummaryTable and TestBenchmarkModuleShape in tests/benchmarks/test_model_backed_table_2532.py; and by the Rust fast_path_flag_tests module in crates/djust_live/src/lib.rs (cargo test -p djust_live --no-default-features), which pins both render_with_diff and the binary path's flag on first render, a text change outside the loop, a text change inside the loop and an attribute change.

  • ADR-027 movement 1: a characterization net over today's variable resolution, and the Django-lookup sink defined but not routed (#2539). python/tests/test_adr027_characterization_net_2539.py — 42 test functions collecting 402 cases — pins what every render path (Django in-process, DjustTemplateBackend, the LiveView path, the page shell) answers TODAY for 45 lookup shapes, as a three-way table: cells where djust already matches Django fail if they regress; cells where it is wrong today (#2502 marker dict, #2504 filtered {% for %} operand, #2505 loop-variable shadowing, #2513 no sidecar on the page shell, #2542 objects inside a top-level list) fail with the message "ADR-027 landed here — move the row to Django's bytes" the day the lazy sink is wired, and fail differently if they become wrong in a new way. The closed security findings #2506 (fail-open on any exception) and #2507 ({{ c.unmount }} runs a mutator) are permanent pins on every path, including a floor set that names a leak as a security regression rather than a parity change. The three #2516 interpreter crashes (one reference cycle, two through __class_getitem__) are held in subprocess cells that assert the signal. In Rust, djust_core::Context::walk_live is the sink itself — Django's Variable._resolve_lookup order (mapping key → attribute → integer index, alters_data / do_not_call_in_templates guards, callable invocation, exception propagation per SECURE_DEFAULTS.md) — with one Rust test of eleven labelled cases in crates/djust_core/tests/test_django_lookup_sink_2539.rs, and a structural pin proving no crate calls it yet. The Django-suite scoreboard is byte-identical to the baseline (44.03%), which is the proof that this movement changes no behaviour. Movement 2 (wire), 3 (flip) and 4 (delete the enumeration arms) follow on #2539; the review of this movement filed #2552 (the parser panics on any non-ASCII byte inside {{ }}).

  • Developer velocity: per-worktree environments, a scoped pre-push hook, and a conformance-loop pipeline template (#2526). Three things serialised a day's work on the template arc: one Rust row held the only usable checkout for hours while other rows queued, because a maturin develop from a worktree repoints the shared venv; every push re-ran the full ten-minute suite (three pushes for one PR); and every scoreboard row went through the full per-tag ceremony. make worktree-env now gives any checkout or git worktree its own .venv (from requirements-dev.txt plus the project's runtime and dev dependencies, installed with pip, never uv) and its own compiled extension, and scripts/run-with-venv-python.sh prefers the current tree's venv, so three Rust rows can build and test in parallel with no shared state (Cargo's target/ was already per-checkout). The pre-push pytest hook now runs a SELECTED set — changed test files, tests that import a changed module, and tests whose text names a changed file (the source-pin class, which is what caught the two broken pins on PR #2569) — and falls back to the full suite when core, config or conftest files change, when the selection is empty, when the branch name says it is a routing flip, or with DJUST_PREPUSH_FULL=1; cargo test is scoped to the changed crates and their dependents. The full suite still runs twice per PR: once in the pipeline's Test Execution stage and once in CI. scripts/select-tests.py carries 39 unit cases in tests/unit/test_select_tests.py, and the hook caught four real failures on its own branch before it was committed. .pipeline-templates/conformance-state.json is a new pipeline shape for rows scored by a family of Django-suite cells: one family, one implementation loop measured with the runner filtered to that family, one adversarial review, and the baseline written at the end.

  • A C016 system check for the TEMPLATES shape (#2562). Warns when a DjangoTemplates entry is listed before DjustTemplateBackend (the Django engine then shadows every template djust could have rendered, with no error to say so), and when no DjangoTemplates entry exists while django.contrib.admin or admindocs is installed (their templates need one). Silent on every real shape checked: the demo project, the djust new scaffold with and without --with-db, djust.org and djustlive. There is deliberately no duplicate-NAME check: Django's own check_templates raises ImproperlyConfigured for that on every supported version before any djust check runs. Cases in python/djust/tests/test_c016_templates_shape_2562.py cover each branch, each accepted shape and the id-set pin; python/djust/tests/test_c0xx_docs_table_pin_2562.py pins that every emitted C0xx id has a row in docs/system-checks.md (which also gains the missing C013–C015 rows).

  • ADR-027 movement 2: the Django-lookup sink is wired, behind a flag that is off (#2539). Value::Encoded now carries a transient live handle to the Python object it was converted from (never serialized — the wire payload keeps its eleven slots — never compared, never handed back to Python), and Context::walk_live has exactly one call site, in Context::resolve_without_builtins, taken only when LIVEVIEW_CONFIG['template_resolve_lazy'] is true. The switch is a per-render thread-local pushed by djust.render_env.apply_render_env beside the timezone and number-format settings, because half the work happens inside the Python-to-Value conversion where no Context exists; components/base.py joined that caller set. With the flag off the engine's bytes are byte-identical to before: the 402-cell characterization net is unchanged and the Django-suite scoreboard is identical to its baseline. With the flag on, 29 of the 37 net cells that are wrong today answer Django's bytes, none becomes wrong in a new way, no security-floor cell moves, and the two #2516 reference-cycle crash cells stop crashing; the rest stay held for movement 3 — the component and Value-reader rows the ADR deferred, and the LiveView-path rows where normalize_django_value replaces a callable with None before Rust ever sees it. Wiring surfaced one real defect in the walk it replaces: a lookup that ended in a failure after an auto-call fell through to the old sidecar walk, which re-walked the object and invoked the callable a second time. A sink answer now terminates resolution, CallOutcome distinguishes Django's two string_if_invalid outcomes (an alters_data refusal mid-path substitutes an empty string and keeps walking; a silent failure returns), and a counting-callable pin asserts an expression never invokes a callable more than Django's once, on every entry and under both flag states. New cases in python/tests/test_adr027_wiring_security_2539.py cover the fail-closed, mutator-guard, serialization-floor and identity requirements recorded on #2539; the net gained a flag-on/flag-off axis and its structural pins now assert the sink is routed only behind the switch. Every shipped mechanism has a gate-off mutation that reddens it alone, including the carriage/decline pair measured in both directions. The review fix pass scoped Django's ignore_failures substitution to the four tags that use it ({% with %}, include-with, tag and filter arguments now resolve strictly, as Django does), fixed a pre-existing typed-argument defect it made reachable (default_if_none handed an integer 0 to the filter as the string "0", which is truthy), wired the live-handle teardown at socket disconnect (draining the raw-Python sidecar too, which a weakref test showed was the other channel keeping objects alive), and made the flag push fail closed when the config read raises. Measured with the flag on at the merged head: the Django suite moves from 44.03% to 44.60%, six cells reach Django's bytes with no regression, and all seven interpreter crashes the suite had to isolate are gone. Filed: #2564 (update_state never removes a deleted key) and #2570 (the msgpack state clone drops handle-bearing lookups) as movement-3 prerequisites; #2568 (a user exception raised inside a callable is re-wrapped by the backend); #2571 (a literal default_if_none:0 argument is truthy); #2572 (an object whose __getitem__ never raises hangs conversion).

  • Every one of Django's 25 built-in template tags and 57 built-in filters is now supported, and {% cache %} was the last library tag left (#2517). Four gaps closed in one arc, measured on Django's own template_tests: the engine subset moved 77.17% → 85.20% (808 → 892 of the 1,047 cells that reach an engine) and the whole label 83.59% → 89.35%, with zero cells regressing.

    {% ifchanged %} was a Node::UnsupportedTag, so a template carrying one failed to PARSE. It is now native, with both forms Django distinguishes: the no-operand form compares the RENDERED body — and reuses that string rather than rendering twice, so a {% cycle %} in the body advances once — while the operand form compares resolved values under ignore_failures. State is scoped to the innermost loop EXECUTION, mirroring Django's one forloop dict per ForNode.render, so an inner tag resets each time an outer loop re-enters it while iterations of the same loop share it. The comparison follows PYTHON equality (0 == False and 1 == 1.0 are one value; 1 != "1"); the randomized differential found the first version's {:?} key wrong within sixty seeds.

    {% cache %} is bridged as a hand-written block handler over Django's own cache framework. Its compile function consumes a body (parser.parse(("endcache",))), which the generic {% load %} bridge cannot serve, and unlike {% blocktranslate %} its body is not data either — what it needs is the body RENDERED, which is exactly what djust's block-handler protocol delivers. Operands resolve through Django's own FilterExpression, so a filter chain ({% cache 2|add:1 k %}) works, an unresolvable expiry raises rather than silently meaning "forever", and a template_fragments cache is preferred over default as Django prefers it.

    django.templatetags.static is bridged for {% get_static_prefix %} / {% get_media_prefix %}. The native {% static %} is skipped explicitly: _may_override consults the handler REGISTRIES, and a native parser tag is in none of them, so without the skip the bridge would silently displace it.

    string_if_invalid is honoured on the {{ var }} path. Django does not merely substitute a marker — a non-empty setting RETURNS from FilterExpression.resolve, so the filter chain never runs, which is why {{ missing|default:"Foo" }} renders the marker and not Foo.

  • {% translate %}, {% blocktranslate %}, the _("…") literal and the i18n / l10n / tz scope tags now work in the Rust template engine (#2558). {% load i18n %} (or l10n / tz) bridges Django's own library: the catalogs, the plural rules, the %-formatting and the escaping are Django's, because the bridge runs Django's compile functions and nodes rather than reimplementing them. Covered: both spellings of each tag, count/{% plural %}, context, with (both the modern and the legacy as forms), trimmed, asvar, noop, as var, filter chains on literals and variables, the seven syntax errors per tag verbatim, the five get_* tags, the four language_* filters, and _("…") in {{ }}, as a tag operand and as a filter argument (including Django's {{ _("100%") }}100%% quirk). The active language is read per render on the render thread, so translation.override, LocaleMiddleware and a per-user language work as they do on Django — including on every WebSocket event.

    Three mechanisms are new. {% blocktranslate %} crosses its body to Django as un-rendered SOURCE through a fourth tag-registration kind (the body is the msgid, so Django's lexer must be the one to turn {{ var }} into %(var)s and its parser the one to refuse a {% block %} inside). _("…") is recognised by the literal recogniser and translated through a render-time hook. {% language %}, {% localize %}, {% localtime %} and {% timezone %} are native Rust scope nodes that switch Python's thread-local and re-push the locale/zone state to Rust around their children, restoring it on the way out — including when a child raises, and when one panics.

    Scoreboard: 62.85% → 70.39% of the Django template tests that reach the engine (658 → 737 of 1047, +79 cells, none worse; whole label 73.28% → 78.71%). Measured on this branch merged with main, against main's own committed baseline at that moment — so the "before" already contains #2547, #2596 and #2556's {% autoescape %}, and this row claims only its own cells. (The i18n work is worth the same +79 against either base: it was 55.68% → 63.23% before #2595 landed {% autoescape %} on main and moved both ends up 77 cells.) All 116 cells of Django's own syntax_tests/i18n pass, and blocktrans (27 cells) and trans (15) leave the unsupported-tag error class entirely.

    Correction to the #2547 entry above, which says Django's own libraries "resolve but are deliberately not bridged: separate rows, native handlers": that was true when #2547 shipped and is now true only of static and cache. i18n, l10n and tz ARE bridged, by this row — which is the separate row it referred to. (The sentence is left as written because [Unreleased] is compiled from fragments and is not edited directly.)

    Two raw-body residues the review found were fixed rather than documented, because both silently mangled author content. A comment inside a {% blocktranslate %} body (a {# c #} b) rendered a b where Django raises 'blocktranslate' doesn't allow other block tags (seen 'c') — the lexer dropped a comment's text, so the body reached Django without it; Token::Comment now carries the raw text and the collector re-emits it verbatim, so the message is Django's own. And an unterminated {{ in the body raised Unclosed raw-block tag on a template Django renders verbatim — the lexer consumed from an opener to end-of-input, swallowing the {% endblocktranslate %}. Django finds tags by regex scan, so an opener with no closer is plain text and a well-formed tag after it still lexes; the lexer now checks before consuming and, on failure, emits only the first brace so the second can still open a real tag. That half was an engine-wide silent mangle, not an i18n one — a bare a {{ unclosed b rendered a , losing every byte after the opener, and the same held for {% and {#. All three arms are fixed together, which also closes the {{% divergence a test had pinned as pre-existing.

    Named residues, each with its own test: the three tz filters (localtime, utc, timezone) raise by name rather than rendering blank, because a datetime crosses the wire as its ISO string (#2216); the DATE half of {% localize off %} is that same wire residue (#2221 piece 3); and {% localtime on %} under USE_TZ = False does not force conversion the way Django's does. New cases in the module-level tests of python/tests/test_i18n_tags_bridge_2558.py, plus 35 Rust #[test] functions against main — 9 in crates/djust_templates/src/parser.rs, 8 in lexer.rs, 7 in renderer.rs, 4 in registry.rs, 1 in filters.rs, and 6 in the new crates/djust_templates/tests/test_scope_guard_pins_2597.rs.

    Two user-visible side effects of the residue fixes, named here because neither is an i18n change. {% verbatim %} now keeps a comment: collect_raw_source is the single re-emitter for both the raw-block registry and {% verbatim %}, so {% verbatim %}{# hi #}{% endverbatim %} rendered "" before and renders {# hi #} now — which is what Django renders (test_verbatim_now_keeps_a_comment_like_django). And the closer lookahead is engine-wide, not raw-body-only.

    The four scope tags unwind on PANIC, not only on Err. {% localize %} and {% localtime %} restore from Drop (UseL10nGuard, ActiveTimezoneGuard); {% language %} and {% timezone %} do too, through one ScopeExitGuard whose release() is the ordinary path and whose Drop is the unwind path. Un-guarded, a panicking child left a translation.override installed on a POOLED worker thread, which then served the wrong language to the next render. Fixing two of the four arms would have been the parallel-path drift rather than the cure (#1646), and the renderer's use of all four guards is pinned mechanically — reverting any arm to its pre-fix shape turns two tests red.

    Merging main's {% autoescape %} (#2556 / PR #2595) into this branch surfaced two divergences neither branch had alone, both fixed here with their own parity rows. resolve_cycle_nodes did not descend into the four scope nodes, so {% cycle "a" "b" as c %}{% cycle c %} inside {% language %} / {% timezone %} / {% localize %} / {% localtime %} rendered a where Django renders ab — the same walker gap #2556 had to close for Node::AutoEscape, one arm above (#1646). And {% autoescape off %} around a {% blocktranslate %} escaped the body, because the raw-body registry — the fourth, added by this row — was the one of four that did not read WANTS_AUTOESCAPE; BlockTranslateNode resolves its %(var)s placeholders against the bridge's own Django Context, so the policy has to reach that Context. Unreachable until #2556 implemented the tag, which is why the residue this fragment used to name ("{% autoescape off %} around a translation still refuses the tag") is retired rather than merely restated: TestAutoescapeOffIsNamedNotFixed — whose own docstring said to replace it with a parity row the day djust parsed the tag — is now TestAutoescapeOffIsAParityRowNow.

Fixed

  • The state-backend round trip's contract for handle-bearing values is pinned, and seven comments that claimed the opposite are corrected (#2570, ADR-027 movement-3 prerequisite). With template_resolve_lazy on, a plain object crosses as a Value::Encoded with a live handle and a deliberately empty eager attribute map; the msgpack clone a state backend returns restores neither, and the Deserialize comments said the value "resolves through attrs" — false. Traced symptom-up: the only framework reader of a backend entry is _initialize_rust_view, behind if self._rust_view is None, and every render entry syncs the full context between that initialisation and the first render, so no framework path ever renders a clone. That contract is now pinned by a real WebsocketCommunicator restore-then-render test under both flag states, plus structural pins on the clone readers, the sync-flag writer, and the initialise-then-sync-then-render order of each entry. The degraded bytes an API-level caller sees when it renders a raw clone without a sync are pinned explicitly as the contract (python/tests/test_restore_round_trip_contract_2570.py). No behaviour changes.

  • <!--dj-if--> VDOM markers no longer leak into DjustTemplateBackend output (#2519). engines["djust"].from_string("{% if foo %}foo{% elif bar %}bar{% endif %}").render({}) gave '<!--dj-if-->' where Django gives '', and every {% if %} on every plain page carried a marker, true branches included (the element-bearing <!--dj-if id="if-…"-->…<!--/dj-if--> pair leaked too). Root cause was parallel-path drift (#1646): the renderer emits the markers unconditionally, and the only "plain" mechanism was a post-render regex strip that _rust.render_template applied and its sibling _rust.render_template_with_dirs, the entry the backend and SimpleLiveView actually bind, never did. The fix is a render-time switch, emit_dj_if_markers on Context, default on and mirroring auto_call: every plain entry turns it off, the LiveView entries are untouched and keep their bytes, and the one place the renderer builds a fresh Context mid-render, the {% include … only %} arm, copies it (without that a plain page with an only include leaks again). The switch lives on the Context and never on the parsed Template, because the template cache is shared by both paths and a parse-time flag would let whichever path parsed first decide for both. The regex strip is deleted rather than mirrored: it cost 1.9 µs (26%) on a small {% if %} and 130 µs (30%) on a 40-conditional page, and keeping it would have been a second mechanism shadowing the first (#2233), the exact shape that let the _with_dirs entry ship without either. Django-suite scoreboard: 43.55% → 44.03% (456 → 461 OK, 227 → 222 FAIL, 364 ERROR unchanged, of 1047; whole label 59.41% → 59.75%). Five of the six dj-if FAILs flip to OK: test_extends inheritance32 and inheritance35, and test_if if_tag06, if_tag_badarg01 and if_tag_badarg02. The sixth, if_tag_badarg03, now reads '' != 'yes': a separate, pre-existing {% if x|default_if_none:y %} undefined-variable parity bug, filed as #2528 together with the observation that the only include does not propagate auto_call either. The #2402 tripwire in python/tests/test_forloop_parity_2402.py (TestTheDjIfMarkerIsOrthogonal, which licensed two cells to compare modulo the marker until the artifact was gone) is retired: those cells now compare byte-for-byte and the class pins the ABSENCE of a marker. test_template_if_markers.py's render_raw now renders through RustLiveView.render(), the path it claimed to test, and its render_template class is renamed from "strips" to "does not emit".

    Behavior change. A LiveComponent with template_name renders through render_to_string, so on a project whose TEMPLATES backend is DjustTemplateBackend (the djust new scaffold default) it takes the plain path, and its HTML inside the parent LiveView's VDOM no longer carries the boundary markers it had only because of the leak. {% if %} blocks inside such a component are matched positionally by the differ, which is the state inline-template components (rendered through render_template, which always stripped) and every DjangoTemplates-backend project have always been in. Who is affected: LiveView pages that embed template_name components on a DjustTemplateBackend project. What to expect: those pages' bytes change (fewer comment nodes), and a {% if %} toggle inside such a component patches positionally rather than as a keyed subtree. Nothing breaks, but the component's conditionals are second-class for VDOM patching by accident of which render entry they hit; #2530 tracks making component rendering marker-aware by design.

  • A Rust panic ("dictionary changed size during iteration") on every LiveView render when TEMPLATES.OPTIONS.context_processors has request but not auth (#2510). A real, reported 500-on-every-page regression for a normal Django config (an app with no user model, so auth's context processor was never added). Root cause, confirmed by reading crates/djust_core/src/lib.rs: two arms of impl FromPyObject for Value (public_dict_attrs, the __dict__ bulk-dump carrier, and the plain-PyDict map-building arm) held a LIVE PyO3 iterator directly over a Python dict and recursively called .extract::<Value>() on each value — which can run arbitrary Python. An unresolved SimpleLazyObject (Django's request.user, before anything has forced it) triggers Django's lazy _setup() on the first dunder check, and AuthenticationMiddleware.get_user's request._cached_user = auth.get_user(request) writes a NEW key into request.__dict__ — the exact dict public_dict_attrs is mid-iteration over. auth's context processor normally forces that resolution earlier, before the walk; without it, the mutation happens live during iteration and PyO3's iterator invariant breaks. The template need not even reference .user — the walk dumps the whole __dict__ regardless of which attribute was asked for. Fixed by snapshotting each dict's items into an owned Vec before any recursive extraction, in both vulnerable arms — a structural fix (any object with an unresolved lazy attribute anywhere in its __dict__ is covered, not just HttpRequest specifically) rather than filtering HttpRequest out of one call site. Reproduced with a 6-line, Django-free unit test exercising the Rust boundary directly — LiveViewTestClient and django.test.Client did not reproduce it; only a real WSGI request did, for reasons not fully resolved and not load-bearing for the fix.

    The same bug class had three more unfixed sites in crates/djust_live/src/ — found by Stage 11 review, after the first pass's own "grepped every remaining .iter()" claim turned out to be scoped to djust_core only. fast_json_dumps (public, exported, used today in examples/demo_project/demo_app/views_old.py with no opt-in gate), serialize_models_fast, and the actor-dispatch path's python_dict_to_hashmap/python_to_value (opt-in via use_actors, but real once opted in) all had the identical live-iterator-plus-reentrant-extraction shape and are fixed the same way, plus two structurally identical sites in actors/component.rs/actors/view.rs whose outer context_dict.iter() loop was unprotected even after the inner conversion was fixed.

    Re-review of THAT fix found one more, in the same file it had already touched: serialize_models_fast's sibling export serialize_models_to_list (public, exported, no opt-in gate) routes through entirely separate helpers — normalize_dict/normalize_value — that nobody had grepped for yet. Fixed the same way. Every remaining file in crates/ referencing PyDict/PyList/PyTuple/PySequence was independently checked afterward and found not reachable by this bug class — strict-typed extraction with no dunder-invoking coercion, Rust-native iteration, or a producer-enumeration invariant guaranteeing the iterated values are always plain strings.

    A further review round found the most significant instance yet: the literal original reproduction — a top-level context dict mutating itself — was still exploitable. Every fix so far protected NESTED arms inside impl FromPyObject for Value; render_template, render_template_with_dirs, and RustLiveView.update_state convert their OUTERMOST context dict through PyO3's own blanket HashMap<K, V>: FromPyObject impl instead — dependency code no edit to djust's own conversion logic can reach, and no guard_panic wrapper can catch, since the panic happens in PyO3's FFI argument extraction before any hand-written function body runs. update_state is the literal call the original report's AuthenticationMiddleware/request.user repro goes through. Fixed with a shared snapshot-first helper at all three entry points; update_state needed its logic split so its Rust-only sibling caller (used by other crates with no Python object at all) keeps working unchanged. Two more hand-written-loop sites this round found: registry.rs's assign-tag-handler result coercion (a public extension point — a handler author's own return value) — sitting right next to the two arms a prior round's commit claimed, falsely, to have already checked — and serialize_context/serialize_python_value. The snapshot creates a contract, stated on snapshot_context_to_value_hashmap and pinned by tests on both render_template and RustLiveView.update_state: a key added to the context while its values are being converted is not seen by that render, and a key removed during conversion still renders from the snapshot (PR #2514 review).

  • {{ component }} no longer renders a raw wrapper dict's Python repr on the LiveView path (#2503). The LiveView render loop special-cased Component/LiveComponent, eagerly rendering and replacing the value in context with {"render": html} for a render-caching optimization. {{ component.render }} hit that dict's render key and worked; {{ component }} hit the SAME dict with no remaining path segments and rendered its Python repr — "{'render': '<div>...'}" — literal braces and quotes, not HTML. Removing the special-case (Component/LiveComponent now fall through to the same generic path every other context object uses) converges the LiveView path onto the identical mechanism #2501/#2508 already fixed for the other three render paths: {{ component }} and {{ component.render }} now render byte-identical output on all four paths, for both Component and LiveComponent (checked separately — they cross the serialization boundary via different carriers). .render still resolves (attribute lookup keeps working) but is no longer the only spelling that does. Cost: a component referenced via .render now calls render() an extra time within the same render cycle for each .render reference (K uses cost K+1 calls total, not a flat 2) — bounded to cycles where the component was already going to re-render, EXCEPT after a WS reconnect or session restore (_force_full_html), where every .render-referencing component pays the extra call once even if untouched, because that path sets context == full_context rather than a change-detection subset. Avoidable by using the now-recommended bare {{ component }} spelling, which costs the same as before (one call).

  • A second, identical copy of the #2503 wrapper-dict bug, on the page-shell render path (_render_full_template_inner in python/djust/mixins/template.py). Found by Stage 11 review of the #2503 fix itself — the exact rendered_context[key] = {"render": ...} shape, reachable via render_full_template(request) called without serialized_context (the default). Removed the same way, and brought in line with the sibling branch (serialized_context is not None) that never had this bug: both now pass the raw context straight to normalize_django_value rather than pre-rendering and re-wrapping. .render does not resolve on this path in either branch (no sidecar is wired for the page-shell's temporary RustLiveView instance) — a pre-existing gap on the already-correct sibling too, not introduced or fixed here.

  • Object attributes, properties and nullary methods resolve in templates on the three non-LiveView render paths (#2501). Reported by an external user in discussion #2437: {{ component.render }} rendered nothing. djust's object conversion carried only the instance __dict__, so class attributes, properties and Django's callable auto-call were all unreachable — a real Component's __dict__ holds only _-prefixed internals, so its render was simply absent. Django's Variable._resolve_lookup does dictionary lookup → attribute lookup → auto-call → list-index; djust reached only the first. The fix walks the existing Context::resolve raw-Python sidecar lazily, consulted only when Context::get misses, so it can add a resolution and never change one — which is what bounds the change to cells that previously rendered empty. {% for %} and {% with %} bound names reach it via alias expansion. Attributes are not measured at conversion time: an eager sweep would evaluate every property getter and call every nullary method on every render, and an earlier eager __dict__ walk is on record as having segfaulted on an ordinary presenter object.

  • {{ component.render|safe }} now renders the component — it was empty before, which is the spelling the reporter had to reach for. {{ component }} and {{ component.render }} now resolve but are still escaped; that half is the serialize_value / normalize_django_value drift, tracked as PR 2 of #2501.

  • A property or method raising Model.DoesNotExist renders empty again, not a 500 (#2508 review). The #2506 narrowing above transcribed Django's three per-step catch tuples but not its OUTERMOST arm: _resolve_lookup wraps the whole chain in except Exception as e: if getattr(e, "silent_variable_failure", False), rendering string_if_invalid. ObjectDoesNotExist sets that attribute and every Model.DoesNotExist inherits it, so {{ profile.latest_order }} on a missing FK is an empty cell in Django — and had become a page-killing 500 on every render path, LiveView included, since Context::resolve is shared. Proven a regression by rebuilding against main. The arm covers the auto-call too, because Django's handler wraps that as well.

  • A propagated exception keeps its type (#2508 review). From<PyErr> for DjangoRustError stringified, so a property raising PermissionDenied reached Django's handler chain as a generic RuntimeError. Django dispatches on type — PermissionDenied to 403, Http404 to 404 — so both rendered as 500. The exception now crosses back whole, and DjustTemplateBackend re-raises Django's five dispatchable types — Http404, PermissionDenied, MultiPartParserError, BadRequest, SuspiciousOperation — unwrapped rather than adding its template-location hint, which is worth nothing next to losing the status code. The list is read from response_for_exception and pinned against Django's own source; the first pass named three of the five, and the two siblings it missed kept rendering 500 where Django sends 400.

  • A template attribute lookup no longer swallows every exception (#2506). The walk caught any error from get_item/getattr; Django catches a specific tuple. A property raising PermissionDenied rendered blank, so {% if not doc.is_restricted %} failed open where Django's smartif fails closed — and this change made that consequential by making the guarded content resolvable. Now narrowed to Django's sets, so such a property surfaces instead of silently passing the gate. djust raises where Django renders the {% else %} branch; that remaining divergence is in the fail-closed direction.

  • {{ block.super }}, relative {% extends %} and variable template names now work, because template inheritance runs in ONE place instead of two (#2517). The Django backend flattened {% extends %} in Python first — a regex/string merge introduced, in its own words, "until Rust template engine supports template loaders". That premise was long obsolete, and running both left two implementations of one invariant (the #1646 parallel-path shape): the string merge strips block wrappers, so {{ block.super }} had nothing to resolve against and rendered empty; it matched {% extends %} with a regex, so a relative or variable target never reached the code that understands one. Inheritance now goes through the single Rust path; the Python resolver is retained and still directly tested, but is off the render path. Measured at +12 suite cells on its own, with none regressing.

    {{ block.super }} resolves recursively, so a three-deep chain renders three two one rather than stopping one level up, and a block nested inside {% if %} / {% for %} in the parent is found. A body that never mentions block.super does NOT render its parent — Django resolves it lazily through BlockContext, so paying for the parent would also mean observing its side effects.

    Relative {% extends "./two.html" %} / "../one.html" resolve against the including template's name (Django's construct_relative_path, including both of its refusals: escaping the template root, and a path that resolves to the template itself). A template whose target is relative bypasses the resolved cache, which is keyed by SOURCE — two templates with identical source loaded under different names resolve to different parents.

    {% extends foo %} and {% include foo %} resolve an unquoted operand against the context, as Django's FilterExpression does. Treating it literally is why {% include template_name %} reported Template not found: template_name — the variable's own spelling, which was the tell.

    A missing {% extends %} / {% include %} target now raises Django's TemplateDoesNotExist rather than a bare Exception. Callers dispatch on the type — Django's own tests assert assertRaises(TemplateDoesNotExist) and the loader chain catches it to try the next loader — so the re-wrap lost both.

  • A LiveComponent with template_name now renders through the LiveView engine entry, so its {% if %} blocks keep <!--dj-if id=…--> VDOM identity inside the parent (#2530). The template_name branch of render() went through Django's render_to_string → the project TEMPLATES backend, which emits no boundary markers (post-#2519, and never on a DjangoTemplates project), so a component {% if %} toggle fell back to positional matching in the differ. The template source is now loaded through the project's loaders and rendered on a RustLiveView (markers on, ids hashed from the component's own source) with the same state / safe-key / raw-object sidecar handling the LiveView path uses; the render falls back to render_to_string when the backend exposes no source or the template uses {% extends %}.

  • A static list[Model] is no longer re-queried on every event, and an in-memory row mutation renders instead of being discarded (#2536). The JIT list path re-ran a pk__in query with the template-derived select_related / prefetch_related / annotations on EVERY render and replaced the caller's instances with the fresh rows — so a list assigned once in mount() cost a query per event, and self.rows[3].title = "edited" (no save()) was silently overwritten by the database. The re-query now runs only when some row actually lacks a relation cache / annotation the template paths need, and the fetched caches are grafted onto the caller's own instances in place. A list the view already select_related never queries; an unoptimized list queries once per session; a mutated row renders its in-memory value (mark it with set_changed_keys("rows"), as for any opaque object). Measured over a real WebsocketCommunicator with the query log on the consumer's worker thread. Contrary to the issue, a list WITHOUT a relation path never queried; the per-event query was the relation-path re-fetch.

  • Three whole-process failures in the Python-to-Value conversion (crates/djust_core/src/lib.rs) are now rendered values (#2555, #2624, #2572). A context str holding a lone surrogate segfaulted — extract::<String>() is an encode, so it failed and the string fell through to the fallback block, which iterated it into one-character strings that failed the same way, recursing until the stack overflowed; the str arm now claims a string BY TYPE and crosses it with one U+FFFD per lone-surrogate code point (via encode("utf-8", "surrogatepass"), which never joins an adjacent high/low pair — so "😀", two code points in Python, is two U+FFFD, not one astral character), matching Django's '\udcc0x' up to the code point Rust cannot hold. {{ v.0 }} on a do_not_call_in_templates container-subclass CLASS segfaulted on both template_resolve_lazy settings — the walk was right (Django's own step 3 current[int(bit)] honours __class_getitem__), but converting the resulting types.GenericAlias recursed without bound because iter(alias) yields a fresh starred copy of itself; the conversion now has a depth ceiling (MAX_CONVERSION_DEPTH = 1000, Python's own recursion limit — the depth at which CPython refuses to repr/json.dumps a structure, and well inside the 8 MiB / 16 MiB stack every real thread has) past which an element crosses as str(o), so the cell renders str(alias) as Django does, and the ADR-027 net's held crash cells H-plain / P-plain / P-liveview plus the undeclared numeric-index cell (now row P0) all render. An object whose __getitem__ never raises hung the render forever — PyO3's Vec extraction drove CPython's legacy sequence protocol (index 0, 1, 2, … until IndexError); the list arm and crosses_as_encoded now share one bounded_sequence_items gate that reads a sequence only when it states a __len__ and declines it the moment it yields past that bound, so {{ v }} is str(v) as in Django (a stated bound is trusted as stated; the huge-__len__ residual is #2678, and {{ v.0 }} over the declined object is #2670). Regression tests in python/tests/test_value_conversion_crashes_2555_2624_2572.py run each input in a subprocess on both flag settings.

  • {% url %} and {% cycle %} keep working alongside {% autoescape %} (#2556). Two defects that existed only in the combination of the {% autoescape %} branch with #2596 (filter / cycle / resetcycle / lorem / debug / querystring) and #2607 ({% url %} raises NoReverseMatch), neither of which either side could see alone and neither of which the compiler catches.

    The autoescape= keyword a bindings handler receives is now an opt-in declaration, WANTS_AUTOESCAPE, read at registration beside RETURNS_BINDINGS / ACCEPTS_AS_VAR / RESOLVE_ARG_POSITIONS. It had been handed to EVERY RETURNS_BINDINGS handler, which is a signature change to a public contract: any handler whose render does not name the parameter raises TypeError. #2607's UrlTagHandler is exactly that shape, so {% url %} did not render at all — in every mode, not merely with the wrong escaping under off. Only the {% load %} bridge needs the keyword, because its mark_safe'd return makes the registry's own escape_handler_return — Django's if context.autoescape: for every other handler — a no-op, leaving Context(autoescape=…) the one place the policy can land. Measured against Django 5.2.16: {% url 'u' v %} with v="a&b" is /hi/a&amp;b/ by default and under on, /hi/a&b/ under off, on Django and on all three djust entries.

    resolve_cycle_nodes now descends into Node::AutoEscape. It assigns each {% cycle %} its per-render state id by walking the tree and listed every other block node, so two cycles inside one {% autoescape %} body would have shared a single counter.

    New cases in TestTheKwargIsOptIn, TestUrlIsDjangoEqualUnderEveryMode and TestCycleStateSurvivesTheBlock. Gate-off, each mechanism alone: 10 mutations, 0 survivors.

    Re-measured on the merged tree, with the tag's own share isolated by gating the parser arm off and diffing per-test outcomes: the 79 Django template_tests cells refused as Unsupported template tag '{% autoescape …' without the arm go 74 OK / 5 FAIL / 0 ERROR with it, 0 regressions. The 5 that remain are owned elsewhere — urlize01 / urlize09 / urlizetrunc01 are the pre-existing urlize href defect (#2582) and include13 / include14 need string_if_invalid (#2518 / #2550). The test_invalid_arg / test_no_arg and cycle27 cells the branch's earlier, pre-merge measurement listed as outstanding are OK here: #2593 gave the first two a parse-time TemplateSyntaxError and #2596 rewrote {% cycle %} onto Django's per-node per-render state. Whole scoreboard against origin/main's baseline: 55.68% → 62.85% of engine cells (583 → 658 of 1047), 68.13% → 73.28% over the whole label, and compare reports no drop. These supersede the pre-merge figures in the {% autoescape %} bullet above, which were measured before this branch merged #2593, #2594, #2596 and #2607.

  • A QueryDict / MultiValueDict context variable resolves like Django's on both engines: {{ qd.a }} on ?a=1&a=2 is 2, not ['1', '2'] (#2556, PR #2596 review). The first cut of the {% querystring %} plumbing passed the raw object through serialize_value / normalize_django_value so the tag could see its multi-values, and the Python→Value converters then walked the dict subclass's LIST storage: {{ qd.page }} rendered ['3'], {{ qd.page|add:1 }} '', {% if qd.page == '3' %} was false. Now every converter (FromPyObject for Value, python_to_value, python_to_json_value) reads a MultiValueDict through one helper, djust_core::multi_value_dict_pairs — last value per key, QueryDict.__getitem__'s rule — so the raw object can still ride the plain backend's render sidecar to the tag; the LiveView path keeps it raw through get_context_data and its own _sync_state_to_rust sidecar, and normalize_django_value hands the state boundary a JSON-native last-value dict. Also makes {{ request.GET.page }} render 3 rather than ['3'] on the plain backend. Pinned against Django by TestQueryDictLookupsKeepDjangosLastValue in python/tests/test_remaining_builtin_tags_2556.py — the four review rows, for, a missing key, a non-QueryDict MultiValueDict, the request.GET rows, and the same variable feeding a lookup and {% querystring %} at once, on the backend and the LiveView WS path.

  • A template error now carries Django's template_debug, so the technical-500 page shows the template name, the line and a source excerpt instead of a bare message (#2557). Django's debug view reads a template_debug dict off the exception and its page renders name, line, during and a source_lines excerpt from it; djust raised Template error: … with no such dict, so a developer got a message with no location — on a large template, a bisect by hand. The issue's premise was that the parser already tracked offsets and the work was carrying them across PyO3; it did not — lexer::Token carried no position at all. Three movements now produce and carry them: tokenize_spanned records each token's [start, end) byte span (Django's Token.position) with tokenize as the same tokenizer with the spans dropped, so there is no parallel-path drift; parse_token tags a failure with the span of the token it was parsing and DjangoRustError::at refuses to overwrite a span an inner frame already attached, so a bad tag three blocks deep reports the bad tag rather than the outermost {% if %}; and compile_template hangs the span on the RuntimeError as djust_token_span, which DjustTemplate._compile turns into the dict via build_template_debug — a port of Django's own get_exception_info, key for key, converting Rust's byte offsets to the character offsets Django slices with. Error messages are byte-identical to before: the span rides beside the text, never inside it. template_debug is None, never absent, where the engine cannot locate the failure — {% cycle %} binding errors, raised while walking the parsed AST rather than the token stream, and every render-time error; Django's reporter renders the plain traceback for a None. New cases in TestTemplateDebugIsPopulated, TestInnermostTokenWins, TestDjangoDifferential (compared field for field against live Django, not a transcription), TestTechnicalFiveHundredRender (Django's real technical_500_response, rendered and asserted), TestSpanCrossesThePyO3Boundary, TestByteOffsetsBecomeCharacterOffsets and TestBuildTemplateDebugPort.

  • A non-ASCII character inside a {{ }} expression no longer panics the parser (#2551, #2552). find_if_keyword walked expr.as_bytes() and evaluated expr[i..] at every byte, including the continuation bytes of a multi-byte character — not a char boundary, so the slice panicked. Since #2549 moved the parse to construction, {{ café }}, {{ x.é }} and {{ 日本 }} — templates Django renders — panicked at from_string and surfaced as a RuntimeError. The scan now walks char_indices, so i is a char boundary by construction, and all of them resolve exactly as Django does. New cases in TestNonAsciiExpression.

  • {{ }} and {% %} are refused where Django refuses them — in the parser, not the lexer (#2557). Django raises Empty variable tag on line N / Empty block tag on line N from Parser.parse (django/template/base.py:483-486 and :497); its Lexer.create_token returns Token(TokenType.VAR, "") quite happily. djust built a Variable("") that rendered as nothing and dropped {% %} on the floor, so an accidentally-empty tag was silently invisible. Both are now a TemplateSyntaxError at get_template() / from_string(), located like every other parse error. The LAYER is load-bearing, not incidental: Django's lexer turns a {% verbatim %} body into TEXT and its parser skips a {% comment %} body, so both legitimately hold an empty {{ }} — and djust's collect_raw_source consumes those tokens without calling parse_token for the same reason. A first pass put the refusal in tokenize_spanned, BELOW the raw-block collector, which raised on {% verbatim %}{{ }}{% endverbatim %} — a regression against main on the shape {% verbatim %} exists to serve (Vue, Alpine, Handlebars, djust's own docs pages). A {{ with no closing }} is still literal text, as Django treats it. A raw-block body's SPACING is still normalised ({{ }} re-emits as {{ }}) — pre-existing on main, orthogonal to the layer, and filed as #2626. Four Django-suite cells — BasicSyntaxTests.test_basic_syntax07 / 08 for {{ }}, FilterSyntaxTests.test_filter_syntax08 / 08_multi_line for {% %}; the scoreboard moves 70.96% → 71.35% engine (743 → 747 of 1047), 79.12% → 79.40% whole, measured against main AFTER #2620's template_resolve_lazy flip landed and re-audited per cell on a freshly rebuilt release artifact for both sides: zero OK→FAIL transitions, zero cells appearing or disappearing. New cases in TestEmptyVariableTag, TestEmptyBlockTag and TestEmptyTagContextAxis (every row measured against live Django).

  • A multi-line tag's error is located on its own line, not line 0 (#2557). build_template_debug ports get_exception_info including its locating condition start >= upto and end <= next_break. Django can rely on that always firing because tag_re has no re.DOTALL — a Django token never spans a line. djust's lexer has no such bound and the engine accepts a multi-line tag, so on a 44-line template with the error at line 40 the page rendered error at line 0, no highlight, and source lines 1-10: a confidently wrong location, worse than the None #2557 replaced. Also reachable through an unterminated {%, whose has_closer scan reaches the next %} anywhere in the file. Such a span is now re-located on the line the token STARTS on with the highlight clamped to that line's end. New cases in TestMultiLineTokenSpan.

  • Every Template::new in djust_live attaches the parse error's span, not just compile_template (#2557). Five of the six sites — render_template and render_template_with_dirs among them, the entries SimpleLiveView and the plain backend render use — raised a span-less RuntimeError, so those paths kept the pre-#2557 experience. That is the parallel-path-drift shape (#1646); one wrapper now covers all six, pinned by span_aware_call_sites_2557::every_template_new_is_span_aware asserting the two counts are EQUAL rather than a floor.

  • A template_debug with no origin says <unknown source>, not None (#2557). get_template() always supplies a real path, but from_string() supplies no origin, and the debug page interpolates the value straight into its heading — so it rendered literally In template None, error at line 1. Django's Template.__init__ defaults the same case to Origin(UNKNOWN_SOURCE). New cases in TestUnknownSourceName.

  • Django libraries the Rust engine refused to serve or bind (#2541, #2591; #2558 verified shipped). The tz filters (localtime / utc / timezone) bridge verbatim instead of raising the #2216 "receives dates as strings" refusal — a datetime crosses the boundary as a typed value carrying the object, and Django's convert_to_local_time = False result flag now crosses too (crates/djust_core attr table + filters::format_date), so {{ d|timezone:"Asia/Tokyo"|date:"H" }} is Tokyo's hour, not the active zone's. {% querystring … as var %} binds the name and emits nothing, as Django's simple_tag does: the handler rides the built-in bridge (DjangoBuiltinTagHandler → Django's own SimpleNode) whose bindings diff carries target_var to the sibling nodes. {% load i18n %} was already served by #2597; the reported "not a registered tag library" with an empty Must be one of is the bare django.template.Engine() adapter, and Django's own bare Engine() refuses identically — pinned as parity. Parity suite (both engines, plain backend + LiveView entry): python/tests/test_library_loading_2558_2541_2591.py.

  • {% if x|default_if_none:0 %} with a LITERAL numeric fallback was truthy (#2571). An unquoted numeric filter argument now crosses the fallback arm as Django's typed value (0 → int, 0.0 → float), so default_if_none / default hand back a falsy 0 where they used to hand back the truthy text "0". The resolved-variable channel was fixed by #2569; this is the literal channel, at the same resolution site. Parity rows in python/tests/test_template_semantics_2571_2613_2529_2660.py.

  • {% for %} over a Python iterator stringified it and walked the repr character by character (#2613). A one-shot iterator — a generator, iter(...), zip, MultiValueDict.lists() — is now carried across the PyO3 boundary with a live handle (ADR-027 row V) and consumed ONCE by the {% for %} sink, Django's list(values) in ForNode.render. Nothing is read at conversion, so {{ g }} still prints the repr and {{ g|length }} is still Django's 0; a second loop over the same object is empty on both engines. An iterator past OPAQUE_ITEM_CAP (100 000) raises rather than hanging — Django would hang on the same itertools.count(). The fix rides ADR-027's shipped default; the eager escape hatch keeps the old decline.

  • {% static var %} on the native Rust node emitted /static/var (#2660). An unquoted operand is resolved from the context, as Django's StaticNode compiles it; a missing variable renders /static/. (The Django-library-backed path already resolved it since #2665.)

  • A template name whose .. transiently leaves the search directory and returns (sub/../../<dir>/ok.html) was refused (#2660). The lexical containment guard from #2653 now normalises the whole joined path first and tests containment once, per directory — Django's safe_join — with no filesystem call before the decision; every escaping name is still refused.

  • Malformed {% if %} conditions now refuse at parse time, like Django's smartif (#2576). Django compiles an {% if %} condition through django.template.smartif.IfParser and raises TemplateSyntaxError for fourteen malformed operator streams — an empty condition, a dangling binary operator (foo and), an operator with no left operand (and), two adjacent operands (abc def), not used as an infix operator (a not b), an {% else %} carrying arguments ({% else if … %}), and a single = where == was meant. djust's Rust template engine previously accepted every one and rendered a branch. It now ports smartif's nud/led/lbp algorithm (validate_if_grammar) and runs it at the exact {% if %} / {% elif %} parse sites Django refuses, plus an {% else %}-takes-no-arguments refusal — surfacing djust's own TemplateSyntaxError wording. The Django template_tests scoreboard rises to 72.59% engine (760 of 1,047 cells that reach the engine). Verified with a live in-process Django parity differential over all fourteen malformed cells and a 32-condition regression corpus asserting no valid {% if %} newly refuses.

  • {% url %} refuses a malformed argument list at PARSE time, matching Django (#2577). Django's do_url compiles the view name and every argument while the template is compiled, so {% url "view" id, %}, id=, a.id=id, a.id!id, and the two unterminated-string forms raise TemplateSyntaxError before the view name is ever reversed. djust reached {% url %} only at render, so the quoted spelling rendered instead of refusing and the unquoted named_url spelling raised NoReverseMatch at render (the missing view was reached before the malformed argument, #2607). The refusal now lives in the Rust parser (crates/djust_templates/src/parser.rsvalidate_url_args), which runs inside compile_template at DjustTemplate._compile(), the shared parse chokepoint for both the DjustTemplateBackend and the LiveView path. It reuses filter_lexer::argument_end (Django's filter_re head grammar) rather than restating the constant/var/num grammar, so the check is not duplicated across the two url grammars (#1646). The empty {% url %} (Django's len(bits) < 2 missing-argument error, url_fail01) is out of scope here — it is owned by the render-time handler, which raises Django's genuine TemplateSyntaxError for it (#2563). Measured against Django 5.2.16: the 12 malformed argument-list cells (url_fail0409, 1419) now refuse at parse, adding +12 to the template_tests engine score (756 → 768, 73.35%, measured together with the sibling variable-grammar refusal #2578).

  • A malformed {{ … }} variable expression is now refused at compile time instead of silently rendering '', matching Django's FilterExpression (#2578). Django tiles a {{ … }} head with filter_re — a numeric or quoted constant, _( … ), or the variable pattern [\w.]+ — and refuses whatever it cannot tile with Could not parse the remainder: '<rest>' from '<whole>'. djust's engine stored the whole string as a variable name that simply did not resolve, so {{ va>r }}, {{ eggs! }}, {{ sp%am }}, {{ moo? }}, {{ (var.r) }}, {{ multi word variable }} and {{ moo #} {{ cow }} all rendered empty rather than raising. A new validate_plain_variable_expr runs the [\w.]-only head grammar — exempting recognised constants via the same django_literal recogniser the render path uses (#1646) — in the plain-variable fallthrough of parse_token, after the inline-if branch has had its chance, so djust's {{ a if cond else b }} extension (whose head is genuinely several space-separated tiles) is untouched. Placement is load-bearing and pinned: moving the check before the inline-if branch reddens test_inline_if_bare_variable_expr_survives_grammar_check. The wording is byte-identical to the sibling filter-lexer remainder refusal (#2409) so the two grammar paths speak with one voice. Measured against Django 5.2.16, this moves the Django template_tests scoreboard from 71.35% to 72.21% (engine) / 79.40% to 80.01% (whole): the seven cited cells plus test_compile_filter_expression_error (Debug + non-debug) flip FAIL → OK, with zero OK → FAIL regressions. New cases in test_malformed_variable_expressions_are_refused_django_parity_2578, test_valid_variable_heads_still_parse_2578, test_inline_if_bare_variable_expr_survives_grammar_check and test_underscore_refusal_precedes_grammar_check_2578.

  • {% include %} now refuses six malformed with/only argument forms at PARSE time, matching Django's do_include (#2579). Django's do_include walks the tag's remaining arguments word by word and refuses: a with clause with zero valid key=value bits (empty, a non-key=value bit, or a dotted key — token_kwargs's \w+= grammar can't match any of the three, so all three collapse to the same "needs at least one keyword argument" error), any top-level word that is neither with nor only (this also covers foo="duplicate" foo="key", which has no with prefix at all — foo="duplicate" itself is the unrecognized word, not a duplicate key inside a kwargs clause), and with/only repeated. djust's parser accepted all six shapes and only failed later, if at all, when the included template's loader ran. The "include" arm of crates/djust_templates/src/parser.rs now walks the same three checks in the same order, with djust's own wording (#2581, the Django-verbatim message-text follow-up, has not landed). A duplicate KEY inside one with clause (with foo=1 foo=2) is deliberately still accepted — Django's token_kwargs builds a dict, so the later value silently wins — and every valid with/only form already in djust's own test suite and templates continues to reach the render stage unchanged. Measured against Django 5.2.16 (post-#2576 baseline), this moves the Django template_tests scoreboard from 73.45% to 74.02% (engine, 769 → 775 of 1047) / 80.91% to 81.32% (whole, 1178 → 1184 of 1456): the six cited cells flip FAIL → OK, with zero OK → FAIL regressions. New cases in test_include_argument_grammar_2579.py (35 cases across TestEveryCellIsRefusedOnBothEngines, TestWithRequiresAtLeastOneKwarg, TestUnrecognizedArgument, TestOptionSpecifiedMoreThanOnce, TestNoFalsePositiveOnValidUsage and TestTheCallerSetIsPinned).

  • Ten small parse-time grammar gaps across regroup/with/widthratio/cycle/named endblock/verbatim/if/extends (#2580). Each of these tags had its own small compile-time check Django performs that djust's Rust parser did not — a wrong argument count, a missing keyword, a mismatched closing-block name, a nested {% verbatim %} treated as real nesting instead of literal text (Django's Lexer.verbatim tracks one string, not a depth), a comma-joined {% cycle %} operand that fails Django's head-atom tiling test, and {% extends %} appearing after other content. Ten independent refusals, each with its own live-Django parity test and its own gate-off; two are deliberate shared mechanisms (a stray-closer helper covering test_invalid_block_suggestion and test_verbatim_tag04; one post-hoc "extends must be first" scan covering both the not-first and second-extends shapes) rather than accidental overlap. Scoreboard 75.17% → 76.50% engine (787 → 801 of 1047), 0 regressions. Not fixed: {{ block.super }} outside a child template needs real render-time support that doesn't exist yet (tracked on #2531).

  • Django-verbatim TemplateSyntaxError message text for 7 cells (#2581). After #2549 moved parsing to construction, these tags already raised at the right time with the right exception type — the cell failed only on Django's exact wording. Closes "Unclosed tag on line N: 'x'. Looking for one of: y, z." for {% if %}/{% for %}/{% block %} (needing the opening tag's line number, now available via #2557's parser position tracking), plus extends/for (×2)/now/static's own argument-count messages. 24 of the original 32 cells had already closed as side effects of other work — the i18n bridge runs Django's own compile functions, so its messages came through byte-identical for free. Scoreboard 76.50% → 77.17% engine (801 → 808 of 1047), 0 regressions.

  • The LiveView HTTP-GET page shell renders with the same raw-Python sidecar as the WebSocket render, so {% querystring %} and attribute walks work on the {% extends %} shell (#2589). render_full_template rendered the shell through a throwaway RustLiveView with no set_raw_py_values sidecar and every HttpRequest dropped, so {% querystring %} raised 'Context' object has no attribute 'request' and {{ obj.prop }} (the #2501 attribute walk) rendered empty on the shell while the dj-root rendered both (#1646). The shell now builds its sidecar with the shared build_render_sidecar (models behind the serialization floor) from the view's raw pre-processor context plus request, on both branches; request rides the sidecar only and never enters state. The #2513 characterization pin that documented the gap now pins the converged behaviour. Reproduced through as_view() GET over real {% extends %} template files.

  • use_actors=True views mount on their own template instead of an empty document (#2599). SessionActor::handle_mount built its ViewActor with ViewActor::new, whose backend carries an EMPTY template, so every actor mount frame was <html><head></head><body></body></html>. The mount message now carries the template source and template dirs (SessionActorHandle.mount(..., template=, template_dirs=)mount_with_templateViewActor::with_template_and_dirs), and ViewRuntime.dispatch_actor_mount passes view.get_template() + get_template_dirs(). Pinned over a real WebsocketCommunicator actor mount asserting the rendered template body.

  • Test git fixtures no longer inherit GIT_DIR from a git hook, which was re-initialising the real repository (#2608). Under pre-push, git exports GIT_DIR and GIT_INDEX_FILE. Two test helpers built their subprocess environment with os.environ.copy(), so a fixture's git init in a temporary directory ran against this repository instead — git init writes to GIT_DIR, not to the directory it was invoked in, and the re-init rewrote the shared config. That is where a stray core.bare = true came from, and the fixtures' subsequent git add / git commit wrote into the real index, leaving worktrees with thousands of staged deletions and a checkout whose git status failed with "this operation must be run in a work tree". Pushes then failed for reasons that looked unrelated to the branch. Fixed at both layers: tests/git_env.isolated_git_env() strips every git execution variable (GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_PREFIX, GIT_INDEX_VERSION, GIT_NAMESPACE) while keeping caller overrides, and scripts/pre-push-pytest.sh unsets them before pytest runs. tests/test_git_env_isolation_2608.py demonstrates the failure directly: with GIT_DIR set, git init in a fresh directory creates no repository there.

  • Django template lexer parity: Preserve exact source text inside {% verbatim %}, including whitespace and JSX-like markup, and recognize named closing tags as Django does. Template delimiters spanning a newline now remain literal text, matching Django's lexer.

  • {% cycle %} state leaked across an {% include %} (#2657). {% for x in v %}{% include 'cyc.html' %}{% endfor %} rendered abc where Django renders aaa, in both the plain and the only form: Template.render pushes a fresh render_context frame for each included render and RenderContext reads only that frame, so an included {% cycle %} restarts on every execution while the parent's own cycles carry on untouched. The cycle store is now keyed on the same render frame {% ifchanged %} already used, so the two cannot drift apart again (#1646). The comment beside the only branch asserting the opposite — that context.new() makes an included cycle advance the PARENT render's iterator — was false and load-bearing: it motivated an equally wrong {% ifchanged %} share during #2650's review. It is gone, and test_the_false_comment_is_gone keeps it gone. Django-differential cases in TestCycleStateIsPerIncludedRender2657.

  • Review fixes for the Django-conformance work (#2665). Keeping _full_template as an {% extends %} stub (right for runtime block.super) starved the JIT variable extractor, which preferred that attribute — silently disabling queryset optimization on every inheritance-using view; a stub now falls through to the inheritance resolver (mixins/jit.py). compile_template re-parsed the full source on every request after the cache short-circuit was dropped to revalidate against the current libraries; the registry now carries a generation counter bumped by every register/unregister/clear (19 sites, pinned by registry_generation_pin.rs), and a cache hit is reused unless the generation moved. python_string_is_safe resolved django.utils.safestring.SafeData by import + getattr for every string crossing into Rust; the class is cached once and an exact str short-circuits. localize_if_number attached to Python for every Value::Encoded; the temporal check is now pure Rust, the localizer callable is cached, and a failure inside localize() wraps with the template origin instead of propagating as user-raised. New cases in test_review_2665_fixes.py.

  • Five TEMPLATE_CACHE inserters bypassed the registry-generation gate (#2669). Only compile_template recorded the tag/filter registry generation a parse was validated under, so a template first compiled through render_template, render_template_with_dirs or any of the three RustLiveView render methods was served from the cache forever — an unregister_custom_filter afterwards left the stale parse live, the exact class the gate was added to prevent, one entry point over (#1646). All six now go through one cached_template() helper that reads the generation before the parse and writes both maps. TEMPLATE_CACHE.insert appearing more than once in crates/djust_live/src/lib.rs is now a test failure (template_cache_insert_has_one_site in crates/djust_templates/tests/registry_generation_pin.rs); new cases in TestEveryEntryPointIsGenerationGated cover all six doors.

  • {{ v.0 }} over an unsized legacy sequence indexed str(v) (#2670). An object defining __getitem__ but no __len__ was declined past OPAQUE_ITEM_CAP to the terminal str(o) path, so the dotted segment then indexed that STRING — {{ v.0 }} rendered 'n', the first character of 'never-raises', where Django calls __getitem__ once and renders 'x'. Such an object is now carried with a live handle and no items, so {{ v }} is still str(v) and {{ v.0 }} walks the real object. Under the shipped template_resolve_lazy default only: the eager escape hatch has no handle to read through, so a decline there would answer from the repr, and it keeps its current behaviour instead. Django-differential cases in TestUnsizedLegacySequenceKeepsALiveHandle2670.

  • |join, {% if x in g %}, |safeseq, |escapeseq and |unordered_list now consume a one-shot iterator, as {% for %} already did (#2674, link N+1 of #2613). A generator or iter([...]) reached those sinks as a carrier with no items enumerated and each answered EMPTY — {{ g|join:"," }} over iter([1, 2, 3]) rendered '' where Django renders 1,2,3. They now read through the same Encoded::consume_live_items handle, once, under the same OPAQUE_ITEM_CAP: whichever sink runs first spends the iterator, exactly as in Django. in uses a sibling that stops at the FIRST match, because Python's in short-circuits and the difference is observable ({% if 1 in g %}{{ g|join:"," }} over iter([1, 2]) is T|2, not T|). {% if x in g %} FAILS SOFT on both the cap and a raising __next__, as Django's smartif does (infix.eval wraps the operator in except Exception: return False); |join propagates, because Django's join catches TypeError only. Both pinned, in TestInFailsSoftLikeDjangosSmartIf and test_an_unbounded_iterator_raises_rather_than_hanging.

    Also fixed: an unquoted numeric filter argument is now read as a literal BEFORE any context lookup, as Django's Variable() does, so a context key literally named "0" no longer shadows {{ x|default_if_none:0 }}. Django-differential cases in TestEveryItemSinkConsumesAOneShotIterator2674 and TestFilterArgumentLiteralsWinOverContextKeys2674.

    Not covered by "all item sinks": dictsort / dictsortreversed still answer '' for a one-shot iterator and for any carried collection where Django sorts. Pre-existing and untouched here; filed separately.

  • A sequence whose stated __len__ is huge no longer hangs the render (#2678). bounded_sequence_items trusted a stated bound unconditionally, so a class returning 10**9 from __len__ with a never-raising __getitem__ was read in full — a billion calls, each converted — and the render timed out.

    The bound now applies to ONE shape: an object with no __iter__ of its own, where PyO3's iteration is CPython's legacy o[0], o[1], … protocol and nothing connects that walk to the stated length. Such an object is carried with a live handle instead, so {{ v }} is str(v), {{ v|length }} is its __len__ and {{ v.0 }} is one __getitem__ call — each what Django answers. Everything with a real __iter__list, set, deque, range, bytes, a QuerySet — is enumerated in full exactly as before, at any length.

    The first version of this fix bounded on LENGTH alone, which claimed every collection past 100,000 items: {% for %} and |join over a 100,001-item list raised RuntimeError, |first raised, and on the eager escape hatch the answers were silently wrong rather than refusals ({% for %} rendered 688,898 repr characters, {{ v.0 }} rendered [). Caught in review; the axis is "can the walk end", not "is the number big". New cases in TestATerminatingCollectionPastTheCapIsUntouched cover every sink for a terminating collection one past the cap, on both template_resolve_lazy settings.

    Not fixed here: range(10**9)#2678's parenthetical twin — still materialises when it enters a context, exactly as it does today. It terminates, so it is not this shape, and bounding it means not materialising any sized sequence at conversion, which changes |first, |slice and in for every collection in the codebase.

  • Typed filter addition: Preserve resolved add argument types for integer conversion and string/list/tuple concatenation. Reject incompatible operand types and infinite numeric arguments instead of concatenating their display text or silently returning an empty result. Django conformance improves to 947 of 1,047 engine tests.

  • Azure upload lifecycle: Reject use before open() initializes the blob key, and type-check correctly when the optional Azure SDK is installed.

  • Backend context types: Preserve tuples, exact decimals, UUIDs, and temporal values when preparing context for native rendering, and materialize lazy translation strings. Keep JSON-oriented serialization unchanged. Django conformance improves from 947 to 950 of 1,047 engine tests with no status regressions.

  • The self-healing WARNING now names the actual mistake, which it did not at first (#2690 review). One sentence told both shapes to "call super().mount(...) from your mount()" — wrong for a class declared (LiveView, FormMixin), which has no mount() at all and whose author would go hunting for a method they never wrote. The discriminator is whether the class itself defines mount, not which class wins the MRO: a first attempt used the MRO winner's module prefix and got BOTH shapes wrong — a test view under a djust. package looked like a framework class, and the reversed-bases view resolved to ComponentMixin rather than the LiveView the author typed, so the advice named a class absent from their source. The reversed case is now told to change (LiveView, FormMixin) to (FormMixin, LiveView).

  • submit_form() raised AttributeError: no attribute 'form_data' on any FormMixin view whose FormMixin.mount() never ran (#2667). form_data and its six siblings exist only as class-level annotations — nothing is on the instance until mount() assigns them — and two ordinary authoring mistakes skip that assignment while raising nothing at mount time: a mount() override without super().mount(request, **kwargs), and bases declared (LiveView, FormMixin) so LiveView.mount wins the MRO. The page renders; the first event is where it blows up, which is why the reporter's workaround was to declare form_data = {} by hand. Root cause is parallel-path drift (#1646): validate_field already carried an inline hasattr(self, "form_data") guard labelled "defensive check", and submit_form, reset_form and the three getters had drifted without one. Rather than repeat that check per attribute per method, mount()'s initialization block is extracted to _init_form_state() and every entry point that reads form state routes through one idempotent _ensure_form_state(), which repairs the whole set — including form_choices, which reset_form never wrote even on the happy path — and logs a WARNING naming the class and the actual mistake, so the self-healing is not silent. Reproduced through the real path (a WebsocketCommunicator mount plus one submit_form event returns an error frame for both shapes and a patch for the correctly-mounted control); 14 cases in python/djust/tests/test_form_state_unmounted_2667.py, one per entry point (#1104), gate-off 11 red / 3 green (the three green are the correctly-mounted control, which must not change).

  • Two instances of one template_name LiveComponent rendered identical <!--dj-if id=…--> markers, so a toggle in the second patched the first (#2686, link N+1 of #2530). The id is if-<template-SOURCE-hash>-<ordinal>; each instance renders on its own RustLiveView with the ordinal restarting at 0, so a parent holding two Card()s emitted ['if-ee0140fe-0', 'if-ee0140fe-0'] — 1 distinct of 2 — and the client resolves RemoveSubtree/InsertSubtree/MoveSubtree by FIRST matching id. This is the #1832 failure class on a different axis (one parsed {% if %} rendered more than once into a single buffer), and takes the same cure: a RENDER-time suffix from a Context FIELD — dj_if_id_namespace, with a validated [A-Za-z0-9_]* grammar, refused rather than escaped, because #2529 is the bug where a user-reachable value interpolated raw into a marker comment let --> forge live markup. RustLiveView::set_dj_if_id_namespace stamps it at all THREE render entries, not only the component one, so they cannot drift; the component render entry passes component_id, the instance identity that is stable across renders (which is what the client needs, since it keys DOM subtrees on these ids). Not salting the parse-time prefix, which is what the issue proposed: template_hash_hex(source) must keep equalling the parse prefix (the Redis state-cache key contract, #1362) and the template parse cache is keyed on source, so a per-instance prefix would break both. Ids gain a trailing segment on the component path only — an unset namespace adds nothing, so every other render is byte-identical. 15 cases in python/djust/tests/test_component_marker_id_namespace_2686.py including a real WebsocketCommunicator mount and the refused-namespace matrix; gate-off run per mechanism, 3 red each. The self-review caught its own positional test being a tautology first: keying the markers by id in a dict silently collapses the two duplicate entries under the bug, after which changed_id in second_instance_html is true because the same id also appears in the first — green while the bug was live (#2135). Every assertion is now by position. The {% include %}-twice twin of the same class is filed as #2689 rather than fixed here (#1079).

  • src/05-state-bus.js and static/djust/security.js deleted — two modules that shipped, or were documented, and ran nothing (#2680, #2679). StateBus had no consumer in src/, never reached window.djust, and had no test; its only consumer was the decorators.js copy #2659 deleted. Its intended consumer, @client_state, is inert in exactly the way #2656 catalogues for @debounce/@throttle/@optimisticgrep client_state src/ tests/js/ returns 0 hits — so the choice was implement (Option A of #2656, with that issue's undecided rate-limit and render-lock-ordering semantics) or delete. Deleted. The first pass then claimed, by hand-count, to have corrected "the four places" its siblings were corrected in #2655; review found 21 — including README.md, an MCP tool description, four demo-project files, and a window.StateBus.subscribe(...) snippet against the deleted class. Counting by hand is what broke, so completeness is now mechanical (#1859): scripts/check-inert-api-claims.py, pinned by python/djust/tests/test_inert_api_claims.py, fails any file that teaches @client_state without saying anywhere that it is inert, or that reaches StateBus as a live object. The demo smart_dashboard, whose docstring credited the deleted bus for coordination the server re-render actually does, is corrected too. security.js assigned window.djustSecurity and two docs taught it as "available globally", but it is not under src/ and nothing injects it, so the global was undefined in every browser that has ever loaded a djust page. Two .semgrep rules were still prescribing it: a developer hitting a real XSS or prototype-pollution finding was told to call djustSecurity.safeSetInnerHTML(). Both rules keep their detection and now give the real remedy (textContent; skip UNSAFE_KEYS, or a Map / Object.create(null)), as does the Banned-Patterns table; the stale djustSecurity eslint global is gone. Shipping it would have added a second, untested copy of protections the bundle already applies at the SINKS (UNSAFE_KEYS in src/00-namespace.js plus guards in four more modules; DOM content arrives as VDOM patches, not innerHTML of a server string) — #1646 with a security surface — so both docs now point at the real sinks instead. Its tests/js/non-bundle-importers-2659.test.js allowlist row, the one entry admitting "NONE — documented API without a loader", is gone, so no loader-less rows remain; the file joins that guard's deletion pin, which makes the pin load-bearing rather than decorative (#1859) — restoring the file turns it red, verified. Bundle: 55 → 54 modules, shipped 58.8 → 58.5 KB gz.

  • Client packaging + render-shell batch (#2662, #2632, #2659, #2663). (1) collectstatic under any ManifestStaticFilesStorage (whitenoise included) no longer fails with MissingFileError: djust/client.min.js.map: the minified bundles no longer carry a sourceMappingURL comment pointing at the gitignored, never-shipped .map (scripts/build-client.sh builds the map without url=; tests/unit/test_client_minified.py fails if any shipped *.js references an unshipped file). (2) autoMount no longer picks a valueless dj-view on a sticky/embedded root that precedes the page container — all three page-container lookups in 03-websocket.js go through one findPageViewContainer() helper excluding [dj-sticky-root] and [data-djust-embedded]; the dead [dj-root][dj-view] fallback is removed. (3) static/djust/decorators.js (tested, never shipped — a duplicate of the src/ logic) and static/djust/js/pwa.js (loaded by nothing) are deleted with their importers; the dj-offline="…" value form they implied is documented as unsupported, and tests/js/non-bundle-importers-2659.test.js refuses any future test that imports a non-bundle static/djust/ file. (4) A tag-like string inside <script>/<style>/<!-- --> — e.g. a JS comment mentioning <div dj-root> — no longer makes the whole document the liveview root and renders the page shell twice: every dj-root locating sink in mixins/template.py and the depth walk search a raw-text-masked copy, and the Rust find_dj_root_content_range skips the same regions.

  • Template regression coverage: Replace stale assertions of add and Decimal-formatting divergences with Django parity checks, and include extends in exact parser-validator caller checks.

  • Engine-scoped compilation: Resolve template libraries using the compiling backend's context. Revalidate explicit compilation even when another engine cached the source, while retaining the parsed result for rendering. Django conformance improves to 961 of 1,047 engine tests without status regressions.

  • Localized date and time filters: Resolve Django format settings and translated month, weekday, AM/PM, noon, and midnight names using the active language at render time, including grammatical month names and cached templates. Standalone Rust rendering retains English defaults without requiring Python.

  • Duplicate template blocks: Track block names across nested parser bodies and reject the second declaration before parsing its content or loading a parent. Keep each template's names independent and report the duplicate declaration's source location. Django conformance reaches 956 of 1,047 engine tests.

  • Engine autoescape compatibility: Honor the engine autoescape option and explicit Django Context overrides for plain template rendering, while retaining escaped defaults, lexical restoration, and isolation from context dictionary data.

  • Template parent expressions: Resolve complete extends filter chains, including quoted operands, and preserve exceptions raised during parent selection. Reject malformed parent expressions at compile time. Django conformance reaches 951 of 1,047 engine tests without status regressions.

  • Invalid template parents: Match Django's exception class and message for falsy extends operands, and honor string_if_invalid for missing parents. Strict tag expressions skip filter evaluation when a missing variable has a nonempty invalid-value marker. Django conformance reaches 952 of 1,047 engine tests.

  • Template differential coverage: Add extends operands to the masked-refusal corpus and test each compile-time refusal class, ensuring a missing parent variable cannot hide invalid filter syntax.

  • Two {% include %}s of one fragment no longer render identical dj-if marker ids (#2689). if-<hash>-N derives <hash> from the template SOURCE and N from a per-parse ordinal, so any mechanism that renders one parsed template twice into one buffer emitted duplicate ids — and the client resolves RemoveSubtree / InsertSubtree / MoveSubtree by first match, so a toggle inside the second include landed on the first. Third instance of the class after {% for %} iterations (#1832) and two template_name component instances (#2686), and cured by the same shape: a new render-time Context::dj_if_include_path, written only by the renderer's include arm from a parse-time Node::Include::site_id. The path is lexical, so a conditional sibling include cannot renumber it and the ids stay stable across renders; its -i<hex>_<n> segments are disjoint from the loop path's pure-digit ones, so the two suffixes compose unambiguously. Covers nested includes, {% include … only %}, includes inside {% for %}, and an included template that itself {% extends %}.

  • {% cache %} resolves its operands the way Django does, so both engines compute the same fragment key (#2658). CacheTagHandler resolved every operand with ignore_failures=True and applied a miss policy of its own — None for the vary operands, an "unknown variable" error for the expiry. Django's CacheNode uses a plain FilterExpression.resolve(context), so an unresolvable vary operand is string_if_invalid (''), not None; since make_template_fragment_key hashes the vary list, djust and Django stored the same fragment under different keys. Every operand now goes through Django's own call against a bound Context, which also brings the three error messages to Django-verbatim text (#2581): %r on an invalid cache alias, and "cache" tag got a non-integer timeout value: '' where djust reported an unknown variable. The body still renders on a cache hit — that needs a lazy-body block-handler protocol and is tracked on the issue.

  • scripts/check-inert-api-claims.py scopes its INERT marker to the mention it discharges (#2692). The marker was file-level, so a caveat belonging to a different decorator satisfied the one under test: deleting only the @client_state caveat in docs/BEST_PRACTICES_AI.md left the checker green because @debounce's marker 28 lines earlier — inside the same code fence — stood in for it, which is precisely the #2680 shape the checker exists to catch. A marker now discharges either the whole file (when it is in the file's opening construct, where a reader meets it before any example) or only its own block. The scan also reaches tests/, python/tests/, scripts/ and every repo-root .md, and the canary runs the real check() against a temporary tree instead of asserting on the regexes in isolation. Tightening and widening reported 29 previously-discharged sites, all corrected here — including a README.md banner that had landed inside a bash code fence while the decision-guide table routing readers to @client_state carried no caveat at all.

  • Ifchanged include isolation: Keep state separate for identical template bodies loaded from different origins, reset non-loop state for each include render, and retain cached-template state sharing within the same loop.

  • Include inheritance context: Apply an include's with bindings and only isolation before resolving the included template's parent. Dynamic parent selection now uses the same context as the included template body.

  • Nested template blocks: Discover and apply inheritance overrides through localization scopes, spaceless, ifchanged, and empty loop branches. Both walks share an exhaustive inventory of node bodies while preserving Django's custom-block-tag discovery rules.

  • Template library context: Honor the active backend's string_if_invalid and debug settings when rendering inline Django tags, including static. Cached nodes use the current render's settings without leaking them between nested backends.

  • In-place mutation of LiveView state now re-renders (#2664). self.kanban_columns[col].append(card) in an event handler ran, updated state, and produced either a noop frame or patches: [] — the pre/post snapshot fingerprinted a dict by (id, len, keys) only, and the Rust state sync compared containers by == against the same object it had just mutated (#1039 aliasing). One structural, reference-free fingerprint (djust.change_detection.deep_fingerprint, 20k-node budget, one-shot warning naming the attribute past it) now drives all four change-detection paths — the event snapshot, the Rust sync, dirty tracking and @computed. Opaque objects (model instances) stay identity leaves; set_changed_keys remains the hatch for an attribute write on those. Lazily-assigned framework bookkeeping (_prev_context_*, _dirty_baseline, …) is excluded from the snapshot; with that the structural walk is within noise of the old shallow one on the model-backed benchmark.

  • T013 flags dj-view="{{ dj_view_id }}" / {{ view_name }} / {{ view_id }} (#2631). djust never injected those names — the docs taught them for months — so the attribute rendered empty and the page could not mount, and the {{ }} exemption (#395) green-lit it. The three phantom names are now refused at manage.py check with the two real spellings in the hint; genuine user variables such as {{ view_path }} stay exempt. Pinned through the real GET path: bare dj-root is stamped with the view's dotted path server-side, as the docs (#2646/#2666) now teach.

  • {% load %} sees a templatetags/ module added while the dev server runs (#2602). The installed-library scan was cached once per process; _find_library now re-scans once on a miss before refusing, and the hot-reload dispatcher drops the cache on any .py change (with importlib.invalidate_caches() so the new file is importable).

  • Loaded-template diagnostics: Preserve token spans and source origins for compilation errors in included and parent templates, highlight the failing tag in debug pages, and retain the original custom-tag exception object.

  • Template parser diagnostics: Match Django's missing-include, malformed-else, and duplicate-extends messages. Preserve the original malformed clause text and highlight its source span. Discard inline comments during parsing so they do not invalidate extends placement. Django conformance reaches 955 of 1,047 engine tests.

  • Template parser diagnostics: Match Django's unknown-tag and misplaced-closing-tag messages, including the active block's expected terminators. Report missing end tags against the original opening block across elif, else, and empty, and reject repeated else/empty clauses. Scoreboard parsing accepts both current and historical error formats.

  • Template include resolution: Resolve relative include paths against their defining template, preserve origins through inheritance, select the first available iterable candidate, and report invalid relative paths and included-template syntax errors with Django-compatible exceptions.

  • Template library names stay local to their configured backend. Constructing another backend no longer makes its extra libraries loadable or visible in unknown-library errors on existing backends.

  • Custom block tag errors: Match Django validation order for simple block tags: check the function signature, then parse the body, then validate arguments. Missing arguments no longer hide unclosed tags or invalid body syntax.

  • Django built-in tags and errors: Render static and now through Django's registered tag implementations, including storage URLs, escaping, localization, assignments, and compile-time validation. Preserve Django's exception types for loop unpacking and invalid widthratio arguments.

  • URL filter quoting: Match Django URL component and query quoting in urlize and urlizetrunc, including HTML entities, Unicode, and encoded separators. Django conformance improves from 927 to 945 of 1,047 engine tests without status regressions.

  • Template compatibility regressions: Preserve inheritance and block.super when rendering the initial LiveView page shell, use valid multiline comments in the browser canary, and isolate locale and conformance runner tests.

  • Close the remaining targeted Django template behavior gaps and document unsupported APIs. Assignments that survive lexical scope update the caller's context, standalone block.super raises only when evaluated, and the upstream adapter preserves uncached include-state identity and template engine access. The raw Django 5.2.16 result is 1,032/1,047 (98.57%). The 15 remaining checks are documented in docs/TEMPLATE_COMPATIBILITY_LIMITS.md: eight Python AST inspections, five Django loader-cache inspections, and two template.extra_data checks. They remain in the denominator.

  • Template library context flags: Preserve Django Context use_l10n and use_tz through custom and nested inclusion tags, restoring the previous flags after each render. Django 5.2 template conformance is now 988/1047 (94.36%).

  • Template context scopes: Preserve assignments through control-flow bodies, restore local bindings and safety metadata when leaving lexical scopes, and let named cycles update the nearest enclosing binding as Django does.

  • Filter syntax errors follow Django's name matching and refusal order. Invalid names report the matched filter name, and argument validation, lookup, and arity checks precede an unmatched suffix. Both parser and runtime consume the same lexer. Django 5.2.16 conformance rises to 984 of 1047 exercised tests.

  • Firstof assignment safety: Preserve the actual safety of firstof output instead of always granting SafeString status, including plain output under autoescape off and the empty result when no operand is truthy.

  • Template diagnostics and metadata: Report the offending extends tag and template name in must-be-first errors, attach its source location, and expose Django-compatible source and origin metadata for templates created from strings.

  • Template inheritance history: Allow same-name parent templates in later directories, keep inheritance history local to each include, preserve skipped-origin diagnostics, and prevent the Django test adapter from overwriting sources from separate in-memory loaders.

  • Missing-template origins: Preserve searched filesystem origins for missing parent templates and return Django-compatible tried entries from backend lookups, while retaining Engine.select_template behavior for missing includes.

  • Shadowed model lookups: Stop falling back to an original model after its name is rebound, and prefer a local binding's registered source alias when resolving model attributes.

  • Backend model lookups: Preserve original model and queryset objects in the protected rendering sidecar before JIT serialization, restoring model representations, methods, and properties while retaining field protection.

  • Naive datetimes use the project default timezone for date/time format fields. The active request timezone and USE_TZ no longer change that lookup. Django 5.2.16 conformance improves to 980 of 1047 exercised tests.

  • Library binding materialization preserves named tuples. Lazy values inside collections.namedtuple, typing.NamedTuple, and Django GroupedResult bindings no longer trigger a constructor TypeError; tuple types and named fields are preserved at the Python bridge boundary.

  • Template differential coverage: Exercise empty, nonempty, and marked-safe named tuples in the filter corpus and resolved arguments. Coverage checks now recognize the named-tuple value variant and detect missing truthiness and item-safety cases.

  • Partial template rendering: Refresh dynamic output after context-mutating tags so cached HTML cannot retain a previous assignment's value.

  • Regroup preserves typed rows and tuple-like group results. The shared Django bridge compiles and resolves source and grouping expressions, including date and autoescape-aware filters. Native named tuples retain field lookup, indexing, unpacking, comparison, slicing, JSON array output, and binary state round trips. The duplicate JSON grouping implementation and native regroup grammar check are removed. Django 5.2.16 conformance improves to 987 of 1047 exercised tests.

  • Resolved SafeString values: Preserve HTML safety returned by properties and methods through filters, tag operands, resolved filter arguments, and default fallbacks. Plain strings and filters that remove safety remain escaped. Tighten differential checks now that the former over-escaping cases agree with Django.

  • Runtime template error locations and compiled-template operands. Errors now retain the defining source and token location through includes, inheritance, and nested control flow, while preserving user exception identity. include and extends accept compiled template objects, preserving context scope, autoescaping, relative origins, and loader history. Compiled djust templates reuse their native AST instead of recompiling custom tags. Django template conformance improves from 1,018 to 1,027 of 1,047 tests (98.09%), leaving 20 failures.

  • The join filter preserves a marked-safe separator. Literal, aliased, loop-bound, and dotted separators retain their own safety while ordinary separators and list items still escape. Django 5.2.16 conformance improves to 982 of 1047 exercised tests.

  • Safe string conversion: Honor SafeString returned by an object's __str__ during rendering and built-in string filters, while preserving Django's escaping of the original object in joins and nested lists. The marker stays in memory, invalidates cached HTML when it changes, and cannot be restored from serialized state. Django 5.2 conformance reaches 995/1047 (95.03%).

  • Template SafeString values: Preserve per-element HTML safety through mixed lists, filtering, bindings, and Python callbacks. Plain siblings remain escaped, serialized state drops runtime safety, and loop caches distinguish safe and plain strings with identical text. Django 5.2 conformance rises to 993/1047 (94.84%).

  • Django template behavior: Preserve application exception identity and traceback across the Rust backend, support legacy with value as name assignments, reject unused with arguments, evaluate not in correctly, trim outer whitespace in spaceless, and retain string_if_invalid in isolated includes. Django suite conformance improves from 895 to 927 of 1,047 engine tests without regressions.

  • Django library compilation: Validate inline, block, and cache tag arguments when compiling a template, preserving the original exception and source location. Invalid tags are rejected even inside branches that never render.

  • Temporal template values: Preserve date, datetime, time, and timedelta types across Python filters and standard-type state restoration. The add filter now supports temporal arithmetic, bare results use Django localization and context flags, and ISO formatting keeps dates and naive datetimes distinct. Django 5.2 conformance is 990/1047 (94.56%).

  • Date/time filters preserve Python temporal metadata. Preserve original timezone names when date/time filters render without conversion, accept timezone-aware time values without exposing timezone fields, and retain Django's date-object restrictions (including TypeError from the date filter). Django 5.2.16 template conformance rises from 975 to 979 of 1047 exercised tests.

  • URL argument compatibility: Use Django's shared tag bridge for URL compilation and rendering, preserving argument types, resolving expressions once, honoring the current request namespace, and rejecting missing URL names at compile time. Python tag contexts now respect local bindings instead of restoring shadowed values from the original LiveView context.

  • URL tags: Resolve every URL through the shared render-time handler, preserving autoescape, conditional and block scope, verbatim/comment bodies, and assignment order instead of substituting URLs into template source. Nested blocks reached through block.super now retain descendant overrides, including their exceptions.

Security

  • A key deleted from a view's context no longer keeps rendering its last value (#2564, ADR-027 movement-3 prerequisite). RustLiveView.update_state merged the new context into the Rust state and never removed anything, so del self.secret (or if self.show: ctx["secret"] = … flipping to false) left the old value in the merged state and the next render still emitted it — a content-gating fail-open that was pre-existing for strings and dicts and that the lazy-resolution flag widened to plain objects. Every sync now calls a new retain_state_keys with the full context's keys before update_state: absent keys are dropped, their safe_keys grants and descendants revoked (the #2300 shape), and the removed set joins the changed-keys set so a partial render patches the region instead of keeping stale text. Static assigns, which the Python side stops sending after the first sync precisely because Rust retains them, are kept explicitly — the plan had missed that and the no-over-removal test caught it. Callers decided one by one: the per-view bridge gains the call, the page-shell's per-request view needs none, the actor session's pure-Rust merge is unchanged, and the caller set is pinned. Regression cases in python/tests/test_update_state_removes_absent_keys_2564.py cover delete-then-render for a string, a dict and a plain object under both flag states, the {% if %}-gated shape, the partial-render patch, a restore-then-delete path, and the static-assigns exemption. Test suites that drive _sync_state_to_rust with a bare Mock() view must give retain_state_keys a list return value.
  • A custom template filter declared is_safe=True no longer marks UNSAFE input safe (#2548, GHSA pending). The renderer's single safety sink granted output safety to any filter registered is_safe=True regardless of its input, so {{ user_text|shout }} through a plain-str-returning project filter rendered user content unescaped: a stored XSS in a text node, an attribute breakout through title="{{ h|shout }}", and a live <script> through any percent-decoding filter. Django's contract (FilterExpression.resolve) is is_safe and isinstance(obj, SafeData) — the flag preserves a safe input's safety, it never creates it — and django.contrib.humanize's intcomma / apnumber return a non-numeric input unchanged, so a project needed no filter of its own to be exposed. The custom-filter term is now folded into the same input_was_safe && conjunction that governs the built-in is_safe filters (the #2274 rule), leaving exactly two unconditional grants in filter_output_is_safe: a SafeString the filter itself returned, and the ten self-escaping built-ins. mark_safe-returning filters, needs_autoescape filters and {{ h|safe|shout }} are unchanged. Regression cases in python/tests/test_custom_filter_is_safe_requires_safe_input_2548.py compare all three djust render entries against Django rendered in-process for the reported row, the earned-grant siblings, chain position, the three renderer arms, and the encoded and attribute-breakout variants; two new module-level cases in the #1121 file cover a plain-return is_safe=True fixture. Affected: every PyPI release from 0.9.0 (#1121; the introducing 0.9.0rc3 tag never reached PyPI) through 1.2.0rc1.
  • The serialization floor now descends into containers (#2501). The floor was applied only at the top level of the raw-Python sidecar. That is sufficient for the walk sink, which re-protects at every step, but not for the custom-tag bridge, which injects the sidecar wholesale — so a model one level inside a list or dict reached a {% tag %} handler unfiltered and {{ users.0.password }} exposed the password hash. {"user": u} was filtered; {"users": [u]} and {"d": {"u": u}} were not. Found by the Stage 8 security check, reproduced against a real django.contrib.auth.models.User, and re-verified filtered on all three shapes after the fix.
  • The serialization floor no longer re-walks a shared subtree once per reference (#2508 review). The identity table was a path-scoped visited set — correct for a true cycle, but a container referenced from N places was re-traversed N times, so cost was exponential in WIDTH while the depth cap bounded only depth. A DAG of 48 shared objects took 1.8 seconds per render, on a structure containing no cycle at all; _SIDECAR_MAX_DEPTH's own comment named this DoS as the thing it prevented. Now a memo keyed on (id, depth): the same case is 0.02 ms, and a shared object yields one proxy instead of N.
  • A model used as a dict KEY no longer reaches a {% tag %} handler (#2508 review). Leaving keys alone was reasoned as "not a shape any template path reads" — true of the walk sink, false of the tag sink the descent exists to serve, where list(ctx["x"])[0].password read the real hash. The floor cannot ride on a key, so the entry is dropped.
  • Component mutators are no longer auto-callable from a template (#2507). Making component attributes resolvable meant {{ c.unmount }} invoked the component's user-authored cleanup during render, and {{ c.trigger_update }} re-entered the parent view's update mid-render. clear_context_providers, mount, trigger_update, unmount and update now carry Django's alters_data marker on both Component and LiveComponent — the same marker Django stamps on Model.save and QuerySet.delete. LiveView's own mutators were never reachable; the view is not in the sidecar.
  • The dj-if marker's loop-path suffix was read from the context key __djust_if_loop_path and interpolated raw into <!--dj-if id="…"> on the LiveView path (#2529). A developer returning that reserved key with attacker-controlled content from get_context_data() could forge live markup ("--><script>…); clients could not plant it (safe_setattr refuses leading underscores). The path is now a Context FIELD only the renderer's {% for %} arm writes — user context cannot reach it, a key of the old name is inert — and the setter refuses anything outside (-<digits>)*. Legitimate per-iteration ids (if-<hash>-N-<i>, nested -i-j) are unchanged; a {% include … only %} body now inherits the enclosing iteration's suffix instead of losing it. Probed with raw, percent-encoded, double-encoded, fullwidth, nested-quote and bare --> payloads in python/tests/test_template_semantics_2571_2613_2529_2660.py.
  • A MultiValueDict in the template context no longer bypasses the serialization floor for custom tag handlers (#2556, PR #2596 re-review). Introduced and fixed inside this same unreleased PR, so no release ever shipped it: the first cut of the {% querystring %} plumbing made _protect_sidecar_tree hand a MultiValueDict / QueryDict over RAW — the reasoning was that it holds request data, strings and uploads, with nothing under it for the floor to protect. Anything can be put in one. Measured through a real registered {% tag %} handler, {"box": {"u": user}} gave the handler a _SidecarModelProxy whose .password raises, while {"box": MultiValueDict({"u": [user]})} gave it the live User and .password was the password hash — at top level, nested in a dict and nested in a list, on the plain backend, and at top level on the LiveView _sync_state_to_rust entry. The {{ x.y }} walk was never affected (Rust re-protects per segment); the custom-tag bridge, which is handed the sidecar's objects wholesale, was. The floor now walks a MultiValueDict's .lists() — the only view that sees every repeated value — protects each value, and rebuilds THROUGH the container with copy() + setlist (carrying _mutable over, since QueryDict.copy() always returns a mutable copy), so {% querystring %} still gets an object with .copy() / .setlist() / .urlencode() and every repeated key. A container with nothing to protect — every real request QueryDict — is returned unchanged by identity, so the hot path and request.GET's immutability are exactly as before. The LiveView sidecar moved from the leaf-only _protect_sidecar_value to the same _protect_sidecar_tree the plain backend's build_render_sidecar uses, so one floor governs both entries rather than two answering differently (#1646). New cases in TestTheFloorHoldsInsideAMultiValueDict, TestTheBackendEntryHandsOverAProxy and TestProtectingAMultiValueDictKeepsItsType in python/tests/test_remaining_builtin_tags_2556.py: a proxy model carrying a floor field (password), an underscore name (_secret) and a @property returning a hash, in all four container shapes across both entries, plus the type-preservation and identity rows. Four mechanisms, four disjoint gate-offs — handing the container over raw reddens 11 rows, rebuilding it as a plain dict reddens only the 3 type rows, reverting the LiveView sidecar to the leaf wrapper reddens 1, and removing the unchanged-container short circuit reddens 1.
  • The actor-session state merge no longer keeps a key the context stopped carrying (#2592, the use_actors=True twin of #2564). The ViewActor pulls the whole get_context_data() on every event and merged it with update_state_rust, which has no removal path — so under use_actors=True a key deleted from the context (del self.secret, or an if self.show: ctx["secret"] = … gate flipping closed) kept its last value in the actor's state, the same content-gating fail-open #2564 closed for the direct path; the #2564 caller pin could not see it because the actor path never runs _sync_state_to_rust. The actor now mirrors that fix in Rust: a new retain_state_keys_rust runs with the full context's keys before the merge, drops absent keys, revokes their safe_keys grants and descendants, and joins the removed set to a pending changed set only — creating one from nothing would flip the next diff render onto the partial path with the removed keys as the only "changes" and serve the caller's real changes stale. No static-assigns exemption is needed on this path: the flag that makes get_context_data skip them is set only by the bridge. The UpdateState delta message keeps its merge semantics (its one production caller is the session mount into a fresh actor) and gains a RetainStateKeys truth half on the handle. Two things the issue's premise got wrong, recorded so the next reader does not re-derive them: the ComponentActor's sync already replaces its state wholesale and is not a twin; and the mount path's ViewActor::new builds its backend with an empty template, so a use_actors=True view's actor render is an empty document today — the leak was real in the state map but not yet in rendered HTML. That is why the behaviour is pinned in Rust, on an actor built with the new ViewActor::with_template and driven through the real sync_state_from_python path by a real Python object under the embedded interpreter (cargo test -p djust_live --no-default-features 2592: delete-then-render for a string and a dict, the key coming back, the {% if %}-gated region, the message contract, the merge pin, the partial-render join, no changed set from nothing, and grant revocation through the Rust entry), each mechanism with a gate-off that reddens it alone. 6 regression cases (11 parametrized runs) in python/tests/test_actor_state_retains_full_context_2592.py pin the Python half — the callers that hand the actor its context, the single writer of _static_assigns_sent, both actor merge sites accounted for — and the real create_session_actor round trip for a string, a dict and a plain object under both template_resolve_lazy states; test_every_framework_update_state_caller_is_decided (#2564) now carries the actor row.
  • A model @property that shadows a sensitive model-method name (get_session_auth_hash, the get_*_permissions family, get_deferred_fields, a get_next_by_/get_previous_by_ name, or any _-prefixed name) is now refused by BOTH serialization channels (#2614). The eager serializer's @property loop consulted only the field floor while the template sidecar proxy also refused the sensitive-method set, so the shadowing property was serialized into the model dict that rides the HTTP GET context, the WS mount/patch frames and the state snapshot — and the permissive channel won, because the engine reads the eager dict before it falls back to the sidecar. Every per-attribute decision (the field loop, the get_* method loop, the @property loop and _SidecarModelProxy.__getattr__) now goes through ONE authority, DjangoJSONEncoder._attr_is_serializable, with fail-closed precedence (_-prefix → sensitive-method name → field floor/allowlist/opt-out); the method refusal is not lifted by djust_serialize_sensitive_fields or an allowlist. Pinned by a parity matrix over every floor and sensitive name against both channels, each channel independently, a real GET + WebSocket mount/event, and a structural pin that all four sites call the chokepoint (python/djust/tests/test_property_shadow_parity_2614.py).
  • A template name can no longer escape its search directory (#2653). FilesystemTemplateLoader::find_template joined the requested name onto each search directory with no containment check — Django's loaders get one from safe_join, which raises SuspiciousFileOperation so the loader skips that directory. Measured before this fix: {% include "../secret.txt" %} rendered the file's contents, where Django answers TemplateDoesNotExist. Today the operand is a literal, so the sink is reached by a template AUTHOR rather than by request data — which still matters wherever template authorship is not fully trusted (user-editable templates, multi-tenant hosting), because the engine is the boundary and was not enforcing one. It also becomes reachable from render DATA the moment the operand resolves against the context, so the guard is a prerequisite for that work rather than a reaction to it. The check is lexical and runs before any filesystem call, so no symlink can race it and the path need not exist; it refuses an absolute name and any .. that pops above the directory at ANY position (a/../../x is refused exactly like ../x), while a .. that stays inside still resolves. The search now also stats for a FILE rather than any existing path, so a directory named like a template no longer matches and then fails on read (#1805 is_dir parity).
  • The JIT codegen serializer now enforces the serialization floor (#2685, link N+1 of #2614). python/djust/optimization/codegen.py emitted any attribute the template named, so {{ m.password }} on a public self.m model shipped the field in get_context_data()['m'], the rendered HTML and the WS mount/patch frames — the floor the eager and sidecar channels enforce was never consulted on that path. Every attribute the generated code reads (root, nested, list item and method sites) is now gated by codegen.emittable_names, which calls the ONE chokepoint DjangoJSONEncoder._attr_is_serializable with the same per-model denylist / allowlist / opt-out resolution the eager loops use; a denied name (password, get_session_auth_hash, any _-prefixed name, …) is omitted from the dict and renders as string_if_invalid (empty), never shipped. The gate resolves once per object LEVEL (a per-object prologue plus a set-membership test per attribute) and memoizes on (set of names, denied, allowed, optout) — the complete argument set of the chokepoint, so a policy change misses rather than going stale. The names are keyed as a set, not a sequence: the Rust caller iterates a Rust HashMap, and keyed as a tuple twelve identical serialize_queryset calls produced eleven distinct keys, so every call missed and the cache walked toward its cap. Pinned by TestJitCodegenChannel / TestJitCodegenParity (parity with the eager channel over the sensitive matrix), a real GET + WebSocket mount/event via the JIT path, and TestStructuralChokepointCodegen in python/djust/tests/test_property_shadow_parity_2614.py, whose guard check now walks the generated code's AST — the previous text check only inspected lines containing hasattr(, so a getattr-shaped emission was invisible to it, and test_the_guard_check_catches_an_unguarded_getattr_site is the canary proving the new check reports exactly that shape.
  • The Rust queryset serializer now enforces the serialization floor too (#2688, link N+1 of #2685). Gating the Python codegen path left a QuerySet uncovered: mixins/jit.py::_jit_serialize_queryset routes it to djust._rust.serialize_queryset, whose serialize_object_with_paths did a bare obj.getattr(name) with no floor at all. So self.users = User.objects.all() with {% for u in users %}{{ u.password }}{{ u.get_session_auth_hash }}{% endfor %} shipped the pbkdf2 hash and the session-auth hash in the GET html and in both the WS mount and patch frames — the Python fallback never fired, because it only triggers when Rust returns FEWER keys than expected and a plain field never tripped that. Rust now resolves djust.optimization.codegen.emittable_names once per serialize_queryset call and calls it once per object LEVEL, skipping any name the gate refuses and threading the gate through both the nested-object and list-item recursions. Rust holds no denylist of its own — one authority in Python, so the two cannot drift (#1646) — and an import or call failure propagates so the Python caller falls back to normalize_django_value, which is itself gated. Pinned by TestRustQuerySetChannel (direct serialize_queryset, benign-name sibling, agreement with emittable_names, nested-per-level) and TestRustQuerySetRealRenderPaths (real as_view() GET plus a real WebsocketCommunicator mount and a subsequent event) in python/djust/tests/test_property_shadow_parity_2614.py; removing the Rust skip turns those five red while every codegen, eager and sidecar class stays green.
  • @humanfs/node 0.16.7 → 0.16.8 (GHSA-p498-v437-472g, medium) — recursive copy followed symlinked files and copied data from outside the source tree. A dev-only transitive of eslint; no runtime surface. Lockfile-only, taken within the existing ^0.16.6 constraint, so no overrides entry was needed. @humanfs/core 0.19.1 → 0.19.2 and @humanfs/types 0.15.0 come along as dependencies of the patched release.

Changed

  • BREAKING: a template that does not parse is refused when it is BUILT, not when it is first rendered, and the exception is Django's TemplateSyntaxError (#2549). DjustTemplate now parses at construction: DjustTemplateBackend.from_string / get_template (and any direct DjustTemplate(...)) raise djust.template.DjustTemplateSyntaxError — a subclass of both django.template.TemplateSyntaxError and RuntimeError — where Django raises, instead of a bare Exception("Error rendering template: …") at first render. A template whose only defect is in a branch that never renders — an unknown tag inside {% if False %}, an unknown filter in an {% else %} — now refuses at load instead of rendering. Django has always refused these; djust was over-permissive and silent. An unregistered tag and an unknown {% templatetag %} argument are refused at parse time (Django parity); widthratio's non-numeric final argument stays a render-time error, as on Django. Message text is unchanged; callers catching RuntimeError keep catching. template_debug is present and None until #2557.

    Where the parse lives, and why it is not the backend. The suite adapter constructs DjustTemplate(...) directly, bypassing both backend methods, so a parse in from_string/get_template alone would have moved zero cells; it lives in DjustTemplate.__init__ (_compile), the one site every constructor goes through, and test_direct_construction_is_the_one_site pins that. The parse goes through the engine's TEMPLATE_CACHE (new _rust.compile_template, guard_panic-wrapped like every other entry point) so the render that follows finds it parsed and does not pay twice — pinned through the read-only _rust.template_cache_contains probe. A failed parse is never cached, so a handler registered afterwards is honoured (pinned). The filter bridge is armed before the parse for the same reason render arms it: the #2419 unknown-filter refusal consults the registry.

    Two render-time refusals move to parse time, one producer. The parser already consulted the tag registry at parse time and, finding nothing, built a Node::UnsupportedTag for the RENDERER to refuse — so a typo'd tag in an untaken branch rendered here and refused on Django. The parser now refuses in that arm itself; Node::UnsupportedTag and its render arm remain only for hand-built trees, and both read the text from parser::unsupported_tag_message, so the two cannot drift (#1646). {% templatetag X %} is checked against Django's eight names at parse. A structural pin asserts no production path in parser.rs builds UnsupportedTag any more.

    Measured on Django's own template_tests (5.2.16). Engine subset 44.03% → 47.09% (461 → 493 of 1047); whole label 59.75% → 62.08% (870 → 907); compare against the committed baseline reports no drop, and cell-by-cell 0 cells moved OK→FAIL or OK→ERROR. The TemplateSyntaxError not raised family goes 111 → 75: 16 FAIL→OK and 15 ERROR→OK (right timing, right type), 32 ERROR→FAIL (right type now; the cell asserts Django's verbatim message — Invalid block tag on line N, Unclosed tag on line 1: 'block'#2581), and 1 FAIL→ERROR (DebugTemplateTests.test_compile_tag_error now passes assertRaises(RuntimeError) and reads template_debug["during"] on None#2557's dict). The 75 that remain are engine-grammar gaps where djust renders what Django refuses ({% if %} operator grammar 14, {% url %} argument parsing 13, variable syntax 9, {% include %} arguments 8, {% load %} 5 → #2547, and 26 smaller ones), filed as #2576, #2577, #2578, #2579 and #2580; the 32 message-text cells are #2581; none of them is a timing question.

    What changes for you. DjustTemplateSyntaxError is the exception type the #2557 template_debug work will fill — code catching TemplateSyntaxError or RuntimeError around get_template/from_string sees it today. A template that compiles under Django's engine is unaffected; to find affected templates before upgrading, compile them with Django's own engine. Re-pointed: the #2517 empirical canary (the unsupported-tag cell is now DjustTemplateSyntaxError at construction), the #2343 guard set (compile_template), and the TEMPLATE_BACKEND doc test that reads the engine's message text from parser.rs. 34 cases in python/tests/test_parse_time_template_syntax_error_2549.py; two gate-offs redden 20 / 14 tests and each mechanism has a red set the other leaves green (#2135).

  • CI: the python-tests job is sharded four ways with pytest-split. On the last completed run before this change the job took 1191s — 1005s of it the single pytest step (21,773 items on the runner's four xdist workers) — while every other job in test.yml finished under four minutes, so the workflow's wall-clock was that one step. Each shard now runs --splits 4 --group N --durations-path .test_durations (-n auto still inside the shard), balanced by the committed .test_durations; every shard collects the full suite and deselects the other groups, so the union is exactly the unsharded run. A missing or stale durations entry makes pytest-split balance that test by count — slower, never fewer tests. Regenerate with make test-durations when the suite grows by more than ~10% (CONTRIBUTING.md "CI shards"); make test-shard GROUP=N runs one shard as CI does. The per-checkout checks (ruff, mypy, ADR/doc-snippet/template-list/lockfile checks, VDOM fixture freshness) run on shard 1 only; the four shards share one Swatinem/rust-cache entry per interpreter (shared-key, saved by shard 1). The test-summary gate is unchanged: needs.python-tests.result is the matrix-wide result, success only when every shard succeeded. pytest-split joins the [dev] extra and requirements-dev.txt. Pinned by tests/test_ci_python_test_shards.py (--splits equals the group count, groups are exactly 1..N, every if: matrix.group == K names a real shard, the durations file exists and covers only the three CI roots, the aggregate AND-chain still names the job).

  • Importing the template backend no longer imports the LiveView stack (#2559). djust/__init__.py imported live_view and its neighbours eagerly, so a project that only wanted DjustTemplateBackend paid for channels, presence, the WebSocket consumer and over 160 djust.* modules at import time. The package now resolves its public names lazily (PEP 562 __getattr__ / __dir__ over a static name map) with an if TYPE_CHECKING: block carrying the original imports so the strict-mypy gate (ADR-023) stays green; from djust import LiveView, __all__, and dir(djust) are unchanged. Measured in a fresh interpreter (module counts under djust.*): import djust 161 → 14; import djust.template.backend 165 → 25; the backend plus django.setup() 179 → 80, with channels, djust.live_view, djust.websocket and djust.presence absent in every case. DjustConfig.ready() is untouched, so every system check and the #1121 filter bridge still register. Two names collide with same-named submodules (live_view, rate_limit): from djust import live_view still gives the decorator, and djust.live_view is the decorator once any lazy name has resolved (__getattr__ re-binds it on every resolution, so it does not depend on which name was touched first). One order differs from the eager init, where the re-bind always won: import djust.live_view BEFORE any lazy resolution sees the module until the first resolution, the same contract djust.rate_limit has always had. All four orders are pinned. Regression cases in python/djust/tests/test_lazy_package_init_2559.py assert the exact imported-module set (an allowlist, not a floor) in a subprocess, plus the collision and identity pins.

  • manage.py check no longer silently skips non-routed LiveView subclasses depending on check-registry order (#2559 review). check_liveviews unioned the __subclasses__() walk with the URLconf walk, subclass walk FIRST — but only an imported module's classes are visible to __subclasses__(), and importing the root URLconf is what imports most view modules. Django's check registry is an id-hashed set, so whether its own check_url_config had already imported the URLconf was memory-layout luck: the eager init happened to land the good order on the demo project (545 messages), the lazy init landed the bad one and dropped 53 (492) — every non-routed subclass went unchecked (V001/V002/V013 vanished, V004 46→0, V005 6→3). The URLconf walk now runs first, so the union is complete in either order. test_v013_dogfood_zero_warnings_on_demo_project was green only by the same luck (demo_app/views/__init__.py re-exports from views_old, so a real manage.py check has always reported its one V013); it now pins that exact message. Regression cases in python/djust/tests/test_check_liveviews_urlconf_order_2559.py run the check in a fresh interpreter with the URLconf deliberately un-imported and assert a non-routed subclass is reported.

  • LIVEVIEW_CONFIG['template_auto_call'] now reaches all three framework render paths (#2508 review). It was read inline at the template backend only, so SimpleLiveView.render_template and Component._render_template_with_fallback kept auto-calling in a project that had switched it off. One shared reader, config.template_auto_call_enabled(), is now called by all four framework paths — the backend, SimpleLiveView, Component, and the LiveView bridge, which the first pass left on its own inline read while claiming to be the single reader. The reader set is pinned by a test that greps the sink, since enumerating the callers you expect is how the fourth was missed (#1646).

  • A template can now reach further into a context object than it could before (#2501). This is Django parity — Django resolves these and the developer placed the object in the context — but it is a real widening: {{ settings_obj.SECRET_KEY }} renders where it previously rendered empty. Two consequences worth knowing: a nullary method that raises is now a 500 where the cell used to render "", and a {% tag %} handler that received ctx["user"] as a dict now receives a _SidecarModelProxy on these three paths, so ctx["user"]["username"] breaks. The _-prefix refusal (#2436) applies at every segment, so {{ o.__class__ }} and {{ o.__init__.__globals__ }} stay refused.

    What this does not close, stated so the container fix is not read as the class being shut: the floor still does not descend into a generator, a custom __iter__, a dataclass, or past depth 12, so a {% tag %} handler can still receive raw models inside those — tracked as #2509, where the fix is to floor at the sink rather than keep enumerating container types. {% for k, v in d.items %} does not reach the sidecar (#2504). A {% for %} / {% with %} name that SHADOWS a top-level context key resolves against the outer object (#2505) — pre-existing on the LiveView path, new on these three. And admission is eager: roughly 1 ms per render for a list of 1000 dicts, where these paths previously paid nothing.

  • A dotted template lookup now resolves against the live Python object, one segment at a time, the way Django's Variable._resolve_lookup does (ADR-027 movement 3, #2539). Movement 2 wired the resolution sink behind LIVEVIEW_CONFIG['template_resolve_lazy'] and proved the engine's bytes unchanged with it off; this flips the shipped default to True. Setting it to False is the escape hatch, and it restores the previous behaviour exactly — the enumeration arms it keeps alive are deleted together with the flag in movement 4 (1.3.0, superseding the ADR's "removal at 2.0": a hatch whose arms have been deleted is not a hatch).

    Behaviour changes, each of them Django's answer rather than djust's:

    • A class placed in a context is now __init__-ed, as Django does — including a model class (unsaved, no query). {"MyForm": MyForm} in a context is a common spelling, so this is the sharpest of the four: a class that did nothing when rendered now runs its constructor. do_not_call_in_templates is the opt-out, as in Django.
    • {{ o }} on an ordinary object renders str(o), not a mapping of its public instance attributes. A presenter object that rendered its attribute dict now renders its __str__ — the change a template is most likely to show.
    • A callable at any segment is called, and do_not_call_in_templates / alters_data are honoured by the segment walk rather than by a conversion that ran before it.
    • {{ o|json_script }} writes str(o) where it used to write a JSON object built from the instance dict. This is a change of shape, not a narrowing, and it is worth auditing rather than skipping. The old dump filtered underscore-prefixed attributes; str(o) filters nothing, and Python's own @dataclass repr prints every field including _private ones. So an object with no __str__ now discloses less (<Foo object at 0x…> in place of its attribute mapping), while a @dataclass — or any object whose __str__/__repr__ names private state — discloses more. Both directions are Django's behaviour and are the intended target; neither is a security improvement to be taken on trust. Grep for objects placed bare in a template or passed to json_script and check what their __str__ says. Django models are unaffected: they stay on the eager, floored path on both settings, and djust's serialization floor keeps {{ user.password }} empty either way.

    Four issues close outright, on both render paths, against Django's own bytes — not by pointer: #2502 (do_not_call_in_templates rendered the marker dict), #2504 (a filtered or dict-view {% for %} operand could not reach attributes), #2505 (a loop or {% with %} variable shadowing an outer name resolved against the outer object), #2510 (the __dict__ bulk dump evaluated attributes the template never named, which could resize the dict mid-iteration). #2510 closes structurally rather than by a guard: the walk touches only the attribute the template asks for, so the dict is never iterated.

    Scoreboard, measured on the merged tree with the flag off and on, gating on the delta rather than an absolute: 70.39% → 70.96% of engine cells (737 → 743 of 1047), 78.71% → 79.12% over the whole label. The delta was measured three times, against bases of 50.33%, 62.85% and 70.39% as #2593/#2596/#2595/#2597 landed underneath it, and came out byte-identical each time — the same 14 transitions, the same cell names — which is what makes the flip's effect separable from everything else shipping this week. Transitions read per-test rather than from the percent-only compare: 0 OK→FAIL, 5 FAIL→OK, 1 ERROR→OK, 6 crash→non-crash. All seven segfaulting cells stop crashing (crashes in the baseline is now []) — the ADR predicted one; nothing walks a __dict__ eagerly any more, so the five DjustTemplate reference-cycle cases fall out too. "Crashing" understates what the old default did, and the honest statement is the reason to take this flip: reproduced outside the suite, in a subprocess so it could not be mistaken for a test failure, a plain object whose __dict__ forms a reference cycle rendered by a bare {{ o }} kills the process with SIGSEGV (exit 139) under template_resolve_lazy=False, and returns normally under the new default. That is a remotely-reachable worker kill on the previously-shipped path, not a scoreboard cell. Two cells go FAIL→ERROR: test_no_wrapped_exception (#2568), already failing, where the exception is raised and only its type is wrapped.

    Known limits, stated rather than left to be discovered. Six cells stay held with named reasons in the characterization net: an object whose str() is SafeData still renders escaped, a generator is still not consumed by {% for %}, and a lambda / class / list-subclass class on the LiveView path is still dropped by normalize_django_value before the sink sees it. Each needs its own mechanism; they are tracked at #2621, which blocks the movement-4 delete. Separately, a RustLiveView clone rendered without a re-sync answers empty for a handle-only lookup (#2570's documented contract): every framework path runs a full sync before the first render after a restore, so this is reachable only through the raw API.

    Two xfail(strict=True) markers came off, one of which named this flip as its own removal condition. The suites that recorded the pre-flip conversion behaviour — #2429, #2477/#2489, #2478, #2481, #2501, #2510 — now say which flag axis they mean rather than relying on the ambient default, and one shared resolve_lazy helper replaced what would have been seven more hand-copies. New cases in TestTheDifferentialTable, TestTheFlagReachesEveryRenderEntry2539 and TestTheSwitch2539, including a per-cell assertion that the shipped default and an explicit push render identical bytes — the flip's actual claim, which two independent against-Django tests would not catch.

  • BREAKING: {% url %} raises NoReverseMatch on a failed reverse, as Django always has; {% url … as var %} stores '' instead (#2563). A template that relied on the silent empty string — a blank href where the pattern name was wrong — now raises on both the plain backend and the LiveView path, for a quoted name and for a variable name, with Django's own message (… 1 pattern(s) tried: […]). Use as var where a reverse is legitimately allowed to fail. Two paths had diverged in opposite directions: the Python pre-pass for quoted names raised even under as var (Django's test_url_asvar03 was an ERROR), while the Rust CustomTag handler swallowed NoReverseMatch and Exception into '' (test_url_fail11/12/13). Underneath, a tag handler's Python exception now crosses the Rust boundary WHOLE at all three call_*_with_py_sidecar sites (DjangoRustError::PythonException, the #2508 mechanism #2547's bindings path already used) instead of being flattened into a string and re-raised as a bare RuntimeError — so a project handler raising PermissionDenied is a 403 again, not a 500 — and NoReverseMatch joins the exceptions DjustTemplate.render passes through by type. A handler that declares ACCEPTS_AS_VAR = True (with RETURNS_BINDINGS) receives Django's trailing as <name> as two literal tokens rather than two resolved variables, with the NAME wearing djust.template_tags.AsVarName — Django asks bits[-2] == "as" ONCE, of the raw tokens, and so does djust, in renderer.rs::resolve_custom_tag_args; a handler READS that decision instead of re-deriving it, so {% url named 'as' v %} and a variable holding "as" stay ordinary arguments and raise as they do on Django rather than silently becoming as var forms. django.template.TemplateSyntaxError also joins the exceptions DjustTemplate.render passes through by type — the Rust engine never constructs one, so a TemplateSyntaxError leaving a djust render is user-raised by construction ({% url %} with no arguments is now Django's TemplateSyntaxError through the engine, not a bare Exception; #2605 will generalize the rule to the whole list). TagHandler.render's declared return widens to the (output, bindings) tuple that contract already implied. Django's test_url family: fail11, fail12, fail13 FAIL → OK and asvar03 ERROR → OK (+4; test_url_reverse_no_settings_module ×2 also flip to OK); url19 now surfaces the #2037 name-position double resolution it was hiding, and test_url_reverse_view_name ×2 fail on Django's traceback-depth proxy alone (the handler's frames survive; the Rust engine has no Template.render/URLNode.render frames to count). Whole scoreboard: 527 of 1047 engine cells (50.33%), up from the previous baseline's 493 and with no drop against it. Two cells do move the wrong way, both named above: test_url_reverse_view_name ×2 went OK → FAIL, because it passed only vacuously — Django puts its traceback-depth assertion inside except NoReverseMatch:, which never ran while djust returned ''. (test_url19 moves FAIL → ERROR, which is the same wrong answer reported honestly, not a loss.) 46 Django-parity rows (23 shapes on both entry points) plus the never-swallowed, three-site type-crossing and single-as-decision pins in python/tests/test_url_noreversematch_2563.py.

  • _is_user_raised now means "arrived whole via DjangoRustError::PythonException", not membership of a type allow-list (#2605). The predicate that decides whether DjustTemplate re-raises an exception unchanged or wraps it as an engine failure carried a five-type allow-list (Http404, PermissionDenied, MultiPartParserError, BadRequest, SuspiciousOperation) plus NoReverseMatch and TemplateSyntaxError beside the two provenance stamps. Every member was already stamped on every path it can actually reach that code by, and a list of types someone thought of is the shape that misses the next custom exception. The list is gone; the stamps decide. No behaviour change on any measured path — all five of Django's dispatched types still survive a real render with their exact type, which test_every_django_dispatched_type_survives_a_real_render now asserts through a render rather than by handing the predicate a synthetic instance.

  • The pre-push cargo test runs in debug, not --release (#2654). Measured on a cold worktree with the exact invocation the hook makes: --workspace --exclude djust_live takes 54.7s in debug and 169.0s in release — 3.1x on the workspace, 6.6x on a single crate — for identical results, since the whole Rust suite passes in debug with 0 failures. That is roughly two minutes back on every push touching Cargo.toml, Cargo.lock or djust_core, which is what select-tests.py --cargo escalates to --workspace for. The --release predates the #2526 scoping work (it was in the original inline hook entry) and carried no rationale in the script, the config, or the commit that moved it. Checked before flipping: the only wall-clock in any Rust test is the 10s HANG watchdog in free_threaded_safety.rs, which finishes in 0.02s in debug — a deadline, not a performance assertion, with a 500x margin. Benchmarks are unaffected (separate cargo bench / pytest-benchmark path, still optimised). DJUST_PREPUSH_RELEASE=1 restores the optimised run.

  • CHANGELOG.md's [Unreleased] section is no longer edited by PRs; each PR writes one fragment to changelog.d/<issue-or-slug>.<section>.md and the release cut folds them in. Six PRs conflicted on [Unreleased] in one day, each conflict costing a local merge-main round — and a conflicting PR gets no pull_request CI run at all, because GitHub cannot build the merge ref. A new file per PR never conflicts. scripts/changelog-fragments.py has three subcommands: check (valid section suffix, non-empty bullet body, the same test-count claim check check-changelog-test-counts.py applies to CHANGELOG.md, and a refusal of any commit that edits the [Unreleased] body directly unless it also deletes fragments — i.e. is the compile), compile (folds every fragment under the right ### Section in canonical order, sorted by filename, then deletes them; idempotent; --dry-run), and preview. make release runs the compile before the tag and stops if it changed anything, so the folded file is committed first; make release-dry-run lists the pending fragments. Pre-commit runs check --cached on changelog.d/ and CHANGELOG.md; the test-count checker scans fragments too. Existing [Unreleased] entries stay where they are; merge=union stays on CHANGELOG.md for the rare direct edit. Covered by tests/test_changelog_fragments.py.

  • Process/CI batch (#2612, #2522, #2634, #2537, #2661, #2603, #2545). The Django template_tests scoreboard job is now a BLOCKING ratchet against scripts/django-template-suite-baseline.json and part of the test-summary AND-gate (it caught the 1032→1031 regression on #2668 while still non-gating). scripts/check-pr-review-verdict.sh <pr> is the new pre-merge gate: it reads the LAST review verdict (APPROVE/REQUEST_CHANGES) and requires it to postdate the head commit (exit 1 blocked, 2 no verdict, 3 stale). scripts/changelog-fragments.py check accepts migrating an existing [Unreleased] bullet into a changelog.d/ fragment (removal-only diff whose lines reappear in a fragment the change adds); 7 new cases in TestFragmentMigration. Template::py_render in djust_templates now switches <!--dj-if--> markers off like every other Python-facing entry. Docs teach {{ component }} as the canonical spelling (26 {{ x.render }} occurrences swept; .render still resolves), pinned by python/tests/test_component_spelling_2634.py. The model-backed benchmark's bucket 2 no longer double-counts nested proxy re-wrap time.

  • Seven small tech-debt items drained in one batch (#2524, #2523, #2637, #2625, #2540, #2566, #2565).

    • The T011 system check no longer flags {% ifchanged %} (supported since #2650): its unsupported-tag set and template_filters._BUILTIN_NAMES (had length_is, lacked escapeseq) are now pinned EQUAL to what the #2533 generator derives from the engine, in TestHandWrittenCopiesAgreeWithTheEngine; docs/system-checks.md says the set is empty (#2540).
    • A templates-only project running with DEBUG=True no longer imports channels / djust.websocket: enable_hot_reload() defers that import to the change event and gates it on channels being installed (#2566). DjustConfig.ready() warms the filter bridge through djust.template_filters instead of djust.mixins, so django.setup() loads 57 djust modules instead of 88 and no LiveView mixin (#2565). Both pinned as exact subprocess allowlist rows in test_lazy_package_init_2559.py.
    • The three streaming batching tests assert ordering invariants (queued → flush task → delivered) instead of a count sampled after a wall-clock margin, with gate-off siblings (#2625, the #1795 family).
    • vitest no longer discovers Django's QUnit tests in the gitignored .django-src/ checkout (#2637); test.yml has a least-privilege permissions: contents: read block and the archived actions-rs/toolchain steps are dtolnay/rust-toolchain@stable (#2523); 15 stale python/tests/ files are ruff-0.15-formatted (#2524).

Documentation

  • ADR-027 proposed: template variable resolution follows Django's lookup rules at one sink (#2535). djust converts the whole context into a Rust Value tree before rendering and reaches object attributes afterwards through a by-name sidecar that enumerates container shapes; that conversion layer is where the v1.2.0-2 arc's open issues live (#2502, #2504, #2505, #2509, #2513, #2516, #2528). The ADR decides that an object with no Value variant becomes a live-object handle resolved lazily at lookup time by exactly Django's _resolve_lookup rules — the __getitem__ guard, then dict key, then attribute with the dir() re-raise, then integer index, each with Django's catch set; auto-call unless do_not_call_in_templates; alters_data and silent_variable_failure render invalid; anything else propagates — at one sink in crates/djust_core/src/context.rs, retiring the enumerate-container-types class rather than adding one more arm to it. Django models, managers and querysets stay eager under the _ALWAYS_EXCLUDED_FIELDS floor, because their floored dict is what the client JSON shape, the msgpack snapshot round trip and container change detection consume; the floor holds at the lazy sink as well. The handle is transient in the same way raw_py_values is: skipped by Serialize, never in a snapshot, re-attached per render. Two segfault mechanisms are recorded, one cell each: a reference cycle in the context recurses in Rust with no visited set (the #2516 class), and step 1's PyObject_GetItem on a class honours __class_getitem__, so {{ MyClass.attr }} yields a types.GenericAlias whose conversion crashes — Django never gets there because it opens step 1 with hasattr(type(current), "__getitem__"). A premise correction for #2532: ADR-024 named two channels; there are three. Top-level model lists are serialised eagerly by the JIT in Python and never enter the sidecar (the #2532 Stage-4 spike measured all four list_* fixtures at zero sidecar crossings), so the handle is for nested opaque objects only, and the JIT channel's own defects (a re-query per event, a discarded in-memory row mutation) are #2536, not this ADR. Honest expectation for the flip against the Django suite (#2517, 44.03% after PR #2534): about 7 of the 1047 engine-reaching cells plus 1 of the 7 crashes; the rest of the ledger is unsupported tags and parse-time diagnostics (v1.2.0-3), and the gain that matters is the retired class and the LiveView-path parity the suite cannot see. Sequenced as dormant-define → wire → flip → delete behind a characterization-test net, scored by the #2532 benchmark, which lands first. Erratum to ADR-024: Django does not stamp do_not_call_in_templates on ModelBase, so {{ M.pk }} with a model class in the context instantiates it under Django — a real auto-call surface that ADR-024's decision table had described as covered "for free"; the row is annotated, not rewritten.

  • Extras decision for a templates-only install, written down (#2560). No djust[templates] extra and no change to what pip install djust pulls in 1.x: an extra can only add dependencies, and moving channels / msgpack behind djust[live] is a breaking change for a 2.0 boundary with a deprecation cycle. 1.2.0 ships the additive half instead — the backend imports without the LiveView stack (#2559) and the C016 check names a missing DjangoTemplates fallback (#2562). The 2.0 migration path is recorded in docs/TEMPLATE_BACKEND.md.

  • "Rust templates for any Django view" as a README headline, with the two measured numbers (#2561). A new section states what docs/TEMPLATE_BACKEND.md already knows: one TEMPLATES entry, no client.js, and the two numbers that back it — the Django-suite percentage (pinned by the same <!-- django-suite-claim --> mechanism as the docs page, now generalized to check every doc that quotes it) and the existing 7–11x filter-heavy speedup claim. python/tests/test_django_template_suite_2517.py::TestDocClaimMatchesBaseline now parametrizes over both files, plus a presence guard so a doc losing its marker cannot pass by universally skipping.

  • Docs teach dj-root alone as the root attribute, and no longer claim dj-view is required (#2631, djust-org/djust discussion #2437). Verified by rendering a LiveView three ways through a real request cycle on main: with dj-root alone the server stamps dj-view onto it with the rendering view's dotted path; with dj-view alone the client stamps dj-root; with neither, no dj-view reaches the browser, no WebSocket opens, and the page is silently static. So dj-root is sufficient and is the form to teach — writing the dotted path into a template duplicates a fact the server owns into a place nothing type-checks, where a class rename breaks it silently, which is the same failure shape as the dj_view_id phantom that opened #2437.

    Updated the README, QUICKSTART, getting-started/first-liveview, core-concepts/templates, the template cheatsheet, and the components/forms/reconnection/vdom-architecture guides to show <body> + <div dj-root>. Explicit dj-view stays documented for the cases that need it: naming an embedded or sticky view, or one template shared by several views. Two false claims corrected outright: QUICKSTART said "Every LiveView template must include two attributes … Both attributes are required" (either alone works), and the sticky-LiveViews guide marked dj-view Required for a normal page view (it is stamped for you). QUICKSTART's "No containers found" fix now points at the real cause — a root with neither attribute — rather than telling users to add the dotted path.

  • Twenty-five medium-severity doc-vs-implementation findings from the #2646 review (#2652). The largest group is a family of decorators documented as working client-side features that are server-side MARKERS: @debounce, @throttle and @optimistic stamp metadata that nothing in the shipped client reads — debounceTimers / throttleState are declared in src/04-cache.js and only ever CLEARED, never written, and window.handlerMetadata has no readers — so a decorated handler fires on every event exactly as an undecorated one would. #2646 established this for @optimistic and stopped one symbol short. The decorator docstrings, schema.py (the AI/MCP-facing surface), STATE_MANAGEMENT_API.md, BEST_PRACTICES_AI.md and JIT_SERIALIZATION_PATTERN.md now all say so — including schema.py's RECOMMENDATIONS entry, which told an AI agent to fix real server flooding with an inert decorator, and the MCP scaffolder, which emitted @debounce into generated code — including the "+0.8 KB" that billed nonexistent client code and the "server validates after 500ms" that described a wait which never happens. @cache is explicitly excluded — it IS wired end-to-end (runtime.py reads its metadata, the client honours the TTL) and should not be swept up with the others.

    Behaviour claims corrected against the code: the PWA sync queue is NOT dispatched automatically (_process_sync_queue() runs only from sync_when_online(), or from handle_connection_change(), which has no callers); the sync hook name is built as f"sync_create_{action.model}" and is therefore case-sensitive, so create_offline("Item", …) needs sync_create_Item; self.storage is a per-instance SERVER-SIDE dict that is empty on every fresh request, and is_online() returns True unconditionally there, making a documented else: branch unreachable; an exception's text is replaced with a generic string when DEBUG is False, so "delivered as an error frame" is true in dev and silently generic in production; the service-worker fast-paint runs only on the popstate path, never on a click; and the missing-attribute checks are T002 / T012, not T001 (which is the deprecated @click syntax check).

    Stale figures and structure: DJUST_VDOM_CACHE_TTL_SECONDS / MAX_ENTRIES documented as 300 / 100 where config.py has 1800 / 50; css_framework omitted 'bootstrap4' and 'plain'; the CLAUDE.md crate tree listed a crates/djust/ that does not exist (the PyO3 entry point is crates/djust_live/, per pyproject.toml's manifest-path) and omitted djust_components; DEVELOPMENT.md prescribed a feature/ branch prefix while the repo in practice uses the conventional-commit prefixes (feat/, fix/, docs/), and prescribed a Co-Authored-By placeholder that is not copy-pasteable; dj-offline-queued and dj-upload-progress are labelled as having no client implementation rather than advertised; multi-tenant.md lost twelve headings in #2645 — the Quick Start ran ### 1.### 3., the ## Mixins parent vanished leaving ### TenantMixin dangling, and five REAL resolvers (PathResolver, HeaderResolver, SessionResolver, ChainedResolver, CustomResolver) were left entirely undocumented; the sections are restored, rewritten against resolvers.py (the deleted copies documented a nested-dict config API and a TenantInfo(id=…) kwarg that never existed) and each config shape verified by executing it; and CONTRIBUTING.md's "client-side JavaScript lives in ONE place" is qualified — the live_view.py copy is gone, but static/djust/decorators.js (~34 KB, outside the bundle, still carrying an "update BOTH files" header) is not, which is open work rather than a finished consolidation.

  • Fixed 30 documentation-vs-implementation discrepancies from the 2026-09-03 audit: phantom APIs (dj_view_id, djust.asgi, LiveViewError, @loading, AdminLiveViewMixin, djust.tenant.mixins, 7 of 8 rust_components), dead-path docs (@optimistic, redirect_url, dj-upload-progress), the multi-tenant/PWA guides rewritten against the real APIs, and stale examples (@throttle(wait=)), per audit issue #2645 and #2631.

  • Corrects twelve claims the #2645 audit sweep got wrong or left half-swept (#2646 follow-up). The audit PR fixed ~20 real discrepancies but introduced new ones, and the most damaging were where a TRUE statement was replaced with a false one: docs/guides/BEST_PRACTICES.md asserted "there is no DEBUG-mode branch" for non-serializable state when live_view.py:1167 raises TypeError under DEBUG on a path that is not opt-in, and docs/guides/services.md asserted URL kwargs "are not an injection point" when runtime.py:2117 merges resolve(page_url).kwargs — including path(route, view, {...}) extras — straight into mount_kwargs.

    The rest: the phantom self.tenant_queryset() survived at seven sites in the same file whose API reference was corrected (real API is get_tenant_queryset()); the multi-tenant test example was broken three ways (TenantInfo(id=…) is a TypeError — the first argument is positional tenant_id; mount() never calls _ensure_tenant(); request.tenant is ignored by resolve_tenant()) and is now a form verified to run; STATE_MANAGEMENT_API.md re-annotated a handler value: str = "" while still calling int(value), which raises ValueError on the empty input the original int annotation handled via validation.py:145; the PWA guide declared the working dj-offline-hide family "not implemented" and promoted a value form handled only by static/djust/js/pwa.js, which is not in src/, not bundled, and loaded by no template tag — so the quickstart paired value-form attributes with the CSS helper that styles the other form and produced no offline behaviour at all; offline_storage was described as selecting a backend when it is only the namespace (storage_name=, backend comes from DJUST_CONFIG['PWA_OFFLINE_STORAGE']); two of the four documented sync_conflict_strategy values do not exist, and an unrecognised one falls back to client_wins silently; make build-client is make build-js; LiveComponent has no .id (that is Component's, and ids are lower-cased); --reload was dropped on the grounds HVR replaces it without noting HVR needs watchdog, a dev-extra dependency; and docs/SECURITY_GUIDELINES.md still imported the phantom djust.tenant.mixins and taught a get_queryset() auto-filter TenantScopedMixin does not provide.

All releases · Atom feed