djust 1.2.0rc1

Pre-releaseSecurityReleased
Install
pip install djust==1.2.0rc1

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

Part of djust 1.2 — read the 1.2 release notes.

Added

  • LiveView.time_travel_excluded_fields — a declarative PII scrub, plus the djust.V014 check that notices you haven't declared one (#1561). Names the top-level public-state keys that must never leave a view inside a shared bug capture. encode_view_state() applies them BEFORE any caller-supplied scrub, so the redaction stops depending on every call site remembering to pass one — the debug panel's Share button routes through the same function and inherits it, pinned by a structural test so the next capture surface can't quietly build a twin (#1646). Composition reuses the existing scrub_fields(), which already handles absent keys and already carries forward scrubbed_fields — which is what makes names from both sources land on the wire together. Any iterable of names is accepted (#1108); a bare string is treated as one name rather than iterating as eight characters and silently scrubbing nothing.

    Registered as V014, not the V012 the issue names — that ID has been taken since #1803 (sticky child declares its own dj-view), and V001–V013 are all in use.

    V014 warns when a view sets time_travel_enabled = True and its model or form declares a field whose name looks like PII (password, passwd, ssn, credit_card, tax_id, email, phone) that is not declared excluded. The design problem is false positives — email is on almost every user model, and a check that fires on every project teaches people to ignore it (#1060). Three gates: time_travel_enabled is a deliberate dev-only opt-in almost no view sets, which does the heavy lifting; token matching rather than substring matching, so telephone_pole does not contain the tokenphone; and field TYPE, so email_verified (Boolean) and phone_confirmed_at (DateTime) are skipped whatever they are called. Field discovery scans class attributes for values that are models/forms/querysets rather than enumerating the attribute names a view is expected to use — djust has no framework-level model attribute, so an enumerate-the-names scan would be reliably one short. Not DEBUG-gated: manage.py check --deploy is exactly when you want to hear that a shipped view records a password field.

    Dogfooded against the demo project and examples/: 0 messages across 57 LiveViews, because none opts into time travel. Forcing the flag on every one produces 10 messages naming 14 fields, all true positives. New cases in TestExcludedFields, TestNoParallelSink, TestV014Firing, TestV014StaysQuiet and TestTokenMatching; the ordering assertion is made from inside the caller's scrub callable, because asserting on the final output cannot distinguish "excluded ran first" from "excluded ran second".

  • djust replay — open, inspect, or diff a bug capture from the terminal (#1561).djust replay <blob> opens the replay URL via webbrowser.open(); --inspect prints the decoded capture as ONE JSON document (event_name, scrubbed_fields, state_before, state_after, vdom_patches) so it pipes straight into jq; --diff prints a unified diff of state_before against state_after. Identical states write nothing to stdout and say so on stderr, so djust replay --diff … > patch still produces a valid empty patch rather than a file with prose in it.

    The blob argument accepts a bare djbug1. blob or a whole replay URL, because a teammate is as likely to paste one as the other. Two guards, because a blob arrives by paste and "run djust replay <this thing I sent you>" is a real way to get a URL opened on someone's machine: the argument must resolve to something starting with djbug1. (so djust replay https://phishing.example/ never reaches webbrowser.open, and the URL handed to the browser is always one the command built itself), and --base-url is restricted to http/https since its scheme reaches the browser directly.

    The replay path comes from reverse("djust:bug_capture_replay") when a URLconf is available, so a project mounting djust.urls under a prefix gets the right link; it falls back to the literal route when the CLI runs outside a project. Host: --base-url, else $DJUST_REPLAY_BASE_URL, else http://127.0.0.1:8000.

    New cases in TestExtractBlob, TestReplayUrl, TestInspectMode, TestDiffMode, TestBrowserMode, TestErrorPaths and TestSubparserWiring — driving the real main() argv path, so the subparser and dispatch-table wiring are exercised rather than assumed.

  • Opt-in snapshot store for large bug captures — djbug1.store.<opaque-id> (#1561). A capture of a busy view does not fit in a URL; browsers, proxies and issue trackers truncate somewhere in the low kilobytes and the recipient gets a base64 error with no idea why. Configure LIVEVIEW_CONFIG['bug_capture_store'] and any payload over bug_capture_inline_limit (default 1536 base64 characters) travels by reference instead. New: the SnapshotStore ABC the iter-C issue assumed an earlier iteration would introduce (neither did), plus InMemorySnapshotStore and RedisSnapshotStore.

    The default is no store, not in-memory — a deliberate deviation from the issue text. An indirect blob is only as shareable as the store behind it, so defaulting to a process-local one would silently convert a blob you could paste to a teammate into a reference only your own dev server resolves, which looks like it worked. None also keeps the feature zero-cost when unused (#246/#1446): a sub-threshold payload never reads the store config, and nothing in djust.bug_capture_store is constructed. A misconfigured store raises rather than degrading to an inline blob, so "I asked for Redis" never quietly becomes "you got process-local URLs".

    RedisSnapshotStore refuses by default to attach to an unauthenticated Redis. The check is not a config flag taken on trust and it does not read the URL — it opens a second, credential-STRIPPED connection to the same server and pings it. A password in the configured URL proves only that we authenticated; it says nothing about what the server demands of anyone else, and redis://:@host, redis://host?password=x and redis://u:p%40ss@host can all point at a server with no requirepass. Credentials are stripped structurally from redis-py's own parsed connection kwargs, so percent-encoding and userinfo-vs-query precedence cannot hide a bypass. An inconclusive probe (unreachable, timeout) refuses too — it fails closed. require_auth=False is the documented mTLS / unix-socket escape and logs a warning naming the server.

    The opaque id is a bearer capability bounded only by its TTL, and the docs say so plainly: anyone who obtains one reads the snapshot in full, with no per-recipient authorization, no revocation and no audit trail. Two bounds keep it from being worse than that: a djbug1.store.<id> blob is validated against the exact 22-character secrets.token_urlsafe(16) shape before the store is consulted — without that ordering the iter-B replay viewer, which takes a blob straight from a URL, would be an arbitrary-key reader for whatever else lives in the same Redis — and stored keys are namespaced under djust:bugcapture:. "Unknown id" and "expired id" produce one identical message, so a probe cannot confirm a guessed id was ever valid.

    New cases in TestSnapshotIds, TestInMemoryStore, TestStoreConfigResolution, TestEncodeDecodeWithStore, TestRedisAuthRefusal and TestReplayViewWithStore. The Redis refusal is exercised against REAL redis-server processes the fixtures start (one open, one requirepass), skipped when redis-server is not on PATH: six bypass URLs that look authenticated to a naive check are asserted refused anyway, and seven hostile snapshot ids are asserted never to reach the store. The memoized store joins the reset_djust_globals inventory (#1883) with its own pin (test_reset_clears_bug_capture_store_cache).

Changed

  • The differential corpus can hold an unpicklable and a non-literal row, and the two shapes of #2466's class it could not reach are now swept (#2482).INPUTS in scripts/filter-parity-differential.py was read by two consumers that each ruled out a class of value, and between them two shapes were not "not added yet" but unrepresentable. measure's @cmp axis DEEP-COPIES the second operand — correct, and load-bearing, because two structurally-equal operands that are not the same object is the whole of what values_equal / try_compare needed (#2335) — but all three of dict_keys / dict_values / dict_items raise TypeError: cannot pickle under deepcopy. So an empty dict_keys could not be a corpus row at all, and was therefore swept nowhere: not on the @cmp axis it breaks, and not on the twenty-odd axes it would have been fine on. The second consumer, test_sequence_op_chokepoint_2451.corpus(), re-evaluates each value from the AST.

    A factory, not a per-row opt-out.INPUTS_LAZY is an additive mapping of zero-argument factories that INPUTS is updated from, and fresh(key) is the single chokepoint the @cmp axis calls: a factory row is built again, every other row is still deep-copied, and no caller decides which. Two independently-constructed objects is exactly the property the copy exists to provide, and it holds for a dict_keys where copying does not. INPUTS stays a dict LITERAL so both AST readers keep working; corpus() reads the factory mapping too and CALLS each entry, because a reader that stopped at the literal would sweep a strict subset of the corpus the differential sweeps — the parallel-path drift (#1646) this corpus exists to measure, one level up and invisible, since a missing row cannot fail anything.

    The second limit was narrower than the issue stated, and a stale exemption rested on it.("str-fallback", "falsy") was EXEMPT from value-truthiness on the grounds that its only inhabitant would be a user-defined class and "a class instance cannot be a row here at all". eval injects __builtins__ into a globals mapping that has none, so the AST reader evaluates type("C", (), {...})() perfectly well — what it cannot evaluate is a reference to a name the script defines. The exemption was wrong rather than stale, and is deleted; the axis goes 16 → 14 exempt and 44 → 46 swept over an unchanged 60 required.

    Three rows, all in the one mapping so there is a single mechanism rather than two that can disagree: dv-keys-empty (the unpicklable shape the issue names), dv-keys-plain (its truthy payload-carrying sibling — without it the empty row cannot separate "the view crossed" from "an empty thing crossed"), and o-falsy-iter (falsy, __iter__, no __len__ — the shape #2466's own doc-comment DECLINED, and the only one that reaches the terminal Value::String(ob.str()?) while being Python-falsy). __module__ is pinned on that class, and that is load-bearing: type() fills it from the calling frame's __name__, which an AST reader's namespace does not have, so without the pin the reader-built instance had none while the script-built one had "__main__" — a corpus row whose behaviour depended on which reader constructed it, and one that made normalize_django_value raise for one and render for the other (filed as #2488).

    Three preservation checks, run rather than argued. Old corpus vs new against ONE build: 375,394 shared cells, 0 whose djust output changed and 0 whose agreement status changed under either definition, 0 cells lost, 5,090 added. --manifest --json from both versions against that build: no axis's required set shrank and no axis lost a swept member — the only moves are the two deleted exemptions becoming swept, and three additions to input-shape. The regression gate re-verified across two genuinely different builds (89f219710de14da8054bc92b2bdd954d, falsy_opaque's first arm reverted to the pre-#2466 shape): --compare exits 1 and names 601 regressions with 0 introduced panics and the live-leak count unchanged at 65. Both corpus checks were re-run after merging #2487 (which moves Value::Encoded, the carrier two of the new rows reach) rather than carried forward — same numbers on build 0749837799e818f9.

    What the new rows surface is FILED, not fixed (#1079).first / last / phone2numeric over an empty dict_keys render on the LiveView path where Django refuses, because normalize_django_value flattens the view to the string "dict_keys([])"#2477's class one type over and a degree worse, since a set flattens to a sorted LIST while a view flattens to TEXT; three rows added to NORMALIZER_FLATTENED, whose existing non-vacuity test covers them. The same three over dv-keys-plain and o-falsy-iter render on BOTH paths, because the CONVERSION stringifies them — a separate table, STRINGIFIED_AT_CONVERSION, with its own non-vacuity test asserting the raw entry point answers identically to the LiveView path, so the two diagnoses cannot be confused (recording them together would encode a diagnosis false for half the rows). Filed as #2489, with {{ p|length }} over the falsy-iterable answering 15 where Django says 0 as the sharpest case; scanning all 3,562 new payload-carrying cells found 0 that gained a live fragment, so the class is a correctness divergence and not a leak.

    Empirical canary + gate-off (#1459/#1468): flipping the corpus row's __bool__ bit from False to True — a mutation that leaves the row, the corpus and the script intact — puts exactly value:str-fallback:falsy and arg:str-fallback:falsy back in missing, and removing only the ARG_CONTEXT binding puts back exactly the arg one, so each channel has a test that goes red when only its own row is removed. New cases in TestTheRowThePreviousHarnessCouldNotHold, TestFreshIsOneChokepointAndNotTwo, TestTheASTReadersSeeTheWholeCorpus, TestTheExemptionsStatedReasonWasFalse and TestTheFalsyIterableRowIsTheShape2466Declined in the new python/tests/test_lazy_corpus_rows_2482.py, plus TestItWouldHaveCaughtTheHistoricalBlindSpots in test_differential_reachability_manifest_2345.py and TestTheReferenceTableIsRunNotTranscribed in test_sequence_op_chokepoint_2451.py. The #2477 canary had to be widened in the same commit: two of the new rows land on the same two value-channel arms the set pair does, so leaving them in turned that canary's four-member gap into two — a canary silently reproducing less than it claims, found by running it.

  • BREAKING: int(value) is a TypeError for the datetime family, so get_digit and divisibleby refuse it as Django does (#2473).#2366 established the rule for the ARGUMENT position — int(datetime) raises TypeError, which get_digit's except ValueError does not catch, so Django raises — and #2448 gave djust the Value::Encoded variant it needs to see the type. The VALUE position did not follow: python_int_value had no Encoded arm, so a datetime fell to the wildcard and answered ValueError, the one exception those bodies DO catch. {{ p|get_digit:"1" }} over a timedelta(seconds=90) rendered 0:01:30 onto the page where Django 500s, and get_digit's echo arm carries a per-call safety grant (#2403), so the value reached the page live. divisibleby refused either way but its message named the wrong exception.

    One arm at the chokepoint, not one per filter.python_int_value is THEint(value) reading (#2435); get_digit, divisibleby and {% widthratio %}'s operands all go through it, so a per-filter fix would have been three copies of one rule (#1646). {% widthratio p|get_digit:"1" 10 100 %} now refuses on both engines — Django refuses it at COMPILE time (widthratio final argument must be a number) and djust rendered empty.

    What changes for you. A template applying get_digit or divisibleby to a datetime, date, time or timedelta now raises instead of rendering — on BOTH paths. These are exactly the templates Django has always refused.

    Both paths, and that claim was rewritten mid-branch. This entry first said the LiveView path was unaffected, because normalize_django_value spelled a datetime as its DjangoJSONEncoder string in Python before the conversion — so the engine really was handed a str there and int("2020-01-01T12:00:00") really was a ValueError. #2475 closed #2467 while this branch was open and removed that flattening, so the LiveView path now builds the same Value::Encoded and refuses too. test_sequence_op_chokepoint_2451.py's pin has now been rewritten three times — extraction boundary → path split → CLOSED — and each rewrite corrected a mechanism rather than a measurement; it is kept in place, with the history, rather than deleted. Asserted in TestBothPathsCarryTheSameEncoded.

  • The differential's value-truthiness axis enumerates conversion OUTCOMES, not Value variants — it was structurally blind to the class it was added to cover (#2477).#2469 built that axis so a falsiness gap could not go unmeasured a seventh time: it reads the Value variants out of the enum and requires a falsy and a truthy inhabitant of each. That is the wrong enumeration for the question, and #2466 is the proof. Every value that issue is about — set(), frozenset(), complex(0), an empty dict_keys, a zero-__len__ class, a __bool__-False class — has no variant, and the absence IS the defect. td-zero supplied Encoded:falsy, so the axis reported 0 MISSING over a class for which the corpus could not construct a single cell.

    The outcome is the conversion ARM. A variant names what the renderer holds; an outcome names what impl FromPyObject for Value did with a Python object, and the two differ exactly where no variant models it. Two arms of that impl's fallback block are in that position — falsy_opaque (#2466) and the terminal Value::String(ob.str()?) — and both are now members of the axis. The arm list is READ from the Rust source, with a count check against the block's exits: every arm but the last ends in a return Ok(…), so an arm added without a pattern, or a pattern that stopped matching, is a loud mismatch rather than a silent reclassification of that arm's objects into an existing outcome. Comment lines are stripped before the scan, and that is load-bearing rather than tidiness — the block's own prose quotes the terminal while explaining the Decimal ordering, so a raw scan finds seven arms for six exits.

    Corpus: set-empty / set-plain in INPUTS, known_set_empty / known_set in ARG_CONTEXT. A set is the only member of the class that is both a builtin and spellable as a corpus literal, and the two answers land on different arms, which is why both are carried: the empty one is Python-falsy and reaches falsy_opaque; the payload-carrying one is truthy, is declined by that arm's own gate, and falls to the terminal str() — the residue STRINGIFIED_AT_EXTRACTION names in test_int_argument_type_2366.py, which no cell had reached. The axis grows 8 required members, 4 newly inhabited and 4 exempt with mechanical reasons (falsy_opaque:truthy cannot exist — the arm opens with if ob.is_truthy() { return None }; str-fallback:falsy needs one of the two shapes #2466 declined, and no builtin type has either). Measured before and after against the same build: no axis's required or swept set shrank, and value-truthiness goes 52 → 60 required, 40 → 44 swept.

    Empirical canary (#1459): the identical corpus, with the four rows removed, reports 4 MISSING through the extended axis and 0 through the one it replaces — a gap the shipped tool called covered. Run as two tests rather than described, with a third asserting the mutation is a corpus edit (the axis is still declared, every other axis is still clean, the missing set is a strict subset of a required set that did not shrink) and a fourth breaking one alternative of the arm pattern to confirm the reader fails loud instead of shrugging. The regression gate was re-verified across two genuinely different builds (f15dc3ac223b24c1a82f6d35625359cb, falsy_opaque's truthy bit flipped — the pre-#2466 shape): --compare exits 1 and names 35 regressions, every one on a row this change adds, with 0 introduced panics and the live-payload-leak count unchanged at 58.

    Four cells are RECORDED rather than allowed.NORMALIZER_FLATTENED in test_sequence_op_chokepoint_2451.py holds first / last over both set rows: they render on the LiveView path where Django refuses, because normalize_django_value has no arm for the class #2466 closed at the conversion and turns a set into a sorted — subscriptable — list. That is the other half of #2477 and is a fix at the normalizer, not a guard at the consumer, so it stays open; the pin is exact in both directions (a recorded cell that stops diverging must be deleted, not left as cover) and its non-vacuity test asserts the weaker TRUE property — the raw path answers differently — rather than "the raw path refuses", which holds for only two of the four. Two more json_script cells join d-typed-key as the same declined refusal direction (#2429), and that test now asserts the direction per cell instead of pinning names alone.

    New cases in TestItWouldHaveCaughtTheHistoricalBlindSpots and TestTheReferenceTableIsRunNotTranscribed. Two follow-ups filed rather than folded in (#1079): the corpus harness cannot hold an unpicklable or non-literal row, which is what keeps a dict_keys and a zero-__len__ class out of it; and Value::Encoded carries no attributes, so {{ dt.year }} renders empty on the backend path where Django resolves it.

  • BREAKING: seven filters now REFUSE a value their Django body cannot iterate, subscript or lowercase (#2451). Five Django built-ins iterate or subscript their value and two call a string method on it, and every one has an except clause that catches nothing relevant — so the operation's exception IS the filter's answer. first/last are value[0] / value[-1] under except IndexError alone; random is random.choice(value), which is value[i]; escapeseq, safeseq and unordered_list are bare comprehensions over value; phone2numeric is "".join(… for c in phone.lower()) and is @keep_lazy_text rather than @stringfilter, so Django never coerces its input. djust failed soft on all seven: {{ p|first }} over an int rendered '' where Django raises TypeError: 'int' object is not subscriptable, {{ p|escapeseq }} rendered 42, and {{ p|phone2numeric }} over None rendered 6663 — the keypad spelling of the word "None", on a page, for a missing value.

    One chokepoint that says WHICH exception, not seven Errs.ValueOpError + value_op_error mirror #2435's IntValueError + int_value_error, because the question is the same one: Django's except clauses catch different subsets, so "did it raise" is not enough. Three thin probes name the operations — python_iter (which wraps the EXISTING iter_values sink so its None can carry the name of the exception Python raises there), python_getitem and python_lower — and all three share one python_type_name. renderer.rs's {% for %} refusal arm (#2382) reads that same answer now instead of carrying its own four-arm copy of it (#1646); test_the_for_refusal_messages_are_unchanged_by_the_unification pins that every message that arm can emit is byte-identical, because the wider answer is unreachable from there.

    d[0] is a KEY lookup, and that is not a detail. Three of the differential corpus's seven dicts carry a 0 key and answer its VALUE; four do not and raise KeyError; none carries -1, so last raises on all seven where first raises on four. A rule saying "a mapping refuses" would have been permissive on three cells and strict on one. ObjectKey already conflates numeric keys the way Python's hash does (#2339), so {True: 'b'}[1] finds 'b' here as it does there. A serialized MODEL is told apart from a genuine dict by the same object_str() marker python_len uses (#2294) and refuses as not-subscriptable, which is what a real model does.

    join keeps the raw sink and random leaves it. Django's join has except TypeError: return value, so it needs iter_values' None rather than a raise — the one filter of the six that must still fail soft. random moved off the iteration sink entirely, because random.choice(seq) is seq[i]: it belongs with first/last. Over a mapping it is genuinely nondeterministic in Python too (draw an index, then look it up), and that is reproduced rather than smoothed over — smoothing it would be a second, quieter divergence. TestRandomOverAMapping pins the two deterministic ends.

    What changes for you. A template applying one of these seven to a value Django cannot iterate or subscript now raises instead of rendering something. These are exactly the templates Django has always refused, so a template that renders under Django's engine is unaffected; the direction that would have broken working templates — refusing where Django renders — is measured at zero. Value::Missing is Django's string_if_invalid and therefore a str, so an ABSENT key still renders nothing rather than 500ing, which is the sharpest way this change could have been wrong. except IndexError: return "" answers the empty STRING and not an absent value: {{ p|dictsort:"k"|first|pprint }} over a non-mapping is '' on Django and was None in the first pass of this fix, which the two-build differential caught as nine regressed cells.

    Three premises in the issue are corrected by running them. The single-filter {{ }} column is 118 cells, not 113: the issue omits random's 17 and includes eleven belonging to other classes. join / slice / default / default_if_none / cf_ident are not this class at all — all ten of those cells are {{ p }} over Decimal("Infinity") or Decimal("NaN") raising TypeError: bad operand type for abs(): 'str' in Django's own numberformat.format, with no filter involved (filed as #2460). And 15 of the 17 surviving cells are get_digit returning a one-character STRING where Django returns an int — a wrong SUBJECT type rather than a wrong consumer (filed as #2459). The issue's explicit exclusion, get_digit over a datetime at the PyO3 extraction boundary, is re-run and still excluded.

    Measured over 353,909 cells, two genuinely different builds (668494847e16b33c4e5cde758a0519f9): the {% widthratio %} bucket goes 1,088 → 12; django REFUSES & djust RENDERS goes 14,058 → 6,187 across every tag shape; djust REFUSES & Django RENDERS is 38,105 → 38,105, so nothing became over-strict; 0 agreeing cells regress, 0 cells newly panic, and the live-payload-leak count is unchanged at 22.

    Corpus: the differential's reachability manifest reported the new message MISSING from the argument axis — correctly, and from the wrong axis. _ARG_ERROR_MARK keeps every literal naming a filter, on the reasoning that "nothing else in these modules does"; the value-side constructors name one too. #2435's int_value_error hid the break because get_digit and divisibleby take an argument, so arg_cells() reaches it by coincidence. #2451's cannot: every filter that raises it takes NO argument. Split into a value-op axis whose required set is read out of the two constructors' bodies and whose swept set is measured over the single-filter corpus.

    New cases in TestTheReferenceTableIsRunNotTranscribed, TestTheDictHalfIsAKeyLookupAndNotAPositionalOne, TestTheIndexErrorArmIsTheOneThingDjangoCatches, TestRandomOverAMapping, TestPhone2numericCallsAStringMethod, TestOneChokepointAnswersWhichExceptionPythonRaises, TestTheUnificationChangedNoForMessage, TestTheResidueThisDoesNotTouch and TestTheCorpusDeclaresItReachesThisErrorClass in python/tests/test_sequence_op_chokepoint_2451.py. The chokepoint's caller SET is pinned and canaried in BOTH directions (#1125/#2233), and the Django-side enumeration CALLS every registered one-argument filter rather than grepping its source — the grep version claimed capfirst/lower/title (all @stringfilter, which coerces first) and missed unordered_list. Eight gate-off mutations, each rebuilding the crate and asserting the .so mtime advanced, redden 8 / 4 / 11 / 5 / 10 / 1 / 7 / 6 tests; no survivors.

  • BREAKING: an unknown filter NAME now refuses the template at PARSE time, as Django refuses it (#2419). Django looks the name up in FilterExpression.__init__filter_func = parser.find_filter(filter_name), at COMPILE time — so a name nothing implements refuses the template whether or not the node ever renders. djust looked it up in filters::apply_filter_full_safe, on the VALUE, which only happens if the node renders. So {% if 0 %}{{ p|nosuchfilter }}{% endif %}, {% if 0 and p|nosuchfilter %}, {% if 1 %}A{% else %}{{ p|nosuchfilter }}{% endif %} and every other unreached position compiled here and refused there — a typo in a branch nobody takes was silent on this engine and loud on Django.

    The blocker #2411 recorded is real and does not hold, and the difference is measurement.#2411 left this class alone because djust's filter registry is filled from PYTHON at runtime, so a parse-time refusal could in principle refuse a project's own @register.filter if the template were parsed first. Four facts, each run rather than reasoned, close it. (1) The registry is complete at the END of django.setup(): DjustConfig.ready() warms the Django→Rust bridge, and has_custom_filter('field_value') is already True before any request — measured in a subprocess by TestTheRegistryIsPopulatedBeforeAnythingCanBeParsed. (2) djust never parses a template outside a render call — Template::new is reached from render, render_with_diff, render_binary_diff and render_template*, all of which are renders — so there is no window in which a user template is parsed with an empty registry. (3) Django's Engine.template_libraries is filled from INSTALLED_APPS at engine construction, WITHOUT {% load %}, so the one bootstrap sweep sees every filter Django itself could ever see; djust's registry is a SUPERSET of Django's per-template view of the names, and a check that refuses only names in neither the built-in table nor the registry can never refuse a template Django compiles. (4) A refusal is not CACHED — TEMPLATE_CACHE and PARSED_TEMPLATE_CACHE are written only after a successful parse — so a filter registered later is picked up on the next render rather than poisoning the process.

    One site, both shapes.#2411's condition for moving this at all was that {{ … }} and the tag operands move TOGETHER, since doing one alone would be new parallel-path drift (#1646). One edit does both: {{ … }} reaches parser::parse_filter_specs through parse_token and every tag operand reaches it through validate_tag_operand, so the lookup went into parse_filter_specs and nowhere else. TestOneSiteClosesBothShapes pins that the call appears exactly once in parser.rs and that both entry points reach it.

    The oracle is the dispatch table, not a copy of it.filters::is_known_filter asks filter_arity::builtin_arity for the built-in half and the custom-filter registry for the other. A second list of the 57 built-in names would be the same drift one layer down, and a SILENT one: an arm present in apply_builtin_filter's match but missing from the list would refuse a filter the engine implements. The two sets are equal today and pinned mechanically — TestTheOracleIsTheDispatchTable extracts the match arms from filters.rs and asserts they are exactly the ARITY table's names, in both directions, and every one of the 57 is separately checked to still compile in an unrendered position.

    What is deliberately NOT refused.{% comment %} and {% verbatim %} bodies are not compiled by Django, so a name inside one is not a name at all; both still compile here. Those two are the control in TestAnUnknownNameRefusesWhereverItAppears — a check that refused them would be stricter than Django rather than equal to it.

    What changes for you. A template naming a filter neither djust nor your project implements now refuses at parse time instead of rendering an empty branch. These are exactly the templates Django has always refused, so a template that compiles under Django's engine is unaffected; to find affected templates before upgrading, compile them with Django's own engine. The message keeps djust's existing Unknown filter: <name> wording rather than Django's Invalid filter: '<name>', because that substring is a published contract (template/rendering.py keys its "not supported by the Rust engine" hint off it) and a second spelling for one condition would be a drift of its own. One ordering is NOT Django's and is recorded rather than hidden: {{ p|nosuchfilter:"a":"b" }} reports the lexer remainder here and Invalid filter there, because split_filter_spec is what produces the name at all and cannot run second. Both engines refuse the template.

    Also fixes the third top-level render entry, which was relying on the startup warm alone: DjustTemplate.render — the plain-Django-view path through DjustTemplateBackend — now arms the filter bridge itself, as _initialize_rust_view already does for the LiveView path. A project setting filter_bridge_warm = False previously had no bridge there at all and its custom filters did not resolve; the same parallel-path shape as #2223, one entry point over.

    Corpus: scripts/filter-parity-differential.py's masked-refusal axis carried nosuchfilter already, but every one of its positions wrote a TAG operand — so the corpus could have reported the tag half of a compile-time refusal closed while the {{ }} half stayed open, and Invalid filter was open on BOTH. Adds three {{ }} positions (dead-branch-var, else-branch-var, block-in-dead-branch-var); a cross of two covered axes is its own axis, which is the lesson that axis exists to carry. Measured over 353,909 cells, two genuinely different builds (879c6fbde55e96cd): 44 masked-refusal cells move out of "djust renders what Django refuses", 0 cells move in, 0 agreeing cells regress, 0 cells newly panic and the live-payload-leak count is unchanged at 22. Every other axis moves 0. Zero nosuchfilter cells still render where Django refuses. Note that the tool's headline agreement count is RAW string equality, so it is structurally blind to a refusal-class fix — both engines raise with different wording — and the moved-cell count is the number to read.

    88 cases in python/tests/test_unknown_filter_parse_time_2419.py; the known-open pins in test_tag_operand_parse_time_2411.py and test_filter_arity_2400.py now assert the refusal, and two fixtures in test_template_edge_cases.py that named invented filters (filter, match) as syntax scaffolding move to real ones. Five gate-off mutations redden 13 / 14 / 5 / 59 / 1 tests; no survivors. One harness bug is recorded rather than papered over: the Python-only mutation first ran against the PREVIOUS row's mutated .so and reported 58 failures for a one-line change that should redden exactly one — the harness now rebuilds after every Rust row, and the number went to 1.

  • BREAKING: a template variable or attribute may no longer begin with an underscore, as Django has always required (#2418). Django's Variable.__init__ refuses a name that begins with _, or that carries ._ anywhere, while the template is being COMPILED. djust implemented that rule nowhere, so {{ _x }}, {{ obj._y }}, {{ p.__class__ }}, {{ p|date:_x }}, {% if _x %}, {% for i in _items %}, {% with v=_x %}, {% firstof _x %}, {% widthratio _x 10 100 %} and {% cycle _x q %} all rendered here and refused there.

    It is a rule about the NAME, not about the value, and that is why #2411's 13,202-template sweep could not see it: the sweep bound no _x, so djust refused those cells for the unrelated "argument does not resolve" reason and they never showed as divergent. With _x BOUND, {{ p|date:_x }}, {% for i in p|date:_x %} and {% with v=p|date:_x %} render here and refuse on Django — three shapes that swallow nothing, which is the measurement that proved this was a SEPARATE defect rather than part of the masking #2411 closed. #2411's own text said a parse-time filter-chain check "subsumes the _-leading-name row"; it does not, because a parse-time check cannot enforce a rule the engine does not have.

    parser::validate_variable_name is that rule, and it is called from the three places djust turns a NAME into a lookup: the {{ … }} head, every filter ARGUMENT in parse_filter_specs, and every TAG OPERAND's head in validate_tag_operand. One function, three callers, pinned as a SET rather than a floor (#1125/#2233).

    Django's ORDER is reproduced, and two of its arms are what keep this from being stricter than Django.Variable.__init__ strips the _( … ) i18n wrapper and exempts a quoted literal BEFORE the underscore check, so {{ p|default:_("_x") }} and {{ p|default:"_x" }} compile on Django and still compile here — a check placed before those arms refuses both, which is the sharpest way this fix could have been wrong. Django's numeric arm is deliberately NOT reproduced: Python rejects a leading _ in a numeric literal (int("_1") raises) and no numeric spelling contains ._, so that arm can never be what saves a name from this rule, and adding it would be a second mechanism with nothing to do (#2233). TestNoNumericSpellingIsRefused checks that against live Python rather than asserting it. Within a chain the head is checked before any filter and an argument's name before that filter's arity, so {{ _x|cut }} reports the underscore and {{ p|upper:"a"|cut:_y }} reports upper's arity — Django's answers, measured off Django.

    A name BINDING is not covered, because Django does not cover it either.{% for _i in items %}X{% endfor %}, {% with _v=q %}X{% endwith %}, {% firstof q 1 as _n %} and {% cycle "a" "b" as _n %} all compile on Django and still compile here: you may bind an underscore name, you may just never read one back. The first version of that measurement was wrong in a way worth recording — every probe referenced the bound name in the body ({% for _i in items %}[{{ _i }}]), so what refused was the {{ }} channel and the binding looked refused when it is not. TestABindingIsNotALookup uses an inert body.

    Four operand-bearing tags #2411's caller set did not name. Grepping the SINK for "what resolves a NAME" — rather than enumerating the tags anyone remembered — turned up {% widthratio %}, {% firstof %}, {% cycle %} and {% include … with k=v %}, none of which called the operand validator at all. They now call the shared validate_tag_operand, which also extends #2411's parse-time filter-chain checks to them; {% firstof p|cut %} refuses at parse time instead of at render.

    What changes for you. A template naming an underscore-leading variable or attribute now refuses at parse time instead of rendering the value (or, for an attribute, rendering empty). These are exactly the templates Django has always refused, so a template that compiles under Django's engine is unaffected. To find affected templates before upgrading, render them under Django's own engine; every new refusal here is one Django already makes. One shipped pattern did change: Component.render injected the key as _component_key and its own docstring told authors to read it as {{ _component_key }} — a template that never compiled on the Django engine _render_template_with_fallback falls back to, so it only ever worked on the Rust path. The same value is now also injected as component_key; the old key is still in the context for any Python-side reader.

    Not a security fix, and worth saying which way. djust's attribute walk already refused a private attribute ({{ obj._y }} rendered empty, {{ p.__class__ }} rendered empty) and _SidecarModelProxy.__getattr__ already refused _-prefixed names outright. What DID resolve was a private dict key{{ d._k }} returned the value — which is the confidentiality reading Django cites for the rule. The parse-time refusal is a second layer in front of the sidecar floor rather than a replacement for it, and TestSidecarSerializationFloor::test_underscore_prefixed_refused now pins BOTH seams: the template does not compile, AND the proxy still refuses the name when reached directly. Pinning only the first would leave the second unreachable from any render and so untested (#2233).

    Corpus: this defect was not constructible in scripts/filter-parity-differential.py. Every head it wrote was p and every one of its argument spellings was a name Django ACCEPTS, so a rule Django applies at three positions went unmeasured while ~345,000 cells reported 0 MISSING on every axis. Adds a variable-name axis — one spelling per arm of Django's ordering (_x, p._priv, "_x", _("_x")) × seven positions — whose required set is read out of parser.rs's validate_variable_name call sites, so a fourth position is reported MISSING until a cell exists for it; three of those spellings also join ARG_SPELLINGS, which crosses the argument position with every argument-taking built-in rather than with default alone. The manifest reported masked-refusal MISSING cycle/firstof/widthratio/include the moment the engine grew those calls, which is exactly what it is for, and the four positions were added in this commit.

    Measured over 353,714 cells, two genuinely different builds: 516 cells move out of "djust renders what Django refuses" and 0 move in, while "djust refuses what Django renders" is unchanged at 38,105 — so it is not stricter than Django anywhere the corpus can see. Of the COMPILE-time refusals djust rendered, 516 of 544 were this rule; the other 28 are Invalid filter (#2419). The issue's own "401 of 495" came from #2411's separate sweep, which is not in the repo and was not reconstructed — the direction it claimed (the largest single remaining bucket) holds here, the exact numbers are this corpus's. The largest remaining TemplateSyntaxError bucket overall is {% widthratio %} coercing a non-numeric first operand to 0 (4,222 cells, RENDER-time on Django) — a separate defect, filed as #2435 rather than fixed here (#1079).

    175 cases in python/tests/test_variable_underscore_rule_2418.py, plus the three shapes kept in TestTheTwoRulesThisDoesNotClose (which #2411 wrote as known-open and which now assert the refusal). Twelve gate-off mutations redden 48 / 3 / 2 / 15 / 15 / 22 / 5 / 6 / 4 / 3 / 1 / 1 tests; no survivors, no two red sets identical, and every mechanism has a test red under it and green under every row that is not upstream of it in the same call chain. Two harness bugs are recorded rather than papered over, because each reported a number that looked like evidence: the Python-only mutation first ran against the PREVIOUS row's mutated .so and reported 15 failures for a one-line change (the harness now rebuilds unconditionally), and the ordering mutation first DELETED the name check instead of moving it — which is another row's mutation under a different label, and the identical red sets said so.

  • BREAKING: a tag operand's filter chain is now compiled at PARSE time, as Django compiles it (#2411). Django runs compile_filter over every {% if %} / {% for %} / {% with %} operand while the template is being COMPILED, so a wrong argument count (#2400), a lexer remainder (#2409) or an unparseable spec refuses the template before any value is resolved. djust reached the chain only at RENDER time, left to right, in renderer::get_value_safe — and {% if %} legitimately absorbs a VariableDoesNotExist. So an EARLIER step that failed to resolve made the condition falsy before the LATER filter's refusal was ever reached: {% if p|cut %} refused on both engines, and {% if p|date:.|cut %} — the same refusal behind one argument Django never resolves — rendered the false branch here and TemplateSyntaxError there. Over-permissive and silent: the developer sees a missing block, not an error.

    Narrowing the swallow is not the fix, and the measurement says why rather than the reasoning. The issue's framing was that evaluate_condition_for_if's catch is applied too widely. Run against Django, the catch turns out to be exactly right and for a second reason: IfNode.render wraps the whole condition.eval(context) — filter ARGUMENTS included, which FilterExpression.resolve does not protect — in except VariableDoesNotExist, so {% if p|date:missingvar %} renders the false branch on BOTH engines. The only reason Django refuses the three-filter spelling is that it never got as far as rendering it. TestDjangoSwallowsResolutionFailuresToo pins that, because it is the premise the whole fix shape rests on.

    parser::validate_tag_operand splits an operand on its unquoted pipes and hands the chain to parse_filter_specs — the SAME validator {{ … }} has always run at parse time, one rule run at two times rather than a second copy of it (#1646). Called from the four tag-operand parse sites: {% if %}, {% elif %} (a second parse site for Node::If, which a fix wired only into the "if" arm would miss), {% for %}'s iterable and each {% with %} assignment. TestTheCallerSetIsPinned pins the SET and not a floor (#1125/#2233), so the next operand-bearing tag that forgets the call fails a test.

    Two shapes have nothing to do with the swallow and are closed by the same move, which is the argument for parse time over a wider render-time walk: a short-circuited operand ({% if 0 and p|cut %}) and a branch that never renders ({% if 0 %}{% for x in p|cut %}{% endfor %}{% endif %}) were both masked, and no render-time fix can reach either.

    What changes for you. A template carrying a filter-chain error in a tag operand now refuses at parse time instead of rendering a falsy branch or an empty loop — the same templates Django has always refused, so a template that compiles under Django's engine is unaffected. To find affected templates before upgrading, render them under Django's own engine; every new refusal here is one Django already makes.

    Measured over a purpose-built 13,202-template sweep (seven argument-taking filters × 37 argument atoms × eight chain tails × four shapes, plus a seeded randomised 3-chain tail): 775 cells move from "djust renders what Django refuses" to agreement, 0 move the other way, and both-render is unchanged at 940 — so it is not stricter than Django anywhere the sweep can see. The arity, remainder and some-characters buckets go to zero; 1,227 of the 1,270 masked cells were {% if %}, which is the issue's claim, confirmed.

    Two rules are deliberately NOT closed (#1079), and TestTheTwoRulesThisDoesNotClose pins each as open with the evidence that it is a SEPARATE defect rather than part of this one. Invalid filter is a RENDER-time lookup on every shape, {{ }} included — {% if 0 %}{{ p|nosuchfilter }}{% endif %} renders here and refuses on Django — so moving it for one shape only would be new drift, and would refuse a custom filter registered after the template was parsed. Variables and attributes may not begin with underscores is Variable.__init__'s rule, which djust has NOWHERE: with _x BOUND in the context, {{ p|date:_x }}, {% for i in p|date:_x %} and {% with v=p|date:_x %} all render here too — shapes that never swallow anything, so it cannot be the {% if %} mask. The issue's claim that a parse-time chain check "subsumes the _-leading-name row" is corrected by that measurement: a parse-time check cannot reach a rule the engine does not have. Filed as #2418 (the underscore rule) and #2419 (Invalid filter's timing, whose difficulty is that djust's filter registry is filled from Python at runtime).

    Corpus: this defect was not constructible in scripts/filter-parity-differential.py. Its arity axis writes {{ p|name:"x"… }} only and its tag axis gives each filter one VALID argument out of FILTER_ARGS, so a cell needing a tag operand AND an argument that does not resolve AND a refusal after it could not be built — every axis reported 0 MISSING over ~345,000 cells while 1,227 {% if %} templates diverged. A cross of two covered axes is its own axis. Adds a masked-refusal axis: six refusal classes (each PROBED against Django rather than declared) × nine positions a refusal can hide in × the payload inputs, with its required set read out of parser.rs's validator call sites so a fifth operand-bearing tag is reported MISSING until a cell exists for it. Empirically canaried: dropping the dead-branch-for position makes the manifest report 1 MISSING.

    New cases in TestDjangoSwallowsResolutionFailuresToo, TestAnUnresolvableArgumentNoLongerMasksTheRefusal, TestShapesNoRenderTimeFixCouldReach, TestItIsNotStricterThanDjango, TestTheCallerSetIsPinned and TestTheTwoRulesThisDoesNotClose (70). Five gate-off mutations redden 11 / 7 / 3 / 3 / 21 tests; no survivors, and the harness asserts the mutation text matched exactly once, that the source changed, that the crate REBUILT, and counts pytest's N error apart from N failed. One mutation was dropped rather than tested around: a first pass carried an IF_OPERATORS set mirroring smartif.OPERATORS, and neutering it changed no behaviour — an operator token carries no unquoted |, so the validator is already a no-op on it. Decorative by the #1859 test, so it is one comment rather than one constant (#2233).

  • BREAKING (security): a custom tag handler's return is now ESCAPED unless it is already HTML (#2379). Django's SimpleNode.render runs conditional_escape over a simple_tag's return unless it carries __html__; renderer.rs's Node::CustomTag arm inserted it VERBATIM. So a handler as ordinary as @register.simple_tag def greet(name): return f"Hello {name}" emitted Hello <img src=x onerror=alert(1)> LIVE where Django renders Hello &lt;img …&gt;. The fail-OPEN half of the asymmetry #2290 found on the way IN, reaching every register_tag_handler / register_block_tag_handler user — djust's own handlers and any project's. The two-build differential closes 15 live-payload cells and introduces none.

    The one-line bridge change is not the work; the audit is. Escaping a return that legitimately IS markup is a rendering regression rather than a fix, so every handler djust registers was enumerated MECHANICALLY — by intercepting the three register_*_tag_handler functions and triggering every registration path — and then CALLED, never read (a mark_safe on line 3 of a body with four returns answers the question for one of them). The issue's own premise did not survive that measurement, in both directions: it names ten modules and reads as about twenty handlers, and says "almost none of them mark_safe". Measured: 221 handlers across thirteen modules, of which 195 already carried __html__, 13 return the empty string and 5 return plain text — leaving 6 that returned markup as a plain str. Those six are call / component (one class), dj_suspense, djust_markdown, slot and toast_container, each marked at its ONE exit rather than at its N returns (#1104): CallTagHandler.render and SuspenseTagHandler.render became thin wrappers over _render_component / _render_state. toast_container is the sharpest of the six — its empty-container early return was the single return out of ~190 in rust_handlers.py that missed the module's own _safe() convention, and nothing could see it until the bridge started escaping.

    The half a return-only fix would have got wrong, found by measuring rather than by inspection. Django's simple_block_tag hands the handler nodelist.render(context) — already-rendered, already-escaped markup, and therefore SafeData. djust passed it across PyO3 as a bare str, so a handler returning its content unchanged lost the marker and the escape applied it a SECOND time: {% cb_ident %}{{ p }}{% endcb_ident %} over a hostile value gave &amp;lt;img …&amp;gt; where Django gives &lt;img …&gt;. The bridge now marks the block body safe on the way in — the block-path twin of #2290's marker loss — and fails SOFT if django.utils.safestring is not importable, so a pure-Rust embedding keeps rendering.

    What changes for you. A handler you registered through register_tag_handler or register_block_tag_handler that returns HTML must now say so: return mark_safe(...), which is what Django requires of a simple_tag too. A handler that returns plain text needs no change and is now correctly escaped. To find affected handlers before upgrading, call each one and check its return: hasattr(handler.render(args, ctx), "__html__"). TestEveryRegisteredHandlerIsAccountedFor does exactly that over djust's own 221, so a handler added later without the marker fails a test rather than rendering as escaped text on someone's page.

    py_value_is_safe_string is #2290's own predicate, EXTRACTED rather than copied (#1646) — one function, three callers, and the security half of it stated once: requiring str subclass-ness and not just __html__, because Value's FromPyObject stringifies an arbitrary object via __str__ and a non-str impostor advertising __html__ would otherwise reach output unescaped. That half is load-bearing on the FILTER path and shadowed on the TAG path (where extract::<String>() refuses a non-str outright), and both proofs are asserted — the gate-off survivor that surfaced the distinction is recorded rather than tested around.

    Two rows this change UNMASKED rather than caused, both djust being STRICTER and both pinned: a mark_safed context value still reaches a handler as a bare str (#2290's argument side) and a quoted literal still keeps its quotes. Both used to agree with Django by coincidence — the marker was lost on the way in and the raw return cancelled it on the way out — and both now over-escape, never leak. unmasked() grew a @ctag arm so the differential can TELL that apart from a real regression, and it needs BOTH conditions (djust's new output IS Django's escaped once more, AND the ct-cond probe over the same input diverges on both builds) so it is a statement about the mechanism rather than an exemption keyed on the input. Three pins in TestKnownDivergencesOnTheCustomTagPath go red as designed and are inverted in place; the class docstring said they would.

    Two-build differential against a baseline pinned at fca704cf, over 345,286 cells: 49 newly agreeing, 0 regressions (1 classified coincidental, mechanically), 15 live-payload leaks CLOSED, 0 introduced (0 escaped / 0 live), 0 panics. New cases in TestAHandlersPlainReturnIsEscaped, TestAMarkedReturnIsStillLive, TestTheBlockBodyIsSafeData, TestTheSharedPredicateRefusesAnImpostor, TestEveryRegisteredHandlerIsAccountedFor, TestKnownDivergencesThisUnmasks and TestTheDifferentialCanTELLThatUnmaskingApart (24). Twelve gate-off mutations redden 10 / 1 / 5 / 8 / 4 / 1 / 1 / 2 / 2 / 2 / 2 / 1 tests; no survivors.

  • BREAKING: {% for %} over a non-iterable is now REFUSED, as Django refuses it (#2382).ForNode.render decides an operand's fate in three steps and not one: if values is None becomes [], then if not hasattr(values, "__len__") runs list(values) — which RAISES for anything that is not iterable — and only then does if len_values < 1 reach the {% empty %} block. djust rendered the empty block for every operand that was not a sequence, collapsing the second step's two answers into the first's, so {% for x in p %}[{{ x }}]{% empty %}E{% endfor %} over True, 42, 1.5 or Decimal("2.5") rendered E where Django raises TypeError. It is not about bools and not about falsiness: None — and an operand that does not resolve, which ignore_failures=True turns into None — reaches the empty branch in Django too, and every other non-iterable raises, so 0, 0.0 and Decimal("0") raise while [], "" and {} do not.

    The decision, and it is not this PR's to make from scratch. The issue lists four options — raise, raise only under DEBUG, warn, or leave it. Three precedents landed in the same week and all three chose Django's answer over silent degradation, in development and in production alike: #2328 (an unparseable or unresolvable filter argument), #2387 ({% for %}'s own unpack arity) and #2400 (a wrong argument count); #2328's maintainer considered a DEBUG-only split explicitly and rejected it, a divergence that exists only in production being a new axis to maintain and the one nobody tests. What djust rendered instead was not "less" — it was the WRONG branch, with no signal anywhere that the operand was a scalar.

    What changes for you. A template whose loop operand can be a scalar rendered its {% empty %} block (or nothing) before and now raises, crossing PyO3 as a RuntimeError carrying CPython's own wording and contained by LiveViewConsumer.receive's error frame. To find affected templates before upgrading, render them under Django's own engine, or grep for loops over a value your view can set to a number or a bool: grep -rnE '\{% *for +[A-Za-z_, ]+ +in +[A-Za-z_][A-Za-z0-9_.]*( *\|[^%]*)? *%\}' templates/ and check each operand's type. Two shapes need no action: an ABSENT operand and a None one still take the empty branch, in their own arm — Value::Missing is Django's ignore_failures answer, and folding it into the raise would 500 every template whose loop operand is simply not in the context.

    The message is CPython's, with Python's type name rather than the Rust variant's: a Value::BigInt is a Python int, and a Decimal is spelled decimal.Decimal because decimal is not a builtin. Four Rust variants reach the arm and two of them spell something their own name does not.

    Four pre-existing pins go red as designed and are inverted in place rather than deletedTestIteratingANonIterableIsNamedNotFixed (#2359, whose own docstring said it would), test_iterating_a_bool_is_a_pre_existing_divergence (#2347, which keeps its BOUND control because that is what shows the answer is not about the literal spelling), and the django-raised residue classifier in both randomised operand sweeps (#2325, #2334). That classifier is deleted rather than kept as a belt: an arm no cell can reach is an exemption the sweep carries silently (#1859), and set(residue) is what makes its absence mechanical. Its replacement is #2387's both-raised predicate, which compares MESSAGES rather than exception classes, so a djust failure that merely coincided with a Django one still gates.

    Corpus: for-bare existed and every scalar INPUTS entry was an int or a float, so the corpus could reach the refusal arm for two of the four Rust variants that land there and for neither shape the issue's own table leads with. Adds b-true, b-false, i-big and dec-plainb-false as well as b-true for the reason ARG_SPELLINGS carries all three builtins, since a corpus with only the truthy one cannot tell a rule about ITERABILITY from a rule about TRUTHINESS, which is exactly the reading this issue had to correct. And a for-operand-outcome axis whose three members (empty-branch, refused, iterated) are PARSED out of Django's own ForNode.render source, raising rather than silently shrinking if Django rewrites it. The axis requires the three OUTCOMES and not the set of Python TYPES that reach the refusal — Django's source names no such set, and that limit is stated in the axis rather than left as a silence.

    Two-build differential against a baseline pinned at fca704cf, over 350,742 cells: 1,811 cells moved, 0 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics — and the zero needs reading exactly as #2387's did. The corpus records a raise as <<EXC {type}: {message}>>, so Django's TypeError and djust's RuntimeError cannot compare equal however faithful the message. Every one of the 1,811 is classified mechanically: 1,141 move from "djust rendered E" to "djust raises Django's message verbatim", and 670 to "djust raises 'X' object is not iterable" where Django raised EARLIER in the chain for its own reason (452 ValueError, 204 TypeError, 8 KeyError, 6 OverflowError — a filter refusing its argument before the loop is reached, the pre-existing #2328 class). 0 moved in the more-permissive direction.

    Not closed, and pinned rather than left silent (#1079): the WIRE RESIDUE. A date, datetime, time, timedelta, set or bare object() has no Value variant and reaches the renderer as its str(), so it is a SEQUENCE by the time Node::For sees it and iterates character by character — {% for x in some_date %} renders [2][0][2][0][-]… where Django raises. No arm here can recover a type the boundary already discarded (#2214 / #2366 family). One shape is now STRICTER than Django and is named rather than left as a surprise: an IntFlag member is iterable in Python 3.11+ and Django iterates it, but it arrives as a plain Value::Integer, so djust cannot iterate it whatever this arm does — it rendered the empty branch before and refuses now, diverging then and diverging now, in the direction to fail in. New cases in TestWhichShapesDjangoRefuses, TestBothEnginesRefuseANonIterable, TestTheAnswersThatMustNotMove, TestTheWireResidueIsNamed, TestTheOneShapeDjustIsNowSTRICTERAbout and TestTheCorpusGapThatHidTheShapesFromTheDifferential (66). Seven gate-off mutations redden 41 / 1 / 3 / 2 / 1 / 1 / 1 tests; no survivors, and the axis-declaration mutation was a survivor until its covering test — the manifest's own axis SET — was added to the target list.

  • BREAKING: a filter given TWO arguments is now refused, and a quoted separator is no longer one (#2409). Django's variable lexer allows a filter at most one argument, and decides that before any filter is looked up: filter_raw_string's argument group is optional and NON-repeating, and FilterExpression.__init__ requires the regex matches to TILE the token, so a second :arg is TemplateSyntaxError: Could not parse the remainder. djust split on the FIRST colon and kept everything after it as one argument, so {{ p|cut:"a":"b" }} handed cut the argument "a":"b" — quotes and all — found no such substring and rendered the page unchanged. A wrong page, silently, from a template Django refuses to compile. This is not #2400's arity check: that reads each filter's own signature, this applies to EVERY filter, which is exactly why upper agreed (two arguments folded into one, then refused as one) while cut, default and truncatewords all diverged.

    The same blindness sat one character over, on the pipe. Django's constant_string admits any character between the quotes, the two separators included, so {{ p|cut:"a|b" }} is one filter with one argument — djust split it into two filters and raised Unknown filter: b" where Django renders normally. Stricter for once, wrong either way; both halves are one quote-aware scan.

    One rule, two call sites.parser::parse_filter_specs (for {{ … }}) and renderer::get_value_safe (for a TAG operand) were independently quote-blind and independently accepted a second argument, so a {{ }}-only fix would have left {% if %}, {% for %} and {% with %} over-permissive — which the measurement across the four shapes shows it does. Both now call the new crate::filter_lexer rather than carrying a copy of the rule (#1646), and the argument's shape is checked against Django's own three alternatives (a quoted constant with backslash escapes, optionally _( … )-wrapped; [\w.]+; [-+.]?\d[\d.e]*) rather than against a "refuse a second colon" heuristic, which would have broken {{ p|date:"H:i" }} and {{ p|cut:":" }}.

    What changes for you. A template carrying a two-argument filter call rendered (wrongly) before and now raises — in development and in production alike, the same posture #2328 took for filter arguments, #2387 for {% for %}'s unpack arity and #2400 for argument counts. It crosses PyO3 as a RuntimeError carrying Django's own wording, and is contained by LiveViewConsumer.receive's error frame. To find affected templates before upgrading, render them under Django's own engine, or grep for a filter call with two argument separators: grep -rnE '\{[{%][^}%]*\|[A-Za-z_]+:[^ |}%]+:' templates/. The reverse direction needs no action: a template using a quoted | or : in a filter argument raised before and now renders.

    The corpus gap that hid it.ARITY_COUNTS was (0, 1) and said so on purpose — "djust cannot SPELL two arguments, so a two-argument cell would be measuring the lexer rather than the arity". Both halves were true and the conclusion was wrong: djust could not spell two arguments because it silently FOLDED them, and measuring the lexer is the point. The bound is now PROBED against Django's own compiler (django_lexer_max_arguments, asserting the refusal's wording so a Django release that moved the boundary for another reason fails rather than silently lowering it), and django_refuses_arity checks it FIRST — reading only the argspec would stop requiring the cell the day Django shipped a two-argument filter, while the lexer would still refuse it. The arity axis grows 48 → 105 required members. A new separator-in-constant axis requires the corpus to carry a quoted argument containing each of Django's two separators, read from django.template.base.FILTER_SEPARATOR / FILTER_ARGUMENT_SEPARATOR rather than written out; before this change the corpus contained no cell where the SPLIT was under test at all.

    Measured over the 92 cells of the four shapes × 23 argument spellings: 12 cells where djust rendered a template Django refuses → 0, and 4 where djust refused or differed on one Django renders → 0. A randomized differential over 12,356 distinct templates assembled from five filters and 37 argument atoms leaves 0 lexer-class cells in the permissive direction for {{ }}, {% for %} and {% with %}, and 0 in the stricter direction that the baseline did not already have (the single remaining stricter row is byte-identical on both builds — an _("x") i18n argument djust renders literally). Two-build differential against a baseline pinned at 0b44d747, over 346,405 cells: 319 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics.

    Left alone and filed rather than fixed (#1079). All 120 remaining rows of that sweep are {% if %}, and they are one mechanism with four Django causes: djust refuses a tag operand at RENDER time, left to right, so an earlier argument that fails to resolve is absorbed by {% if %}'s VariableDoesNotExist arm — the arm that matches Django's ignore_failures — and masks a LATER refusal Django would have raised at COMPILE. It masks _-leading names (56), this fix's own remainder (28), #2400's arity (33) and Django's own if-parser (3) alike; {% if p|cut %} raises correctly, {% if p|date:.|cut %} does not. New cases in TestDjangosLexerBound, TestATwoArgumentCallIsRefused, TestAQuotedSeparatorIsPartOfTheArgument, TestBothSitesRefuse and TestTheCorpusGapThatHidThisFromTheDifferential (58), plus 10 in crates/djust_templates/src/filter_lexer.rs. Seven gate-off mutations redden 4 / 7 / 26 / 1 / 1-cargo / 2 / 2 tests respectively; no survivors.

  • BREAKING: a filter given the wrong ARGUMENT COUNT is now refused, as Django refuses it (#2400). Django validates a filter's argument count in FilterExpression.__init__ — at COMPILE time, before any value is touched — and raises TemplateSyntaxError. djust's dispatch read arg: Option<&str> and silently ignored or defaulted it, so 48 of Django's 57 built-ins rendered a template Django refuses: {{ p|upper:"x" }} was 'ABC' where Django says upper requires 1 arguments, 2 provided, and {{ p|default }} was 'abc' where Django says default requires 2 arguments, 1 provided. Over-permissive on a dimension orthogonal to what any of them computes: a typo in a template was silent here and loud there.

    What changes for you. A template carrying such a call rendered (wrongly) before and now raises — in development and in production alike, the same posture #2328 took for filter arguments and #2387 for {% for %}'s unpack arity. The error crosses PyO3 as a RuntimeError rather than Django's class, carries Django's own wording verbatim, and is contained by LiveViewConsumer.receive's error frame rather than dropping the socket. To find affected templates before upgrading, render them under Django's own engine, or grep for the two shapes: a no-argument call to one of the 20 filters whose argument is required (add, center, cut, default, default_if_none, dictsort, dictsortreversed, divisibleby, get_digit, join, ljust, rjust, slice, stringformat, truncatechars, truncatechars_html, truncatewords, truncatewords_html, urlizetrunc, wordwrap), and an argument passed to one of the 28 that take none. The full djust suite (16,595 tests) and the demo project needed no template edits, which is the scale of the change in practice.

    The issue's own count, corrected by measurement. It says 28 built-ins raise a TemplateSyntaxError on an extra argument; 23 do. The other five — linebreaks, linebreaksbr, linenumbers, unordered_list, urlize — are needs_autoescape=True and args_check reads the RAW argspec, so plen = 2 <= alen = 2 COMPILES and the failure is a render-time TypeError: got multiple values for argument 'autoescape'. That is why the table carries two upper bounds and the fix has two sites: parser::parse_filter_specs takes the COMPILE bound (the only site that can see {% if False %}{{ p|upper:"x" }}{% endif %}, which Django refuses even though the node never renders) and filters::apply_filter_full_safe takes the CALL bound, first, before the argument is resolved — Django's order, so {% if p|upper:missingvar %} is an arity error rather than a VariableDoesNotExist. A single bound would refuse five templates Django compiles. Custom filters are NOT checked (the Rust parser cannot introspect a Python signature); that half is tracked separately. The Rust table is a transcription and TestTheTableIsDjangosOwnArity re-derives all three bounds for all 57 from the live registry, so a Django release that changes a signature fails a test rather than drifting. New cases in python/tests/test_filter_arity_2400.py and crates/djust_templates/src/filter_arity.rs. The differential grew an eleventh axis for this — it reported 0 MISSING on ten axes over ~345,000 cells while this was the largest divergence class in the corpus, because no cell it built could have a wrong argument count. Measured over the new axis: 192 of 192 cells moved, every one from "Django refuses, djust renders" to "both refuse"; 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics, and no refusal carries the input in its message.

  • scripts/filter-parity-differential.py grows a dict-view path axis, a sequence-comparison axis, and a dict with hostile keys (#2334, #2335). The tool reported clean over the whole of both bugs, for the third time in the same shape (after #2281 and #2325): its tag axis writes p|<filter> as every operand, so the corpus contained no dotted path and nothing that iterated a dict without a filter in the way, and its {% if %} cells bind only p, so it could not construct a comparison at all. A corpus gap is silent by construction, so TestTheCorpusGapsThatHidTheseFromTheDifferential pins all three additions. The existing coincidental-agreement and escaped-text classifiers are untouched and both still fire.

  • scripts/filter-parity-differential.py gained a tag-operand axis, and two classifications that widening it exposed (#2325). Every cell the tool built was a {{ p|… }} chain, so a filter on a tag operand — a different resolution path — was structurally invisible to it, which is why #2325 shipped unmeasured. The same corpus-gap shape once let the tool report clean over a live XSS (#2281). It now sweeps every registry filter and the hot 2-chains across {% for x in p|… %}, {% with q=p|… %} and {% if p|… %}, with tag cells carrying a third \t-separated id field so {{ }} ids stay byte-identical and an older baseline file remains comparable. Widening also surfaced two ways the report misled: a tag cell whose own {{ }} twin diverges on both builds agreed on the baseline only by coincidence — the operand bug rendered nothing and Django rendered nothing for its own reason — so it is now reported as coincidental rather than as a regression (this accounted for 445 of #2325's 445 reported regressions, and calling them regressions would have taught the next reader to ignore the number); and live() substring-matches, so a fragment such as onerror= also matches inside fully-escaped text, which is now split out from genuinely-live output. Both halves are printed in full and only the live half gates the exit.

  • {{ }} now renders values the way Django does — True, None, [1, 2], {'a': 1}, (1, 2) (#2203).impl Display for Value diverged from Django for 5 of the 7 Value variants, and not only cosmetically: Display is the lookup key for {% if x in dict %}. All 19 bare-variable types now render byte-identically to Django. Gated on LIVEVIEW_CONFIG['django_value_repr'], default ON; set it False to restore the previous rendering verbatim. Do that if a template interpolates a bool straight into a script block — var f = {{ flag }}; was valid JS and becomes a ReferenceError under Django semantics. Django has the identical hazard, and its answers (|yesno:"true,false", {{ data|json_script:"id" }}) work here too. Three structural changes were needed, each carrying a trap worth recording. (1) Value::Null split into Missing and None. Django distinguishes an absent variable ("", its string_if_invalid) from a present None ("None"); djust collapsed both. resolve(..)?.unwrap_or(Value::Null) folded missing into it — and so did CallOutcome::Empty, which is an alters_data refusal or a serialization-floor denial. Mapping the old Null to "None" without splitting would have made every missing variable render the literal text None, and put text where a refused password field rendered nothing. Both variants stay falsy and both still satisfy is None, verified against Django for present-None, absent, 0 and "x". (2) Object is now an IndexMap. Rust randomises HashMap iteration per process — measured across runs as ["epsilon","beta",…], ["gamma","alpha",…], ["delta","epsilon",…] — so dict repr would have been non-deterministic, the same template rendering differently between requests. The knock-on the compiler could not catch: loop_cache hashed dict keys sorted, for an order-independent hash that was correct only while order could not affect output. Two dicts with the same pairs in different orders would now hash alike and render differently — a stale cache hit serving wrong output. Hashing is insertion-ordered, with distinct tags for None and Tuple. (3) A Tuple variant so (1, 2) is distinguishable from [1, 2] — and this is where the real risk was. About 20 Value::List matches have a _ fallback, so the compiler cannot flag a missing twin: tuples silently vanished from {% for %}, |length, |join and |first. That was caught by a security test whose loop over a tuple of models stopped running at all. All 18 accessor arms now match List(x) | Tuple(x), while Display, value_to_json, pprint and hash_value deliberately still distinguish them — clippy's unreachable_patterns caught a blanket sweep that had made pprint's tuple arm dead code. Also fixed in passing: a dict with non-string keys used to degrade gracefully (the old extract::<HashMap<..>>() simply failed and fell through), and hand-iteration turned that into a hard TypeError; it falls through again and now renders {1: 'x'} / {True: 1}, matching Django. 13 cases in the new crates/djust_core/tests/test_display_django_parity_2203.rs, plus 4 in crates/djust_templates/tests/test_loop_cache_value_keys_2203.rs pinning the cache-key mechanisms, gate-off verified on all five mechanisms — including the flag gate itself, which a first pass left untested because every case ran the default-ON path.

  • CI runs the Python suite in one pytest invocation instead of two — 136s → 109s at 4 workers. With rust-tests fixed, python-tests became the critical path (339s), and it invoked pytest twice: tests/ python/tests/, then python/djust/tests/. Splitting them costs wall-clock for no benefit — each invocation pays its own startup and collection, and each drains its own xdist worker pool, so end-of-run stragglers hold one worker while the other three idle. Measured at -n 4 (what the runner provides): 93s + 43s = 136s split, 109s merged. Collection parity was checked before merging rather than assumed — 5085 + 5404 = 10489 either way. The split was not deliberate isolation.python/djust/tests/ was historically absent from CI (#2032): explicit paths override pyproject.toml's testpaths, so listing two roots silently drops the third, with no error and a green run — and a RED TestSetattrChokepoint CWE-915 guard sat undetected on main because of it. It was restored as a separate soak step, then promoted to blocking (#2034). Merging preserves that gate exactly: same invocation, same AND-condition. But merging is also precisely the edit that could re-drop a root, since the paths become one list someone might tidy — so 5 cases in the new tests/test_ci_python_test_roots.py pin all three roots against the CI invocation itself, not against pytest's discovery (a test checking testpaths would pass while CI ran a subset, which is the original bug). Gate-off verified: dropping python/djust/tests/, splitting back into two invocations, and removing -n auto each redden a test.

  • CI drops LTO for the Rust test build, cutting rust-tests from 407s to 187s. The Tests run is effectively one job — jobs run in parallel, and rust-tests was 407s of a 420s total. Splitting its log by timestamp, that job was 234s compiling against 73s running, and the cargo cache hits (4s restore), so it was never a cold-build problem. [profile.release] carries lto = true + codegen-units = 1 — right for a wheel compiled once that users never pay to build, expensive for a binary CI discards. Measured locally: full release 62s compile / 14s run; debug 31s / 186s; release without LTO and with parallel codegen 26s / 10s. Debug is the obvious idea and is wrong — these tests are compute-heavy (VDOM diffing, template rendering, html5ever parsing), so a debug build runs them 13× slower and loses ~3× overall despite compiling quicker. opt-level = 3 buys the speed; LTO and codegen-units = 1 buy almost nothing at run time and cost 2.4× at compile. Applied through per-job CARGO_PROFILE_* env vars rather than by editing Cargo.toml, so the profile the published wheel is built from is untouched and bit-identical. ([profile.bench] was tried first and does nothing — cargo test --release reads [profile.release] directly.) Scoped to rust-tests alone, because the measurement said so. It was applied to python-tests too and reverted: that job's build dropped 118s → 89s but its tests rose 104 → 112s and 72 → 91s, taking the job 347s → 355s. The difference is the ratio — rust-tests is compile-dominated, while python-tests is test-dominated (118s building / 176s testing) and its tests execute through the Rust extension, so a no-LTO build slows every one of them by more than the cheaper build saves. Same trade as debug-vs-release, milder. python-free-threaded showed no signal (86s → 85s). Not applied to benchmarks, which enforces latency thresholds and must measure the binary users receive — the dangerous case, since a no-LTO benchmark build shifts every threshold while the suite keeps passing. Comments say so, but a comment is not a guard (#1859), so 7 cases in the new tests/test_ci_cargo_profile_overrides.py make it mechanical: rust-tests must carry the override, the test-dominated jobs must not, benchmarks must not, no workflow-level env: may leak it to every job, and Cargo.toml must still ship lto = true. Each gate-off verified against a distinct mutation. Verified on CI: rust-tests407s → 187s, benchmarks unchanged at 194s.

  • The pre-push suite runs in parallel — 330s → 86s on every push (#2187-adjacent). The hook ran the full suite serially, and the gap is wider than the core count explains: user+sys total only ~210s of that 330s, so roughly 120 seconds was spent blocked rather than computing — most of it the eleven tests in python/tests/test_deploy_cli.py, each standing up a real loopback HTTPServer for the OAuth callback flow. It was serial only by inheritance. Its documented reason — being the only place enforcing benchmark latency thresholds — was deliberately removed in #2156, which called serial "the worst possible place" for them, since a warm, fragmented heap after 10,000 tests makes the median systematically slower. Nothing replaced it: no test declares xdist_group or a serial marker, the FAILED-id parsing is unaffected by sharding, and test.yml already runs -n auto over these same paths. What serial still provided was undocumented, and is preserved.pytest-randomly is not installed, so a serial run executes in deterministic definition order — a different ordering from xdist's sharding, and order-dependent bugs hide under one while surfacing under the other (#2187 is an open instance). main-health already runs this suite serially every day, so definition order still runs daily, just off the push path — the better home for it, because an ordering flake is a property of main rather than of the branch being pushed, so blocking a push on one tells the pusher nothing actionable. xdist is probed, not assumed: passing -n auto to a pytest without it does not degrade, it aborts with unrecognized arguments: -n, so the suite never runs and the pusher gets an argparse usage dump from the one script whose job is making a blocked push legible. That was found empirically rather than reasoned about — all 22 cases of test_red_main_attribution_behaviour_2139.py, which drives this script against a synthetic repo under a minimal interpreter, failed exactly that way when the flag was added unconditionally. Both files carry a comment naming the dependency between them, but a comment is not a guard (#1859), so 4 cases in the new tests/test_suite_ordering_coverage_2187.py make it mechanical — the suite must be exercised in both orderings across the two runners, and dropping the parallelism, dropping the xdist probe, or parallelising main-health each redden a different, specific test.

Fixed

  • Two opaque Value::Encoded values compare by Python's CONTRACT, so {% if p == q %} on two set()s answers Y (#2480).opaque_value set cmp_key: None, so Encoded::python_partial_cmp answered None for every pair either side of which came from that arm — never equal, never ordered:

                         p, q                          django    djust
    {% if p == q %}      the SAME set()                Y         N
    {% if p == q %}      two equal {'a'}s              Y         N
    {% if p <= q %}      the SAME set()                Y         N
    {% if p == q %}      the SAME complex(0)           Y         N
    {% if p == q %}      complex(0) and 0              Y         N
    

    Eight shapes, and "widened from four to eight" is the honest count. Before #2476 a set() had no variant and landed on the terminal Ok(Value::String(ob.str()?)), where two of them compared equal by TEXT through the (String, String) arm — Django's answer, reached by the same accident that made {{ p|length }} count the characters of a repr, so the accident and the defect could not be separated. #2476 moved the FALSY half onto the carrier (set(), frozenset(), {}.keys(), complex(0)) and #2477/#2489 moved the TRUTHY half ({'a'}, frozenset({'a'}), {'a': 1}.keys(), complex(1)), pinning the cost in TestTheComparisonAxisThisWIDENS in the diverging direction. This closes all eight and FLIPS that class rather than deleting it, so the widening it recorded stays legible.

    No carried field decides it, in either direction — and that is a measurement, not an argument.set() == frozenset() == {}.keys() == {}.items() is True acrosstype_names; LenZero() == LenZero() on two distinct instances is False within one; and set() == {}.keys() is True while set() == {}.values() is False, even though both views carry the same (empty) items. So neither the type name nor the items nor any carried spelling separates them. A NAME LIST — {set, frozenset, dict_keys, dict_items} — is wrong in both directions: it misses every collections.abc.Set registration a user writes, and it claims any user class merely namedset, because type(o).__name__ is unqualified. Both halves are run in test_a_name_list_would_have_been_wrong_in_both_directions.

    So a fourth fact is MEASURED at the conversion — Encoded::eq_class, the PROTOCOL Python itself dispatches on, with four arms each justified by a contract:

    armmeasuredequalityordering
    EqClass::Setisinstance(o, collections.abc.Set)the carried items, both containmentsa real SUBSET partial order
    EqClass::Numberisinstance(o, numbers.Number), as complex(o)the two componentsnone
    EqClass::Identitydefault __eq__and default __repr__the repr tokennone
    Noneeverything elsenever equalnever ordered

    Arm 1 is the one that answers the hard direction: the ABC defines__eq__ as len(self) == len(other) and self <= other and __le__ as containment, so set() == frozenset() == {}.keys() == {}.items() falls out ACROSS type names and set() != {}.values() falls out for free — a dict_values is not a Set. Arm 3 is a restoration rather than a new hazard: before #2476 a LenZero() crossed as Value::String("<LenZero object at 0x…>") and compared by exactly that string, so the address-reuse caveat is the one it already had — and within a single render it cannot bite, because every context object is alive at once and their addresses are therefore distinct.

    The ordering trap, which is why this is not a one-line cmp_key. Python's two operators come apart INSIDE this family: set() <= set() is Y (subset order) while complex(0) <= complex(0) is N (< RAISES, and Django's smart_if swallows it to False). An implementation that reaches equality by handing these values a comparison key gets the first right and flips the second from N to Y — eight cells bought, a new divergence sold. So renderer::encoded_partial_cmp is the ONE wrapper all three comparison sinks read (values_equal via encoded_equal, try_compare, dictsort's compare_sort_values); it carries the Set order and answers None for the two equality-only classes, and Encoded::python_partial_cmp keeps the datetime family unchanged with exactly one caller. The Set order lives in an Option<Ordering> because Python's is partial — {1} and {2} are incomparable, and None is already rendered as "false for all four operators", which is Django's answer.

    Cross-carrier, closed too.complex(0) == 0 is True in Python and Y in Django, and no (Encoded, Encoded) arm reaches it. The new (Encoded, Integer) / (Encoded, Float) arms compare in the INTEGER domain because Python's comparison is exact: complex(2**53) == 2**53 + 1 is False even though the float cast rounds, and complex(1e300) == 2**63 - 1 is False even though as i64 saturates onto that bound. Both guards are load-bearing and each has its own case.

    Four things are DECLINED, and each is pinned in the DIVERGING direction so widening one is a decision. A class overriding __eq__ — only Python can run it, and its answer is arbitrary. A class with default __eq__ and a custom __repr__: a dict_values is the builtin case, and two DISTINCT empty ones share the spelling dict_values([]), so using the token would call them equal where Python says they are not — a NEW wrong answer rather than an unfixed cell. A Decimal or a big int against a complex, both exact types an f64 cannot answer. And two sets past SET_COMPARE_CAP (1,000 items a side): containment without a hash is quadratic and a set states its own length, so opaque_value enumerates it in full — past the cap the answer is the pre-fix one rather than a render that does 10^10 comparisons.

    Wire. Slot 11, appended for the sixth time and for the sixth identical reason: the class is measured from a live Python object that no longer exists when a state entry comes back, so an entry that dropped it would answer {% if a == b %} with the pre-fix rule after one cache hit — the reopening ENCODED_TAG exists to prevent. It is a MAP, never nil — an absent class is the EMPTY map — and that is the one structural decision here rather than a preference: eleven used to be a width no build wrote, so an_interior_insert_is_refused_rather_than_silently_misread could rely on WIDTH to refuse a ten-element payload with one element inserted; now that eleven is real, only a TYPE can, and every such insert pushes the ITEMS (a list or nil) or the intruder itself into this position, never a map. Writing nil for the absent case would have surrendered that — and the EXISTING canary could not say so, which is the sharper half of this. an_interior_insert_is_refused_rather_than_silently_misread inserts into the CURRENT payload, so it produces TWELVE elements, a width no arm matches however slot 10 is typed: it is answered by width and would stay green under the very mutation it looks like it guards. A gate-off found that (the mutation SURVIVED), so a second canary was added that inserts into a ten-element payload — the shape real state entries carry — and requires all ten refused, for both item shapes. Under a mutation that drops the Value::Object pattern, an insert at slot 6 decodes as an Encoded with repr: "intruder" and the last three slots silently emptied, and the new canary is the only test that reddens. Every narrower width (10 / 9 / 8 / 6 / 4 / 3) restores eq_class: None, which is the answer that entry was written with; each keeps its existing fail-to-absent read for every slot below 11. Growing the attrs map instead was rejected: that map is what context::lookup_segment resolves {{ p.x }} against, so a synthetic key there would be a template-visible attribute Django does not have.

    Corpus movement, two builds of scripts/filter-parity-differential.py over 380,484 cellsorigin/main at e5d499a0 against this branch, --compared, and the build hashes confirm they are genuinely two builds:

    agree BEFORE : 277729   (refusal-collapsed: 324951)
    agree AFTER  : 277777   (refusal-collapsed: 324999)
    django REFUSES & djust RENDERS: 4722 -> 4722   (+0)
    djust REFUSES & Django RENDERS: 39253 -> 39253   (+0)
    cmp             48 moved  of  19663      (every other axis: 0 moved)
    newly AGREEING: 48   no longer agreeing: 0   REGRESSIONS: 0
    panics 0 -> 0     live-payload leaks 60 -> 60 (0 introduced)
    

    The raw headline is blind to refusal-class movement, so both refusal columns are reported: neither grew by a single cell. Of the 19,663 @cmp cells the corpus reaches, 48 disagreed with Django before and 0 disagree after>= 12, <= 12, == 8, != 8, > 4, < 4 — which includes the 10 the #2477/#2489 compare counted as regressions.

    And the full cross-product reproduction, every shape against every shape over all six operators on both djust paths, with Django CALLED as the oracle: same-object divergences 48 → 8, two distinct instances 38 → 2, cross-shape 196 → 60, cross-carrier 9 → 6. Every survivor involves one of the declined classes and nothing else — the 60 cross-shape cells are all the custom-__eq__ class against something.

    Regression coverage: 28 cases in python/tests/test_opaque_equality_2480.py (the cross-product sweep, the protocol facts asserted in both directions, the ordering trap on both halves, the cross-carrier boundaries, the state round trip and the chokepoint pins); new cases in test_encoded_wire_positions_2471_2472.rs (including an_insert_into_the_ten_slot_payload_is_refused_by_the_last_slots_type, the one a gate-off proved was missing); and TestTheComparisonAxisThisWIDENS (now …WIDENED), TestAFalsyOpaqueEncodedIsNotComparable and the @cmp corpus row in test_lazy_corpus_rows_2482.py are FLIPPED rather than deleted, so the same rows that measured the gap now measure its closure.

  • A Python collection reaches the renderer as its ITEMS, not as its repr (#2477, #2489).impl FromPyObject for Value's fallback block ends in Ok(Value::String(ob.str()?)), so an object no variant models arrived as a plain string — and every consumer that iterates, sizes, subscripts or slices then read the repr, one character at a time, while Django read the object:

                         value                django                       djust
    {{ p|length }}       {}.keys()            0                            13
    {{ p|length }}       a falsy __iter__     0                            15
    {% for x in p %}     {"k": 1}.keys()      [k]                          43 cells, one per repr char
    {{ p|escapeseq }}    a falsy __iter__     ['&lt;img …']                ['F', 'a', 'l', 's', …]
    {{ p|first }}        {}.keys()            <<TypeError>>                d
    {{ p|phone2numeric }} {"…": 1}.keys()     <<AttributeError>>           dials the repr
    

    {{ p|length }} being 15 where Django says 0 is the sharpest one: it is silently wrong rather than visibly broken, and it is the kind of value a template branches on. Not a leak — of 3,562 payload-carrying cells scanned when the corpus rows were added, 0 gained a live fragment.

    The truthiness split was never a property of the class.#2466 closed the FALSY-and-empty half by carrying bool(o) on a Value::Encoded, and declined the rest with a reason that was correct for the carrier as it stood: "this carrier cannot produce those items without RUNNING the object". falsy_opaque becomes opaque_value and the gate stops asking about the sign of bool(o) — a {'a'} is the same kind of object as a set(), and was declined only because the carrier had no way to say what it contains. Encoded gains items (list(o), enumerated at the conversion) and its sized_empty: bool widens to len: Option<usize>: Django's |length reads __len__ under except TypeError: return 0 while its iterating filters are comprehensions that call iter(), so a falsy __iter__ class with no __len__ answers 0 and one item at the same time, which a single bit cannot carry.

    Why Encoded and not a new variant. A collection needs seven facts this struct already measures and none of them is derivable from a list of items: {{ p }} renders {'a'} for a set and ['a'] for a list, so the container spelling must be display; {{ p|first }} RAISES for a set, so the refusal needs type_name to name 'set'; {% if p %} is False for a __bool__-False collection with two items; {{ p|pprint }} wants repr and {{ p.a }} wants attrs. Value::DictView — the one existing variant with a collection's shape — is documented as built ONLY by Context::dict_view during a render, has no wire format, and derives its truthiness from !items.is_empty(), which is false for two of the objects this carries. Splitting the class by emptiness so set() took one carrier and {'a'} another is the drift shape (#1646) rather than a design.

    Three shapes are DECLINED, and a decline is an unfixed cell rather than a regression — each keeps the string path it already had. A one-shot iterator (iter(o) is o — a generator, a zip, a map): reading it consumes the caller's object, so the template would iterate items the view can never see again. This is #2466's own decline, and it stands as the ONLY reason iteration is refused; test_a_one_shot_iterator_is_not_consumed_by_the_conversion is the assertion that justifies it rather than merely restating it. An unsized iterable past OPAQUE_ITEM_CAP — a class whose __iter__ returns itertools.count() is re-iterable, so the one-shot guard does not catch it and enumerating it would hang the render; declined at the cap rather than truncated, because a short collection is a silently wrong answer. And a TRUTHY, NON-iterable object with public attributes — the __dict__ bulk-dump arm's cell, left where it is, because retiring that arm is a much larger decision. Both qualifiers are load-bearing: a FALSY such object is claimed (#2478), and an ITERABLE one is claimed too, since an object with __iter__ is not a mapping of its attributes and Encoded::attrs keeps {{ obj.a }} resolving either way.

    normalize_django_value stopped flattening the same class, which is the #2477 half and is needed for either fix to be visible on a page. A set became a sorted list there — subscriptable, where a set is not — so {{ tags|first }} rendered an element on the LiveView path while the raw path refused; everything else took its str(), so an empty dict_keys was T for {% if p %} on one path and F on the other. Both paths now answer Django. The gate is _rust.crosses_as_encoded, and it took three shapes to get right — each mistake found by running it rather than by reading it. The FIRST transcribed the fallback block's last two arms, which answers "would the fallback claim this if it got there": a bytes and a collections.deque satisfy opaque_value's gate in isolation but are claimed by PyO3's SEQUENCE extraction long before it, so the transcription said TRUE, the normalizer stopped stringifying them, and {{ p }} over b"ab" went from Django's b'ab' to [97, 98] — six regressed cells across two types, caught by the before/after sweep. The SECOND ran the REAL conversion and matched on the result, which is exact and segfaulted: the normalizer's fallback is precisely where an ordinary "presenter" object lands, and converting one eagerly walks its __dict__ into a raw QuerySet and Manager and down through theirs, deep enough to overflow the stack — work the render path never does, because it resolves through the protected walk one segment at a time. What stands is neither: opaque_value's gate is split out as opaque_gate, which measures bool(o), iter(o), len(o) and the __dict__ KEYS and converts nothing, with exactly two consumers — the payload build and the predicate — and the arms above the fallback are probed SHALLOWLY (Vec<Bound<PyAny>> collects references). A cheap probe that restates a gate needs a differential against the thing it stands in for, so crosses_as_encoded_by_conversion is exported alongside it and swept against it over every shape; that sweep immediately found the probe answering true for None, True, 7, 1.5, "ab" and a Decimal — six shapes its one caller can never send it, and a predicate whose correctness depends on which caller it has is one the next caller breaks. The predicate is consulted AFTER #292's warning and its strict_serialization raise, deliberately — that signal is about LiveView STATE, whose paths pass state_roundtrip=True and never reach the line, so it is unchanged in volume and wording. What DOES change is the value that branch returns, which is why #2488's two tests move with it: their subject is that naming a type with no __module__ does not crash, and that is now asserted on the WARNING they were already reading rather than on the return type they happened to check.

    Corpus movement, two builds of scripts/filter-parity-differential.py over 380,484 cellsorigin/main against this branch, --compared:

    agree BEFORE : 277323   (refusal-collapsed: 324275)
    agree AFTER  : 277729   (refusal-collapsed: 324951)
    django REFUSES & djust RENDERS: 4992 -> 4722   (+270; djust more permissive)
    djust REFUSES & Django RENDERS: 39253 -> 39253   (+0; djust stricter)
    live-payload leaks: 65 -> 60   (5 closed, 0 INTRODUCED, 0 live)
    REGRESSIONS : 12
    

    The raw headline is blind to refusal-class movement, so both are reported: the permissive column SHRANK by 270 and the strict column did not grow by a single cell. Twelve cells moved the wrong way and all twelve are named — ten are the @cmp widening below, and two are {{ p|timesince:obj }} / {{ p|timeuntil:obj }}, where Django raises AttributeError on the operand and djust rendered a duration before this fix and renders a different one after: a cell inside the column that shrank, diverging before and after.

    Three of those regressions were found this way and fixed rather than filed, and each was a case where the OLD answer was right by accident. {% regroup tags by k %} over a set built ZERO groups where Django builds one — the operand reaches the handler through value_channel_arg_string, and an Encoded fell to the general encoder's _ arm, so the handler got the text {'a'}, which is neither JSON nor a variable name. Before the carrier existed a set crossed as a Value::String, which that channel JSON-quotes, so the handler decoded a string and iterated its CHARACTERS. The arm belongs in the VALUE channel and not the general one, and putting it in the general one first is how the split was found: a custom tag RENDERS its argument, where Django shows {'a'} and the items show ["a"] — 21 @ctag cells the wrong way. {% if tag in tags %} answered N, and it had worked by a worse accident: in over a Value::String is a SUBSTRING match, so 'a' in {'ab'} was true and so was any character of the repr's punctuation. And into_pyobject goes BACK to the display — handing the items back looked conservative, but measured against main the premise was false (a truthy set was declined by the pre-#2477 gate and already came back as str(o)), and what the items DID change was 20 custom-filter cells.

    And the focused sweep, 25 shapes × 16 consumers (400 cells, three columns): raw-path divergences 192 → 44, LiveView 266 → 67, 0 cells regressed in either column. Restricted to the 19 shapes the carrier claims it is raw 153 → 5 and LiveView 204 → 5, with the two paths AGREEING for every one of them. The five survivors are refuse-vs-refuse with a different exception CLASS. Everything else remaining is pre-existing and untouched: range / bytes / deque / a __getitem__ sequence are claimed by an earlier arm, a Counter is a dict, a generator is the decline.

    What it costs, measured and pinned (#2480). An Encoded from this arm carries no comparison key, so python_partial_cmp answers None for every pair either side of which came from it: never equal, never ordered. {% if p == q %} over two equal sets is N where Django says Y. #2466 already did that to the falsy half — set(), complex(0), an empty dict_keys — and filed #2480; this widens it from four shapes to eight. As Value::String these compared by TEXT and got the right answer for the wrong reason, which is the same mechanism that made {{ p|length }} count the characters of a repr, so the accident and the defect cannot be separated. None of the sixteen consumers in the sweep is a comparison, and this was found by a pin in test_encoded_value_position_2471_2472_2473.py going red rather than by the sweep — a curated table samples one axis and blinds you on the next. It has its own class now, TestTheComparisonAxisThisWIDENS, with the count and both halves named, pinned in the DIVERGING direction so closing #2480 reddens it. The same move made |pprint over an aware time BETTER: it gives the constructor form Django spells, where a Value::String gave the quoted display.

    Wire. The ENCODED_TAG payload grows to a tenth positional slot: slot 5 widens from #2466's sized_empty boolean to len(o) itself (a bit cannot say Some(3)) and slot 10 is appended for the items. Slot 5 is the one slot whose TYPE changes, and it is safe for the reason the #1541 canon is actually about — that canon forbids a conditionally-skipped field, which shifts later slots WITHIN one width, while this payload is dispatched on width and no build ever wrote a 10-element one. Carrying both is what makes the fix survive a cache hit: without them a {'a'} in state comes back unable to answer {% for %}, |join or |length after one msgpack round trip, the reopening ENCODED_TAG has now prevented five times. The 9 / 8 / 6 widths restore len from the boolean they carry, which is EXACT rather than approximate — the pre-fix gate declined every object whose len was not 0. into_pyobject is UNCHANGED and returns the display, which is #2458's filed decision — widening it cost 20 custom-filter cells and its premise (that a set in state used to come back a list) was false against main.

    It lands beside #2485, and the two compose because they are widenings of different KINDS.#2485 grew the attribute MAP at slot 9 and added no position; this widens slot 5 and appends slot 10. Ten, not eleven — and the arithmetic is the weakest part of that sentence, so it is not what the pins check. crates/djust_core/tests/test_encoded_wire_positions_2471_2472.rs gains two: an_interior_insert_is_refused_rather_than_silently_misread inserts a plausible STRING at every one of the ten interior positions and requires each to be refused as a plain dict, because an insert shifts UP where the existing remove-and-swap canary shifts DOWN — had #2485 taken a position of its own, the width would still have looked plausible while attrs decoded as repr and items as cmp_key; and the_slot_that_grew_inside_itself_did_not_take_a_position grows the map by four names and re-reads the width and the trailing slot, so "the slot is unchanged" is a run rather than a sentence nobody re-checks. The merged payload was also read off a real serialize_msgpack for six shapes rather than inferred from either description.

    Pins deleted on their own stated terms: NORMALIZER_FLATTENED (#2477) and STRINGIFIED_AT_CONVERSION (#2482) in test_sequence_op_chokepoint_2451.py, thirteen cells between them, all now agreeing — so that sweep subtracts NOTHING, which is a stronger statement than any exemption list. #2382's RESIDUE / ITERABLE_RESIDUE and #2366's STRINGIFIED_AT_EXTRACTION empty the same way, each keeping a PARITY row for every name that left plus a test that the list is empty AND its rows arrived somewhere — an emptied residue whose rows went nowhere is a pin quietly narrowed. The decline pins in #2466 and the LenTwoBoolFalseWithAttrs row in #2478 are kept with their assertions INVERTED rather than deleted, because each names the exact cell the decline cost.

    value-truthiness loses one exemption and gains one.("opaque_value", "truthy") was exempt with the stated reason "the arm opens with if ob.is_truthy().ok()? { return None }" — true of the code, and the defect; set-plain and dv-keys-plain inhabit the slot now. str-fallback becomes exempt on both answers, and the reason is checked: every shape still reaching the terminal str() is one a corpus row cannot BE — a one-shot iterator is consumed by its first cell, an unbounded re-iterable costs the cap on every one of ~350,000 conversions, and a raising __bool__/__repr__ breaks the harness's own printing. #2482's canary loses its subject and says so; #2477's own canary shrinks from four members to two, because a row #2482 added moved onto the arm when the gate widened — which is the hazard that canary's own note already records, one issue further on.

    Regression coverage: 40 cases in python/tests/test_opaque_collections_2477_2489.py (18 shapes × 16 consumers, three columns — Django, the raw entry point, the LiveView path — with a decision per member asserted in both directions); new cases in test_encoded_wire_positions_2471_2472.rs, test_falsy_conversion_2466.py, test_for_non_iterable_2382.py and test_int_argument_type_2366.py.

  • {{ dt.isoformat }} renders — a Value::Encoded carries the auto-called half of Django's lookup (#2485). Django's Variable._resolve_lookup AUTO-CALLS a callable attribute (ADR-024), so {{ p.isoformat }} is an EVALUATION where {{ p.year }} is a lookup. #2481 gave Value::Encoded a map of the lookup half and left the call half open:

    {{ p.isoformat }}      datetime    django '2026-03-04T05:06:07.000008'   djust ''
    {{ p.total_seconds }}  timedelta   django '259290.000005'                djust ''
    {{ p.utcoffset }}      aware dt    django '0:00:00'                      djust ''
    

    <time datetime="{{ obj.created.isoformat }}"> is an ordinary Django idiom and it rendered nothing.

    A SECOND table (ENCODED_CALL_NAMES) read by a SECOND producer (collect_called_attrs), writing into the SAME map — so context::lookup_segment stays the ONE reader of Encoded::attrs. A second resolution path for "the names a dotted lookup reaches" is the #1646 shape this map exists to avoid; a second table is right, because the auto-call is a different mechanism from a getattr and its membership rule is a different rule.

    The membership rule is a measurement, and the issue's own list was wrong in both directions. A name is carried when carrying its result makes djust render what Django renders — narrower than "nullary and cheap". Sweeping dir(o) on live objects and comparing three columns per name (Django's answer for {{ p.<name> }}, djust's, and djust's for the call's RESULT) says the issue's twelve-name list omitsisoweekday and includes three names carrying them would not close:

    nameDjango rendersthe result renders as
    isoformat2026-03-04T05:06:07.000008the sameCARRIED
    dateMarch 4, 20262026-03-04DROPPED
    timetupletime.struct_time(tm_year=…)(2026, 3, 4, …)DROPPED

    Every dropped name is dropped for that one reason: its result is itself a date / time / datetime / struct_time / IsoCalendarDate, whose BARE djust render already differs from Django's LOCALIZED one, so carrying it would move the cell without closing it. Six more the issue never named fall the same way (timetz, astimezone, replace, isocalendar, utctimetuple, plus now / today / utcnow, which are dropped for a second reason on top — their value is the CURRENT time, so carrying them would do nondeterministic work at every conversion). A method that requires ARGUMENTS (strftime, combine, fromisoformat) needs neither an entry nor an exclusion: Django's auto-call catches the TypeError and renders string_if_invalid, which is the empty string djust already renders, so those cells agree today.

    The calls fail soft, per name. A getattr that misses, a call that RAISES, or a result that will not convert is SKIPPED rather than stored, leaving lookup_segment answering None — the pre-#2485 empty cell. That matters more here than for a plain attribute read, because a call runs code the framework does not own: a tzinfo subclass decides what utcoffset() / tzname() / dst() do, and timestamp() on a naive value is platform-dependent. So a raising call cannot make any cell WORSE than it was, and the skipped cell is one Django itself 500s on — more permissive than Django, which is the direction to fail in.

    What it costs, measured rather than argued. The eagerness objection in the issue is real and the number is this: converting a datetime goes from 4.09 µs to 8.78 µs (naive) and 6.31 µs to 15.36 µs (aware), so a render whose context holds 200 datetimes the template never asks about goes from 1.31 ms to 2.23 ms (naive) / 1.76 ms to 3.55 ms (aware). The calls themselves are only ~1.3 µs of that (isoformat 0.4 µs, ctime 0.35 µs, weekday / toordinal / utcoffset / tzname / dst ~0.02–0.1 µs each); the rest is the nine extra map entries. utcoffset in particular costs nothing new — comparison_key has called it on every datetime and every time since #2471 to build the CmpKey. The complete correct set ships and can be pruned later on evidence, which is the direction #1447 prefers: pruning a name is a one-line change with a regression test, while a name that was never there is a cell nobody notices.

    min / max / resolution stay open, and are pinned as still-divergent. They are DATA attributes whose values are values of the same family (datetime.min.min is datetime.min), so collecting them does not terminate; closing them needs a depth bound, which is a design decision rather than three more strings. Worth recording for whoever takes it: the sweep says resolution (a timedelta) and timedelta's own min / max WOULD agree if a depth bound existed, while datetime/date/time's min/max would not — Django localizes those too.

    Regression coverage: new cases in python/tests/test_nullary_autocall_2485.py — every carried name through BOTH render_template entry points and through a msgpack state round trip, the aware and ZoneInfo subjects where the tz calls answer a real value, an overriding subclass, and the fail-soft cases. #2481's exemption is FLIPPED rather than deleted (#1859): METHODS_RESULT_SPELLS_DIFFERENTLY is what is genuinely still exempt and it GREW by the six names the sweep found, each with a companion test that MEASURES the reason (render the call's result; it differs from Django's answer) rather than asserting it. Gate-off verified against five independent mutations with a rebuild between each and the .so mtime asserted to advance, __pycache__ cleared, and cargo test --no-fail-fast for the Rust side: neutering the producer (122 red), making the merge find nothing (122), removing isoformat from the datetime row (16), removing total_seconds from the timedelta row (5), and the CROSSED mutation that ADDS date to the call table (1 red — the exemption pin, proving it is load-bearing rather than decorative). A sixth mutation was reported INVALID by the harness because it did not compile, and was replaced rather than counted.

    Two-build filter differential over 380,484 cells: 0 moved, 0 regressions, django REFUSES & djust RENDERS 4992 → 4992 and djust REFUSES & Django RENDERS 39253 → 39253 (both +0), live-payload leaks 65 → 65, with the two build hashes differing so this is not a stale baseline. That zero is the corpus's blind spot rather than a claim about the fix: its path axis (3,392 cells) is the dict-view path axis from #2334 and builds no datetime × method-name cell, which is exactly the case the differential's own NOTE describes.

  • A class with no __module__ no longer crashes the branch that names it (#2488).normalize_django_value's final fallback built its warning message with an unguarded type(value).__module__. __module__ is not guaranteed: type(name, bases, ns) fills it from the CALLING FRAME's __name__, so a class built in a namespace that has none — which is exactly what eval(compile(...), {}) gives you — has no __module__ at all and the attribute lookup RAISES.

    cls = eval(compile(ast.Expression(...), "<x>", "eval"), {})   # globals with no __name__
    cls.__name__      # 'C'
    cls.__module__    # AttributeError: __module__
    normalize_django_value({"p": cls()})   # AttributeError, not a warning
    

    The path is the LiveView render path — every WebSocket event normalizes the context — so the failure is a 500 on a page rather than a warning. And the value that reaches this branch is already the "we don't know how to serialize this" case: the guard that was supposed to produce a helpful warning was the thing that raised, in the branch least likely to be exercised.

    Grep the SINK, not the caller. Three unguarded reads of type(...).__module__ on an ARBITRARY value existed, all of them message-building or diagnostic paths, and all three are fixed: serialization.py (the cited one), observability/tracebacks.py (the exception RECORDER, where a second exception has nowhere to go) and checks/configuration.py (the ASGI middleware walk in manage.py check, where the pre-existing or "" covered a __module__ that is None but not one that is ABSENT). templatetags/live_tags.py reads the same pair inside its own except AttributeError and is the one named exemption; a source pin asserts that SET rather than a floor (#1125), so a fourth unguarded read reddens it as loudly as a deleted guard.

    A fourth read the fix did not reach, found by the regression test rather than by inspection. Guarding tracebacks.py's own __module__ left the recorder still raising one line later: CPython's traceback.format_exception makes the same unguarded read in TracebackException.format_exception_only (smod = self.exc_type.__module__), so a __module__-less exception class cannot be formatted at all — loudly enough to take pytest's own reporter down with it (an INTERNALERROR, which reports a SHORT pass count rather than a failure). The formatting call is now wrapped fail-soft, narrowly, so a genuine bug in that module is not swallowed with it.

    How it was found: #2482 put a type()-built class instance in the differential corpus. The script builds it in a module that HAS __name__, so the script-built instance rendered fine; test_sequence_op_chokepoint_2451.corpus() rebuilds the same expression from the AST in a names-only namespace, so the reader-built instance crashed — the same corpus row behaving differently depending on which reader constructed it.

    Regression coverage: new cases in python/tests/test_module_guard_2488.py, including the premise measured against live CPython in both directions and the four fixed sites exercised through their REAL paths (normalize_django_value, record_traceback, and check_configuration driven through a real ASGI_APPLICATION setting rather than by calling the expression directly). Gate-off verified against four independent reverts — the serialization guard (5 red), the tracebacks getattr (3 red), the tracebacks fail-soft wrapper (1 red), the checks guard (2 red) — each asserting the mutation matched exactly once, the source changed, and __pycache__ was cleared, and counting collection errors and ABORTED runs apart from failures. The first counting pass reported two of those four as GREEN because pytest's reporter died on the mutated code and printed neither failed nor error, only a short pass count — a harness that reads the wrong instrument, rerun after teaching it to compare passed + failed + errors against collected.

  • {{ p }} on a None renders None and keeps rendering it — a state round trip no longer turns every Value::None into a Value::Missing (#2484).Value::None and Value::Missing are deliberately DISTINCT (#2203): None renders "None" as str(None) does, Missing renders "" as Django's string_if_invalid does. The msgpack codec collapsed them — impl Serialize for Value wrote both as one nil, and visit_unit read every nil back as Missing:

    {{ p }}      p = None            django 'None'   djust 'None'  -> after one round trip: ''
    {{ d.a }}    d = {"a": None}     django 'None'   djust 'None'  -> after one round trip: ''
    

    SerializableViewState.state round-trips through msgpack on EVERY read of the default InMemoryStateBackend and of the Redis backend, so the value rendered correctly on the first render and rendered the EMPTY STRING after one cache hit — nondeterminism an app author cannot explain from the template. It is the CODEC's, not any one variant's: a None at the top level, in a dict, in a list, or in an Encoded's attribute map (#2481) was affected equally. It predates #2481 and #2448; #2203 gave Missing | None one serializer arm and the round trip has collapsed them ever since.

    Blast radius, measured rather than reasoned about. Over Django's LIVE defaultfilters registry with p = None, 35 of 58 cells agreed with Django on the first render and stopped agreeing after one round trip — {{ p|default_if_none:"D" }} ("D""") and {{ p|yesno:"y,n,m" }} ("m""n") among them, the two filters whose whole purpose is branching on this value, plus |wordcount ("1""0"), |make_list ("['N', 'o', 'n', 'e']""[]"), |linebreaks ("<p>None</p>""<p></p>"), |upper, |truncatechars and every bare display cell. In a plausible LiveView state blob, 13 of 27 leaf values were None. After the fix, 0 of 58 cells move.

    The encoding decision, which is why this was filed separately rather than folded into #2481. The four sibling tags (DECIMAL_TAG#2214, BIGINT_TAG#2260, TUPLE_TAG#2276, ENCODED_TAG#2448) each gave a NEW spelling to a value that previously had a DIFFERENT one. This one separates two values that shared a spelling, so it has to choose WHICH of the two moves — and a state blob outlives a deploy, so both cross-version directions are answered explicitly:

    what it readsrendered
    OLD payload (nil), NEW readerValue::None"None"fixed
    NEW payload (nil), OLD readerValue::Missing"" — unchanged, today's behaviour

    The tag goes on Missing, the rare variant, so the COMMON value's bytes do not move: a None is still one msgpack nil. The OLD-payload direction is a fix rather than a guess because FromPyObject maps Python None to Value::None and has NO arm producing a Missing — a Missing is a render-time sentinel (renderer.rs's resolve(...)?.unwrap_or(Value::Missing)) and RustLiveView::state is filled only through that conversion — so a nil in a pre-fix blob can only have come from a Python None. Measured, not asserted: test_a_missing_cannot_enter_state_through_the_python_conversion sweeps 18 Python shapes through set_state and checks the tag never appears in the blob.

    Tagging None instead — the obvious fifth application of the mechanism — was rejected, because it changes the encoding of the most common value in any state blob: an old reader would see a one-key Value::Object where it used to see nil, giving a dict spelling from {{ p }} and the TRUE branch from {% if p %}. Strictly worse than the defect. Dropping the tag entirely — just reading nil as None — was also rejected: it works for every value that exists today and leaves the codec lossy in the other direction, ready to reopen this with the opposite sign the first time a Missing did become reachable. The tag costs 21 bytes on a value no real path emits and makes the codec injective. JSON is unchanged and deliberately stays lossy (one null for both), exactly as TUPLE_TAG's arm is — json.dumps has one null too; what did change there is that a null now reads back as None, which is what json.loads("null") is.

    Two pins that recorded this gap in the DIVERGING direction are flipped rather than deleted, so the same two halves that measured the gap now measure its closure — test_encoded_wire_positions_2471_2472.rs::a_none_attribute_survives_the_round_trip_as_a_none_2484 (which also shows a PLAIN Value::Object keeping it, with no Encoded in reach) and test_encoded_attributes_2481.py::test_a_None_attribute_survives_the_state_round_trip_since_2484. #2481's two-row round-trip exemption (the naive tzinfos) is REMOVED rather than left standing (#1859): a stale exemption is a pin that can no longer go red.

    Regression coverage: new cases in crates/djust_core/tests/test_none_missing_codec_2484.rs and python/tests/test_none_missing_state_round_trip_2484.py. The wire pin is literal — Value::None must encode as exactly [0xc0] (the compatibility statement itself, written as the byte rather than as "whatever we emit") and Value::Missing as the byte-for-byte tagged map. Gate-off verified against three independent reverts — the serializer arm (5 Rust cases red), the nil reader (6 Rust, 27 Python), the tag reader (3 Rust, 1 Python) — each asserting the mutation matched exactly once, the source changed, the .so mtime advanced and __pycache__ was cleared, and counting collection errors apart from failures. The serializer arm reddens nothing on the PYTHON side, and that is the measurement the fix rests on rather than a coverage hole: no Python value can reach the serializer as a Missing, which is exactly why the tag was put on that variant. The first counting pass under-reported every row (1 / 2 / 1) because cargo test is fail-fast across binaries — a harness that reads the wrong instrument, rerun with --no-fail-fast for the numbers above.

  • {% if p %} is False for a falsy object WITH attributes — it reaches the carrier instead of the __dict__ bulk dump (#2478).#2466 closed the falsiness class that lands on FromPyObject for Value's final Ok(Value::String(ob.str()?)) — a set, a frozenset, complex(0), a bare zero-__len__ class. One member never got there: an object with a non-empty __dict__ was claimed by the bulk-dump arm ABOVE the fallback and became a non-empty Value::Object, whose truthiness is the mapping rule.

    class LenZeroWithAttrs:
        def __init__(self): self.a = 1
        def __len__(self):  return 0
    
    {% if p %}T{% else %}F{% endif %}       python False   django F    djust T
    {{ p|length }}                                         django 0    djust 1
    {% for x in p %}[{{ x }}]{% endfor %}                  django ''   djust '[a]'
    {{ p }}                          django '<LenZeroWithAttrs object …>'   djust "{'a': 1}"
    

    The fix is a REORDER plus one field, and that is only possible because #2481 landed first.falsy_opaque was placed after the __dict__ arm deliberately: routing an attribute-carrying object through the Encoded carrier would have fixed {% if %} and broken {{ obj.a }}, because an Encoded had no attributes. #2481 gave it an attribute map, so the objection is answered rather than worked around — falsy_opaque moves ABOVE the __dict__ arm and carries the object's public __dict__ on the carrier. test_falsy_conversion_2466.py's pinned decline is kept and flipped to the CLOSING case: it now asserts BOTH that the divergence is gone AND that {{ p.a }} still resolves, which is the one cell this fix had to keep.

    Six independent facts, not the four the issue names — and the extra two decide the fix's SHAPE. Swept over 45 cells × 8 object shapes against live Django: truthiness ({% if %}, not, and/or, {% with %}, {% firstof %}, |yesno, |default, membership of a list), length (|length), iteration ({% for %}, {% for k,v %}, |join, |safeseq, |escapeseq, |unordered_list, .items, .keys), display ({{ p }}, |default_if_none, |stringformat:"s", |linebreaks, |lower, |striptags, {% cycle %}, |make_list, |slice), repr (|pprint, |stringformat:"r") and attributes ({{ p.a }}, {{ d.p.a }}). The issue's own suggested remedy — a truthiness override on Value::Object — reaches the first of those and nothing else: length, iteration and display read the MAPPING, and the __dict__ arm's whole claim is that the object IS a mapping of its attributes. Patching one answer of a wrong carrier value-by-value is the non-converging shape #2129 took five rounds over; moving the object to the right carrier answers all six from spellings the struct already has. TestTheIssuesOwnRemedyWouldNotHaveReached measures the split rather than asserting it.

    Corpus movement, two builds of the same corpus (scratch/sweep_2478.py, 360 cells): byte-equal agreement 131 → 252, refusal-collapsed 164 → 295, django REFUSES & djust RENDERS22 → 12, djust REFUSES & django RENDERS3 → 0 — it SHRANK, and the three were {% for k, v in p %}, which djust refused where Django renders the empty branch. 0 cells regressed out of agreement, and only the four shapes the gate ADMITS moved: the declined shapes (falsy with a non-zero __len__; iterable with no __len__) and the three controls (truthy, no attributes, private attributes only) answer byte-for-byte what they answered on the previous build, pinned against a table captured by reverting the change and rebuilding.

    The gate is #2466's, unchanged, and both serialization floors stay above the arm: __djust_serialize__ and the raw-Model arm (#1986, and its vector 7) are ordered BEFORE falsy_opaque, so a Django model cannot reach it and cannot have its denylisted fields collected into the attribute map. Asserted by source ORDER with a canary that proves the check can go red, because the ordering IS the enforcement. The _-prefix filter is stated ONCE, in a shared public_dict_attrs with exactly two callers — two copies of that filter is the #1646 shape, and this arm's copy would be the one that leaks.

    Two cells stay divergent and are pinned exactly in both directions: |json_script, which Django refuses over any non-JSON-serializable object (#2429's recorded refusal direction — though djust now emits str(o) rather than a JSON object of the attribute VALUES, so strictly less of the object reaches the page), and |dictsort over an empty iterable, which is unrelated to the carrier.

    Regression coverage: 383 cases in python/tests/test_falsy_with_attributes_2478.py; new cases in test_falsy_conversion_2466.py::TestWhatThisDeliberatelyDoesNOTClose.

  • {{ post.published.year }} renders 2026 instead of nothing — a Value::Encoded carries its attributes (#2481). Django's Variable._resolve_lookup tries three things at every dotted segment: mapping item access, then getattr, then an integer index. context::lookup_segment implemented steps 1 and 3, and said so in as many words — "attribute access — see the note above; a Value has none". So every dotted lookup on a datetime / date / time / timedelta resolved to nothing, on every path with no raw-Python sidecar — which is every DjustTemplateBackend render:

    {{ p.year }}    datetime(2026, 3, 4, 5, 6, 7)   django 2026   djust ''
    {{ p.days }}    timedelta(days=3)               django 3      djust ''
    

    It predates the variant: before #2448 a datetime was Value::String(str(o)), which has no attributes either. The LiveView path had a partial escape — crates/djust_live/src/lib.rs attaches a raw_py_objects sidecar, so {{ dt.year }} could resolve through getattrthere — and a fallback on one path is not the rule (#1646), which is why the fix is at the carrier rather than at one caller and why TestBothPathsAgree asserts the two now answer the same.

    21 cells, measured against live Django, through BOTH entry points python/djust/template/backend.py binds. lookup_segment is the ONE reader of the map, pinned as an equality in both directions so a second dotted-path walker that does not consult it reddens as loudly as a deleted arm (#1125/#1646); lookup_segment itself has exactly one caller, pinned the same way. Over the swept attribute surface the mismatch count goes 64 → 41, and nothing moves into djust REFUSES & Django RENDERS.

    What the map carries is a rule, not a list. It holds what Python answers WITHOUT a call and WITHOUT recursing. min / max / resolution look like they belong and cannot: their values are values of the same family, and datetime.min.min is datetime.min — measured, in test_the_class_attributes_would_not_terminate — so a collector that carried them would not terminate. test_the_name_list_is_the_whole_of_the_policy fails if they ever enter the table. The nullary methods (isoformat, weekday, ctime, total_seconds, date, time, …) are absent because Django reaches them through its auto-call (ADR-024), which turns a LOOKUP into an EVALUATION — eager at conversion time, paid whether or not a template asks, and inheriting whatever the call raises. Both families are pinned in the DIVERGING direction and filed as #2485; Decimal is Value::Decimal, a different carrier with no attribute slot, and is filed as #2486.

    Wire. The ENCODED_TAG payload grows to a ninth positional slot, appended, written unconditionally as a map — an empty one costs a byte and the slots stay aligned, which is the choice cmp_key makes one slot over and for the same reason (#1541). The reader accepts 9 / 8 / 6 / 4 / 3, and an older width restores NO attributes: the answer that entry was written with. Carrying it is what makes the fix survive a cache hit — SerializableViewState.state round-trips through msgpack on every read of the default InMemoryStateBackend, so without the slot {{ dt.year }} would answer once and go empty afterwards, the reopening ENCODED_TAG has now prevented four times. crates/djust_core/tests/test_encoded_wire_positions_2471_2472.rs grows the slot-9 pins: a 13-shape sweep of what the map can hold, order, the empty map, a malformed slot, and a key↔attrs SWAP — which neither changes the payload's width nor trips any type check, so only the values can catch it.

    Encoded's derived PartialEq becomes a hand-written one. The map holds Values and Value deliberately has no PartialEq: Django's == for a template value is renderer::values_equal, which equates 1 with 1.0 and asks python_partial_cmp for this family. Deriving a second == onto Value would put a structural answer one keystroke from every site that wants the Django one — two mechanisms for one question (#1646) — so the structural comparison is reachable by NAME only, as values_structurally_equal, with test_every_variant_is_structurally_equal_to_its_own_clone so a new variant cannot land on its wildcard unnoticed.

    Surfaced rather than folded in (#1079).Value::None and Value::Missing are deliberately distinct (#2203) and share ONE msgpack nil, so every None in state comes back as Missing and renders '' after one cache hit. Pre-existing and general — pinned with a plain Value::Object losing it too, which is what makes "pre-existing" a measurement — and filed as #2484.

    Regression coverage: 143 cases in python/tests/test_encoded_attributes_2481.py; new cases in test_encoded_wire_positions_2471_2472.rs. Gate-off verified against four independent reverts (the reader, the producer, the wire write, the wire read), each asserting the mutation matched exactly once, the source changed, the .so mtime advanced and __pycache__ was cleared, and counting collection errors apart from failures.

  • Two Value::Encoded values now compare as Python compares them — a datetime was not equal to ITSELF, on every operator (#2471).values_equal enumerated Missing|None, Bool, Integer, Float, the mixed int/float pair, String and same-kind sequences, then _ => false; try_compare had the matching hole. So two Encodeds were never equal and never ordered: {% if p == q %} on the SAME datetime took the {% else %} branch — the direction that HIDES content — {% if p != q %} was true, {% if p <= q %} and {% if p >= q %} were both false, and {% if a < b %} on two timedeltas was false in both directions. Exactly the shape #2335 fixed for lists, and the comment that fix left behind says so in as many words; Value::Encoded arrived in #2448 and got neither arm.

    Neither carried string can answer, which is the finding. The issue suggested an (Encoded, Encoded) arm keyed on something derived, and running it shows there is nothing derivable to key on. display (str(o)) does not ORDER — "10 days, 0:00:00" sorts before "2 days, 0:00:00" — and does not answer == either, because two aware datetimes naming the same instant in different zones ARE equal in Python and have different str(). json (DjangoJSONEncoder.default(o)) is worse in the direction that matters: it truncates a datetime's microseconds to milliseconds (r[:23] + r[26:]), so two datetimes 1 µs apart encode identically and a string compare would call them equal; duration_iso_string leaves the day count unpadded and appends microseconds only when non-zero, so it does not order either.

    So the answer is carried, exactly as #2458 carries bool(o).Encoded grows a CmpKey(domain, days, microseconds-in-day) — measured from the live object at the PyO3 boundary. A domain is "the set of values Python will compare this one with", and splitting on it is not tidiness: date(2020,1,1) == datetime(2020,1,1) is False in CPython even though datetime IS a date subclass, date < datetime RAISES, and naive-against-aware is the same pair of answers — all of which fall out of "different domains do not compare" rather than needing their own rules. Two limbs rather than one because timedelta.max is ~8.64e19 µs and i64::MAX is ~9.22e18; Python normalises a timedelta to (days, 0 ≤ seconds < 86400, 0 ≤ microseconds < 10⁶), so the pair orders lexicographically exactly as the delta does, negatives included. An aware datetime is normalised to UTC, which is what makes the cross-zone equality right.

    One function, three readers.values_equal, try_compare and dictsort's compare_sort_values all call Encoded::python_partial_cmp, and equality is Some(Equal) rather than a second rule — so == and < cannot drift apart, which is what #2244 (Bool), #2243 (Float) and #2335 (List) each shipped once. The caller set is pinned as a SET and canaried in both directions (#1125/#2233): a floor cannot see a REMOVED arm, and a removed arm is the regression. dictsort over a DateTimeField column sorted as all-Equal — i.e. not at all — and now sorts.

    There are FIVE domains and not six, and the missing one is deliberate. A timezone-aware time never becomes a Value::Encoded at all: DjangoJSONEncoder.default raises ValueError: JSON can't represent timezone-aware times. for it, so the conversion fails closed and the value stays the Value::String(str(o)) it was before #2448 — the refusal direction #2429 declined, unchanged here. An aware-time domain would have been an arm no test could reach (#1859), so it is not written; the premise is run rather than quoted in TestAnAwareTimeIsNotAnEncodedAtAll.

    A randomised corpus is only as good as the axis its generator varies, and the gate-off is what said so. Gating the aware-to-UTC normalisation off reddened exactly one test — the hand-built same-instant pair — while a 400-cell randomised sweep stayed green over a genuinely wrong engine, because the aware generator draws a random YEAR and a utcoffset() bounded to ±24h can never flip an ordering between values years apart. A second sweep over NEAR pairs (wall clocks within ±36h, random offsets on both sides) is the axis that can see it: 1 → 5. compare_sort_values had one column shape and now has one per domain: 2 → 7. Both were coverage the suite could not have reported missing; the mutation is what reported it.

    Corpus, measured over 375,394 cells against CURRENT main (f15dc3ac223b24c189f219710de14da8, two genuinely different builds on the identical corpus): the cmp axis moves 12 — the number #2471 predicted — filter 9, tag 63. Refusal-collapsed agreement 320,045 → 320,100 (+55); django REFUSES & djust RENDERS4,755 → 4,730, so 25 cells stopped being more permissive than Django; djust REFUSES & Django RENDERS is 38,965 → 38,965, so nothing became over-strict; 0 agreeing cells regress, 0 cells newly panic, and the live-payload-leak count is unchanged at 58. Read the collapsed and moved numbers rather than the raw headline: both engines refusing with different wording is a raw-string disagreement, so the headline is structurally blind to the #2473 half.

    This is the THIRD baseline, and the first two are worth recording because the corpus is what changed under them. Measured against main before #2476/#2475 and again after, the movement was identical (cmp 12, filter 6, tag 42, +40 collapsed) — because neither of those PRs moves a single cell here: INPUTS held no set(), so nothing reached falsy_opaque, and the differential renders through raw render_template, which normalize_django_value was never on. Then #2483 (#2477) put set() and frozenset() INTO INPUTS, and the same two engines answered 3,942 more cells: filter 6 → 9 and tag 42 → 63, every one of them a set reaching |pprint or an int(value) refusal. The engine did not change between the second and third measurements; only what the corpus could ask did.

    cmp stayed at 12 across that widening, and that is the #2480 measurement rather than an argument. The new set rows added 1,372 comparison cells and moved none of them: both builds answer N where Django answers Y, so this PR's arm is confirmed on the corpus to neither close nor worsen the regression #2476 introduced.

    47 regression cases in python/tests/test_encoded_value_position_2471_2472_2473.py — 329 parametrized cells — covering all three issues and the #2476 merge. Eleven gate-off mutations — each asserting the text matched EXACTLY once, that the source changed, that the rebuilt .so is not byte-identical to the previous one, with __pycache__ cleared and N error counted apart from N failed — redden 27 / 25 / 17 / 21 / 26 / 12 / 7 / 5 / 36 / 16 / 1 tests; no survivors, no INVALID runs.

    Two of those numbers were themselves findings. The harness REFUSED to run M9 after the merge, because falsy_opaque introduced a second ob.repr() call and the mutation text stopped matching exactly once — the "assert the mutation matched" rule catching an ambiguity that would otherwise have mutated an arbitrary one of the two. And M11 (the new one: falsy_opaque copies display into repr) reddens exactly 1, which is a question rather than a pass: a crossed run named the single failing test, and test_that_one_test_is_the_COMPLETE_set_of_distinguishers now asserts why one is the whole set — every builtin the widening carries has str(o) == repr(o), so only a user class can tell the two implementations apart.

  • {{ p|pprint }} over a datetime spells repr(o), and so does a datetime NESTED in a list or dict (#2472).pprint::flat_repr spelled py_repr_string(&e.display) — the repr of the display string, '0:00:00', quotes and all — and Value::py_repr delegated to Display, which is str(o). Django spells the constructor form: datetime.timedelta(0). The comment on the pprint arm said the real answer was "out of reach because Encoded carries str() and the encoder's JSON and not repr()", which is what this fix answers by putting repr() on the variant.

    The nested position is the one that matters more, and the issue did not name it. A container's str calls repr on each element, so {{ p }} over [timedelta(0)] — the ORDINARY render path, no filter — rendered [0:00:00] where Django renders [datetime.timedelta(0)]. {{ p|stringformat:"r" }} and {{ p|stringformat:"a" }} are Value::py_repr too and moved with it. Four sinks, one field.

    Carried rather than derived, because repr for this family is not a format string.repr(timedelta(0)) is datetime.timedelta(0) while repr(timedelta(seconds=90)) is datetime.timedelta(seconds=90) — the KEYWORD is chosen by the value — and repr(datetime(2020,1,1)) prints its zero time fields but not its zero microsecond. A hand port is four transcriptions with a per-value branch in each; repr() answers it exactly, once, at the conversion. The parity is measured by a randomized sweep against live Django rather than a curated table, for the reason the v1.1.1-2 canon gives: the shapes that get it wrong are the ones nobody thinks to sample.

    The state round trip carries both new fields, because #2448 and #2458 were each reopened by exactly that trip: ENCODED_TAG's msgpack payload grows to eight elements — #2466's sized_empty/iterable, then repr and the comparison key, every widening appended at the END, the only safe position in a positional payload (#1541). A three-, four- or six-element payload from a pre-upgrade process still reads and restores to the answers that entry was WRITTEN with — no comparison key, and display as the repr — rather than fabricating a constructor form it cannot know.

    Two PRs appended to the same positional tuple in one release, so the slots are now pinned in Rust (crates/djust_core/tests/test_encoded_wire_positions_2471_2472.rs). That merge's naive resolution — writing this PR's two fields before #2466's — compiles, serializes, and passes every same-process test, because both sides use the same field order; it corrupts only a state entry crossing builds. Three structural facts were verified rather than assumed, and the canon's specific hazard turns out not to apply: neither Encoded nor CmpKey derives Serialize (the encoding is the hand-written impl Serialize for Value), and no field anywhere in djust_core carries skip_serializing_ifcmp_key is written unconditionally as nil or [domain, hi, lo], so no optional can drop its slot. The last of those is asserted against the source, so the reasoning cannot quietly stop being true.

    The pin sweeps all 16 combinations of the three consecutive BOOLEAN slots × key-present, the nested key at i64::MIN/i64::MAX and negative-hi, every domain constant, all four accepted payload widths, the two widths that must NOT forge an Encoded, and five malformed keys that must read as absent rather than guessed. Its own canary is four slot-order mutations of the writer — including the naive merge verbatim — each reddening 3 tests; the fixtures give the three boolean slots distinct values precisely so a shift among them cannot pass.

    Value::Encoded is no longer only the datetime family, and the merge with #2476 turned two of its assumptions into checks.#2466's falsy_opaque builds an Encoded for set(), frozenset(), complex(0) and any falsy user object, so both new fields had to be answered there too:

    • repr is measured (ob.repr()), not cloned from display the way json is. For every builtin falsy_opaque was written for the two spellings coincide — so a display-copying implementation would have looked correct on all of them and been wrong for the case the widening exists to carry: a user class defines __str__ and __repr__ independently, and {{ p|pprint }} renders whichever field is carried. Pinned with a class whose two spellings differ.
    • cmp_key is None there, so python_partial_cmp answers None for any pair either side of which came from falsy_opaque — byte for byte the _ => false those values already got. That leaves {% if p == q %} on two set()s answering N where Django answers Y: a regression #2476 introduced by moving them off the (String, String) arm, which this PR neither closes nor worsens (gate-off M1, which reverts the new arm to a literal false, leaves every case in TestAFalsyOpaqueEncodedIsNotComparable green). No carried spelling can decide it — set() == frozenset() is True ACROSS type names while LenZero() == LenZero() is False WITHIN one — so it is filed as #2480 rather than guessed at (#1079).

    Three stale pins are INVERTED rather than deleted, so the record of what the residue was survives: test_json_script_datetime_value_2448.py's |pprint divergence (whose stated premise — "out of reach because Encoded carries str() and the encoder's JSON and not repr()" — is what this fix moved), test_filter_arm_parity_2399_2401_2403.py's get_digit-over-a-datetime row, and test_sequence_op_chokepoint_2451.py's, which is now a PATH split rather than a residue. Each read "is still X" and went red the day X closed, which is what they were for.

  • The LiveView path carries a datetime to Rust instead of flattening it, so djust's two paths answer the same (#2467).normalize_django_value converted a datetime / date / time / timedelta to a string in Python, so Value::Encoded (#2448) was never constructed on the LiveView path and every downstream decision was made on text. #2456 fixed the raw DjustTemplateBackend path and declared this bound for itself in its CHANGELOG, its docstring and a TestWhichPathThisFixIsOn class; this is the other side of it. Measured on a real mount + render rather than through render_template — a renderer-only harness runs the raw path, which is exactly why the earlier fix could not see this: 14 of 49 path-pairs diverged across 7 values × 7 templates, and they are 0 now.

    The sharpest row is not a spelling — it is a permissiveness gap.#2451 made seven filters refuse a value their Django body cannot iterate or subscript, and its sweep renders djust through normalize_django_value. With a timedelta in the corpus (#2469) it reported twelve cells rendering where Django refuses: the flattened "P0DT00H00M00S" is a string, so {{ p|unordered_list }} emitted thirteen <li>s and {{ p|phone2numeric }} emitted 7038004006007 where Django raises TypeError: 'datetime.timedelta' object is not iterable. All twelve refused correctly on the raw path throughout — djust was more permissive than Django on the path most djust pages use and stricter on the one they do not, which is what makes this a fix rather than a preference. The other headline row is #2458's: {% if p %} over timedelta(0) answered T here and F there, because a non-empty string is truthy.

    The Decimal branch verbatim (#2239): carried through UNCONVERTED for the renderer, converted only at the state_roundtrip=True boundary. The consumer audit the issue asks for, run per consumer rather than assumed — the template context takes it; the wire encoders are json.dumps(…, cls=DjangoJSONEncoder) and since #2462 djust's encoder spells a datetime exactly as Django's does, so the bytes on the wire are byte-identical (asserted, not argued); every request.session[...] write already passes state_roundtrip=True (mixins/request.py:270, :275, :718, :728; mixins/components.py:149); and the Rust state round trip — the one that would have been the blocker — was already solved, because Value::Encoded has a TAGGED msgpack encoding (ENCODED_TAG, payload [type_name, display, json, truthy]) pinned literally since #2448/#2458. So the #1448 wire snapshot this change needs already existed; what changes is that the LiveView path now reaches it.

    The cost, stated.{{ p }} on the LiveView path renders str(o) now — 2020-01-01 03:04:05 where it rendered 2020-01-01T03:04:05, and …+00:00 where it rendered …Z. Both were already non-Django (Django LOCALIZES a bare datetime), so this moves one non-Django spelling to the other one djust already uses, and buys agreement between djust's own two paths; #2462 made the mirror-image trade in the other direction and said so. {{ p|date:… }} / {{ p|time:… }} are unaffected for datetime / date / time, including timezone conversion (#2216), and {{ p|time:"H:i" }} over a timedelta improves from empty to 00:01.

    What this NARROWS, said out loud.#2252's state_roundtrip flag was documented as having "ONLY the Decimal branch" as its effect, and its TestEveryOtherTypeIsUntouched swept 14 types asserting flagged output equals unflagged. That was true when written and is false now for four of them, by construction — the datetime family is carried unconverted without the flag and encoder-spelled with it, exactly as Decimal is. The alternative (carry it on BOTH sides, keeping the flag a no-op) is not available and is not a preference: Django's session serializer passes no encoder and raises on a bare datetime, which is the entire reason the flag exists. So that class is split rather than exempted — _UNTOUCHED (10) keeps the no-op claim under the rule behind it ("the flag changes nothing for a value holding no carried-through type"), _CARRIED_THROUGH (4) asserts positively that the flag moves them AND that the session serializer refuses the unflagged form, and a third case pins that every untouched type is one the serializer already accepts — so the two lists cannot drift apart silently. #2252's own randomized corpus is split the same way instead of dropping its date leaf: 500 carry-free values stay bit-identical, and 500 carried-type values are swept for the property that matters for them, with a floor asserting the generator actually produces them. Its [Unreleased] bullet's "2828 rows over 14 types" and "86 in …" predate this and are left as written; this paragraph is the correction.

    New cases in TestTheTwoPathsAgree (the full 7×7 cross), TestTheTwelvePermissivenessCells, TestTheNormalizerCarriesTheObject, TestTheWireBytesAreUnchanged, TestStateRoundtripBoundary, TestTheRustStateBackendRoundTrip and TestWhatThisCosts in python/tests/test_liveview_path_carries_datetime_2467.py, plus test_the_flag_DOES_move_a_carried_through_value, test_the_carry_through_types_are_exactly_the_ones_the_boundary_must_convert, test_an_untouched_value_needed_no_conversion_in_the_first_place and test_a_randomized_corpus_of_CARRIED_types_still_reaches_the_session in TestEveryOtherTypeIsUntouched. Five sets of pins that asserted the flattening by name are INVERTED rather than deleted, the way #2462 inverted #2448's: TestWhichPathThisFixIsOn (#2448), TestWhatThisDeliberatelyDoesNOTClose (#2462, whose aware-time row stays because #2429 is genuinely still open), #2451's TestTheLiveViewPathNormalizesBeforeRustSeesIt (renamed with its claim to TestTheLiveViewPathCarriesTheTypeSince2467) and its twelve-cell #2467 pin, and every remaining assertion that spelled a normalize_django_value output as a string — TestDateTimeTypes in tests/unit/test_normalize_django_value.py, test_normalize_date / test_normalize_dict_with_complex_values in test_serialization_hardening.py, test_succeeded_recurses_into_result in test_async_result_serializer.py, test_the_timedelta_gap_is_CLOSED in test_decimal_converters_2239.py, and test_normalize_then_rust_update_state_no_quote_wrap in test_filter_literal_args_1081.py. Each keeps BOTH halves — carried, and converted at the boundary — because asserting only the first would let the boundary silently stop converting; the last additionally keeps the reporter's original string-valued trigger as a second swept case, since a session restore still hands one back.

  • bool(set()) is False on both engines — the falsiness rule now reaches the CONVERSION (#2466).{% if p %} over a set() or a frozenset() rendered the TRUE branch where Python and Django render the false one. A set has no Value variant, so FromPyObject for Value landed it on its final Ok(Value::String(ob.str()?)) and it arrived as the non-empty string "set()", whose is_truthy is !s.is_empty(). The #2458 shape one level up: a Python-falsy object arriving as a non-empty display string, which #2464's fix cannot reach because a set never becomes an Encoded.

    The class is seven shapes, not two, and it is OPEN. Swept against live Django over 32 container and scalar shapes rather than transcribed from the issue: set(), frozenset(), complex(0), an empty dict_keys / dict_values / dict_items (the DictView variant exists, but only the template's own d.keys access ever built one — the conversion never did), and any user class with a __len__ returning 0 or a __bool__ returning False. The last two are user classes, so the set cannot be enumerated — which is the argument for carrying bool(o) over giving set a variant. A one-type fix here is the shape #2129 took five rounds over. Nineteen falsy shapes that were ALREADY right ({}, [], (), "", 0, Decimal("0"), timedelta(0), b"", range(0), deque(), memoryview(b""), …) are swept too, because they are what an over-reaching fix would take with it.

    No new carrier.Value::Encoded already IS one — a Python object held by its type_name / display / json / truthy spellings because the object itself cannot cross. #2448 built it for the four DjangoJSONEncoder types and #2458 added the truthiness bit; falsy_opaque widens the set of objects that use it and adds no mechanism. A new Value variant would be a second carrier for one question (#1646) and would have to be classified at every wildcard match arm in the workspace. json stays str(o) for these — exactly what the Value::String path already wrote — so json_script does not move; Django REFUSES {{ p|json_script:"x" }} over a set, and that is #2429's declined refusal direction, unchanged rather than grown.

    The fix carries TWO bits, and the second one is a defect the first version shipped. A truthiness-only fix made {% for x in set() %} REFUSE where Django renders the {% empty %} block — a regression in the one direction this class of change must not move — because a value the crate models as "not iterable" reaches the {% for %} refusal arm. Django asks two different questions and gets different answers for the same object: ForNode.render reads __len__ when the object has one and calls list() only when it does not, while join / safeseq / escapeseq / unordered_list are comprehensions and call iter() unconditionally. So a class with a zero __len__ and no __iter__ renders the {% empty %} block AND raises from |safeseq — on Django. Encoded now carries sized_empty (len(o) == 0) and iterable (iter(o) succeeds) separately; python_len and the {% for %} arm read the first, iter_values reads the second. Found by running the axis after the first version was green, not by inspection.

    Two shapes are DECLINED, and the declines are why nothing became stricter than Django. A falsy object with __iter__ and no __len__, and one with __bool__ returning False and a non-zero __len__: Django renders their items, and this carrier cannot produce them without RUNNING the object — which would consume a generator or hang on itertools.count(). Both keep their previous Value::String path, so they stay permissively wrong rather than becoming refusals. A falsy object carrying ATTRIBUTES is declined too: it reaches the __dict__ bulk-dump arm rather than the str() fallback, and routing it through this carrier would take {{ obj.a }} with it. All three are pinned in the diverging direction.

    Wire format. The __djust_encoded__ msgpack payload grows from four elements to six, both new ones TRAILING — the safe position in a positional payload (#1541). The reader accepts six, four and three, so a Redis state entry written by a 1.1.x or a #2448-era process still loads with the truthiness it was written with.

    What changes for you.{% if %}, {% for %}, {{ p|length }} and the iterating filters now answer Django's answer for an empty set / frozenset / dict view and for a falsy user object. {{ p }} is byte-identical — it was str(o) and still is. Two cells become refusals ({% for x in complex(0) %} and {% for %} over a __bool__-False class), and both are refusals Django already makes, now carrying CPython's own 'complex' object is not iterable instead of a message about a str.

    Corpus, stated rather than swept.scripts/filter-parity-differential.py cannot construct a single cell of this class, and the reason is structural rather than an omission: its value-truthiness axis (#2469) reads the Value VARIANTS out of crates/djust_core/src/lib.rs and requires a falsy and a truthy inhabitant of each — and the values this fix is about are exactly the ones with NO variant. Adding a set to INPUTS was tried and reverted: it surfaces four django REFUSES & djust RENDERS cells (first / last) that belong to a DIFFERENT defect — normalize_django_value turns a set into a sorted list on the LiveView path, so subscript-refusing filters render there — and a corpus row cannot carry the deepcopy a dict_keys needs. Filed as a follow-up rather than folded in (#1079). So the two-build differential over this change is a NON-REGRESSION result on the 48 shapes the corpus does hold — the Encoded family included, since #2469 added a timedelta — and not evidence that the fix works; that evidence is the 138 cases below, every one against live Django.

    138 cases in python/tests/test_falsy_conversion_2466.py, including the seven-shape sweep with bool() CALLED rather than transcribed, the nineteen already-right shapes, the {% for %} / filter split with its LenZero row, and the three declined shapes asserted in the diverging direction. Two pins that asserted the old behaviour are flipped in place — test_a_set_is_still_truthy_because_it_never_becomes_an_Encoded (#2458) and #2344's exclusion note, which now has set(), frozenset() and complex(0) in the sweep proper. In Rust, every_variant() gained the Encoded samples it never had (the variant was added in #2448 and neither iterability probe named it for two releases) and python_len_agrees_with_iter_values gained the one exemption Python itself requires. Twelve gate-off mutations, each rebuilding the crate and asserting the .so mtime advanced, redden 43 / 12 / 1 / 7 / 13 / 3 / 11 / 13 / 1 / 4 / 8 / 3 tests; no survivors, and the two that redden a single test each are the two DECLINES — each has one dedicated case, which is what makes the decline a mechanism rather than a comment. python_len's arm first reddened only its structural pin, because |length answers 0 through its own unwrap_or(0) either way — a valid mutation that is a semantic no-op for the tested inputs, the gate-off failure mode the v1.1.1-2 canon names. It is now reached behaviourally through {% for a, b in … %}, whose refusal reports the item LENGTH: Django says got 0. and the un-armed engine would say got 1.

  • The filter-parity differential can build a cell where an argument is FALSY, and holds a timedelta (#2469).ARG_CONTEXT bound six objects and every one was Python-TRUTHY, so the corpus could construct no cell where a resolved argument's falsiness is the question — which is the whole of what ArgType::is_falsy's first arm answers. And no entry of INPUTS was a timedelta: the one member of the Value::Encoded family with a falsy inhabitant, and the only way to reach that variant from the value corpus at all. So #2458, whose entire subject is bool(timedelta(0)), reported 0 moved cells on every axis while changing four measured behaviours. Sixth time a corpus gap has hidden a real change, and the file documents five of them in its own docstrings.

    Measured against a real regression, in both directions. The same two builds — clean, and one with #2458's Value::Encoded(e) => e.truthy reverted to its pre-fix !e.display.is_empty() — compared under each corpus. Pre-#2469: 0 moved on every axis, REGRESSIONS: 0, exit 0. This corpus: 43 moved, REGRESSIONS: 38, exit 1, every one of the 38 a known_td_zero argument or a td-zero value reaching default / yesno / json_script / slice / join. That is #2454's failure — a gate reporting clean over a genuine regression — reproduced and then closed, which is the half of that lesson the corpus never got.

    A value-truthiness axis, so this is the last time.Value::is_truthy is a match over Value, so the ENGINE does name the set: the variants are read out of crates/djust_core/src/lib.rs and each must have a falsy AND a truthy inhabitant, in the value channel and the argument channel. The four uninhabitable combinations are exempt with a written reason — Missing and DictView never arrive from Python, Value::None has one inhabitant and it is falsy, a BigInt is never zero — so a stale exemption is reported rather than silent. input-shape stays UNVERIFIED for everything else: this closes the one slice of it the engine names, and says so.

    Corpus: INPUTS grows a falsy inhabitant of the five variants that had only truthy ones (i-zero, f-zero, dec-zero, t-empty, d-empty) plus td-zero / td-plain; ARG_CONTEXT and ARG_SPELLINGS grow one resolved binding per variant in both answers, plus known_str_zero — a TRUTHY str spelling 0, the row that separates a value-typed falsiness rule from a text-shaped one. 353,909 cells to 371,452, every axis stable or growing and none shrinking, and the djust side of all 353,909 pre-existing cells byte-identical.

    What the widening surfaced, filed rather than fixed (#1079): three Value::Encoded divergences in the VALUE position that no cell could previously reach, all confirmed across the whole datetime family — two values never compare equal, not even to themselves (#2471, the #2335 list bug one variant over); pprint spells str(o) where Django spells repr(o) (#2472); and get_digit echoes its input where Django raises TypeError (#2473, the value-position twin of #2366).

    New cases in TestItWouldHaveCaughtTheHistoricalBlindSpots reconstruct the pre-#2469 corpus in a copy and assert the axis names all 21 gaps, with a non-vacuity sibling proving the mutation removes the corpus rows rather than the axis. test_removing_the_pad_cap_spelling_makes_the_cap_unreachable now removes two spellings: known_big reaches pad_width's cap by a second, resolved route, and that test going red on the first run is how it was found.

  • {% regroup %} refuses a source Python cannot iterate, as {% for %} already did (#2463).{% regroup p by k as g %} over a bare int failed soft to an empty grouping where Django raises TypeError: 'int' object is not iterable — no filter involved. {% for x in p %}, asking the same question one tag over, refused correctly since #2382/#2451. One invariant, two implementations, one fixed: the #1646 shape.

    The issue's cited location does not exist, and tracing symptom-up is what found the real one.#2463 says to look at "crates/djust_templates/src/renderer.rs, the {% regroup %} node". There is no {% regroup %} node — regroup is a Python assign-tag handler (djust.template_tags.regroup) dispatched from Rust, and the fail-soft was RegroupTagHandler._decode_source's except TypeError: return [].

    The fix DELETES the second answer rather than adding a third. The handler holds the real Python object, so the sink Rust's python_itermodelsiter(x) — is directly available to it: list(decoded), whose message is CPython's verbatim rather than a reconstruction. Only None still answers "no groups", which is Django's own single guard (if obj_list is None) and covers the unresolved operand too, since ignore_failures=True produces None for it. Django's groupby calls iter() unguarded, so everything else raises.

    A second half the issue does not mention, found by running the axis rather than the cited value. After the swallow was gone a bool source still answered [0], because value_channel_arg_string handed the handler Python's True — which is not valid JSON, so json.loads raised and the handler took its "this must be an unresolved bare name" branch. 42 and 1.5 ARE valid JSON and so were already refused correctly; the doc comment claiming "every other scalar's Display form (42, True, None, 1.5) is unambiguous against a bare name" was false of exactly one of the four. A Bool is now encoded as JSON true/false — the same type-tag argument #2385 made for the String arm. None keeps its Display spelling: its mis-decode is harmless, because the fallback answers None, which is exactly what Django's guard wants (recorded rather than ridden along, #1079).

    Every other site that asks "is this iterable?" was enumerated and decided. Python: regroup._decode_source is the only one in the whole djust/template_tags/ package, and it is the one fixed. Rust: filters::iter_values is the sink, filters::python_iter names its None for the refusing filters (#2451), and renderer.rs's {% for %} arm writes the message directly through the shared python_type_name. Exactly two production sites emit 'X' object is not iterable, and TestNoSecondIterabilityCheckWasAdded pins that SET by equality — so a third copy and a removed arm both redden it, which a floor-based count cannot do.

    What changes for you. A template regrouping a value Django cannot iterate now raises instead of rendering an empty region. These are exactly the templates Django has always refused; the direction that would break working templates — refusing where Django renders — is checked across the iterable axis, including "", [] and {}, which iterate to nothing rather than refusing. Known and unchanged: a bare object() still renders one group, because it never reaches the handler as an object — Value's conversion has no variant for it and it arrives as Value::String(str(o)). That is the #2466 conversion gap, one tag over, and guarding it inside regroup would be a fix at the consumer for a defect at the source; pinned in the diverging direction in TestWhatThisDeliberatelyDoesNOTClose.

    72 cases in python/tests/test_regroup_non_iterable_2463.py, including a verdict-identity pin against {% for %} across the whole value axis (the #1646 assertion, written as an identity rather than a list for the reason #2459's is). Three pins that asserted the old fail-soft are flipped in place rather than deleted — test_regroup_is_a_SEPARATE_pre_existing_divergence (#2459) and TestTheDivergenceThatIsNotClosedHere (#2385), both of which said in as many words that they would redden the day regroup refused. Three gate-off mutations — restoring the swallow, dropping the None guard, and reverting the Bool encoding — redden 43 / 12 / 9 tests, each rebuilding the crate and asserting the .so mtime advanced; no survivors.

  • A datetime reaching a client through the LiveView path or the wire encoder is spelled the way DjangoJSONEncoder spells it (#2462). Django's encoder truncates microseconds to milliseconds (r[:23] + r[26:] for a datetime, the DIFFERENT r[:12] for a time) and rewrites a trailing +00:00 to Z. Three djust sinks spelled it isoformat() instead, so {{ p|json_script:"d" }} in a LiveView emitted "2020-01-01T03:04:05.123456" where Django emits "…05.123", and "…05+00:00" where Django emits "…05Z". #2448 closed the RAW DjustTemplateBackend path with Value::Encoded; this is the same defect on the other one, in Python, which the Rust variant cannot reach because the value is already a str by then.

    The issue's own measurement names the wrong encoder, and that is where the fix goes. It reports normalize_django_value violating its docstring identity for 4 of 10 datetime shapes. DjangoJSONEncoder in djust/serialization.py is djust's own subclass, not django.core.serializers.json's — and djust's spelled a datetime with a bare isoformat() too, so against the encoder the docstring actually names the identity held for all four. The table was produced against Django's encoder. So the defect is real and wider than reported: both the pre-pass and djust's encoder disagreed with Django, and djust's encoder is the one that feeds the WebSocket frame (websocket.py), the SSE stream (sse.py) and the HTTP-API body (api/dispatch.py). Fixing only normalize_django_value would have created the violation the issue describes — a pre-pass spelling .123 while the encoder it feeds spelled .123456.

    Three sinks, one helper, found by grepping the SINK (#1646).djust/serialization.py's encoder, its normalize_django_value, and djust/template/serialization.py::serialize_value — the third was not in the issue and was found by grepping isoformat() rather than by listing the callers already known. All three now call django_json_datetime, which callsDjangoJSONEncoder.default rather than re-implementing it, for the reason #2448's Rust side gives. A hand port has three chances to be wrong and the issue body took one of them: it quoted the datetime slice pair as the time rule, and r[:23] + r[26:] is a no-op on a time"03:04:05.123456" is 15 characters, so nothing is truncated. timedelta joins djust's encoder's branch as well, which had raised TypeError where Django's has always answered duration_iso_string; that is why normalize_django_value documented it as an "enhancement beyond DjangoJSONEncoder" — a claim true of this encoder and false of Django's.

    Three tests should have caught it, and each was blind on the axis another one covered. The issue names TestParityWithJSONRoundtrip and diagnoses its 17-value list as sampling only microsecond == 0 with no tzinfo. That is true and it is not the load-bearing half: the test imports DjangoJSONEncoderfrom djust.serialization, so it compared the pre-pass against a copy of the same defect. Measured — 3,923 randomized values spanning every microsecond and every offset produce zero failures of that assertion, so doing exactly what the issue recommends (widen it to a randomized differential) would have left it green and the class exactly as reachable. Meanwhile test_decimal_converters_2239.py::TestEncoderMatchesRealDjango had the right reference — it compares byte-for-byte against Django's own encoder — and the same three narrow values (datetime(2024,6,15,12,30,45), date(...), time(8,0,0)); and test_template_serialization.py::TestDjangoJSONEncoderTypes asserted a hand-written "2024-06-15T14:30:45.123456", a literal neither encoder produces. All three are re-derived here. The value set comes from the branches default() actually has (o.microsecond truthiness, r.endswith("+00:00"), is_aware(o), and duration_iso_string's sign/day/microsecond splits), CROSSED rather than sampled — 6 microsecond values × 7 offset shapes × the four types, including the two near-misses a curated table skips: timezone(timedelta(0)), which is not timezone.utc but formats identically, and +00:01, which ends in 0:00 without ending in +00:00 — plus a 3,000-value seeded randomized differential against Django's encoder whose own branch coverage is asserted.

    What is deliberately NOT closed. An aware datetime.time, for which Django's default() raises ValueError: JSON can't represent timezone-aware times., keeps emitting its isoformat() — the more-permissive direction djust takes for every unserialisable value (#2429), and the direction django_json_encoded takes by failing closed. It is an explicit branch rather than a bare except, so it cannot swallow a different failure. And the LiveView path still FLATTENS a datetime to a str in Python, so Value::Encoded is never built there and {{ p }} renders that string; spelling it correctly is what this closes, and not flattening at all would change what every consumer of normalize_django_value receives — the session round trip, the wire encoders and the JIT serializer all need a JSON-able value — so it is filed separately (#1079). The stated cost of the spelling change is that a bare {{ p }} over an aware datetime renders …Z where it rendered …+00:00 on that path; both already diverged from Django, which localizes a bare datetime, and the string still parses to the same instant, which is pinned so the date filters downstream are provably unaffected.

    New cases in TestTheIdentityHoldsAgainstBOTHEncoders, TestTheRandomizedDifferential, TestTheEncoderIsCalledAndNotTranscribed, TestTheSinkSetIsPinned, TestThreeTestsWereBlindOnComplementaryAxes and TestWhatThisDeliberatelyDoesNOTClose in python/tests/test_datetime_encoder_spelling_2462.py. The caller SET is pinned and canaried in BOTH directions (#1125), and .isoformat() is asserted to survive in exactly ONE place across both modules — the documented aware-time residue. tests/unit/test_normalize_django_value.py::TestParityWithJSONRoundtrip is re-derived onto the crossed value set AND a Django-referenced assertion with its own gate-off; test_decimal_converters_2239.py's datetime rows are widened and its test_timedelta_is_a_known_pre_existing_gap inverted; test_template_serialization.py's hand-written literal now asserts Django's answer rather than a third one; and test_json_script_datetime_value_2448.py's four-row still-divergent pin plus its source pin on the parity list's sampling are both inverted to agreement rather than deleted — the source pin now checks BOTH axes moved. Four gate-off mutations redden 191 / 112 / 120 / 45 tests; no survivors, no invalid runs.

  • get_digit answers an int, as Django's docstring says it always does — not a one-character string (#2459). Django's body ends return int(str(value)[-arg]) and its except IndexError arm answers 0; its docstring says "output is always an integer". djust's arm answered Value::String((*b as char).to_string()) and Value::String("0"). The text was identical, which is the whole of why it survived: every assertion at the Rust arm read .to_string(), and every differential cell that renders the digit alone agreed. The type is what a consumer reads, and a str iterates, subscripts, has a len() and is truthy at "0" where an int does none of those.

    Three classes, and the issue names only one of them.#2459 lists five consumers (safeseq, escapeseq, unordered_list, first, last) and calls them the cost. Swept over Django's live registry rather than transcribed: (1) refuses in Django, rendered here — those five plus random (random.choice is len then value[i]) and phone2numeric (.lower() on an int), and {% for %} over the digit; (2) renders on both, DIFFERENT textpprint (2 vs '2'), length (Django's len(int) raises into its own except and answers 0; a string answers 1), stringformat:"d" ("%d" % "2" is a TypeError Django's except swallows, so djust answered ""); (3) silently takes the wrong branch{% if p|get_digit:"9" %} and {{ p|get_digit:"9"|yesno }}, because the IndexError exit is 0, which is falsy, and "0" is not. The third class is the one worth the issue: no exception, no visible difference at the digit itself, a template gate that opens where Django's closes. A django-refuses / djust-renders count — which is how #2459 arrived at "15 cells" — cannot see classes 2 or 3 at all.

    Nothing is added below the filter.#2451's ValueOpError / value_op_error chokepoint was already right about every refusal cell — an int is not iterable and not subscriptable, and it refuses when it is given one. It was being handed a str. So the diff is one arm's return type: Value::Integer(i64::from(b - b'0')) and Value::Integer(0). int_value_of is deliberately not called — it exists to parse an arbitrary digit string and widen past i64, and neither question arises for a single ASCII digit. The two exits that hand back the INPUT are untouched, including the documented --sign divergence that sits between them (#1195), and structural pins assert no consumer arm and no renderer path learned this filter's name (#1646).

    The load-bearing test is an IDENTITY, not a list of consumers. A hand-written list is one short by construction — the issue's was three short, and #2216#2227#2228 is the same lesson three times. So TestTheOutputIsIndistinguishableFromDjangosOwnReturn asserts, for every filter Django registers and each of get_digit's four exits, that {{ p|get_digit:<n>|F }} over the subject equals {{ q|F }} over django.template.defaultfilters.get_digit(subject, n) — on djust and on Django. Nothing in it is transcribed: the consumer set is the registry, the arguments are read out of the differential's own FILTER_ARGS, and the expected object comes from Django's function. It goes red for a consumer nobody thought of.

    Measured over 353,909 cells, two genuinely different builds (adac3068f0802d1da0923abeeb3c9c90): django REFUSES & djust RENDERS goes 6,106 → 6,009, and djust REFUSES & Django RENDERS is 38,105 → 38,105 — flat, which is what shows the agreement was bought without becoming stricter. Refusal-collapsed agreement moves +136; the byte-exact headline moves only +39 and is blind to this class by construction (both engines refusing is not byte equality), so it is not the number to read (#2454). 197 cells move, all of them naming get_digit: 97 become a refusal, 40 now agree while still rendering (the firstof / {% if %} truthiness class, 0 vs "0"), 57 are the {% regroup %} axis and are #2463's, and 3 stop agreeing — every one of them a coincidence the differential classifies as such (no longer agreeing: 1, coincidental: 1, REGRESSIONS: 0). Those three are {{ p|length|get_digit:"1" }} over a serialized model: {{ p|length }} alone is 4 in Django and 0 here on BOTH builds — a pre-existing python_len divergence about the model-vs-dict marker (#2294) — and the branch used to match only because djust's 0 arrived as the truthy string "0". Two different numbers agreeing on one boolean; pinned in test_one_cell_stops_agreeing_and_it_agreed_by_COINCIDENCE with the length control, because a fix that made that cell "agree" again would have to make a falsy value truthy. 0 regressions, 0 cells newly panic, live-payload leaks unchanged at 22. The other 48 moved cells are random's <NONDET> marker drawing differently between two runs, which the comparison collapses.

    Not fixed here and filed (#1079): #2463{% regroup %} fails soft to an empty grouping where Django raises 'int' object is not iterable, and it is not this filter's cell. The control is {% regroup p by k %} over a plain int with no filter in the template, which already diverges; {% for %}, the same question one tag over, agrees. #2451 wired the type-named refusal into the for arm and the regroup arm kept its old fail-soft — parallel-path drift (#1646). Pinned live in TestTheTagOperandPositions::test_regroup_is_a_SEPARATE_pre_existing_divergence, asserted in the diverging direction so it closes itself.

    25 regression cases in python/tests/test_get_digit_returns_an_int_2459.py (68 collected, most parameterized over the four exits), plus test_get_digit_answers_an_int_on_both_numeric_exits in crates/djust_templates/src/filters.rs — the first assertion at that arm to read the Value VARIANT rather than .to_string(), which is the shape that hid this. The residue pin in TestTheResidueThisDoesNotTouch inverts as its own docstring instructed. Four gate-off mutations, each rebuilding the crate with the .so mtime asserted to have advanced, __pycache__ cleared, the mutation text asserted found exactly once, and N error counted apart from N failed.

  • bool(timedelta(0)) is False, as it is in Python and Django (#2458). Every other member of the datetime family is truthy for every value — a midnight time has been truthy since 3.5 — so a zero timedelta is the whole of the divergence, and it was live in the plainest possible template: {% if p %}T{% else %}F{% endif %} rendered T here and F there. It predates #2448: a timedelta crossed the PyO3 boundary as Value::String("0:00:00"), which is non-empty and therefore truthy under the string rule, and #2448's Value::Encoded deliberately kept that answer (!display.is_empty(), i.e. always true) rather than let a JSON-spelling fix change truthiness silently.

    The bit is carried, not derived, and both available derivations are wrong.Encoded grows a fourth field set from Python's own bool(o) at the conversion. Reading it back off the ENCODER spelling (json == "P0DT00H00M00S") is exact for the builtin but answers a truthiness question with a string comparison and cannot see a subclass overriding __bool__; reading it off the DISPLAY spelling (display == "0:00:00") is additionally wrong, because that is also the text of the perfectly ordinary and Python-TRUTHY str"0:00:00" — one text, two answers. TestTheBitIsPythonsAndNotADerivation runs both counterexamples: a NON-zero timedelta subclass whose __bool__ says False (every spelling-derivation answers True) and a ZERO one whose __bool__ says True (every spelling-derivation answers False).

    The timesince half was a second, text-shaped copy of a rule the codebase already answered value-typed (#1646).{{ p|timesince:q }} with q = timedelta(0) measures from now in Django and RAISED here, because timesince_arg_is_falsy(&str, bool) recovered Python's if not now: from the argument's Display text — while ArgType::is_falsy (#2413), computed from the RESOLVED Value two frames up, was already sitting there with the right answer. The copy is deleted and the filter reads the shared bit; #2448's own test_every_display_arm_that_can_be_falsy_is_handled had refused a text fix on exactly this ground and named the value-typed predicate as the condition for closing it.

    Three divergences the convergence closed that the issue did not predict. A resolved Python str spelling a falsy object — "0", "None", "False", "0.0", "[]", "{}" — was read as the object it spells and measured from now, where Python calls every non-empty str truthy and Django raises AttributeError on .year; it now raises on both. And under legacy_display, which renders EVERY sequence as the literal [List], an empty list was indistinguishable from a full one and both raised; the Value is not, so both modes now agree with Django. The four rows that used to be TestTheFalsinessResidueIsNamed's residue are down to one, and the survivor is genuinely about the wire format rather than about truthiness: a date-SHAPED str is still read as the datetime it spells, because a Python datetime crosses into Rust as a string and has no other spelling.

    The state round trip carries the bit, because the Decimal version of this shipped once without it (#2135).SerializableViewState.state round-trips through msgpack on EVERY read of the default InMemoryStateBackend, so an untagged answer flips back after one cache hit. ENCODED_TAG's payload goes from [type_name, display, json] to [type_name, display, json, truthy], and the THREE-element form is still read — a Redis backend hands one back on the first request after a rolling deploy, which is a live input rather than a hypothetical. It restores to the truthiness that entry was written with (!display.is_empty()), which is the honest answer rather than a guess. test_the_payload_is_what_carries_it decodes the real blob and asserts the fourth element, so the field cannot be dropped with the round-trip test still green.

    What is deliberately not closed. A set has no Value variant at all, so it lands on the conversion's final Value::String(str(o)) and arrives as the non-empty "set()"bool(set()) is False and {% if q %} renders T. Same family, one level up, at the CONVERSION rather than in the truthiness rule, and out of a datetime fix's scope (#1079); pinned as still-divergent in TestWhatThisDeliberatelyDoesNOTClose and filed separately.

    New cases in TestPythonsOwnAnswerForTheWholeFamily, TestTheBitIsPythonsAndNotADerivation, TestTheStateRoundTripKeepsTheAnswer, TestTheConvergenceDividend, TestTheSinkHasExactlyTheCallersItClaims and TestWhatThisDeliberatelyDoesNOTClose in python/tests/test_encoded_truthiness_2458.py. The value set is the CROSS of every axis the family has — type, microsecond zero and non-zero, naive and aware, Z and +HH:MM and negative offsets, zero/positive/negative/sub-second/multi-day durations — with bool() computed per row rather than transcribed, and a non-vacuity test asserting exactly one row of the sweep is falsy. python/tests/test_timesince_comparison_instant_2344.py's falsiness class is re-derived onto the value-typed answer and python/tests/test_json_script_datetime_value_2448.py's zero-timedelta exemption is inverted to agreement rather than deleted. Four gate-off mutations, each rebuilding the crate and asserting the .so mtime advanced, redden 9 / 10 / 3 / 30 tests; no survivors, no build breaks.

  • json_script spells a datetime / date / time / timedelta VALUE the way DjangoJSONEncoder does, not str() (#2448).django.utils.html.json_script is json.dumps(value, cls=DjangoJSONEncoder) and that encoder's default() is not str() — it is isoformat() with the microseconds truncated to milliseconds and a trailing +00:00 rewritten to Z for a datetime, and duration_iso_string for a timedelta. djust reached it with the TEMPLATE DISPLAY spelling, so {{ p|json_script:"d" }} over {"a": datetime(2020,1,1,3,4,5)} put "2020-01-01 03:04:05" on the wire where Django puts "2020-01-01T03:04:05", and timedelta(seconds=90) went out as "0:01:30" rather than "P0DT00H01M30S". Not cosmetic: neither is parseable by Date.parse, and neither is an ISO-8601 duration, so client code reading the <script> body gets a string it cannot use.

    Two rows the issue's table did not have, both from running the encoder rather than reading it. It listed time as AGREEING (✓); it agrees only at microsecond == 0, which is the band the report sampled — time(3,4,5,123456) is "03:04:05.123" in Django and was "03:04:05.123456" here. Same for a datetime carrying microseconds. And timedelta(seconds=-90) is "-P0DT00H01M30S" against a str() that normalises to "-1 day, 23:58:30". A fix scoped to the issue's own table — "datetime and timedelta" — would have left a live divergence one microsecond away, the coincidence-in-the-sampled-band shape #2425's float keys had. date is the only member that agrees for every value and is carried anyway, so the fix's type set is a SET rather than a list of the members that happened to diverge.

    Why this is decidable where #2429 was not.#2429 (djust emits where json.dumps REFUSES) was declined because the value position cannot see the type. That erasure is real and it is a CHOICE MADE AT THE CONVERSION, not a property of the boundary: FromPyObject for Value landed a datetime on its final Ok(Value::String(ob.str()?)) fallback three arms below a Decimal arm that reads its type with an isinstance. So the fix stops discarding the type rather than reconstructing it downstream. Value::Encoded carries str(o), DjangoJSONEncoder.default(o) and CPython's tp_name; value_to_json is the ONE place that reads the encoder field. The encoder is CALLED, never re-implemented — a hand port would have to reproduce the millisecond truncation, the Z rewrite and duration_iso_string's negative normalisation, three transcriptions the issue's own table got at least partly wrong.

    Which PATH this is on, stated because it bounds the claim. djust has two ways into the renderer. The RAW one — render_template(tpl, ctx), which template/backend.py takes, so a plain Django view rendering through DjustTemplateBackend — hands Rust the Python object, and that is the path Value::Encoded exists for and the one this closes. The LiveView path runs its context through normalize_django_value first, which flattens a datetime to an ISO string in Python, so Value::Encoded is never built there. That path was already mostly right — and only mostly: the normalizer violates its own documented DjangoJSONEncoder identity for 4 of 10 datetime shapes (it applies neither the millisecond truncation nor the Z rewrite), and the parity test written to pin that identity samples no microsecond and no tzinfo value. Filed as #2462 and pinned as still-divergent here, so this entry cannot be read as closing the LiveView path.

    The state round trip is closed in the same commit, because the Decimal version of this was shipped without it once (#2214/#2135).SerializableViewState.state goes through msgpack on every read of the default backend, so an untagged Encoded comes back as a Value::String holding the display spelling and the whole defect reopens after one cache hit. ENCODED_TAG carries [type_name, display, json]; TestTheStateRoundTripKeepsTheEncoderSpelling exercises both directions and asserts the tag is what does it.

    Two dividends fell out of the boundary learning the type, neither planned: {% for x in dt %} now raises 'datetime.datetime' object is not iterable instead of iterating the display string CHARACTER BY CHARACTER (#2382's residue, closed for four of its five shapes — a bare object() is still on the str() path), and a datetime filter ARGUMENT now raises as Django's int() does, so {{ p|floatformat:dt }}, |get_digit, |truncatechars and |truncatewords agree. #2366's own assertion message had named the condition — "if the extraction boundary learned the type, move this row" — and those four rows moved.

    Unchanged, and pinned as such: the bare render ({{ dt }} is still str(o), which already diverges from Django's localizing path — a separate defect, not one to move under a JSON fix), the KEY position (that IS #2429's refusal question), an aware time (whose encoder RAISES, so the helper fails closed to the pre-fix path), bool(timedelta(0)) (#2458) and |pprint. Each has a test in TestWhatThisDeliberatelyDoesNOTClose so a stale exemption goes red.

    114 cases in python/tests/test_json_script_datetime_value_2448.py, including a 3,000-value randomized differential against live Django rather than three samples per type. Six gate-off mutations redden 43 / 117 / 11 / 4 / 1 / 14 tests; no survivors, and the harness asserts the mutation matched exactly once, that the .so mtime advanced, and counts pytest error apart from failed (#2129/#2135).

  • {{ nope|random }} renders "" as Django does, instead of refusing with a message that is false of every str (#2449 reconciliation).#2461 landed the sequence-filter refusal on main first — first / last / random / unordered_list / safeseq / escapeseq / phone2numeric through one ValueOpError chokepoint — and this branch's independent implementation of the same fix is deleted rather than landed beside it, along with its test file, since a second copy is the #1646 class this PR avoided once already. What is left is the part reconciling the two surfaced.

    Three probes, one question, and one of them had a different model.Value::Missing is Django's string_if_invalid, which is "": type str, length 0, and subscripting it is an IndexError. python_type_name and python_getitem both said so — the latter with the comment "string_if_invalid is "", and ""[0] is an IndexError" — while python_len answered None ("len() of the thing that was not there is not a number"), reasoning about ABSENCE where the other two reason about the substituted string. random is the one caller that distinguishes None from Some(0), so {{ nope|random }} refused with 'str' object is not subscriptable — a self-contradicting message, since every str is subscriptable — where Django renders "". Stricter than Django, on the most ordinary shape a template has. Fixed by giving Value::Missing a length of 0, with a probe-level pin asserting all three answer the empty string's answers.

    It survived #2461's own sweep because that sweep binds a value for p on every cell and skips random as nondeterministic — two exclusions meeting on the one filter that had the bug. TestAnAbsentVariableIsStringIfInvalidOnEveryOneOfTheSeven is the missing axis, and TestTheDatetimeFamilyReachesTheChokepointWithItsRealTypeName is the other one its corpus cannot reach; both are added to test_sequence_op_chokepoint_2451.py rather than to a parallel table. The d[0]-is-a-key-lookup half that #2457 was filed for is closed by #2461 itself, so that issue is closed as superseded.

    New cases in those two classes plus python_len_agrees_with_the_other_two_probes_about_missing in crates/djust_templates/src/filters.rs. Seven gate-off mutations over the merged chokepoint redden 15 / 31 / 11 / 3 / 13 / 3 / 2 tests; no survivors.

    Corpus: measured over 353,909 cells, two genuinely different builds (4e5cde758a0519f9588bb3f544eb9c3d), with #2455's refusal-aware gate — the one that can actually see this class: 222 cells moved, 81 newly agreeing (refusal-collapsed), 0 regressions, 0 panics, and the live-payload-leak count is unchanged at 22.djust REFUSES & Django RENDERS is 38,105 before and 38,105 after — 0 cells became stricter than Django, which is the number the python_len fix protects. The raw agreement count is unchanged at 257,355 and is structurally blind here, exactly as #2454 describes.

  • A PWA tag's render-failure diagnostic stays an invisible HTML comment instead of printing as visible text (#2434).template_tags/pwa.py's _render_django_tag diagnoses a failure by returning <!-- djust: <tag> render failed (check server logs) -->, and returned it as a plain str. Since #2379 the Rust tag bridge ESCAPES a handler's return unless it carries __html__ (Django's SimpleNode.render rule), so the comment reached the page as the visible text &lt;!-- djust: djust_pwa_head render failed (check server logs) --&gt; — a server-side failure shouted at the end user, on the one path whose whole job is to be readable in view-source and invisible on the page. All four handlers share the exit: djust_pwa_head, djust_pwa_manifest, djust_sw_register, djust_offline_indicator.

    Marked, not emptied, and the alternative is why. Dropping the comment and leaving logger.exception as the only record was the other option. It loses the only signal a front-end developer has: the failure is an absence — no manifest link, no service-worker registration — which is unattributable from the browser, and the comment is what names the tag that went missing without server-log access. It would also make two diagnostics of the same kind disagree: {% call %}'s missing-component-name comment in components/function_component.py is ALREADY marked, by #2379's own single-exit safe_html. That sibling is asserted at runtime rather than read off its docstring, since it is the whole consistency argument. escape(tag_name) on the interpolated value per CLAUDE.md's mark_safe rule, so the marker covers a constant shape plus a value that cannot carry -->; gating the escape off reddens exactly one test, so it is load-bearing rather than decorative (#1859).

    Why #2379's audit could not see it, and the net that now can. That enumeration calls every handler with render([], {}), and under this repo's own settings all four PWA tags render SUCCESSFULLY there — Template.render returns a SafeString — so only the success exit was ever reached; the failure exit needs the {% load djust_pwa %} library to be unavailable or the generated source to be unparseable. _ARG_VECTORS in test_custom_tag_return_escape_2379.py now carries a kwarg whose value holds a double quote, which breaks _build_django_tag's key="value" assembly and so reaches the failure exit from an ARGUMENT rather than from a settings change — a real end-to-end trigger, no monkeypatching. Crossed gate-off (bug restored × vector removed) confirms the vector is what lets the general audit see this branch at all: with it the enumeration goes red, without it green.

    No other handler has this shape. The enumeration re-run over all 221 registered handlers with arguments finds pwa.py:77 and function_component.py:302 as the only comment-returning exits behind the bridge, and the second was already marked by #2379.

    New cases in TestTheFailureDiagnosticReachesThePageAsAComment, TestTheSiblingDiagnosticAgrees, TestTheMarkerCannotBeWidenedByItsArgument and TestEveryPwaHandlerRoutesThroughTheOneExit (python/tests/test_pwa_failure_diagnostic_2434.py). Four gate-off mutations redden 11 / 1 / 3 / 0 tests; the survivor is the vector removal, which is equivalent-given-the-fix and shown load-bearing by the crossed run above rather than left as a silence.

  • A custom tag handler's ARGUMENT keeps its SafeData marker, and a quoted literal loses its quotes (#2416). Django's SimpleNode.render compiles each operand with parser.compile_filter(bit) and resolves it with FilterExpression.resolve(context), handing the handler the resolved object. djust flattened every operand to a String through value_to_arg_string, which lost two things Django keeps. (1) The marker: {% ct_cond p %} over p = mark_safe("<img src=x onerror=alert(1)>") — a handler whose body is the ordinary defensive conditional_escape(value) — is a no-op in Django and the markup renders; djust handed it a bare str, so the handler's own escape fired. That is #2290's finding on the ARGUMENT side of the tag registry rather than the filter registry. (2) The quotes: Variable('"<b>"') ends with self.literal = mark_safe(unescape_string_literal(var)), so a quoted literal loses its surrounding quotes AND arrives as SafeData; djust passed the token verbatim, so {% t "<b>" %} handed the handler the five characters "<b>" and — since #2379 escaped the return — the page spelled them out as &quot;&lt;b&gt;&quot;. The quotes half is not only a markup problem, and that is what nothing could see: {% t "post" %} handed the handler "post" WITH the quotes where Django hands it post, so the defect reached every quoted literal argument and not only one containing a tag. Both were MASKED before #2379 — the marker was lost on the way in, the bridge emitted the return raw on the way out, and two wrongs cancelled — so neither is a regression from #2379; #2379 is what made them visible, and both were pinned there and in #2356's file as named limits, which is how this landed.

    One resolver, not a second literal rule. The argument channel now transports a TagArg { text, safe } and mints the SafeString in ONE place (registry::build_py_args, shared by all three registries), and Node::CustomTag resolves through get_value_safe — which ends at django_literal, the one place a bare token is recognized as a literal and the one place the grant one carries is minted (#2376). {% t "<b>" %} and {{ "<b>" }} therefore answer from the same place by construction rather than by agreement; a literal rule written at the tag site would have been a second mechanism shadowing the first (#2233).

    What becomes live, and why that set is safe. Every change here moves in the LESS-escaping direction on the path where #2379's XSS lived, so the set is stated and asserted rather than argued. An operand is marked only when the resolver reports SafeDataand the value is a Value::String — Django's own rule, since SafeString is a str subclass. That first bool is the same one that decides whether {{ p }} escapes, so the newly-live set is a SUBSET of what the primary output channel already renders live: if it contains attacker data, {{ p }} is already an XSS and nothing here changes that. A quoted literal is the TEMPLATE AUTHOR's own source bytes, never context data — #2376's argument, and Django's. Nothing else is marked: not an unmarked context string, not a number / bool / None, not a container, not a key=value composite (the transported text is key=<value>, so marking it would mark the key= bytes too — left over-escaping and otherwise unchanged), and not an operand that failed to resolve. TestTheGrantDoesNotWiden asserts each row plus a sweep over the whole probe grid × five hostile inputs.

    The issue's own premise about the third divergence is wrong, and running it is what showed that.#2416 says fixing the marker "would also close" #2379's remaining divergence, where a handler that type-checks its argument sees "5" while Django hands it 5. It does not: marking a string SafeData does not make it an int. The two are different halves of the same flattening and only the safety half moved; the type half would rework the value_to_arg_string contract every handler decodes against — RenderSlotTagHandler's JSON round-trip among them. Pinned in TestTheArgumentTYPEIsStillAString and in #2356's test_every_argument_arrives_as_a_string, so the remainder is a named limit.

    A sibling the same seed closes.get_value_safe's pipe branch seeded the chain from context.is_safe(var_name), and a literal is not a name — so {% firstof "<B>"|lower %} came out ESCAPED where the {{ }} arm, seeded from django_literal's own bool, was already right. lower is registered is_safe=True, so a safe input stays safe; an upper cell cannot tell the two seeds apart, which is why the corpus's existing ct-filtered shape never moved.

    Unquoting has one fallout, and it needed a guard of its own.TagHandler._resolve_arg — which url, static, djust_markdown, live_render, dj_flash and the PWA family all call — resolves a bare dotted-identifier token against the context, and that was harmless only while a quoted literal arrived WITH its quotes, because the quoted branch returned before the lookup. Stripping them makes {% url "home" %} arrive as home, which matches the variable-token regex, so a context variable named home SHADOWED the URL name — the #2041 footgun one channel over, introduced by this very change and measured before it shipped: {% t "home" %} with home = "/SHADOWED/" resolved to /SHADOWED/, and {% t "post.slug" %} walked a post dict. The guard is the marker this PR adds: SafeData means the engine already resolved the operand — the template author's own literal (Variable.__init__ marks exactly that) or a value the view vouched for — so neither is looked up as a context KEY. It NARROWS the class rather than widening anything; a plain resolved string is still re-resolved, which is the pre-existing hazard #2037 named, and test_a_PLAIN_resolved_string_is_still_re_resolved pins it so the guard stays honest about what it does.

    Composed with #2423 by hand, and the one bit that forced a decision.#2423 landed the inline-tag RESOLVE_ARG_POSITIONS policy into the very block this replaces, so git could not combine them — its policy branch sits inside the code this deletes. resolve_custom_tag_args states the order once: the policy applies FIRST, so a declared-literal position short-circuits before any resolution (that is the point of the policy — resolution is lossy for a handler that must parse the token itself), and every other position routes through resolve_custom_tag_arg. The literal-passthrough position returns an UNMARKED arg, and that is a decision rather than an omission. A resolved quoted literal is a VALUE: django_literal hands back the unescaped text and those exact bytes reach the page, which is why Django marks it (Variable.__init__ ends its quoted branch with mark_safe(unescape_string_literal(var))) and why this PR does. A passthrough token is a NAME — slots.col.0.content, p, or "slots.col.0" with its quotes still on — that the handler is about to resolve into something else. SafeData asserts "these bytes are ready for the page"; that is not true of a name, and it says nothing whatever about the value the name resolves to. Minting one would let a hostile {% render_slot p %} ride a grant issued for the single character p, which is the class #2379 and #2421 closed on the one handler that made it framework-reachable with no |safe and no mark_safe. Django has no rule to copy here, because Django never hands a simple_tag an un-resolved token at all — the policy is a djust extension — so with no reference behaviour the escaping direction is the one to fail in. It costs nothing: render_slot never reads its argument's marker; it resolves the path itself and marks its own RETURN at the one exit terminating in a slot entry's content.

    Both harnesses were measuring the wrong thing.test_custom_tag_return_escape_2379.py::both and test_tag_operand_axis_2355_2356.py::djust_render called _rust.render_template, which has no safe_keys parameter — only render_template_with_dirs carries the context-safety channel (#2287). So no row in either file could grant anything, and their mark_safed-context rows measured "the engine was never told" rather than "the marker did not survive the hop". Both now derive the grants with djust's own _collect_safe_keys, so a test cannot claim one the bridge would not produce.

    Corpus: three custom-tag shapes, because the argument axis crossed with the operand SPELLING is its own axis and ct-literal alone could not separate the two stacked defects. ct-literal-plain ({% ct_ident "post" %}) is the quotes alone; ct-literal-cond is the grant alone; ct-literal-filtered ({% ct_ident "<B>"|lower %}) is the literal crossed with a FILTER, which neither the name-based ct-filtered nor the bare ct-literal could construct. unmasked()'s @ctag arm is DELETED: it excused a cell whose new output was Django's escaped once more while the ct-cond twin diverged on both builds, and with the input-side loss fixed that second condition can never hold again — a classifier that could only ever mask a future custom-tag regression (#2233). A @ctag regression is now always reported. Still unreachable, and said rather than left silent: the built-in-tag axis writes p|<filter>, a NAME base, so the {% firstof %} half of the seed fix moved 0 cells there — the built-in-tag × literal-operand cross has no shape and is pinned in the test file instead.

    The #2379 enumeration now reaches the argument-bearing branches (#2423's audit gap). It called every handler with render([], {}), so a handler returning "" for no arguments was audited on a branch that CANNOT return markup — which is what #2421 cost, render_slot sitting in the empty-string bucket while its markup branch shipped double-escaped. Re-run with representative arguments: 18 of the 221 handlers return "" for the no-argument call, 4 of those (djust_markdown, kbd, render_slot, static) reach a non-empty return once given one, and exactly one return carries markup without __html__render_slot, which is #2423's own limit. TestTheEnumerationReachesTheMarkupBranchesToo asserts the offender SET, so a NEW handler returning unmarked markup on an argument-bearing branch fails here.

    Two-build differential over 352,237 cells: 188 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics. Every moved cell is on the ctag axis (189 of 492); the one that moved without newly agreeing is random, nondeterministic by construction. New cases in TestAMarkedContextValueArrivesAsSafeData, TestAQuotedLiteralLosesItsQuotesAndIsSafeData, TestTheGrantDoesNotWiden and TestTheArgumentTYPEIsStillAString and TestTheHandlerBaseClassDoesNotReResolveALiteral (41 in python/tests/test_tag_argument_safedata_2416.py), in TestTheDivergencesThisUnmaskedAreNowCLOSED, TestTheDifferentialHasNoCtagExemptionAnyMore and TestTheEnumerationReachesTheMarkupBranchesToo, and in crates/djust_templates/src/renderer.rs (tag_arg_marks_only_a_safe_string_value, three resolve_custom_tag_arg_* cases and every_handler_arg_construction_site_is_accounted_for, which pins the construction-site SET rather than a floor) plus crates/djust_templates/src/registry.rs (every_registry_builds_its_args_through_the_one_builder). Six gate-off mutations — crate rebuilt per iteration, mutation text asserted found exactly once, source asserted changed, __pycache__ cleared, and pytest N error counted apart from N failed — were RE-RUN against the merged code, together with #2423's seven and an eighth for the merge decision itself, because a mutation that reddened before a merge can go green after it if the two mechanisms now shadow each other. Fifteen mutations redden 2 / 19 / 9 / 19 / 2 / 1 / 3 (#2416) and 7 / 7 / 9 / 8 / 8 / 7 / 12 (#2423) Python tests. The one that matters most is the quoted literal is passed VERBATIM again, which reddens 9 — exactly its pre-merge count, so the policy branch does not shadow the unquoting fix — and the renderer ignores the declared policy, which reddens 7, so the unquoting does not shadow the policy either. The fifteenth SURVIVED on the Python side and the answer was missing coverage, structurally so: marking the literal-passthrough position failed 0 Python tests, because render_slot is the only shipped handler that declares a policy and it never reads its argument's marker — no behaviour of the shipped handler set can see the bit. It was caught by the source-level pin, and is now caught behaviourally too by test_a_DECLARED_LITERAL_position_arrives_unmarked, whose probe declares a policy and ECHOES the token: marked, {% t "<b>" %} puts the template's own markup on the page raw. One was a survivor first, and the answer was missing coverage rather than a redundant mechanism: dropping the Value::String narrowing reddened NOTHING, because _collect_safe_keys descends to the SafeString leaves and never emits a container path, so no row could reach the branch. render_template_with_dirs's safe_keys is a public entry point taking arbitrary paths, and a caller CAN grant safety to a container — with the narrowing gone the handler then receives that container's JSON encoding marked, and the identity probe puts the payload on the page live. That row is asserted leak-first, so the mutation's failure is definitionally the leak.

  • {% render_slot slots.col.0.content %} renders LIVE, and a bare context string still does not (#2423).#2421 restored live rendering at RenderSlotTagHandler._render_value's slot-entry exit and deliberately left one spelling over-escaping: the scalar passthrough, where {% render_slot slots.col.0.content %} — a slot body the parent already rendered and escaped — and a hostile {% render_slot p %} arrived as the SAME opaque string, because the Rust engine resolved both before the handler ran. With nothing left to separate them the exit took the escape.

    The discriminator has to come from BEFORE resolution, and now it does.RenderSlotTagHandler declares RESOLVE_ARG_POSITIONS = frozenset() and the engine hands it the LITERAL token — the inline-tag twin of the policy {% regroup %} has used since #2041 to keep its keyword operands literal, which until now existed only on the ASSIGN registry. With the path in hand the two spellings are structurally distinct again: slots.col.0.content terminates at the content key of a {"name", "attrs", "content"} slot entry, p terminates at a bare context value. Both registries now read the policy through ONE read_resolve_positions rather than a hand-copy, because the rule's two halves — a missing attribute and an explicit None — are exactly the pair a copy gets wrong (#1646).

    It grants nothing {% render_slot d %} did not already grant, and that is asserted rather than argued. A string is marked only when its path's last segment is literally content and the segment before it resolves to a dict with EXACTLY the key set _extract_slots builds — a set derived from that builder in the test rather than transcribed, so a fourth key fails a test instead of silently turning every slot body back into visible text. A context dict shaped like a slot entry IS marked through the .content spelling; test_the_slot_shaped_dict_grant_is_the_one_2421_ALREADY_gives renders the same dict both ways and asserts they agree, which is the property — the spelling reaches a grant the entry spelling already had, rather than a wider one. Everything else stays escaped: a bare hostile string, a content key on a dict with an extra or a missing key, a top-level content variable, and _render_value's trailing str(value) (the framework-reachable half of the #2379 XSS, re-asserted here because a re-route is exactly where a guard gets dropped).

    The #861 dual-caller split is retired rather than patched. The engine now hands this handler exactly what a direct Python caller does — a literal path — so there is ONE arg shape instead of two, and the JSON arm survives only for a caller that chose to encode its own structure.

    New cases in TestTheEngineHandsOverTheLiteralToken, TestTheScalarSpellingRendersLive, TestTheGrantIsNotWidened and TestTheDiscriminatorsPremises (python/tests/test_render_slot_scalar_path_2423.py). #2421's own test_the_scalar_spelling_is_over_escaped_which_is_a_LIMIT_not_a_leak went red as designed and is rewritten in place as a two-direction parity assertion, so a revert goes red on the row that named the limit.

  • json_script spells a dict KEY the way json.dumps spells it — true / null / Infinity, and 1e+16 (#2425).json.dumps does not call str() on a non-str dict key; it has its own five-entry table, in CPython's c_make_encoder order: str unchanged, the three JSON literals for True / False / None, float.__repr__, int.__repr__, and TypeError for anything else. djust routed the key through ObjectKey::to_display_string(), so {{ p|json_script:"d" }} over {True: "b", None: "c"} emitted {"True": "b", "None": "c"} where Django emits {"true": "b", "null": "c"}. One new sibling of json_float_body (#2270), json_key_body, called from the one object-key site in value_to_json — the count pin value_to_json_escapes_every_string_through_the_one_helper still sees its four json_string_body sites, because the new helper wraps the ARGUMENT rather than adding a fifth escape.

    The issue's own table was the thing to check, and it was wrong about floats in both directions. It said "the int and float arms agree by coincidence, because str(0) and str(1.5) are already the JSON forms", and re-deriving over every key type rather than inheriting the list says the premise is false, not merely incomplete: the old coercion was never str(). to_display_string() is the key's TEMPLATE display — what {% for k in d %}{{ k }}{% endfor %} writes — and it parts company with float.__repr__ well before infinity. {1e16: "v"} was {"10000000000000000": "v"} against Django's {"1e+16": "v"}, and {1e-5: "v"} was {"0.00001": "v"} against {"1e-05": "v"}. Only the middle band of small finite values coincided, and that is the band the issue sampled. The non-finite keys (inf / nanInfinity / NaN) were found by the key-type sweep; the exponent band was found by the gate-off, which failed 1e16 and 1e-05 when the Float arm was deleted and nothing had predicted it.

    Two-build differential over the key-type axis: 18 of 29 key types divergent before, 10 after, 0 moving the other way. The 10 that remain are exactly the types json.dumps REFUSES, and they are deliberately not closed here (#1079): djust emits a key's str() where Django raises TypeError: keys must be str, int, float, bool or None. That half stays open because djust does not refuse an unserialisable VALUE either{"a": object()} renders a document here and raises Object of type Obj is not JSON serializable on Django — so refusing keys alone would make the two positions disagree, a new inconsistency wearing a fix's clothes. Both positions want one decision, taken together; filed as #2429 and pinned live in TestTheRefusalHalfIsADecidedLimit, whose second method asserts the VALUE position is permissive and is the reason the first is left alone. The date key is the sharpest argument for taking them together: DjangoJSONEncoder.default serialises a date VALUE (so both engines agree there) while a date KEY never reaches the hook, so any refusal design has to model the encoder hook and not json.dumps's bare table.

    scripts/filter-parity-differential.py's corpus stays at exactly one divergent argument-less json_script cell before and after — d-typed-key, now held there by its (1, "t") key rather than by its True / None ones — and #2413's scope claim is re-worded to say so. The corpus was silent about this axis rather than wrong: it carries no non-finite and no exponent-form float key, which is the curated-table-samples-one-axis shape.

    11 test cases in python/tests/test_json_script_typed_keys_2425.py (38 with parameterisation), plus #2413's test_a_bool_or_None_KEY_is_spelled_Python_not_JSON flipped to test_a_bool_or_None_KEY_is_now_spelled_the_JSON_way, which is the rewrite that class was written to force. Five gate-off mutations redden 16 / 7 / 4 / 6 / 7 tests with no survivors, one per mechanism — the true and false arms are separately reachable, so a fix handling only the truthy one fails — and the harness asserts the mutation matched exactly once, that the source changed, that the crate REBUILT and __pycache__ was cleared, and counts pytest's N error apart from N failed.

  • _rust.pyi's register_tag_handler example now runs, and documents the real contract (#2417). The stub showed a bare function (def handle_custom_tag(args, kwargs)), which the runtime rejects with TypeError: Handler must have a 'render' method — so the first thing a project author writing a custom tag would copy raises on paste. All twelve Example:: blocks in the stub were executed: six raise, but five of those are ordinary placeholders (an undefined Article, a fictional template path); register_tag_handler is the only one that supplies every name it uses and still fails. Its siblings register_block_tag_handler and register_assign_tag_handler document render(...) correctly, and all 221 registered handlers follow that contract, so only the stub disagreed. It survived because scripts/check-doc-snippets.py does not read .pyi. Writing the replacement surfaced a second inaccuracy: args arrive already resolved against the context, so a natural-looking context.get(args[0]) would look up the resolved value as a variable name — {% custom p %} with p="<b>hi</b>" gives args == ['<b>hi</b>']. The example now also shows the post-#2379 escaping contract. New cases in TestTheStubExampleRuns, which execute the block rather than inspecting it, since the failure mode is an example that reads correctly and raises when run; 4/4 gate-off verified (reverting to the bare function reddens 2, dropping the mark_safe 1, dropping the escape 1, restoring the wrong args contract 1).

  • {% render_slot %} emits the parent's rendered slot LIVE again, and a bare context string stays ESCAPED (#2421).#2379 made a tag handler's plain-str return get escaped — right in general, and wrong for the one value RenderSlotTagHandler echoes that the PARENT already rendered. {% render_slot p %} over {"content": "<strong>rendered</strong>"} gave &lt;strong&gt;rendered&lt;/strong&gt; where 1.1.0 gives <strong>rendered</strong>, so every function component and named slot rendered its own markup as visible text, and context data inside a slot compounded to &amp;lt; — escaped once by the engine writing the body, once more by the bridge. A release blocker, reproduced on a release build.

    The two returns are opposite directions, so both obvious fixes are wrong.value["content"] is a slot entry's body, already rendered and already escaped by the parent; the trailing str(value) is a bare value straight out of the render context. Marking the whole return restores a shipped vulnerability — render_slot is the one handler of #2379's 221 that echoes a context value, which is what makes that XSS framework-reachable on 1.0.0 / 1.0.8 / 1.1.0 with no |safe, no mark_safe and no app-written handler: using slots is enough. Escaping both is the regression. The mark goes at the one already-escaped exit (#1104), and the restored surface is strictly NARROWER than what shipped — at v1.1.0registry.rs had no escape_handler_return at all, so the bare-string exit rendered raw there too.

    The premise is verified, not quoted. The handler's docstring calls the content "already-escaped HTML from the parent", which is exactly the kind of claim this drain keeps finding wrong. Run instead: {% slot h %}{{ evil }}{% endslot %} over <img src=x onerror=alert(1)> puts &lt;img …&gt; in the entry while literal markup written beside it survives raw, and the sentinel's html.escape / html.unescape round-trip is a byte-for-byte no-op on it — so the escape came from the ENGINE rendering the body. That is {% include %}'s trust status rather than a simple_tag return's, which is what licenses the mark.

    render()'s Shape-3 scalar passthrough stays unmarked, as a named limit rather than a silent one.{% render_slot slots.col.0.content %} and a hostile {% render_slot p %} both arrive there as an opaque pre-resolved string — the engine resolved them before the handler was called — so that exit cannot separate them and takes the escape. Over-escaping, never a leak; it is not a spelling the docs use (docs/website/guides/components.md and the ROADMAP use the slot-entry forms, all fixed here), and it is tracked at #2423.

    The two siblings from the same #2379 sweep were decided explicitly rather than assumed (#1646), and both are right.SlotTagHandler returns a <!--DJUST_SLOT_V1:…--> sentinel whose payload _emit_slot_sentinel has already html.escape-d, so nothing in it is left to escape and escaping the comment would break slot collection outright; CallTagHandler returns a component's rendered markup, which is markup by contract.

    Why it shipped, and what the tests now do.tests/unit/test_named_slots.py (14) and test_function_components.py (18) are green before and after — 32 between them, not 32 each as the issue reports — because every slot body they render is plain text, which escapes to itself, so a double escape is invisible to all 32. The #2379 enumeration calls each handler with no args and render_slot returns "" there, so its markup branch was in the "13 return the empty string" bucket and the audit never reached it. 27 cases in python/tests/test_render_slot_markup_2421.py, covering the premise, both directions end-to-end, the trust contract of the dict exit, the marker at all four exits, and both sibling handlers. Gate-off three ways, since one mechanism guards two opposite failures and a third exit guards the bare string: removing the mark reddens TestASlotsMarkupRendersLive (9 failed), widening it to the trailing return reddens TestABareContextStringStaysEscaped (4), and marking Shape 3 reddens it too (6); no survivors. The widening mutation needed a row nothing had — the bare-string spelling never reaches _render_value at all — so {% render_slot p %} over ["<img …>"], traced to that exit rather than assumed to hit it, is what makes the trailing return guarded.

  • json_script matches json.dumps's ensure_ascii, and omits the id attribute for a falsy element_id (#2413). Two byte-level divergences in one filter, both a different mechanism from the key ordering #2405 closed and both named in that PR's TestKnownDivergencesOnTheSamePath so they would go red the day they were fixed — which is how this landed. ensure_ascii: django.utils.html.json_script calls json.dumps(value, cls=encoder or DjangoJSONEncoder) and passes no ensure_ascii, so it takes the default of True (DjangoJSONEncoder overrides only default(), falsification-tested rather than read); djust emitted raw UTF-8, for KEYS as much as values and at every nesting depth. The rule is DERIVED by running json.dumps over every codepoint rather than read off the C encoder: what comes back raw is exactly U+0020..U+0021, U+0023..U+005B, U+005D..U+007E, an astral codepoint is a UTF-16 surrogate PAIR (json.dumps("\U0001f600") is "\ud83d\ude00", not a six-hex escape and not the raw character), the hex is LOWERCASE where _json_script_escapes writes UPPERCASE — both spellings are Django's, from the two steps it composes — and 0x7F (DEL) escapes, which the Rust helper's own doc had argued against by citing ensure_ascii=False, true about a call Django does not make. Escaping lives in json_string_body, the ONE helper every quoted string in value_to_json goes through (#2241), so keys, values and every depth get it from one place. json_escape_for_script's U+2028 / U+2029 arms go with it: they were the right compensation while the engine emitted raw UTF-8, nothing non-ASCII can now reach that stage, and a dead second mechanism for a job the first already does can only shadow it (#2233) — their absence also makes the map Django's exact three characters. The id: the issue reported it as a MISSING element_id; running Django says the premise is narrower than the defect. The source is if element_id: — a TRUTHINESS test on the resolved Python OBJECT, not is not None — so None, "", 0, 0.0, False, [] and {} all omit the attribute WHOLE, and djust wrote id="data", id="", id="0", id="False", id="[]" for those. Two argument-less {{ …|json_script }} calls on one page therefore collided on the same DOM id. str(0) is "0", so the dispatch table's &str cannot answer the question; the resolved value's truthiness is threaded as a third ArgType bit — computed once at the resolution site for BOTH argument channels, the context one and the literal one, because FilterExpression.resolve produces a Python object either way — and the invented "data" default is deleted rather than left unreachable. Measured: a randomized differential of 3,000 assembled nested values × 4 templates, over an alphabet spanning every branch of the escaper, went from 9,227 divergent of 12,000 to 0. Un-masking dividend: every argument-less json_script cell in the corpus already diverged on the id, so any divergence in the JSON body sat underneath and could not be attributed — which is why #2405's own corpus shape had to pass an explicit id. Of the 41 plain json_script <value> cells, 41 diverged before and 1 does now: d-typed-key, where json.dumps spells a bool / None KEY true / null and REFUSES a tuple, bytes or object key while djust emits its repr — filed as #2425 and pinned in TestTheDivergenceTheUnMaskingRevealed, with a scope test asserting it is the ONLY one so a new body divergence cannot hide there. Two-build differential over 351,898 cells: 5,988 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics.Three pre-existing pins go red as designed and are rewritten as parity assertions: #2405's two named divergences and its exact-set test_each_one_agrees_with_django (which went red the second way it was written to); #2241's byte-parity differential and short-form table, whose reference calls passed ensure_ascii=False and so pinned the wrong encoder; and #2347's _KNOWN list, now empty. The last surfaced a defect of its own — _is_about_the_literal's probe was named __djust_2347_control, which Django refuses at PARSE time ("Variables and attributes may not begin with underscores"), so the control raised for all 84 swept cells and the sweep's mismatched assertion was permanently vacuous; the probe is fixed and the canary re-based on the classifier itself rather than on incidental production divergences (#1859). New cases in TestEnsureAscii, TestAFalsyElementIdEmitsNoIdAttribute, TestRandomizedDifferentialAgainstDjango and TestTheDivergenceTheUnMaskingRevealed (80 total in test_json_script_ensure_ascii_and_element_id_2413.py), plus test_json_script_escapes_delete_as_ensure_ascii_does, test_json_script_encodes_astral_as_a_surrogate_pair, test_json_script_omits_the_id_attribute_for_a_falsy_element_id, json_string_body_output_is_pure_ascii_2413 and the four-test json_script_arm_structure module in crates/djust_templates/src/filters.rs. Six gate-off mutations — with the crate rebuilt per iteration, the mutation text asserted found exactly once, and pytest N error counted apart from N failed — redden 34 / 17 / 19 / 18 / 26 / 6 Python tests and 4 / 1 / 2 / 2 / 2 / 1 Rust tests; no survivors. The two id mechanisms are independently reachable (#2135): dropping the is_falsy guard reddens test_every_falsy_resolved_argument while test_no_argument_at_all stays GREEN, and restoring the default reddens test_no_argument_at_all and test_the_invented_default_is_gone. The structural pin banning a bare "data" literal does not catch a writer that spells the attribute inline, so a second pin counts id=-bearing literals — measured by mutating the arm that way and watching the first stay green, not assumed.

  • A serialized mapping keeps INSERTION order, as json.dumps does (#2405).{% for x in p %}{{ forloop|json_script:"d" }}{% endfor %} over [1] put counter first where Django puts parentloop — same keys, same values, different order, and json.dumps preserves insertion order, so the serialized BYTES differ. Cosmetic to a consumer that parses the JSON; not cosmetic to a snapshot test, a checksum or a diff in CI. The issue's cited location was wrong, and so was its fix shape: it located the defect in Node::For's dict construction and called the fix "one-line reordering", but that construction is CORRECT and always was — {{ forloop }}'s own repr already agrees with Django's, key for key, in order, and TestTheForNodeDictWasAlreadyRight pins that as the premise the real diagnosis rests on. The order was destroyed one layer down, in value_to_json's Object arm, which ran parts.sort(). So it was never a forloop defect: every dict json_script touched came out alphabetized — top level, nested, and inside a list — and a forloop-shaped fix would have special-cased one instance of a general one. The sort's own comment already said it was a remaining divergence deliberately left alone (#1079); this is the issue that makes it in scope, and the fix retires the class. Enumerated rather than sampled: order_observable_filters() runs every filter in Django's LIVE registry over two mappings differing only in insertion order and keeps the ones whose output differs, then asserts djust matches Django on each — a hand-picked list is the transcription this area keeps finding one short (#2218, #2223). A randomized differential over 1,600 assembled nested values takes the ASCII-only 1,052 from 241 byte-divergent to 0. Corpus: #2402 added seventeen forloop shapes and not one could see this, because every one renders a MEMBER or the REPR and both engines already agreed on the repr; a forloop-json shape closes it, with an explicit id because {{ p|json_script }} diverges on the id ATTRIBUTE on both builds and would mask the body — a gate-off that drops the id reddens exactly the test asserting nothing else masks the cell. The honest correction to the issue's "no gate covers it": the corpus's own @arg json_script:"5" cells over d-plain and d-model DID carry the divergence, and nothing attributed it. Left alone and pinned rather than folded in (#1079): json.dumps defaults to ensure_ascii=True and djust emits raw UTF-8 (548 of the 1,600 fuzz values, unchanged by this fix), and {{ p|json_script }} writes id="data" where Django omits the attribute — both are named in TestKnownDivergencesOnTheSamePath so they go red the day they are fixed. Two-build differential against a baseline pinned at 0b44d747, over 346,020 cells: 75 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics. New cases in TestTheForNodeDictWasAlreadyRight, TestASerializedMappingKeepsInsertionOrder, TestEveryOrderObservableFilterAgrees, TestTheCorpusGapThatHidThisFromTheForloopCells and TestKnownDivergencesOnTheSamePath (18). Three gate-off mutations — re-introducing parts.sort(), deleting the corpus shape, and dropping its explicit id — redden 9 / 3 / 1 tests; no survivors, and the third was a survivor until the "nothing else masks that shape" assertion was added.

  • Three filter arms gave the wrong answer on their NON-computing branch (#2399, #2401, #2403). Each is a filter body with more than one exit, where djust implemented the computing exit and got the other one wrong. yesno (#2401) ran a three-way branch of its own over a mix of the argument's parts and the built-in defaults, diverging on four axes at once: a one-part argument fell through to yes/no/maybe where Django's if len(bits) < 2: return value hands the VALUE back ({{ True|yesno:"only" }} was only, Django says True); a falsy-but-not-None value took the maybe arm that Django reserves for None alone ({{ ""|yesno }} was maybe, Django says no — and an ABSENT variable is falsy too, since Django substitutes string_if_invalid before the filter runs, so {{ absent|yesno:"a,b,c" }} is b); a four-part argument read bits[2] for None where Django's unpack raises for any length that is not exactly three and falls back to bits[1]; and Value::Bool(false) had its own arm answering no while every other falsy shape answered maybe, so the arm looked right from the one input a curated test reaches for. Transcribed from Django's body rather than repaired four times. The issue says the escaping half is already correct on both engines; measured, it is not — the len(bits) < 2 exit returns the INPUT OBJECT, so {{ p|yesno:"only" }} over a mark_safed value emits live <b>x</b> in Django, and that grant is now in builtin_produced_safe beside default's. timesince / timeuntil (#2399) had no if not value: return "" guard at all and ECHOED the input for every value they could not read — {{ p|timesince }} over "abc" rendered abc where Django raises, and over 0 rendered 0 where Django renders nothing. Django's timesince() reaches value.year on its first line, so a truthy non-datetime raises AttributeError, which neither of its excepts catches; mirroring date's "" (#2383) onto those rows would have been a THIRD answer, neither the echo's nor Django's. This is a behaviour change: a template rendering a truthy non-date through either filter now raises where it used to print the value, the same posture #2387 took for {% for %}'s unpack arity. The error crosses PyO3 as RuntimeError rather than Django's class, as every djust render error does, and it names the FILTER rather than the value — an error string reaches logs and the client's error frame, and the value is application data. get_digit (#2403) has two return value statements and they are not the same answer: value = int(value) runs inside the try, BEFORE the arg < 1 test. So an int() that raised returns the INPUT OBJECT, SafeData and all — {{ p|get_digit:1 }} over mark_safe("<b>x</b>") was escaped where Django emits it live — while arg < 1 returns the CONVERTED int, which djust returned unconverted ({{ False|get_digit:0 }} was False where Django says 0, and {{ 1.5|get_digit:0 }} was 1.5 where Django says 1). It comes back as a NUMBER, so the rest of a chain does arithmetic rather than concatenation, and it carries no safety grant, because an int is never SafeData. New cases in TestYesnoIsDjangosBody, TestTimesinceRefusesWhereDjangoRefuses, TestGetDigitsPassThroughBranch, TestNoArmIsMorePermissiveThanDjango, TestTheResiduesThisPRDoesNotTouch and TestTheIssuesOwnClaims — the last of which records the three premises each issue stated that a live Django contradicted. Four pre-existing pins went red as designed and name their successors in place; yesno was the last row of #2328's OUTPUT_DIVERGES_FOR_ANOTHER_REASON, now empty. One more yesno row came out of the two-build differential rather than out of the issue: if arg is None is an IDENTITY test and str(None) is "None", so a bare None literal, a variable bound to None, and the STRING "None" reach the dispatch table as the same four characters while Django answers maybe for the first two and None for the third — the argument's resolved TYPE is now threaded (ArgType, which also carries #2366's int(arg)-is-a-TypeError bit) rather than sniffed off the text, because a spelling fallback gets the bound-string row wrong. Measured against a baseline pinned at eb7d89cd, over the differential's 344,980 cells: 2,006 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics.

  • A quoted literal in a FILTER ARGUMENT is SafeData: {{ p|default:"<b>" }} emits live markup (#2389).FilterExpression.resolve marks a CONSTANT argument safe (if not lookup: arg_vals.append(mark_safe(arg))), so default hands the SafeString back unchanged and conditional_escape leaves it alone; djust escaped it. Over-escaping — a lost capability, never a leak. Two of the issue's premises did not survive, and both made this smaller than it looked: the grant did NOT need a signature change across 57 arms (apply_filter_full_safe has taken arg_was_quoted since #2202 and the add arm already reads it — the change is two arms and one if), and the issue's candidate list of filters that return the argument was wrong in both directions. Running all 57 built-ins against a hostile quoted-literal argument, rather than reading the bodies, gives four: default, default_if_none, join (already agreed — conditional_escape(arg) leaves a SafeString separator alone) and json_script, which the list does not name and whose format_html interpolation puts a literal id in raw. yesno and pluralize, which the list does name, are NOT members: they str.split(",") the argument, and splitting a SafeString yields plain strs. The gate is arg_was_quoted, exactly Django's not lookup, so the VARIABLE channel — the half that can carry attacker data — is unchanged and measured clean on both engines across every built-in. test_xss_prevention.rs::filter_chain_default_still_escapes pinned the old behaviour and is replaced by three tests, not deleted: the literal is live, and a variable default argument and a variable json_script id are both still escaped. Three premises of the tests' own first draft were also corrected by live Django and are recorded in the bodies: |upper LOSES the grant (is_safe=False), Django's escape filter is conditional_escape and so is a no-op on SafeData, and the derived set is four filters rather than three. Sweeps: literal-argument 378 → 360 diverging cells with all 18 Django-emits-live cells closed and none remaining; variable-argument 0 live payloads on either engine before and after; 336 chain cells with 0 more-permissive cells, live or escaped. Two-build differential, both readings because the honest one is the pair: this change alone moves 34 cells into agreement and 4 out — {% regroup p|default:"D"|safeseq … %} and its siblings, where the grant makes safeseq's list collapse to its repr exactly as Django's mark_safe(list) does, and the collapsed operand then meets the regroup-over-a-string bug (#2385, the #2272 two-wrongs shape); with #2385 fixed in the same tree, 17,805 newly agreeing and 0 regressions. 0 introduced live-payload leaks and 0 panics in both readings. New cases in TestAQuotedArgumentIsSafeData, TestTheBranchThatWasAlreadyRight, TestTheVariableChannelIsUntouched and TestTheEnumerationIsMechanical; five gate-off mutations redden 11 / 3 / 3 / 2 / 2 tests, the last two being the over-permissive ones.

  • {{ forloop.counter }} and every other forloop member rendered EMPTY (#2402). Django's ForNode.render writes context["forloop"] — a dict carrying parentloop, counter0, counter, revcounter, revcounter0, first and last, updated on every iteration. Node::For bound none of them, so all seven names missed and rendered string_if_invalid: {% for a in p %}[{{ forloop.counter }}]{% endfor %} over [1, 2, 3] was '[][][]' where Django is '[1][2][3]'. A numbered list with no numbers, {% if forloop.first %} never true, {% if not forloop.last %},{% endif %} a comma after the last element — silent under-render with no error anywhere, the same class as #2325, #2334 and #2377, and reachable from every operand shape those fixes rewrote (a bare dict, d.items/d.keys/d.values, a string, a filtered operand, both unpack spellings). Three details the arithmetic does not give away.counter is the ITERATION ordinal, not the item's index: Django reverses the sequence and THEN enumerates, so {% for x in p reversed %} counts 1,2,3 in render order — __djust_if_loop_path deliberately uses the item index, and reading it here agrees on every forward loop while silently reversing the numbering on a reversed one. parentloop at the outermost level is Django's empty dict, not missing, so {{ forloop.parentloop }} renders {}. And the {% empty %} branch must NOT see this loop's dict — Django writes it only after the len(values) < 1 early return — while a NESTED empty branch must still see the OUTER one. ctx.revoke_safe_subtree("forloop") is load-bearing, not defensive: without it a context variable named forloop carrying a mark_safe value grants the engine's own dict, and the whole repr — including the attacker-controlled parentloop — goes out UNESCAPED; gated off, that emits a live <script>alert(1)</script> where Django escapes it. The loop render cache's forloop guard, which loop_cache.rs describes as defensive because "the Rust renderer does not currently implement forloop", was protecting nothing until now — the members rendered empty, so a stale cached fragment was byte-identical to a fresh one, and the existing forloop_counter battery case compared '' to ''. It is live for the first time, and the two gates that can disable caching do NOT cover the same spellings (Gate 1 does not look inside a {% with %} / {% firstof %} / {% widthratio %} argument; Gate 2 does), so the new suite asserts the INVARIANT — identical output AND zero cache traffic across all 12 spellings — rather than either gate's rule, with a forloop-free control proving the cache was enabled at all. Corpus: no cell in PATH_SHAPES / TAG_SHAPES / BUILTIN_SHAPES referenced a forloop name, so the differential reported 0 MISSING on nine axes over ~315,000 cells while all seven members were empty — the sixth corpus gap of this shape (#2281, #2325, #2334, #2376, #2377). Adds 17 forloop shapes (629 cells over the 37 inputs) covering each member alone, the whole dict, nesting, the nested {% empty %}, reversed, {% if %}, a filter chain, {% with %}, {% firstof %} and both shadowing directions; and a loop-variable axis whose requirement is parsed out of Django's own ForNode.render source rather than transcribed, so the day Django adds a member the manifest reports it MISSING. TestTheLimitTheManifestDoesNotClose is sharpened accordingly: emptying PATH_SHAPES is now noticed by exactly one axis (loop-variable) where it was noticed by none, and the dotted-path half it documents remains uncaught. test_forloop_is_not_available_through_render_template was the pin naming this bug; it is inverted in place, as its class docstring said it would be. Two-build differential against c2d1405b: 432 path cells moved, 424 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped-fragment, 0 live), 0 panics — and the 8 that moved without reaching agreement are accounted for: all 8 are forloop-if, they moved TOWARD Django, and what still separates them is the <!--dj-if--> marker djust emits for any {% if %} in a loop with or without a forloop in it (test_a_forloop_free_if_in_a_loop_already_disagrees_on_the_marker pins that it is orthogonal). New cases in TestEverySevenMembers, TestArithmeticBoundaries, TestReversedUsesTheIterationOrdinalNotTheItemIndex, TestParentloopAndNesting, TestEmptyBranch, TestEveryOperandShapeTheLoopNormalises, TestUnpackingBothSpellings, TestCoordinatingTagsInsideTheLoop, TestFilterChainsOnForloop, TestShadowing, TestTheEscapingDirection, TestTheDjIfMarkerIsOrthogonal and TestTheCorpusGapThatHidThisFromTheDifferential, plus 4 Rust tests in crates/djust_templates/tests/test_forloop_loop_cache_2402.rs. Ten source mutations and two corpus mutations redden 76 / 2 / 38 / 14 / 9 / 11 / 13 / 5 / 1 / 76 / 2 / 3 tests respectively; no survivors.

  • {% for a, b in x %} refuses an arity mismatch instead of padding, as Django does (#2387).ForNode.render computes len(item) (a TypeError counts as 1) and raises ValueError("Need N values to unpack in for loop; got M. ") when it does not equal the loop-variable count. djust filled the extra names with Value::Missing and rendered, so {% for a, b in p %}[{{ a }}={{ b }}]{% endfor %} over "abc" rendered '[a=][b=][c=]' where Django refuses the template — more permissive than Django, and silent. Django's message is now used verbatim, trailing space and all; it crosses to Python as a RuntimeError rather than Django's ValueError, as every djust render error does. The check alone was not the whole fix: zip ITERATES the item rather than indexing it, so an item whose length DOES match unpacks by Python's iteration — ["ab"] binds a="a", b="b" and [{"x":1,"y":2}] binds a="x", b="y" — where djust bound the whole item to the first name and Missing to the rest, rendering a dict's own repr into {{ a }}. Both shapes PASS the arity check, so both needed the zip; the new arm grants no safety to any component, because _collect_safe_keys spells a dict BY KEY NAME and a positional lookup there is the #2334 collision. The length rule is now stated once, in filters::python_len, which returns Option<usize>None where Python raises — because the two call sites disagree about the fallback (defaultfilters.length writes return 0, ForNode.render writes len_item = 1), and collapsing them would have made one wrong (#1646). The short-item padding branch is deleted rather than kept as a belt: after the check the two lengths are equal by construction (#2233). TestTheUnpackArityDivergenceIsNamedNotFixed moved out of test_for_unpack_comma_spelling_2377.py as it said it would; what stays there is the #2377 half — every comma spelling raises the same message. TestDictIterationRandomised's residue classifier grew a both-raised row that compares MESSAGES rather than exception classes, so it cannot absorb a coincidental djust failure. Two-build differential: 135 path cells moved, 0 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 0 panics — and the zero needs reading: all 135 classify mechanically as django-raises-unpack / djust-rendered-before / djust-raises-the-same-message-after, and the corpus records a raise as <<EXC {type}>>, so ValueError and RuntimeError cannot compare equal however faithful the message is. Every one moved in the less-permissive direction; none moved the other way. New cases in TestBothEnginesRefuseAnArityMismatch, TestAMatchingArityUnpacksByIteration, TestTheAnswersThatMustNotMove, TestTheLengthRuleIsStatedOnce, TestNoSafetyGrantSurvivesTheNewUnpackArm, plus python_len_agrees_with_iter_values and two siblings in crates/djust_templates/src/filters.rs. Six gate-off mutations redden 18 / 5 / 4 / 2 / 1-cargo / 6 tests.

  • {% regroup %} iterates its source the way Python does, so a STRING builds Django's one group instead of zero (#2385, #2394).RegroupNode.render runs groupby(obj_list, …) over whatever the target resolved to, so Django iterates with Python's own semantics — a str yields its characters, a dict yields its keys. djust's handler matched list/tuple and answered [] for everything else, so {% regroup s by k as g %}[{{ g|length }}] over s = "ab" rendered [0] where Django renders [1], silently, with every {% for %} over the groups empty. #2385 measured the class at 8,505 corpus cells. #2394 and #2385 are one defect, described twice: #2394 ran three operand spellings against each other and localised the gap to the handler rather than the operand resolver, #2385 called the same handler "List/Tuple-only". Every spelling that resolves to a string was affected — p.0, p.a, p|first, p|upper, p|slice:':2' and a quoted literal all arrived as text. The handler half alone would have traded one divergence for a worse one: this arg channel's contract is "unresolved ⇒ the caller keeps the raw token", so a resolved string and a missing variable arrived as the same bytes (ab and nope), and Django's answer for the second is zero groups — iterating the text would have grouped nope into four characters, MORE permissive than Django on a cell that already agreed. So resolve_tag_value_arg now JSON-encodes a resolved Value::String at a position the handler declared in RESOLVE_ARG_POSITIONS, and the quoting is the type tag that was missing; Decimal/BigInt deliberately keep their Display form, since their JSON is also a string and Python cannot iterate either. That ambiguity had a second live symptom, now fixed: the handler's bare-name fallback looked its text up as a context key, so s = "q" grouped over the unrelated variable q whenever one existed. Decided in the same pass per #2385's "check the other shapes": a dict source groups its keys. Left alone and pinned instead (#1079): a non-iterable source (int, bool, Decimal) renders an empty region where Django raises TypeError — pre-existing and never more permissive. resolve_tag_operand is split into resolve_tag_operand_value plus two encodings so the two channels cannot fork on WHAT they resolve, only on how they serialize it (#1646). Two-build differential: 17,771 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 live, 0 escaped), 0 panics; the 71 cells that stopped agreeing are all classified coincidental (the filter itself diverges on both builds). 17,771 against #2385's estimate of 8,505 — the extra is the dict source, the context-key shadow case and the operand spellings its three-row table did not enumerate. TestTheRegroupUnmaskingIsNamed said "if #2385 is fixed, delete this class"; it is flipped to TestTheRegroupUnmaskingIsCLOSED instead, because its evidence chain stays worth checking in the other direction. New cases in TestAStringSourceBuildsDjangosGroup, TestTheAnswersThatMustNotMove, TestTheDivergenceThatIsNotClosedHere, TestBothMechanismsAreReachable and TestTheWiringIsLoadBearing; four gate-off mutations redden 12 / 13 / 1 / 15 tests respectively, with no two mechanisms shadowing each other.

  • Django's step-3 index subscripts a str: {{ s.0 }} on "abc" is 'a' (#2373).Variable._resolve_lookup's third step is current[int(bit)], and Python subscripts a str — by CODE POINT, so {{ s.1 }} on "héllo" is 'é' and a byte index would split a two-byte character in half. djust rendered the empty string. The issue's own premise was wrong, and checking it is what made this small: #2373 scoped itself out of #2371 on the reading that closing it needed an owned return across every Context::get caller ("renderer.rs alone has 15") and was therefore a refactor. But Context::resolve already returns an owned Value and is the door every operand site reaches — {{ }} calls it directly, and {% if %} / {% with %} / {% for %} / {% firstof %} / {% cycle %} reach it as get_value_safe's last arm — so the step is one helper beside Context::dict_view, which exists for the same reason in the same place. Context::get's signature is untouched and no caller changed. The asymmetry closed is #1646's shape: the raw-Python SIDECAR walk has had Django's step 3 for strings since #1997 (it ends in current.get_item(idx)) while its value-stack twin did not. Recursive, because a character is itself a str ({{ s.0.0 }} is 'a'). Deliberately out of reach and measured rather than assumed: a NEGATIVE index (a Django parse error, already pinned), a Value::DictView (dict_items is not subscriptable; {{ d.items.0 }} stays empty on both), and the sidecar, which needs nothing. No new grant: a character sliced out of a mark_safed string is a plain str in Django (SafeString overrides __add__, not __getitem__), so both engines escape it — asserted through render_template_with_dirs with the whole-string control that makes the claim non-vacuous. Two-build differential: 82 path cells moved, 82 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 0 panics. TestTheStringIndexStepIsNamedNotFixed named itself as the thing to move and is now TestTheStringIndexStepIsCLOSED; #2371's 3,000-cell randomised sweep keeps its _walks_through_a_string_index count, which now bounds COVERAGE instead of an exclusion.

  • {% regroup p.values by k as g %} silently misses: the assign-tag operand channel resolved through get, not resolve (#2368).renderer::resolve_tag_operand had two branches — a pipe-bearing one #2333 routed through get_value, and a bare one still on Context::get. The dict views (d.items / .keys / .values) live in Context::resolve (dict_view is only reachable from there, which is where #2334 put it), so the pipe branch saw a view and the bare dotted path did not: the tag fell to its "unresolved ⇒ keep the raw token" contract, the handler received the template's own source text, and {{ g|length }} rendered 0 with no exception and no warning. Same class as #2333, one operand form over — that fix made this channel FILTER-aware and left it dict-view-blind (#1646). Each thing resolve adds beyond get is decided rather than inherited: the dict views (the point); the raw-Python sidecar walk and ADR-024's auto-call (the SAME widening the pipe branch already had, since get_value_safe ends with a context.resolve fallback); and template_builtin, which is textually inert here because None/True/False serialize back to the same bytes the raw token carried. The keyword-operand hazard #2041's RESOLVE_ARG_POSITIONS exists to prevent is measured, not asserted: a handler that declares a mask (regroup declares {0}) never routes its by/<attr>/as/<var> through this function, and the tests render with context entries deliberately named k, by, as and g to show none of them shadows a keyword. Two-build differential: 24 path cells moved, 24 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 0 panics. test_a_view_reaches_a_tag_operand_through_the_pipe_branch_only named itself as the thing to move the day this was fixed and is now test_a_view_reaches_a_tag_operand_through_BOTH_branches. New cases in TestTheBareDottedPathReachesTheHandler, TestTheControlsThatAlreadyAgreed and TestTheKeywordOperandsStayLiteral.

  • {% for a,b in x %} — the comma WITHOUT a space — is tuple unpacking (#2377). Django's do_for joins the tokens before in and splits that on re.split(r" *, *", …), so a,b, a, b and a ,b are one three-name loop; djust split on WHITESPACE and only trimmed a trailing comma, so a,b became ONE variable literally spelled a,b. Nothing resolves that, so every {{ a }} / {{ b }} in the body rendered empty and the loop's whole output silently vanished — the same shape as #2325 ({% for x in p|slice %}) and #2334 ({% for k in d %}), and the spelling in Django's OWN do_for docstring ({% for key,value in dict.items %}). The split creates an empty-name case the whitespace split could not ({% for a, in p %}), so do_for's invalid_chars refusal comes with it verbatim — empty, space, either quote, or | — and NOT isidentifier(), because Django accepts {% for a-b in p %}. Sibling check: {% for %} is the only modern-Django built-in with a comma-separated argument list; cycle's legacy a,b,c form is gone and with/firstof/regroup/ifchanged/widthratio all raise on a comma — measured, not assumed. Corpus gap closed in the same change: every loop the differential built used the spaced spelling, so it reported clean over the whole of this; five PATH_SHAPES entries now spell it four ways. Two-build differential: 61 path cells moved, 6 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 0 panics. New cases in TestEverySpellingOfTheUnpackList, TestTheGrantStillTravelsUnderTheNewSpelling, TestTheInvalidArgumentRuleIsDjangos, TestTheUnpackArityDivergenceIsNamedNotFixed and TestTheCorpusGapThatHidThisFromTheDifferential.

  • A safety grant on a SUB-PATH follows the name across a binding (#2375).#2378 made a bind carry the grant at the NAME granularity, and _collect_safe_keys writes a dict's marks at p.<key> — so nothing ever wrote q.a, and {% with q=p %}{{ q.a }}{% endwith %} escaped a value Django emits live. The single-variable {% for %} did NOT have the bug, and that is the whole of the fix: set_loop_mapping was an ALIAS (is_safe rewrites the dotted path through it) where bind is a COPY, and it could express exactly one shape. loop_mappings is now a plain name -> <dotted prefix> map the loop and the binding tags share, retiring the copy-vs-alias split (#1646) rather than adding a second copy. Extending #2378's "a bind REPLACES the grant" to the alias took three rules, and two were found by probing AFTER the first version was green — both live XSSes in the fix itself: rebinding the alias's TARGET ({% with q=p %}{% with p=r|safe %}{{ q }} marked the NAME p and the surviving q -> p alias read it, emitting q's original hostile value RAW), and a MULTI-ASSIGNMENT tag (Django resolves every value against the OUTER context, so b in {% with a=p b=a %} binds the outer a while an alias read the mark the same tag had just put on a). The cures are about the OPERATION rather than the values (#2129): rebinding either END of an alias retires it, and an alias may not target a name the same tag rebinds. An alias is registered only where the correspondence is REAL — a filtered expression and the dict-view unpack keep their #2334 refusal, both measured over-escapes. All three binding sites decided explicitly (#1646): {% with %}, {% include … with %} and the {% for %} unpack alias; the {% … as x %} assign tags do not. Gate-off: nine mechanisms, three of which SURVIVED the first pass and none of which was a no-op — each was a second mechanism covering for the first, and five tests were added for the separating cases. Two-build differential: 1 cell moved, 1 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 0 panics; and both new corpus shapes are proven load-bearing by empirical canary — gating the target sweep off makes the differential report 13 LIVE payload leaks and gating the multi-assignment exclusion off reports 1. New cases in TestTheGrantReachesASubPath, TestTheGrantDoesNotLEAKSIDEWAYS, TestRebindingTheALIAS_TARGET_RetiresItToo, TestABindReplacesTheAliasToo, TestEachMechanismIsIndependentlyREACHABLE, TestTheAliasIsRefusedWhereTheCorrespondenceIsFalse, TestTheLoopMappingItReplacedStillWorks and a 3,240-cell generated sweep in TestNoBindingSHAPEEmitsAPayloadRaw.

  • A quoted or numeric LITERAL resolves in {{ }}, and a quoted one is SafeData (#2376). djust had two resolvers that could see a bare token and only one knew what a literal is: renderer::get_value_safe — the {% if %} / {% with %} / {% firstof %} / {% cycle %} operand channel — had int, float and quote-strip arms, while Node::Variable and Node::InlineIf had none. So {% if "<b>" %} was right and {{ "<b>" }} rendered the EMPTY STRING — the text vanished rather than appearing escaped — and {{ 5 }}, {{ 5.5 }} and {{ "a"|upper }} were empty for the same reason, which is the half the issue title does not name. Same two-resolvers-one-blind split as #2347 (#1646). The half that DID resolve had its own defect: Variable.__init__ ends its quoted branch with mark_safe(unescape_string_literal(var)), so {{ "<b>" }} renders LIVE markup in Django — resolving without the grant gives &lt;b&gt;, a third answer, which is why both halves are one function (django_literal). The grant SEEDS the filter chain rather than being OR-ed in at the end, so it re-taints as Django's does: {{ "<b>"|upper }} is &lt;B&gt; (upper is is_safe=False) while {{ "<b>"|escape }} stays live (Django's escape filter is conditional_escape). Django's ./e gate before float() is reproduced rather than simplified and is load-bearing — float() and Rust's f64 parser BOTH accept inf/nan, and only the gate keeps a variable named inf from silently becoming a float. Two more cells fall out of deciding literals at COMPILE time as Django does: a context key spelled 5 no longer shadows {{ 5 }}, and an int past i64 renders every digit. Two-build differential: 394 builtin cells moved, 352 newly agreeing, 0 introduced live-payload leaks, 0 panics; the 16 non-agreeing are the PRE-EXISTING date/time unreadable-value echo (#2388), which reads identically on both builds at {{ p|date }} and became visible here only because the literal now resolves. Known narrower than Django and pinned rather than silent: {{ 1_000 }} (Python's digit separator) and a literal in a FILTER ARGUMENT (#2389) — both the over-escaping direction. New cases in TestTheEmitArmResolvesALiteralAtAll, TestTheLiteralCarriesDjangosGrant, TestTheTagOperandChannelAgreesWithTheEmitArm, TestTheInlineIfArmAgreesWithTheEmitArm, TestTheGateDjangoKeepsClosed, TestKnownNarrowerThanDjango and TestTheCorpusGapThatHidThisFromTheDifferential.

  • int(arg) is a TypeError for a non-str non-number argument (#2366).truncatechars, truncatewords, get_digit and floatformat have a Django source that catches ValueErroronlytry: length = int(arg) / except ValueError: return value — and int() raises TypeError for anything that is neither a string nor a number. So Django RAISES for a list, a tuple, a dict or a None argument, and djust returned its input: the more permissive direction, and inconsistent with djust's own Raise-policy filters (center, ljust, rjust, wordwrap, divisibleby, urlizetrunc), which already raised for the same argument. The issue's own dichotomy is false, and where the line falls is the finding: a list, a tuple and a dict reach Context::resolve as Value::List/Value::Tuple/Value::Object, so their type is intact ONE LINE above where to_string() discards it and the fix is to read one bit there rather than push a whole Value through 57 filter arms; while a datetime, a date, a time, a set and an arbitrary object are already Value::String by then, their type lost at the PyO3 extraction boundary{{ q }} on a datetime renders 19 characters and {{ q|length }} answers 19 — which is why the datetime the issue's headline uses is the half that stays, pinned with the measurement that locates the loss and a list control that makes the claim falsifiable. One mechanism, not two: #2328 asked this question of the one spelling it had noticed, a bare None; int_arg_is_type_error asks it of the TYPE and subsumes it, since None resolves to Value::None (#2347). The rule is stated as what int() ACCEPTS — CPython's own wording — so a new Value variant defaults to refused, the conservative direction. A SPELLING fallback in the first pass was deleted rather than tested around: gating it off changed nothing, because every renderer call site passes Some(context), so it could only ever answer false; the invariant that made it dead is pinned mechanically on renderer.rs instead. The differential could not construct any of thisARG_CONTEXT bound one variable and it was a plain string — so the corpus grows four typed bindings, the fourth (known_dt) being the counter-example that keeps the axis honest. Before that widening the two-build run reported 0 moved cells over the whole fix; after it, 708 moved, 0 regressions, 0 panics, 0 new live-payload leaks, of which 243 are djust ceasing to render where Django raises and 465 are the error-message rename. New cases in TestTheFourReturnInputFiltersNowRaise, TestADictViewArgumentRaisesToo, TestEveryRendererCallSiteResolvesItsArgument, TestAnAcceptedArgumentStillWorks, TestOneMechanismNotTwo, TestTheExtractionBoundaryResidueIsNamed and TestARandomisedDifferentialOverTheArgumentAxis.

  • The six tags that take a filter-expression operand, and the custom-TAG dispatch path (#2355, #2356). The reachability manifest (#2345) reported six of Django's built-in tags exempt from the parity corpus with the reason "TAKES A FILTER-EXPRESSION OPERAND and is not swept" — an admission rather than a property — and three _rust entry points exempt because nothing dispatched through them. Sweeping the six found four divergences, three of them silent, and every value below is Django 5.2.16 run rather than remembered. {% widthratio %} answered 0 for every NON-NUMERIC operand where Django answers "" (float(value) raises a ValueError that WidthRatioNode.render catches) — 16,006 of that shape's 17,298 cells. Three more defects in the same arm, each its own gate-off row: it rounded half-away-from-zero where Python's round is half-to-EVEN, so {% widthratio 1 2 5 %} was 3 and Django's is 2; it answered i64::MAX for a non-finite ratio where round(inf) is an OverflowError Django catches into ""; and it answered 0 rather than raising for a non-numeric final argument, where Django raises TemplateSyntaxError — that operand goes through Python's int(), not float(), so int("100.6") raises where float("100.6") does not, and the test that separates them is the one the first gate-off pass was missing. {% widthratio … as w %} and {% firstof … as v %} RENDERED the value Django assigns silently, and bound nothing: as and the name were parsed as two more operands, exactly as Django's own compilers guard against (if len(bits) >= 2 and bits[-2] == "as"). The bound value is render_value_in_context(...) for firstof — a SafeString, measured, so without the grant {{ v }} re-escapes an already-escaped string and renders &amp;lt;b&amp;gt; — and a plain str for widthratio, which is why only one of the two is marked. {% cycle nope 'z' %} echoed the operand's own SOURCE TEXT onto the page; Django compiles each operand with compile_filter and a missing variable resolves to string_if_invalid, so the answer is "". That is the #2325 echo symptom in the one tag whose operands nothing had built a cell for, and the comment being deleted claimed the opposite ("output the raw name (Django behavior)"). {% regroup p by k|upper as g %} dropped the by chain and grouped every row under None — one group where Django builds three, every {{ x.grouper }} empty. #2333 fixed the SOURCE operand; by is a filter expression too, since regroup compiles <var>.<attr>, and the chain is now run through Django's own FilterExpression because that is literally what the Python engine does with it. The three hand-copied Node::AssignTag arms in the sibling-aware render loops converge onto one sibling_updates helper, so the second kind of context-mutating node did not become a fourth copy of two arms (#1646) — and the convergence is what made the remaining gap findable: an as <var> node mutates the context for LATER SIBLINGS exactly as an assign tag does, so it needs the same "*" wildcard dependency, or partial render skips it whenever its own operands are unchanged and the binding never happens. Self-review caught that by reading the comment on Node::AssignTag's dep arm, which states the reason in full. ifchanged and filter stay UNSUPPORTED by the Rust engine and their cells are built anyway: "no cell exists" and "every cell is the same refusal" are different states, and only the second goes red the day someone implements the tag and gets its escaping wrong. #2356 built the custom-TAG axis — six probes registered on BOTH engines from the same function bodies, through register_tag_handler / register_block_tag_handler / register_assign_tag_handler — and it reported 12 cells where djust emits a live payload Django escapes: a handler's return value is inserted RAW, where Django's SimpleNode.renderconditional_escapes any return lacking __html__. That is the #2290 asymmetry with the arrow reversed (#2290 was the way IN, and fail-CLOSED; this is the way OUT, and fail-OPEN), it is not fixed here because the fix makes every one of djust's ~20 built-in handlers start emitting escaped markup unless each is audited, and it is filed as #2379 with the handler inventory. TestKnownDivergencesOnTheCustomTagPath pins the current behaviour so that change cannot land silently. A harness bug the as-form shapes exposed: render_both handed Django the CALLER'S dict, and Context(d) keeps d as dicts[-1], so a Django assignment tag wrote a name the djust render then read — the two engines were not being handed the same input, and djust looked like it assigned v when Django had put it there. Manifest: tag25 required / 22 exempt → 31 / 16, entrypoint10 / 7 → 10 / 4, 0 missing on every axis in both states; corpus 115,395 → 281,121 cells. Measured with the two-build differential against a rebuilt origin/main: 49,675 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 0 newly panicking cells, all 59,829 moved cells on the tag axis. The 12 ctag LIVE cells and the 42 on cycle/firstof/firstof-as are identical on both builds — the second set is the already-measured add:"1"|safe divergence reaching three more operands, not a new class. New cases in TestWidthRatio, TestFirstOf, TestCycle, TestRegroupByOperand, TestTheUnsupportedTwo, TestTheCustomTagPathIsReachableAtAll, TestKnownDivergencesOnTheCustomTagPath and TestTheCorpusReachesWhatTheseIssuesSaidItCouldNot (61 collected in python/tests/test_tag_operand_axis_2355_2356.py), 3 in crates/djust_templates/src/renderer.rs::asvar_standalone_tests, and a new test_2355_six_tags_took_a_filter_operand_and_were_exempt blind-spot canary in python/tests/test_differential_reachability_manifest_2345.py — whose sibling test_2325_no_tag_cell_existed_at_all grew from three expected tags to nine, which is the growth being the point. Twenty gate-off mutations, each with the mutation text asserted found exactly once, the mutated source asserted different, pytest errors counted separately from failures, a cargo compile failure distinguished from a cargo test failure, and every restore verified byte-identical: all twenty KILLED, after the first pass's four survivors were each answered — one a semantic no-op for the tested inputs (the int()-vs-float() case above), two shadowed by sibling_updates on every template-reachable path and given the Rust unit test that makes the arm reachable, and one a mutation too weak to remove the symbol it was gating. The harness earned its precondition assertions on the rebase: two hunks were reflowed by cargo fmt and it refused with MUTATION TEXT FOUND 0 TIMES rather than reporting a green over a mutation that no longer applied.

  • date / time / add / pluralize give Django's own failure answer (#2359). Ten cells were reported, about the VALUE being a bool or None{{ True|date }} rendered 'True', {{ None|add:"1" }} rendered 'None', {{ True|pluralize }} rendered 's', each where Django renders nothing, and each with a bound control proving it was about the value and not its spelling. Measuring the three mechanisms across 20 value shapes rather than the three the issue names put the count at 100+, not 10: date/time echoed the value for EVERY non-date (a string, an int, a float, a list, a dict, a Decimal), add's third branch echoed for None and every unsummable container, and pluralize had an Integer arm, a sequence arm and _ => suffix — three of Django's four answers and never the empty one. So the fix is per-MECHANISM and not per-value (CLAUDE.md #2129): each filter gets Django's own failure answer and the bool rows fall out of that. All three rendered the unfiltered INPUT where Django renders nothing, which is the more permissive direction; the counter-argument in the code — "turning a rendered value into silent emptiness on upgrade is the silent-wrong-output class this engine keeps having to fix" — inverted on measurement, because the values reaching these branches are exactly the ones Django decided have no answer. The diagnostic it defended survives in the tracing::debug! both date arms still emit. pluralize is rewritten as Django's body, which also closes a gap the issue did not name — the comma form was entirely unimplemented, so {{ n|pluralize:"y,ies" }} rendered the literal text y,ies — and its two except arms are NOT one arm: a ValueError (a string that is not a number) falls straight to "" and does not try len(). The randomised sweep found a rule the issue did not have and the first pass got wrong: Django's answer for a non-date is not always "". dateformat.Formatter.format splits the format string on UNESCAPED specifier characters and touches the value only when it reaches one, so a format carrying no specifier never raises and its literal text comes back — {{ 0|date:"1-1" }} is '1-1'. A flat empty string disagreed on 296 of 4,000 cells; django_literal_only_format states the rule once for both filters (#1646), and its specifier test is POSITIONAL to match the regex lookbehind (?<!\\), so "\\Y" carries no specifier and renders \Y. Not fixed, and named: {% for %} over a non-iterable (Django 500s, djust renders {% empty %}) is a product decision with a blast radius far past the bools, filed separately; and the date WIRE residue stays, because Value has no date variant so a Python date arrives as its ISO string and {{ "2020-01-01"|date }} cannot be told from a real one here. Six stale exclusions elsewhere went red and named themselves — in #2347's, #2328's, #2303's, #2294's and #2253's files — and every row is removed or inverted rather than relaxed; three of this drain's five flipped-expectation tests — pre-existing tests that asserted DJUST's answer as the correct one, and so stayed green for exactly as long as the bug existed. That is a distinct category from a stale exclusion, which at least names itself as a divergence and asks to be revisited; these pinned the wrong answer as right, and #1081's called it "correct, defensive behavior" in a comment above the assertion. Same shape as #2221's pin that justified a revert, and the reason a green suite is not evidence that the engine agrees with Django. The three here (including #1081's) moved to Django's, with #1081's quote-preservation half re-covered by a sibling that renders the same value without the filter in the way. Measured, and the measurement had to be widened twice before it was honest. Re-run against main after #2381 grew the corpus to 282,977 cells, the differential reported 610 echo cells closed of which 70 were live — the rest HTML-escaped. Every one of those 70 had a CONTAINER input, which made the class look like "containers only". It is not. add was the only ECHOING filter on the chain axis, and add reaches its echo path only for a container, because "<img …>" + "1"succeeds and Django emits that concatenation live too. date and time echo for EVERY non-date, so {{ p|date:"Y-m-d"|safe }} over a plain string is live on main and empty in Django — and the corpus built zero chains beginning with date, so no cell could say so. Adding the two to HOT2 takes the live count from 70 to 280, of which 70 have string inputs. The general class is an echoing filter composed with a safety grant, not a container shape; sampling one echoing filter and generalising to the axis is the same error the fix itself is about, one level up. Final numbers, re-measured against main at the merge (314,847 cells, after #2380/#2386/#2390/#2391/#2392/#2393 landed): 17,423 newly agreeing, 0 panics, 2,443 echo cells closed (2,611 before, 168 after), split 2,163 escaped / 280 live by the tool's own UNESCAPED_TAG rule. #2376 made a quoted literal SafeData, which changes what is live for {{ "<b>" }} and so could have moved this split; it does not, and that is checked rather than assumed — 0 of the 2,443 closed cells is a literal cell, because a literal reaches these filters as a value Django also emits live. The 2,163 are unwanted input echo — a correctness and mild information-disclosure issue, rendered as text by a browser. The 280 are the security half, across date, time and add composed with safe, linebreaks, linebreaksbr, join, unordered_list and json_script; they are pinned in TestAnEchoingFilterComposedWithASafetyGrantIsNotLive, whose string and container rows go red independently. 42 cells stopped agreeing, all of them {% regroup %}, and they are the #2272 two-wrongs-cancelling shape rather than a regression: correcting the echo changed a {% regroup %} operand from a list to the string Django also computes, and a pre-existing bug — {% regroup %} over a non-empty string builds zero groups where Django builds one — shows through. That bug is on the BASELINE in 8,505 cells, reproduces with no add in the template at all, and is filed as #2385; the evidence chain is asserted in TestTheRegroupUnmaskingIsNamed rather than argued. New cases in TestTheReportedCells, TestDateAndTimeRenderNothingForANonDate, TestAddsThirdBranchRendersNothing, TestPluralizeIsDjangosFourAnswers, TestIteratingANonIterableIsNamedNotFixed, TestTheArgumentTypeResidueIsNamed, TestTheDateWireResidueIsNamed and TestARandomisedDifferentialOverTheFourFilters.

  • stringformat is CPython's %-format grammar, not a last-character switch (#2358).apply_stringformat dispatched on spec.chars().last() and fell to _ => value.to_string() for every character it had no arm for. That one arm held two disjoint groups and was wrong for both, and a third group was wrong inside an arm that was implemented. Group 1 — specs CPython rejects, where Django answers "": "5", ".", "-", "0", ".2", "l", "%" and a bare True. djust was MORE PERMISSIVE than Django on every row — it rendered where Django renders nothing, and the value it rendered was the unfiltered input. Group 2 — conversions CPython supports and djust did not implement: x, X, o, c, r, a, g, G, u, plus the trailing LITERAL ("ss" is %s followed by the letter s, so Django answers '42s'). Group 3 — %e writes its exponent with a sign and at least two digits ('4.200000e+01'); Rust's {:e} writes neither. Turning the catch-all into "" fixes group 1 and BREAKS group 2; leaving it fixes neither — the non-convergence CLAUDE.md's #2129 rule names — so the shape is the grammar itself, in a new crates/djust_templates/src/stringformat.rs that scans "%" + spec the way CPython scans a format string. The grammar was pinned against live CPython 3.12 with a prototype before any Rust was written, over ~197,000 (spec, value) pairs, and four of its rules are ones reading the docs would not have given: %% is an early-out checked BEFORE the flags ("%+%" is unsupported format character, not a flagged literal percent); a list suppresses the unconsumed-argument check exactly as a dict does, because CPython's guard is PyMapping_Check; the mapping key RESOLVES immediately after it is parsed, so "%()" % {'a': 1} is a KeyError and not incomplete format; and Python's 0 flag is not C's — "%08.5d" % 42 is '00000042' and "%05.2f" % inf is '00inf'. Base conversion is long division on the exact decimal digits rather than a cast, so "%x" % 2**70 is exact (an as u64 here is the #2265 class: a fabricated constant, silently). Bounded residue, named rather than silent: a * width over a value larger than a machine integer, %d/%c on a value CPython cannot make an integer of, a missing mapping key, and a width that would allocate the heap all make Django raise a 500 where djust renders "" — strictly LESS permissive, and every one predates this change; pinned in TestTheRaiseResidueIsNamed. Two stale exclusions elsewhere went red and named themselves, exactly as built to, and both rows are removed rather than relaxed (TestOnlyAddWasBrokenByTheBareLiteral in #2347's file, and OUTPUT_DIVERGES_FOR_ANOTHER_REASON in #2328's); two of this drain's five flipped-expectation tests — pre-existing tests that asserted DJUST's answer as the correct one, and so were green for exactly as long as the bug existed. That is a distinct category from a stale exclusion, which at least names itself as a divergence and asks to be revisited; these pinned the wrong answer as right, and one of the three in #2359 called it "correct, defensive behavior" in a comment. Same shape as #2221's pin that justified a revert, and the reason a green suite is not evidence that the engine agrees with Django. The two here pinned djust's answer rather than CPython's — test_stringformat_filter_scientific's 1.23e3 and #2343's multi-byte echo — moved to Django's. Measured: a 108,244-cell direct sweep per seed across three seeds reports zero value-class divergences and zero live-payload leaks, and the two-build corpus differential — re-measured against main after #2381 grew the corpus to 282,977 cells — reports 318 newly agreeing, 0 regressions, 0 panics, and 68 unwanted-echo cells closed (824 flagged before, 756 after). Those 68 are not XSSes, and the distinction is worth keeping: the differential's live-payload leaks metric substring-matches a payload fragment against djust's output where Django's carries it not — a real finding, djust putting input on the page that Django discards — but every one of the 68 was HTML-escaped, so a browser renders it as text ({{ p|stringformat:"5" }} emitted &lt;img src=x onerror=alert(1)&gt;). Measured by splitting the closed set with the tool's own UNESCAPED_TAG rule: 68 escaped, 0 live. The right reading is unwanted input echo — a correctness and mild information-disclosure issue — not script execution, and the metric's name invites the stronger claim. New cases in TestGroupOneSpecsCPythonRejects, TestGroupTwoConversionsCPythonSupports, TestGroupThreeTheExponentFormat, TestTheFourGrammarRulesTheSweepFound, TestTheTupleIsStringifiedFirst, TestTheIntegerConversionsAreExactPastF64, TestTheAlternateFlagAndTheGeneralFormat, TestPrecisionMeansDifferentThingsPerConversion, TestTheWidthAndPrecisionLIMITSDifferFromEachOther, TestTheRaiseResidueIsNamed and TestARandomisedDifferentialAgainstLiveDjango.

  • A numeric path segment follows Django's three-step lookup (#2371).{{ d.0 }} resolved nothing on a dict, whatever the key's type — {0: 4} and {'0': 4} both rendered empty where Django renders 4. Silently: no exception, no warning, which is the silent-wrong-output class. It composes with any filter, and {{ d.0|divisibleby:"2" }} is the sharpest of those, answering a definite False rather than nothing so an {% if %} gate reads a wrong answer instead of an obviously missing one. The walk branched on the SPELLING of the segment: a numeric segment reached Django's step 3 (integer index) and only that, a non-numeric segment reached step 1 (mapping item access) and only that — so each spelling was missing the other's half, and {'0': 4} (which needs step 1) and {0: 4} (which needs step 3) both fell through. Context::resolve's raw-Python sidecar walk beside it has done all three steps in Django's order since #1997, with a comment saying so; one path had the rule and its twin did not (CLAUDE.md #1646), and both now state it once through lookup_segment. The order is measured, not assumed — a dict carrying both spellings, {'0': 's', 0: 'i'}, renders 's' in Django, so the string lookup runs first. Numeric keys conflate as Python conflates them, inherited from #2339's ObjectKey, so {{ d.1 }} resolves against {1.0: …} and {True: …}; int(bit) means {{ d.007 }} is the key 7. A Value::DictView is deliberately absent from the index arm, because Python's dict_items is not subscriptable and {% with q=d.keys %}{{ q.0 }} must stay empty on both engines. Scoped out and named rather than left silent: Django's step 3 subscripts a str too ({{ s.0 }} on "abc" is 'a'), which needs an owned return across every Context::get caller; filed as #2373 and pinned in TestTheStringIndexStepIsNamedNotFixed so it goes red the day it is closed. The differential could not construct any of this — no PATH_SHAPES entry spelled a numeric segment and no input carried a numeric key — so the corpus grows a d-numeric-key input (holding 0, "1" and 1, the only shape that can measure the step order) and eight numeric-segment path shapes; the two-build sweep — re-measured against main after #2381 grew the corpus to 284,536 cells — reports 18 newly agreeing, 0 regressions, 0 panics, and no change to the echo count (710 before and after). New cases in TestTheReportedCells, TestTheThreeStepsAndTheirOrder, TestNumericKeysAreConflatedTheWayPythonConflatesThem, TestTheWalkIsPerSegment, TestTagOperandsResolveThroughTheSameWalk, TestTheMissesThatMustStayMisses, TestTheStringIndexStepIsNamedNotFixed, TestTheLexerLevelDivergenceIsNamedNotFixed, TestANewlyResolvableValueIsEscapedExactlyAsDjangoEscapesIt and TestARandomisedDifferentialOverTheSegmentSurface (a 3,000-case randomised sweep against live Django whose own preconditions are asserted).

  • timesince/timeuntil measure against their ARGUMENT, not always now (#2344). Django's argument is the comparison INSTANT — timesince(value, arg) — and djust's two arms read the VALUE and discarded the argument entirely (format_timesince(&datetime_str) took no comparison instant). So {{ then|timesince:other }} silently answered "since now" whatever other was, and {{ then|timesince:"notanumber" }} rendered a duration where Django raises. Both are the silent-wrong-output class, and the second is why #2328 exempted these two from its raise sweep: making an unparseable argument raise while a valid one was still discarded would have been a half-fix — strictly worse than the honest "the argument does nothing", because it would have looked handled. Django's control flow has exactly three outcomes and all three are reproduced, measured against live Django 5.2.16 rather than read from the source: a falsy argument falls through to the wall clock (if arg: in the filter, if not now: inside timesince); a date or datetime is that instant, with a bare date truncated to midnight; and anything else truthy raises AttributeError from now.year, which is NOT in the filter's caught (ValueError, TypeError) and so escapes. A fourth outcome IS caught: an aware value against a naive argument makes now - d a TypeError, so Django renders the empty string, and so does this. arg_was_quoted is load-bearing, as it is for add and floatformat: {{ p|timesince:0 }} is the integer zero and measures from now, while {{ p|timesince:"0" }} is a non-empty str, which is truthy, and raises — one character of template syntax between a duration and a 500. One body for both filters (timesince_or_until), because they are one computation in Django too (timeuntil(d, now) is timesince(d, now, reversed=True)) and because a shared argument rule written twice is exactly what drifts (#1646) — which is how these two got here, as near-copies. The VALUE is read first, which is Django's order and the same rule floatformat carries (#2328): a value djust cannot read falls soft to the value unchanged and the argument never gets to decide anything. A quoted argument raises even when it is date-shaped, measured: {{ p|timesince:"2020-01-01 15:30:00" }} is 'SafeString' object has no attribute 'year'. The rest of the fix reads a date-shaped string as a date because a Python datetime crosses into Rust as a string and has no other spelling — the convention the VALUE side has carried since #2203 — and a quoted literal never came from Python, so the convention has nothing to justify for it. That is what bounds the residue, which is pinned rather than hoped away: a RESOLVED argument that is genuinely a str spelling "0", "None", "False" or a date is indistinguishable from the object it spells, and TestTheFalsinessResidueIsNamed asserts the divergence rather than claiming exactness — including a mechanical pin over Value's Display arms, so a new variant with a falsy inhabitant has to be considered. Display has TWO modes and the rule knows both: django_value_repr (on by default, #2203) spells a bool True/False while legacy_display spells it Rust's true/false, so a rule that knew only the default would raise for a resolved False under a flag whose entire purpose is rendering parity — the parallel-path shape one render MODE over (#1646). One residue survives and is stated rather than implied: legacy_display renders EVERY sequence as the literal [List], so an empty list is indistinguishable from a full one under that flag only; the default mode has no such gap, and the test asserts both halves. Both rows are deleted from RAISE_BIT_NOT_CLOSED, whose non-vacuity pin went red exactly as designed; the #2328 sweep now covers all 29. Measured with the two-build differential: on the argument axis, 120 newly agreeing cells, 0 regressions, 0 introduced live-payload leaks over 4,466; on the default corpus, 0 cells move in either direction and 0 regressions — because timesince/timeuntil are clock-dependent there and collapsed by name, which is the #2345 corpus gap this fix surfaced and which #2345 closes on the argument axis. New cases in TestTheArgumentIsTheComparisonInstant, TestAFalsyArgumentMeansNow, TestATruthyNonDateRaises, TestAwarenessMixing, TestTheValueDecidesFirst, TestTheFalsinessResidueIsNamed and TestOneBodyForTwoFilters in python/tests/test_timesince_comparison_instant_2344.py. #2340's Value::DictView is handled too, and it was the mechanical pin that said so: TestTheFalsinessResidueIsNamed walks Value's Display match and demands a falsy-text answer for every variant, so the new one could not slip past a green suite. The answer is a real divergence rather than a formality — bool({}.items()) is False, so Django measures from now, while Display spells an empty view dict_items([]), which the rule did not accept; {{ then|timesince:d.items }} on an empty dict would have raised. All three views are accepted and a NON-empty one must still raise, which is the non-vacuity half. The corpus could not reach this fix's new error, and the #2345 manifest is what reported it — twice, and both reports were right. First about the corpus: no input was date-shaped, and this fix parses the VALUE before the argument (Django's order), so every timesince/timeuntil cell took the unreadable-value branch and the argument logic was never reached; s-datetime is what it asked for. Then about the manifest itself: _swept_argument_errors open-coded the corpus product as sorted(FILTER_ARGS) x ... while arg_cells had moved to django_argument_filters(), and timesince is one of the four names in the second set and not the first — so the axis measured a narrower corpus than it ships. It iterates arg_cells() itself now. A third followed: nondet_agreement compared the two engines' raw output, which for a cell where BOTH raise compares Django's AttributeError text against djust's wrapping RuntimeError text — strings that can never match — so a raise-bit fix read as unchanged and the differential reported zero moved cells on every axis. _outcome reduces a raise to the fact of it while keeping a PANIC distinct (#2343). Measured with the two-build differential against a rebuilt origin/main: 28 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 0 newly panicking cells over 115,115, all 28 on the argument axis. Thirteen gate-off mutations, each rebuilt and re-run with the mutation text asserted found exactly once, the restore verified byte-identical, and a cargo compile failure distinguished from a test failure: all thirteen KILLED.

  • A safety grant did not travel with the value across a BINDING (#2361, #2363).{% with %}, {% include … with %} and the {% for %} loop variable all bind a resolved value to a NEW NAME. djust's safety channel is keyed BY NAME — Context::safe_keys, dotted paths written by rust_bridge._collect_safe_keys — and a bind copied the VALUE and not the GRANT. One defect with three faces, and it points in both directions. #2363: every safe-output filter loses its grant across {% with %}, |safe included, so {% with body=post.text|linebreaks %} renders escaped tag text on the page while {{ post.text|linebreaks }} one line over is correct — the EMIT path was never broken, and that asymmetry IS the bug. #2361: a mark_safe value reached through d.values / d.items loses its mark, because the collector spells a dict's paths BY KEY (p.a) while the loop's positional mapping looks for p.values.0 — two spellings of one path that never meet. Both over-escape, so both are lost capability rather than leak. The third face was found by measuring those two, and is an UNDER-escape: a bind that SHADOWS a marked name inherited the stale grant, so with p marked in the context {% with p=hostile %}{{ p }}{% endwith %} emitted the hostile value RAW where Django escapes it — four such cells, on {% with %} and {% for %}, at the name and at the sub-path. The cure is a rule about the OPERATION, not about the values (#2129).Context::bind is the one door for every binding: it revokes name and every name.… beneath it, then grants what the resolved value actually carries. Writing it as "a bind also carries a grant" — the shape both issues ask for — would have fixed the two reported directions and left the under-escape open; "a bind REPLACES the grant" retires all three, and holds against shadowing shapes nobody enumerated. The descendants go because they described the value being SHADOWED. The {% for %} arm hoists the O(len(safe_keys)) subtree revoke OUT of its iteration and calls the O(1)set_safety per item — a COST decision, not a semantic one, and the_loop_decomposition_of_bind_agrees_with_bind pins that the two spellings agree so the split cannot drift; without the hoist a loop over an N-element list of marked items pays O(N²). Every binding sink was enumerated and decided (#1646), and the grep found two the issues did not name.{% with %} and {% include … with %} now keep the bool that sat beside the Value they already resolved (get_valueget_value_safe, the same shared resolver #2325 routed them through, keeping the half it discarded). {% for %} over a dict view resolves per-item safety from the operand's own PROVENANCE — item i came from key k, so its grant is at <prefix>.<k> — and {% for a, b in rows %} tuple unpacking, which had NO channel at all, resolves each component at its positional <expr>.<i>.<j>. The {% … as x %} assign-tag merge revokes: a handler returns plain Values across PyO3 with no safety channel, so the honest grant is false and a stale one must not be inherited. Unchanged and deliberately so: {% for x in list %} keeps its loop-mapping alias (#2287), a FILTERED operand still grants nothing (slice shifts indices, dictsort reorders — #2325), and {% firstof %} / {% cycle %} EMIT rather than bind and already threaded the flag (#1672). The #2334 hostile-key gate holds, re-verified rather than assumed. That collision is a POSITIONAL lookup landing on a NAMED path: give a dict a key spelled "1" whose value is marked and a by-index mapping resolves the SECOND key's mark, attacker-controlled if keys are user data. The new lookup is by KEY NAME on both sides, so a key can only ever resolve its own value's grant; the positional mapping is still refused for every normalised operand; and the two mechanisms are mutually exclusive by construction (derived grants exist only when normalised, the mapping only when not), so they can never disagree about one item. A key containing a . is refused outright — p.a.b is BOTH {"a.b": …} and {"a": {"b": …}} and no lookup can tell them apart — and a grant applies only to a Value::String, because mark_safe_keys accumulates and is never cleared (#2300). Two-build differential over 109,571 cells against a rebuilt origin/main: 5,157 newly agreeing, 0 regressions, 0 panicking cells before or after; 6,559 cells moved on the tag axis and 2 on path, and 0 on every other axisfilter, chain, argument, argument-filter, cmp, custom and builtin are untouched, which is the shape a binding-only change should have. 4 cells are flagged as newly-live and each is p|add:"1"|safe: add's third branch is a DOCUMENTED divergence (djust returns the value where Django returns ""; the reasoning is in filters.rs), and the EMIT twin of all four was already live on the baseline build — measured on both builds, not argued. |safe does on the bind spelling what the author asked it to do and what the emit spelling already did; before this fix only one spelling obeyed. That containment is now a permanent sweep rather than four listed cells: TestTheBindPathGrantsNothingTheEmitPathDoesNot asserts over 429 cells that no bind emits live markup its {{ }} twin does not, with a non-vacuity case proving the sweep can see a live bind at all — an always-grant mutation reddens it. The one cell the differential calls a regression ({% with p="<script>" %} over a marked p) agreed BEFORE only by way of the under-escape: djust does not mark quoted string literals safe on ANY path, including bare {{ "<script>" }}, which resolves to EMPTY — pre-existing, and filed as #2376 with the table proving nothing about literal handling changed. Three adjacent divergences measured and filed rather than fixed (#1079): #2375 (a grant on a SUBPATH still does not follow the name across a bind — a different granularity, needing a general name→prefix alias rather than a copy), #2376 (the string-literal pair above) and #2377 ({% for a,b in x %} — the comma without a space — silently renders nothing, a parse bug the differential's corpus cannot construct because every loop it writes uses the spaced spelling). The {% with %} / {% include with %} rows of #2325's operand pin move from get_value to get_value_safe and are updated rather than widened, so the pin still reddens on a bare context.get. 43 collected in python/tests/test_safety_survives_a_binding_2361_2363.py (13 in TestTheGrantSurvivesAWithBinding, 12 in TestTheGrantReachesTheLoopVariableThroughADictView, 4 in TestTheGrantCrossesAnIncludeWithBinding, 5 in TestTheHostileKeyGateStillHolds, 7 in TestABindReplacesTheGrantRatherThanAddingToIt, 2 in TestTheBindPathGrantsNothingTheEmitPathDoesNot), plus 7 Rust cases in crates/djust_core/src/context.rs. Fifteen gate-off mutations — 10 Python, 4 Rust, 1 on the permissiveness ceiling — each asserted found EXACTLY once, asserted to change the source, each rebuilt with the artifact digest asserted to differ, every restore verified byte-identical, a cargo compile failure distinguished from error: test failed and a pytest N error counted separately from N failed: all fifteen KILLED, 0 survivors. The first run had two survivors — {% include … with %} and the tuple-unpack channel — which is how the coverage for both was found; each mechanism now reddens a test only it reddens (#2129/#2135).

  • Three argument-axis divergences whose cause is not int(arg) (#2346).#2328 routed every built-in that reads its argument as a NUMBER through one chokepoint and made an unparseable argument raise; these three were left alone because their divergence is not in the parse. Every value below is Django 5.2.16, run rather than remembered. urlizetrunc's ellipsis is Urlizer.trim_url"%s…" % x[: max(0, limit - 1)] — and djust appended THREE ASCII dots while reserving THREE characters for them, so the divergence compounds: a wrong character and a wrong budget, which is why a length assertion alone would have passed over it (limit - 3 plus three dots is also limit characters long). {{ p|urlizetrunc:"5" }} on see http://example.com/aaaa now gave ht... where Django gives http…, and everyurlizetrunc cell in the differential's sweep differed for this reason alone. It is the same ellipsis fix that landed for truncatechars in #2203 and never reached urlize — parallel-path drift on a CONSTANT (#1646) — and the two deliberately still do not share a code path, because Truncator.chars normalizes to NFC, skips combining characters and subtracts the truncation text's own visible length while trim_url is a plain code-point slice; routing one through the other would be tidier and would not be Django. divisibleby's zero divisor: Django is int(value) % int(arg) and x % 0 is a ZeroDivisionError; djust guarded divisor != 0 and answered False, a guard Django does not have. Reachable two ways and the second only recently — :"0" always, and :False since #2328 made int(False) be 0 as Python has it. The old divisor != 0 && is DELETED rather than left beside the new raise: with the early return above it the condition is provably always true, which is the two-mechanisms-shadowing shape, and the gate-off mutation that re-adds it is a provable no-op. floatformat's empty argument: if isinstance(arg, str): last_char = arg[-1] is the FIRST statement in Django's floatformat, ahead of the value parse, so "" raises IndexError for every value — including one that would otherwise have taken a give-up path. The placement is as load-bearing as the raise and is pinned structurally: #2328 had to move its own None-argument guard BELOW the value parse for the exactly opposite reason (36 cells where an arm-level guard raised for a dict or a datetime value), so the two guards now sit on opposite sides of it and a future tidy-up that merges them reintroduces whichever bug the merge picks. Not gated on quoting, because isinstance(arg, str) is true for a resolved context value as much as for a quoted literal. Two stale pins are updated rather than deleted, per their own contracts: test_an_empty_floatformat_argument_raises_in_django_and_not_here is INVERTED to assert agreement (its reasoning was wrong twice over — an IndexError is not a crash, and "treated as the absent argument" was a silent different answer, which is worse than the raise), and test_urlizetrunc_truncates_for_a_negative_limit drops its "not a parity assertion" caveat and asserts parity outright. Measured with the two-build differential: on the argument axis, 36 newly agreeing cells, 0 regressions, 0 introduced live-payload leaks over 4,466, with 47 cells moved — the 11 that moved without newly agreeing are {% with %} cells whose remaining divergence is a separate pre-existing bug this surfaced and did not fix (#2363: every safe-output filter, |safe included, loses its grant across {% with %} — conservative direction, so not a leak). On the default corpus, 0 cells move: it carries no input containing a URL, so urlizetrunc never truncates there, which is the input-shape blind spot #2345's manifest declares UNVERIFIED, demonstrated rather than argued. New cases in TestUrlizetruncEllipsis, TestDivisiblebyZeroDivisor, TestFloatformatEmptyArgument and TestTheEmptyArgumentIsAskedFirst in python/tests/test_argument_axis_divergences_2346.py. Eight gate-off mutations, each rebuilt and re-run with the mutation text asserted found exactly once, the restore verified byte-identical, and a cargo compile failure distinguished from a test failure: seven KILLED and one survivor that is provably equivalent (the redundant divisor != 0).

  • The parity differential DECLARES its axes, and reports what it cannot reach (#2345).scripts/filter-parity-differential.py has now reported CLEAN over five surfaces it could not construct — a filter added to a safety set and not the composed sets (#2296, a live XSS reported as 0 introduced); tag operands, where no tag cell existed at all (#2325, four resolution sites); dict-view paths over dicts with tame keys (#2334); the custom-filter path, which no built-in dispatches through (#2290, SafeData invisible across PyO3); and invalid filter ARGUMENTS (#2345 itself — #2328 moved 1,601 cells of the same filters and this tool reported zero in both directions, while its first pass shipped 508 regressed cells that a 13,933-green suite was also silent over). Each time the remedy was to hand-add one axis plus one bespoke coupling test, and a corpus gap is silent BY CONSTRUCTION: "no axis reported a problem" and "no axis exists for the problem" print identically. #2354 closed the INSTANCE — it added the sixth hand-written axis, ARG_SPELLINGS, and made a Rust panic a <<PANIC …>> cell rather than an aborted sweep. All of that is kept verbatim here and pinned by AST-parsing rather than grep, because a resolution that dropped it would look clean and the loss would be invisible (TestTheManifestAbsorbedRatherThanReplacedWhatLandedFirst). This retires the CLASS. The corpus declares its axes in AXES, each naming the set the ENGINE says it must cover — recomputed at check time from Django's live registry or from the Rust source, never transcribed. Nine axes: filter, chain (both safety channels), whitespace, argument (every error the argument chokepoint can raise, parsed from filters.rs's own format! strings), argument-filter, tag (every Django built-in tag), entrypoint (every _rust function that renders or changes how rendering works), grant-shape, and input-shapedeclared UNVERIFIED, because nothing in either engine's source says a dict's keys must be hostile (#2334) or a tuple must sit at the nesting position (#2317); that is the class this design does NOT close, and it is PRINTED rather than left as a silence. Four existing one-off couplings converge onto it and become named entry points into one computation rather than second implementations of it (#1646). --manifest prints what is and is not reachable; each results file carries its own manifest and the _rust build's digest, so a baseline states what it could see. It is not a second mechanism beside #2354's axis — the evidence is that it CHANGED that axis twice, on its first run against merged code. (1) pad_width's cap — the guard standing between a template-supplied width and an allocator ABORT (#2328) — was UNREACHABLE from the nineteen spellings, none of which parses to a width past isize; there is a twentieth now, and a canary removes it again to prove the report was real. (2) Four of Django's 29 argument-taking built-ins were absent from the sweep entirely — json_script, timesince, timeuntil, urlencode — because arg_cells iterated FILTER_ARGS, the ESCAPING axis's table of one benign argument per filter and a different question with a 25/29 overlap. It iterates django_argument_filters() now (8,700 argument cells over 29 filters), and the argument-filter axis is what stops the two drifting again. A third, found the same way: render_both's new except BaseException also caught Ctrl-C, so a 95,275-cell sweep could not be interrupted — both engine arms re-raise KeyboardInterrupt/SystemExit ahead of it, and the test asserts the ORDER, since an except-clause order bug is invisible to any test that does not interrupt the process. The same-build guard is answered rather than inferred: identical agreement counts used to mean "the baseline is not real", which is one of TWO causes and #2328 hit the other — each file now records the _rust build's digest, a genuinely two-build run with no movement is reported as what it is, and --require-moved <axis> makes that a failure for a change that declares its axis. Two further gaps are filed rather than fixed (#1079): #2355 (six tags that take a filter-expression operand and are not swept — #2325's class, one tag over) and #2356 (the custom-TAG dispatch path — #2290's shape, one registry over). No engine behaviour changes; the Rust is untouched. New cases in TestTheManifestIsCleanOnMain, TestItWouldHaveCaughtTheHistoricalBlindSpots, TestTheLimitTheManifestDoesNotClose, TestTheManifestAbsorbedRatherThanReplacedWhatLandedFirst, TestTheArgumentAxisCorpus, TestTheSameBuildGuardIsAnswered and TestRenderBothSurvivesAPanic in python/tests/test_differential_reachability_manifest_2345.py. The blind-spot class is the empirical canary (#1459) for the whole design: each case rebuilds a pre-fix corpus inside a COPY of the script and asserts what the manifest says — #2296, #2305, #2325, #2290 and #2345 go red; #2334 does not, in either of its halves, and both are pinned as the limit, because a coverage tool that overstates its reach is the exact failure this issue is about one level up. Twelve gate-off mutations, each asserted found exactly once, asserted to change the source, restored byte-identically, and counting pytest errors separately from failures: all twelve KILLED.

  • {{ True }} rendered nothing, because djust's Context lacked Django's three template builtins (#2347).django.template.context.builtins is [{"True": True, "False": False, "None": None}], added to every Django Context at dicts[0]. The three names are NOT literals — Variable.__init__ does not special-case them — they RESOLVE through the ordinary lookup, which is why {{ True }} renders True and {{ True|yesno }} is yes. djust rendered '' and maybe. Two resolvers can reach a bare name and only one of them knew (#1646): renderer::get_value_safe carried inline arms, so {% if True %} and {% firstof None False True %} were always right, while Context::resolve — the resolver {{ }} output, the built-in filter-argument channel and the custom-filter argument channel all use — had none. template_builtin is now the one statement of the rule, consulted in Context::resolve AFTER get, which is Django's own precedence (builtins is dicts[0] and __getitem__ walks reversed(self.dicts), so a user variable named True shadows it — measured, not assumed). The renderer's arms were DELETED rather than repointed at the helper.get_value_safe already ends in a context.resolve(expr) fallback, so an arm there is a second mechanism shadowing the first — and the gate-off measured exactly that: with the arm present, gating it off reddened only a source pin while every behavioural case still resolved through the fallback (#2129/#2135). Deleted, per #2233. The lowercase true/false/none spellings stay in the renderer; they are a djust extension Django does not have, and template_builtin is exactly the Django set. The issue's own remedy was wrong, and that was measured rather than reasoned. It predicted the fix would make python_int_arg's "True" => 1 coercion redundant. It does not: the built-in argument channel is Option<&str> and apply_filter_full_safe calls .to_string() on the resolved value, so every built-in still sees the text "True"69 divergent argument cells before the resolve fix, 69 after. Only the CUSTOM-filter channel, which hands the value to Python through into_pyobject, receives a real bool. Running each remaining cell against its NUMERIC control (the same cell with 1/0) showed the argument-side defect was one filter: add has its own int()int_digits_of, arbitrary-precision because a sum past i64 used to saturate (#2253/#2260) — and so never reached #2328's chokepoint where the bool rule lived. Both now call bare_bool_arg_as_int, and is_literal_filter_arg's True | False | None arm is deleted as unreachable. Two-build differential against a rebuilt base: 115 newly agreeing, 0 introduced live-payload leaks, 0 newly panicking cells, and 7 unmasked — six date/time and one {{ None|add:"1" }}, each of which agreed before only because both engines rendered '' for unrelated reasons, and each with a BOUND control that diverges identically on both builds (so none is new behaviour). Filed as #2359 with the measured table; #2358 covers the stringformat spec family found alongside. The corpus grew a builtin-value axis (192 cells) and a False argument spelling — before them the tool bound p in every cell and could not construct a bare builtin in the value position at all, which is why it had never reported this. Five gate-off mutations, each rebuilt and re-run with the mutation text asserted found exactly once and every restore verified byte-identical; each mechanism reddens a test only it reddens, including a VALUE mutation (mapping False to true) that reddens 17. New cases in TestTheValuePosition, TestUserVariablesShadowTheBuiltins, TestTheHalfThatWasAlreadyRight, TestTheArgumentChannel, TestKnownPreExistingDivergencesNotFixedHere, TestOnlyAddWasBrokenByTheBareLiteral, TestRandomisedDifferential and TestOneStatementOfEachRule (154 collected in python/tests/test_template_builtins_2347.py).

  • {{ x|stringformat:"" }} took the WebSocket session down, and nothing guaranteed a filter raises rather than panics (#2343, #2345). The defect is one line: apply_stringformat read the conversion character as spec.chars().last().unwrap_or('s'), so an EMPTY spec entered the 's' arm and reached &spec[..spec.len() - 1], where 0usize - 1 underflows. Debug traps it as attempt to subtract with overflow; release wraps to usize::MAX and the slice panics one line later with end byte index 18446744073709551615 is out of bounds — same blast radius, different message. Every arm (d/i, f/F, e/E) carries the same spec.len() - 1, so the guard sits ABOVE the dispatch rather than in the arm the unwrap_or default happened to select. Django's answer is "", measured on 5.2.16: its body is ("%" + arg) % value, and a % that ends the format string is ValueError: incomplete format, one of the two exceptions its own except (ValueError, TypeError) catches. The severity is not the wrong answer — it is that a PANIC is not an Exception. PyO3 converts an unwind into pyo3_runtime.PanicException, whose MRO is [PanicException, BaseException, object], deliberately NOT under Exception so a panic propagates like KeyboardInterrupt. LiveViewConsumer.receive wraps its dispatch in except Exceptionhandle_exceptionsend_json, which is what normally turns a bad render into an error frame while the socket stays open. A panic walks straight past it, so the blast radius of a template typo was the SESSION, not the render. guard_panic now wraps the 16 _rust entry points that run the engine — every one that executes template source, walks HTML or converts a user value — converting any unwind into a RuntimeError. The boundary is the only place that can make "the engine raises rather than panics" true by construction; fixing panicking filters one at a time cannot. It is a BACKSTOP, not a licence, which is why the underflow is fixed at its source as well: a caught panic names an internal file:line rather than the template construct at fault. It cannot catch an allocator ABORT (not an unwind) — that is what #2348's MAX_PAD_WIDTH caps are for, and a panic = "abort" profile would disable the whole mechanism, which is pinned. Cost on the hot path is below this machine's noise floor: the same-build median spread on a 50-row loop render (131–161 µs) is wider than the between-build difference. The instrument was blind to both halves (#2345).FILTER_ARGS gave every filter exactly ONE argument and it was always VALID, so the differential's corpus could not construct stringformat:"" at all — and render_both caught Exception, so when a panic did occur the sweep ABORTED rather than recording a cell. #2343 was found by that traceback. ARG_SPELLINGS now sweeps 19 argument spellings across the 25 argument-taking built-ins and 15 hot inputs (7,125 new cells, corpus 95,275 → 102,400), and a panic is recorded as <<PANIC …>>, kept distinct from <<EXC …>> because a raise is contained and a panic is not; --compare reports newly-panicking cells on their own line and exits non-zero on any. That axis is what makes the number below real: on origin/main the corpus now reports 15 panicking cells, and 0 after. Two-build differential against a rebuilt origin/main: 15 newly agreeing, 0 regressions, 0 introduced live-payload leaks, 15 panics closed and 0 introduced. Three gate-off mutations, each rebuilt and re-run, mutation text asserted found exactly once and every restore verified byte-identical: reverting the stringformat guard reddens 13 Python cases and 1 Rust case; making guard_panic stop catching reddens ONLY the Rust mechanism test — correctly, because with the underflow fixed there is no reachable panic left for a behavioural test to fire, which is exactly why the coverage is pinned structurally; drifting one guard_panic label reddens only the 2 structural pins. Each mechanism reddens a test only it reddens (#2129/#2135). New cases in TestEmptySpecMatchesDjango, TestGuardCoversTheRenderSurface and TestNoReachablePanicAcrossTheFilterSurface (29 collected in python/tests/test_panic_boundary_2343.py), 3 in crates/djust_templates/tests/test_stringformat_empty_spec_2343.rs (the DEBUG build, where the underflow traps differently and the Python suite never looks), and 3 in crates/djust_live/src/lib.rs::panic_boundary_tests — which is where the premise is falsification-tested, because PyO3 creates pyo3_runtime lazily on the first panic and after this fix there is none, so a Python-side check could only ever skip.

  • {% if inf == inf %} was False, and every NaN pair answered True for > and >= (#2349). Every arm that compared two floats spelled the ordering as if (a - b).abs() < f64::EPSILON { 0 } else if a < b { -1 } else { 1 }, and that idiom is undefined for a non-finite operand: (inf - inf) is NaN and every comparison against NaN is false, so the tolerance answered "not equal" for two infinities and the chain fell through its else to "greater" for every NaN pair. Python answers False for all four ordering operators on any NaN, and True for inf == inffloat("inf") is an ordinary value a view can hold, and {% if x == y %} on two of them silently took the {% else %} branch. 26 divergent cells.Six sites spelled the idiom — four ordering arms ((Float, Float), (Integer, Float), (Float, Integer) and the numeric_pair wildcard a Decimal or a BigInt reaches) and two equality arms ((Float, Float) and the is_decimal_pair wildcard) — the "N similar sites need N tests" shape (#1104); all six now call one order_floats or one floats_equal, and a source pin asserts .abs() < f64::EPSILON appears in exactly two CODE lines so a seventh site cannot arrive with a seventh copy. The guard is is_nan, not !is_finite, and that distinction is the whole fix: -inf < 1 < inf are all True in Python, so guarding on finiteness would trade one set of wrong cells for another — gate-off M2 makes exactly that mutation and reddens 37 cases. It is also not #2338's mechanism: a NaN is not a pair Python REFUSES to order (nan >= nan raises nothing, it evaluates to False), so try_compare was never asked the question #2338 taught it to answer — but None is still the right vehicle, because it means "all four operators are false". The NaN EQUALITY answer was right only by accident: the epsilon produced False for the same undefined-comparison reason that made inf wrong, so a future change to the tolerance would have flipped it silently with nothing failing; floats_equal makes it intentional — for a non-finite operand IEEE == IS Python's answer. The finite-float epsilon (#2243) is untouched and pinned as a deliberate divergence, so a change to it made in this fix's name cannot pass unnoticed. order_floats deliberately does NOT guard its tolerance on finiteness: the gate-off found that guard SURVIVED its own mutation, and it proved to be a semantic no-op — reaching that line requires neither operand NaN and a != b, so a - b for a non-finite pair is always ±inf and never below EPSILON. Deleted rather than tested around (#2233), with the proof in the comment. Two self-naming pins close themselves in the same commit, as each instructed: #2338's test_non_finite_floats_still_diverge ("delete this test when #2349 is fixed") now asserts the same cells AGREE, and #2244's test_a_bool_inherits_the_integer_arms_nan_answer ("pinned so that the day the integer arm is fixed, this test says so") inverts its second half while keeping the bool/integer equivalence that is its actual claim. The corpus gained inf, -inf, nan and the Decimal forms of Infinity/NaN: every numeric INPUT was FINITE, so the differential could not construct a single cell where the idiom is undefined and reported clean over all 26 — 3,285 cells now touch a non-finite input, where there were none. Two-build differential against a rebuilt origin/main: 68 newly agreeing, 0 regressions, 0 introduced live-payload leaks. Six gate-off mutations, each rebuilt and re-run, mutation text asserted found exactly once and every restore verified byte-identical; each mechanism reddens a test only it reddens, including a VALUE mutation (making two NaNs equal) that reddens 20 and a mutation of the #2243 boundary that reddens only its pin. Not fixed and filed (#1079): #2365 — Python's identity-first container comparison (x is y or x == y) makes [n] == [n] True for an ALIASED NaN, which djust cannot express because a Value carries no object identity; distinct NaN objects agree on every operator, and inf needs no shortcut at all. 165 collected in python/tests/test_non_finite_floats_2349.py, over an exhaustive 570-cell non-finite matrix plus a 3,000-case randomised sweep against live Django.

  • d.items / d.keys / d.values are dict VIEWS, not lists (#2340).#2334 made the three methods resolve, to a Value::List. Everything a template usually does with one was exact — iteration, unpacking, |length, |join, truthiness, {% with %} — and two observable properties of a real view were not: {{ d.items }} read [(&#x27;a&#x27;, 1)] where Django reads dict_items([(&#x27;a&#x27;, 1)]), and {{ d.items|first }} answered ('a', 1) where Python raises TypeError. Value::DictView { kind, items } closes both.

    The issue's list of what raises was wrong in two directions, and running Django over ALL of its built-in filters against all three kinds is what showed it. |slice does not raise — Django's slice catches the TypeError and returns the value UNCHANGED, so {{ d.keys|slice:':1' }} renders the whole view repr and {{ d.keys|slice:':1'|join:'' }} is still every key; modelling it as "returns nothing" would have shipped a new divergence. |dictsort does not raise either — it is sorted(value, key=…), and sorted() takes any iterable, so {{ d.values|dictsort:"k" }} is a real working idiom returning a LIST. And five filters raise that the issue did not name (divisibleby, get_digit, phone2numeric, timesince, timeuntil) — for any non-scalar, not because of the view.

    A third of the registry sees the view's str(), which the framing of the repr as "debug-only" missed entirely: |truncatewords, |wordcount, |linebreaks, |stringformat, |striptags, |pprint, |escape, |safe, |yesno and |make_list all operate on the text dict_keys([…]). The repr is their INPUT.

    Every exhaustive-match site rustc surfaced was decided rather than defaulted: is_truthy (an empty view is falsy), Display, Serialize, IntoPyObject, ObjectKey::from_value (a view is unhashable in Python too, so d.keys in x misses rather than matching by text), value_to_json, pprint::flat_repr, and loop_cache::hash_value — the last with its own tag and the kind in it, since a view and a list of the same items render differently and must not share a cache key. iter_values yields the items so every iterating filter works from one sink, while |random and |json_script guard at their own arm: a view IS a sequence, and it is subscripting that it refuses.

    rustc enumerates only the exhaustive matches, so all 19 Value::List | Value::Tuple or-patterns were audited by hand — an or-pattern with a _ fallback compiles fine and sends a view silently down the scalar path. Four needed a decision and two were wrong: pluralize returned the suffix unconditionally (right for a 2-entry view by luck, wrong for a 1-entry one), and value_to_arg_string collapsed a view to to_string(), handing an assign-tag handler the text dict_items([…]) instead of the rows — the #2042[List]-collapse class one placeholder over. The other 15 were decided and left: first/last (Django raises; _ => Missing is right), apply_slice (already returns the value unchanged), context.rs's numeric-index walk ({{ d.items.0 }} is '' in Django too), and the rest either handled or operating on a view's ELEMENTS, which can never themselves be views.

    legacy_display deliberately does NOT name the container, and the first version got that wrong. The naming arm was written into both display paths on a comment asserting "the container spelling is Python's on BOTH display paths" — a prose invariant nobody had run. The gate-off surfaced the legacy arm as a surviving mutation, and the test written to close that gap failed on its first execution with dict_items([[List]]) (CLAUDE.md #1867). legacy_display is the pre-#2203 rendering where every container is a [List] placeholder, and before #2340 a view WAS a Value::List — so [List] is exactly what {{ d.items }} printed under the flag, and naming it there would make a legacy-rendering switch less legacy.

    Retires TestTheDictViewModelIsAList and the dict-view-modelled-as-a-list residue classifier, which named this issue as their contract. Two structural pins were corrected rather than worked around: test_the_iteration_sink_has_exactly_the_callers_it_claims now delimits each arm's own body instead of using a 400-char proximity window (the new guard's comment pushed random's iter_values call past it, and the pin reported a filter had stopped routing through the sink when it had not), and test_every_bare_list_site_is_one_of_the_documented_list_always_four now strips // comments before grepping — the rule its sibling test_bool_before_int_converters_2212._strip_comments already states, since it reported a new construction site for a comment that merely namesValue::List(items).

    New cases in TestTheIssueTable, TestAViewIsStillASequenceWherePythonSaysItIs, TestEveryFilter and TestNotMorePermissive, plus each_kind_names_its_own_container_in_str in context.rs, a_dict_view_names_its_container_only_on_the_django_parity_path in test_display_django_parity_2203.rs, and two cache-collision cases in test_loop_cache_value_keys_2203.rs. The filter sweep runs every dict SIZE and a second ARG — one of each was the blind spot that hid pluralize and dictsort respectively — and its exemption is a MECHANICAL predicate: a cell is exempt only when the same filter over a plain LIST of the same elements diverges too, asserted both to fire and to produce an identical set across the three kinds. Two-build differential over 96,779 cells: 42 newly agreeing, 0 regressions, 0 introduced live-payload leaks; gate-off 14 mutations, 1 survivor (a measured semantic no-op), 0 invalid. Two adjacent divergences found, measured and filed rather than fixed: #2361 and #2368.

    The #2360 interaction is pinned, because it did not exist when this was written: True / False / None became context BUILTINS in the same resolution path a typed key (#2339) and a view live in, and a key spelled like a builtin is where the three could collide. All 26 cells agree with Django, and the ordering is Django's rather than convenient: {% if True in d %} is False for {"True": 1} and True for {True: 1} or {1: 1} (the builtin resolves to a bool, and #2339's typed key is what stops it matching the string — before that fix the coercion made this open on a dict that merely has a key spelled "True"); {{ d.True }} is the value under the STRING key, since a dotted segment is a mapping lookup and the builtin applies to a bare name only, which is also why it misses a dict keyed by the bool; and a context variable named True shadows the builtin. TestTheContextBuiltinsInteraction carries the discriminating pair — the same template answering N for a string-keyed dict and Y for a typed-keyed one.

  • A dict key keeps its Python type, so {% if 0 in d %} no longer matches a "0" key and an int-keyed dict is a mapping at all (#2339).Value::Object was an IndexMap<String, Value>, and two bugs followed. {% if 0 in d %} compared contains_key(&needle.to_string()) — a gate opening on a coincidence of Display formatting, so 0, 1.0, None and True all matched the keys spelled "0", "1.0", "None", "True". And a dict with ANY non-string key was not a mapping at all: PyO3's extraction required string keys, so {0: 1} fell through to its own repr{% for k in d %} then iterated that string BY CHARACTER ([{][0][:][ ][1][}]) and {{ d|length }} counted 14.

    The issue said these pulled in opposite directions, and the premise was false.#2339 argued djust's wire format coerces every dict key to a string, making the to_string() the only thing keeping {% if pk in d %} alive against a view's own {pk: …} mapping — which is why PR #2341 wrote the Python-faithful fix, measured it, and reverted it. Measuring the claim through the real LiveView.render() shows there is no JSON hop on the render path at all: the live Python dict goes straight to PyO3, so an int-keyed dict was never string-keyed here and {% if pk in d %} already answered MISS on it. The coercion protected nothing; its only effect was to make djust wrong for the string-keyed case. With ObjectKey carrying the type, both answers become Python's simultaneously. Pinned in TestThePremiseThatBlockedThisFix, because the whole design turns on it.

    Numerics are conflated the way CPython conflates themhash(1) == hash(1.0) == hash(True), so {1: "a"}[True] resolves — while the variant is kept for DISPLAY, so repr({True: 1}) is still {True: 1}. Comparing by variant would have bought a NEW divergence, which is the "a partial model is not a fix" shape.

    Blast radius stayed small by design: ObjectKey::Str hashes EXACTLY as its str does and implements Equivalent<ObjectKey> for str/String, so all 232 map.get("literal") call sites compile and behave unchanged; only 6 sites in djust_core and 5 in djust_templates needed a semantic decision. Both Python→Value converters now share ONE key extractor rather than a second copy (#1646) — djust_live's python_to_value used to ? on a non-string key, failing the whole conversion where the other path dropped to a repr — and the three copies of "a dict iterates its keys" that appeared with it were converged into object_key::dict_iteration_values before they could drift.

    The wire is still lossy, and says so: a key serializes as its string form in JSON and msgpack, matching CPython's own json.dumps({0: 1}) == '{"0": 1}'. Letting msgpack carry a typed key would make the same view render differently per transport, for a shape no template can observe. Pinned in TestTheWireStillStringifies.

    Retires three pins that named this issue as their contract, and corrects #2221's dict-lookup case, which asserted HIT for an int needle against a STRING-keyed dict — a cell Django answers MISS for, and which passed only because of the coercion it was cited to justify. New cases in TestThePremiseThatBlockedThisFix, TestInOverADictComparesTypes, TestANonStringKeyedDictIsAMapping, TestNotMorePermissive, TestTheWireStillStringifies and TestRandomisedAgainstDjango. Two-build differential over 96,547 cells: 148 newly agreeing, 0 regressions, 0 introduced live-payload leaks; gate-off 8 mutations, 0 survivors, 0 invalid.

  • {% if a >= b %} is False for a pair Python cannot order, as Django has it (#2338).compare_values returned -1 | 0 | 1 and collapsed "these two cannot be ordered" into 0. For > and < that reproduced Django exactly — Python raises TypeError, {% if %} catches it, the branch resolves False, and 0 makes both of those false. >= and <= read the SAME 0 as equal and answered True for every pair with no ordering arm: {% if p >= q %} on "a" and 1, {% if p <= q %} on [1] and (1,), a dict against anything, two Nones, two absent variables. Per-pair rather than per-type, silent, and permissive in the direction that matters — a {% if x >= threshold %} gate opened on operands with no ordering at all. try_compare(a, b) -> Option<i32> replaces it and all four operator arms consume the Option via is_some_and. There is deliberately no i32 wrapper left: #2335 briefly carried one and removed it before merge precisely because, with every caller reading only the i32, the Option was observationally equivalent to 0 — a second mechanism shadowing the first. > and < are bit-identical either way, which is why the gate-off's > control mutation reddens only the source pins, and it is what bounds the behavioural delta to the two arms that were wrong. Both arms, not one: fixing >= to False and leaving <= answering True is the same bug mirrored (#1646), so every cell is swept for all six operators — and the differential moved <= and >= in exactly equal numbers, 568 each. The per-element walk propagates the element's None rather than returning Some(0), which is what keeps #2335's length tie-break bug closed one operator over: [[], 'a', ('b',)] >= [1] would otherwise answer True because three elements beat one. An EQUAL unorderable element still continues the walk, so [{}, 1] < [{}, 2] stays True as Python has it. == / != are untouched and now deliberately disagree with ordering on the null pair: values_equal calls Missing/None equal, because Django's ignore_failures resolves an absent variable to None, while Python's None < None raises — so {% if a == b %} over two undefined names is True and {% if a >= b %} is False, on both engines. Answers are measured against live Django 5.2.16 rather than asserted from a table: a 2,166-cell curated matrix went 606 → 0 divergences, a 3,000-cell randomised sweep went 822 → 0, and the two-build filter differential reports 1,136 newly agreeing cells, 0 regressions and 0 introduced live-payload leaks over 95,275 — every one of the 1,136 a cell where djust said Y and Django said N. TestSequenceComparisonRandomised.OPS gains <= and >=; they were excluded when #2335 wrote that sweep because every incomparable pair diverged on them, which is exactly how a corpus sampling only < / > kept this invisible. Six gate-off mutations, each rebuilt and re-run, with the mutation text asserted found-exactly-once and the restore verified byte-identical: every mechanism reddens a test that only it reddens — the walk's propagation and the Missing/None arm are independently reachable rather than shadowing each other (#2129/#2135). Not fixed here and filed (#1079): non-finite floats (#2349) — a NaN is not a pair Python refuses to order (nan >= nan raises nothing, it evaluates to False), so try_compare is never asked this question; the defect is the (a - b).abs() < f64::EPSILON idiom, undefined for NaN, at six sites, and the same idiom is why inf == inf is False. Confirmed pre-existing by running the probe against the pre-fix build — 28 non-finite divergences before, 26 after, the 26 identical. 26 regression cases in python/tests/test_incomparable_ordering_2338.py (144 collected), plus four unit tests at the function in renderer.rs.

  • mark_item's non-str branch is deleted — it had no producer left (#2337).filter_registry::mark_item, the helper both the PyList and PyTuple arms of mark_input_safety call, wrapped a sequence element in mark_safe only if it was a str and passed anything else through. #2324 closed the last thing that could hand it something else, so the pass-through had no producer. Proving that is the work, and it is not rhetorical: the tuple arm of the same function is a worked example of exactly this claim expiring — #2290 deleted a parallel PyTuple arm as unreachable, correctly on the evidence available, and #2287 then added a second grant producer that reached it (#2305). Being wrong is not benign either: mark_safe(["<b>"]) is SafeString("['<b>']"), a string carrying a raw < that then bypasses escaping — the more-permissive-than-Django direction Context::items_are_safe's own doc-comment says this code must never take. Proven three ways.Analytically: every writer of InputSafety.items = true can only grant on a sequence whose elements are Value::String, which IntoPyObject turns into a PyStringContext::items_are_safe requires matches!(item, Value::String(_)) for every element, safeseq/escapeseq CONSTRUCT Value::String elements unconditionally, and slice only preserves a grant already held. Empirically: the branch was replaced with a panic! and the extension rebuilt, and nothing reached it across the full suite, the 95,275-cell filter-parity-differential.py corpus, or a 233-cell adversarial sweep that fed the wrap 31 distinct element-type signatures. Adversarially: the new suite crosses every producer with every non-str shape a sequence can carry — int, float, bool, None, Decimal, a bigint, a nested list, a nested tuple, a dict — through explicit safe_keys, the loop-variable alias arm, the stale-grant case (#2300), slice's fail-soft arm, and all three renderer seed sites. The producers split in two, and conflating them was the first draft's mistake:safeseq/escapeseq stringify every element themselves, and so does Django, whose [mark_safe(obj) for obj in value] turns ['<b>', 2] into two SafeStrings — verified against live Django rather than assumed. So on those paths the assertion is Django parity plus a structural pin on the constructors; only Context::items_are_safe is non-converting, and there it is TYPE PRESERVATION, because a widened grant shows up as the element's type changing. Behaviour-preserving, measured not asserted: two genuinely different builds over the 95,275-cell corpus differ in zero cells (all 17 that moved are the random filter's own nondeterminism). --compare refuses the pair, because identical agreement counts are its stale-baseline heuristic and a behaviour-preserving change is indistinguishable from one by that test — so the measurement is the cell-level diff. Five gate-off mutations, each rebuilt with the mutation text asserted found exactly once and the restore verified byte-identical: relaxing items_are_safe's String narrowing reddens 128 cases, reverting #2324's stringify 2, re-inserting the guard 1 (the structural pin — its presence has no behavioural signature, which is the finding), and making the wrap a no-op 59 (the non-vacuity sibling). One survivor with an answer rather than a pass: letting items_are_safe grant on a dict changes nothing, because mark_input_safety casts to PyList/PyTuple and a dict is neither — two independent barriers, and the arm count is pinned. A sixth mutation found a defect in this PR's own tests: a first draft asserted "every element reaching the wrap is a str" on the converting paths, and reverting #2324 left it GREEN — with the guard gone the wrap stringifies the element and destroys the evidence before the probe runs. A test that cannot go red for the thing it names is the two-mechanisms-shadowing shape, so it was deleted rather than tested around, with a comment where it stood recording why. What replaces the guard is TestANonConvertingProducerRefusesANonStrSequence plus TestTheProducerEnumerationIsComplete, which pins the producer enumeration MECHANICALLY — writing it in a doc-comment is precisely what let the tuple arm's claim expire unnoticed. 22 regression cases in python/tests/test_mark_item_dead_branch_2337.py (202 collected).

  • {% for key, value in mydict.items %} renders the dict instead of nothing (#2334). One of the most common Django loop idioms there is, and it produced an empty region — silently, with no exception and no warning. Two independent gaps, both of them "match Python's iteration protocol". First, .items / .keys / .values are Python METHODS, not keys, and Context::get's nested walk only ever does obj.get(part), so the lookup missed; Django reaches them through Variable._resolve_lookup's attribute step plus its auto-call. They now resolve in ONE place — Context::resolve, which every operand site reaches ({{ }} directly, and {% for %} / {% if %} / {% with %} / {% include … with %} as get_value_safe's last arm) — placed AFTER the get, so a dict that has its own key named items still resolves to that key's value, which is Django's mapping-before-attribute order. Second, Node::For had no Value::Object arm at all, so a bare {% for k in d %} fell to the wildcard and rendered the {% empty %} block; Python iterates a dict's KEYS, which is exactly the argument #2325's string normalisation already made, so it is the same normalisation one variant wider and the loop body is shared rather than copied (#1646). Iteration order is the IndexMap's insertion order — Python's — because a hash order would make the loop nondeterministic across renders and thrash the VDOM. The dict view is modelled as a plain list: everything a template does with it is exact, while the container's str() reads […] rather than dict_items([…]) and it is subscriptable where Python's view raises. Both residues are measured, pinned by a mechanical predicate rather than a name list, and tracked at #2340.

  • Two equal sequences compare equal (#2335).values_equal had no structural arm, so {% if a == b %} over two equal lists answered False — a list was not even equal to itself — and the template silently took the {% else %} branch, which is the direction that HIDES content. compare_values had the same hole from the ordering side, so {% if a < b %} was false for every sequence pair. Both now recurse through the same function, which is what carries the numeric widening down ([1] == [1.0] and [True] == [1] are both true, through the #2243 / #2244 arms rather than a second copy of them), and it fixes {% if x in list_of_lists %} at the same time, since in is the third caller. List-against-tuple stays False, as Python has it — a "both are sequences" arm would be wrong in exactly the direction a curated table is least likely to probe. Ordering is Python's own algorithm and not an approximation: the walk continues only past an EQUAL pair, so [{}, 1] < [{}, 2] is True even though two dicts cannot be ordered, and an unequal pair decides whatever it answers. The first draft asked "is this pair ordered?" first and continued on a 0, which conflates "equal" with "incomparable" and falls through to the length tie-break — [[], 'a', ('b',)] > [1] answered True because three elements beat one. The randomised differential caught it in 27 of 28,500 cells; no curated case had the shape.

  • {% regroup cities|dictsort:"country" by country as … %} applies the filter (#2333). The fourth and last of the operand channels #2325 enumerated, and the one that PR could not reach: {% regroup %} is a Python-side assign tag whose source arrives through RESOLVE_ARG_POSITIONS plus a JSON hop, not through the renderer's get_value. So it asked for a variable literally namedcities|dictsort:"country", missed, and handed the handler the template's own source text — {{ groups|length }} rendered 0 and every {% for %} over the groups rendered nothing. Django compiles this operand with parser.compile_filter, and its own regroup docs open by noting the input usually needs sorting first, so the idiom is close to canonical. One resolve_tag_operand now resolves a pipe-bearing operand through get_value; a non-pipe operand keeps the plain lookup, because this channel's contract is "unresolved ⇒ pass the raw token" and get_value's literal arms have no way to say "unresolved" — they would turn regroup's own by / <attr> / as keyword operands into values. The module docstring's "filter expressions on the source are not supported" limitation is gone.

  • A dict operand no longer resolves a loop safe-key mark belonging to a different key (#2334). The {% for %} safe-key mapping asserts that the loop variable IS <iterable>.<index>, and a NORMALISED sequence falsifies that — the loop iterates something built from the resolved value, not its own indexable elements. For a dict it is a live XSS rather than a theoretical one: _collect_safe_keys writes a dict's paths by KEY NAME (d.1), so a dict with a key spelled "1" whose value is mark_safe(…) puts d.1 in safe_keys, and the loop's second key — an entirely different, attacker-controlled string — would resolve that mark and be emitted unescaped. The mapping is now gated on the sequence not having been normalised, which covers the string case by the same argument, and the whole path is exercised end to end through the production _collect_safe_keys collector.

  • A filter argument that is unparseable or unresolvable now RAISES, at one chokepoint, instead of silently becoming a per-filter default (#2328). TWELVE dispatch arms read their argument as a number through FOUR different parsers — six inline copies of arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(N) each with its own N, wordwrap's seventh spelling of the same thing, the truncate_arg helper serving four more, and floatformat::parse_int_like in its own module — and one more site in apply_filter_full_safe fell back to the argument's RAW TEXT when a bare identifier did not resolve — so {{ p|wordwrap:widht }} wrapped at 75 and {{ n|pluralize:es }} rendered the literal word es. Measured against Django 5.2: of the 29 argument-taking built-ins, an unparseable quoted literal had 16 already agreeing, 8 raising in Django and not here, and 5 differing for reasons that are not parsing; an unresolvable bare identifier raised in Django for all 29 and in djust for none. This is a behaviour change: a template that renders today can raise after upgrading. It raises in production as well as in development — LiveViewConsumer.receive already catches a render error and sends a safe error frame (stack trace in DEBUG, generic message otherwise) without dropping the socket, so degradation is decided once, at the transport, where it can be environment-aware; a second environment-aware decision at the filter level would have duplicated a policy that is already correct one layer up. Fixing twelve filters in twelve places is the drift class this codebase keeps paying for (#1646), so every built-in that reads its argument as a number parses it through filter_int_arg, which takes the policy Django's own source takes: Raise where Django writes a bare int(arg) (center, ljust, rjust, wordwrap, urlizetrunc, divisibleby) and ReturnInput where it writes except ValueError: return value (the four truncates, get_digit, floatformat). floatformat had a second int() of its own and is now the chokepoint's other customer — with the opposite policy, which is what keeps that parameter load-bearing rather than decorative. Routing through one parser also brought int()'s spellings that every scattered parse::<usize> refused: int(" 5 ") is 5, int("1_0") is 10, int(True) is 1, and an UNQUOTED float literal truncates (int(2.7) is 2) while a quoted one raises — one character of template syntax separating the last pair. int(None) is a TypeError, which no except ValueError catches, so None raises under both policies — but only after the VALUE has parsed, because Django parses the value first and one that fails never reaches int(arg). {% if %} is the one construct that swallows the resolve failure, because IfNode.render wraps its condition in except VariableDoesNotExist; the catch is deliberately narrow (it does NOT cover the unparseable-argument ValueError), which is why the miss carries its own DjangoRustError::VariableDoesNotExist variant. Two pre-existing defects fell out of the measurement. ljust/rjust panicked — Rust's format spec holds its width in a u16, so format!("{s:<width$}") raises "Formatting argument out of range" at exactly 65536, one past u16::MAX. That arrives in Python as a PanicException, whose MRO is BaseException directly — it does not inherit from Exception at all, so it escapes the consumer's except Exception and kills the SESSION rather than the render. The width had to PARSE to get there, which is what makes it easy to miss: ljust:"999999999999999999999" is 21 digits, past usize::MAX, so the old parse::<usize>() failed and fell back to width 0 — as do "x", "-5", "0" and "". One digit shorter is usize::MAX itself, which parses, and panicked. Both pad filters build their padding explicitly now, as center always did. Second: urlizetrunc refused a negative limit and did not truncate at all, where Django's Truncator.chars(-3) keeps nothing. And one defect this fix INTRODUCED and then closed, found by pinning the panic as a boundary rather than a single point: python_int saturates past isize rather than failing — right for slice, where a magnitude past isize selects the same elements — so routing the pad filters through it turned that same 21-digit width from a harmless width-0 no-op into a request for isize::MAX spaces, which the allocator answers by aborting the process, not by raising. center/ljust/rjust now cap the width at MAX_PAD_WIDTH (1,000,000, mirroring floatformat::MAX_PLACES) and raise past it; Python's own answers there are MemoryError and OverflowError, which also fail the render. urlizetrunc is deliberately uncapped — its limit is a comparison bound, never an allocation. The chokepoint is pinned mechanically by TestChokepointIsTheOnlyParser, which fails if a bare parse-and-default on the argument reappears; a comment would not (#1859). New cases in python/tests/test_filter_argument_contract_2328.py, in TestWidthArgument, and in crates/djust_templates/tests/test_builtin_filter_arg_resolution_2202.rs. The first pass of this fix regressed 508 cells that the full suite AND scripts/filter-parity-differential.py were both green over — that script gives every filter one VALID argument, so it reported zero moved cells in either direction for a change entirely about invalid ones; widening its corpus is #2345. A purpose-built argument-axis sweep (26,448 cells, two builds) found all three regressions and finishes at 1,601 newly agreeing, 0 regressions, 0 introduced live-payload leaks. Left open, each pinned as still-divergent so it cannot rot: #2343 (stringformat:"" panics), #2344 (timesince/timeuntil ignore their argument entirely), #2346 (three non-int() divergences), #2347 (the missing True/False/None context builtins).

  • A filter on a tag operand is applied, instead of the tag silently rendering nothing (#2325). Django resolves a tag's operand with a FilterExpression — the same object {{ }} uses — and djust had ONE filter-aware resolver (get_value) alongside FOUR tags that each open-coded a bare variable lookup. So the chain after the | was never applied: the lookup asked for a variable literally namedp|slice:":2", missed, and the tag proceeded on the miss. {% for x in p|slice:":2" %} rendered nothing where Django renders ab; {% if p|slice:":1" %} took the {% else %} branch on a non-empty list. The {% with %} and {% include … with %} sites were louder still — their miss fell back to Value::String(expression), echoing the template's own source into the page, so {% with q=p|upper %}{{ q }}{% endwith %} rendered the literal text p|upper, {% with q="lit" %} rendered &quot;lit&quot; quotes and all, and {% with q=nope %} rendered the variable name. Silent-empty output is the worst failure shape a template engine has: no exception, no warning, nothing in the console, just a page missing a list. Four spellings of one lookup is the parallel-path-drift class (#1646), so all four now call get_value rather than each learning about filters separately, and TestEveryFilteredOperandSiteIsAccountedFor pins the enumeration mechanically — a fifth tag that grows its own bare lookup fails it. Three supporting changes fall out: get_value_safe gains the Context::resolve getattr walk as its last arm ({% for %} called resolve() directly for #806, and routing it through get_value without this would have regressed {% for x in user.orders %} over a DB relation to empty — the exact symptom being fixed); {% for %} iterates a string by character as Python does, which #2325's own repro table needs since upper/join/first/last all hand the loop a string; and the loop safe-key mapping is registered only for a bare operand, because it asserts itemis<iterable>.<index> and a filter falsifies that (slice shifts indices, dictsort reorders). The runtime-safe flag is deliberately discarded at all four sites, so a filtered operand can only ever be escaped at least as hard as before. Not fixed here and pinned as separate mechanisms (#1079): {% regroup %} with a filtered source, {% for k, v in d.items %}, and sequence equality in {% if p == q %}.

  • slice implements Python's slice semantics rather than approximating them (#2326). Django's filter is value[slice(*bits)] — a passthrough, so every Python rule applies — while parse_slice_indices read at most two parts and clamped instead of wrapping. It failed in the two directions a template author notices: {{ items|slice:":-1" }} (drop the last) rendered nothing, and {{ items|slice:"-3:" }} (last three) rendered everything. Patching those two cases would have left the rest, because a one-part spec is slice(stop) so "2" means [:2] and not [2:], a :step was parsed and then discarded, and a negative step never reversed — value-by-value fixes on a semantics gap do not converge. So this reproduces CPython's algorithm (PySlice_AdjustIndices plus the walk) in one slice_positions helper shared by the string and sequence branches, which had duplicated the index math and are exactly the pair that would drift apart again (#1646). Argument parsing follows suit: python_int is CPython's int(), not Rust's parse::<isize>(), so surrounding whitespace, a leading + and single underscores between digits are accepted and a bigint saturates — the underscore case fails open if unsupported, since rejecting 1_0 returns the input unchanged and renders every element where Django renders none. Anything slice(*bits) or the indexing would raise on (more than three parts, a zero step, a non-integer, a lone-space part) returns the input untouched, matching Django's except (ValueError, TypeError, KeyError). A 1,494-cell randomised sweep against live Django went from 364 disagreeing to 0. The container rule from #2317/#2321 is preserved across every newly-supported spec, and slice's two rebuild_like exits collapse into one — a strictly stronger form of the same guarantee, since there is no longer a branch that could forget.

  • A sequence filter now returns the shape it was given — slice of a tuple is a tuple, and unordered_list accepts one as a sublist (#2317, #2321). Python and Django both preserve the container a sequence arrived in, and djust collapsed it at two points. ("a","b","c")[:2] is a tuple and Django's slice filter is a bare value[bits] passthrough, so {{ p|slice:":2" }} rendered ['a', 'b'] where Django renders ('a', 'b'); and Django's unordered_list accepts isinstance(x, (list, tuple, GeneratorType)) as a sublist while djust matched Value::List alone, so a tuple sublist rendered its escaped repr in its own <li> instead of nesting a <ul> — and an empty tuple emitted a spurious <li>()</li> where Django emits nothing. Both are visible only when the value is rendered directly: every consumer that iterates (join, and unordered_list for the slice half) saw the same elements either way, which is exactly why the sequence-filter suites — which compose slice with join — agreed for as long as they did. A third instance of the same rule shipped in #2316 (the PyTuple arm of mark_input_safety), so this fixes the rule rather than the two cells: every production site in filters.rs that rebuilds or matches a sequence is enumerated and decided. Six filter-level rebuild sites, and four of them are right to build a list — Django's own bodies are sorted() (dictsort, dictsortreversed), @stringfilter + list(str(value)) (make_list) and list comprehensions (safeseq, escapeseq); the decision is Django's implementation per filter, not a blanket rule. The two that were wrong are slice's populated and empty branches, now routed through one rebuild_like helper (#2326 has since collapsed those two branches into a single exit, so the enumeration below reads five sites and one shape-preserving call — the pinned constants in TestEveryRebuildSiteIsAccountedFor are the mechanical source) — the counterpart of iter_values, and the single place "does this filter collapse a tuple?" is answered. () and [] are different reprs, so the empty branch is as shape-sensitive as the other and gets its own test. Not an escaping change in either direction: the renderer's safety machinery already matched Value::List(x) | Value::Tuple(x) at every production site, so a tuple is subject to exactly the checks a list is. Measured rather than reasoned — a two-build differential against a rebuilt origin/main baseline over 29,723 cells: 28 newly agreeing, 0 regressions, 0 introduced live-payload leaks (38 before, 38 after). The corpus gains a t-nested shape, because t-plain puts a tuple at the top and l-nested puts a list in the sublist slot, so between them it could not construct the one cell unordered_list's sublist test reads — the enumerate-every-variant lesson again. Three adjacent divergences were found and deliberately left, each pre-existing and shape-independent (every one reproduces identically for a list): safeseq does not stringify its items the way mark_safe does (#2324mark_safe(obj) is SafeString(obj), a str for any input, and matching it needs a Value::py_str() because Display is deliberately Django's numberformat.format for Float/Decimal), a filter on a {% for %} iterable renders nothing (#2325 — the tag stores its iterable as a bare variable path, so {% for x in p|slice:":2" %} is empty), and slice clamps negative indices and ignores :step (#2326 — 7 of 10 specs tried diverge, {{ items|slice:":-1" }} renders nothing and {{ items|slice:"-3:" }} renders everything). Their exclusion is mechanical rather than a blind spot: each is pinned by a test that goes red when its issue is fixed and names the rows to restore. New cases in TestTheReportedCells, TestTheTwoMechanismsAreIndependentlyReachable, TestNeighbouringSequenceFiltersAreUnchanged, TestKnownAdjacentDivergences, TestRandomisedUnorderedListWithTupleSublists (3,000 nestings in which any sublist may be a tuple — #2301's sweep is the same generator with tuples excluded, and it read 3000/3000 while this class of divergence was live), TestRandomisedSliceShape (every supported spec × both containers, against Django and against Python's own repr(value[sl]) — the shape's actual source), TestNotMorePermissiveThanDjango, TestEveryRebuildSiteIsAccountedFor and TestTheNormalizationBoundary, plus rebuild_like_returns_the_container_it_was_given and rebuild_like_does_not_touch_the_items in filters.rs. Reproduction fidelity is load-bearing here and pinned rather than asserted: normalize_django_value collapses a tuple to a list before the framework paths cross into Rust, so every test hands its context to _rust.render_templateun-normalized, and a test that routed through the normalizer would render a list and pass regardless of what the Rust side does. Gate-off ran with the mutation asserted found, unique and non-no-op, a rebuild between iterations and a byte-identical restore: slice populated 6 red, slice empty 5, unordered_list tuple arm 9, helper neutered 6 — and each mechanism has a uniquely failing test (2 / 1 / 8), so none shadows another. The structural pins were gated separately against a synthetic future-drift trigger — a new filter building a bare Value::List — which they catch, so "six rebuild sites" is a grep the suite enforces rather than a sentence in this entry.

  • safeseq now stringifies its items, because mark_safe does (#2324). Django's filter is [mark_safe(obj) for obj in value], and mark_safe(obj) is SafeString(str(obj)) — it does not merely mark an item, it replaces it with the item's str(), for any input. djust kept the typed value, so {{ p|safeseq }} on [1, 2] rendered [1, 2] where Django renders ['1', '2'], and — because the item's type stays readable by the rest of the chain — {{ p|safeseq|unordered_list }} on ['<b>', ['c', ['d']]] nested a <ul> where Django emits <li>['c', ['d']]</li>, reading the string mark_safe made of the sublist. Identical for a tuple sublist, which is how #2317 found it. The spelling is str(), not the render form, and it is the mechanism that already existed rather than a second one.Value's Display is Django's numberformat.format() for Float/Decimal (#2214, #2258), so str(1e20) is 1e+20 where {{ f }} renders 100000000000000000000, and str(Decimal("1E-9")) is 1E-9 where the render is 0.000000001 — a naive item.to_string() fixes every container row and introduces a numeric one, which is why this was filed separately rather than folded into #2317. That split is the @stringfilter coercion at the top of apply_builtin_filter, which is how #2303 fixed the scalar half (|safe) — by listing safe in STRING_FILTERS rather than growing a stringify inside the "safe" arm. This reuses it: the split moves into Value::py_str() (djust_core, sibling of py_repr(), which is the same split one nesting level out), and both the coercion and safeseq call it — two .py_str() call sites in filters.rs, pinned mechanically by TestOneStringifyMechanism so a future third stringify has to answer for itself. The float→string sink pin follows the sink: every_float_to_string_sink_routes_through_an_approved_repr scanned only djust_templates/src and its own doc noted djust_core was "out of this pin's reach", so moving a sink there would have silently un-pinned it — the scan now walks djust_core/src too (three approved sinks, py_str / py_repr / Display, plus the one deliberate RUST_DISPLAY, legacy_display's frozen pre-#2203 arm) and its entries are crate-qualified, since both crates have a lib.rs. Not a permissiveness change: safeseq grants its items safety by name (ITEM_SAFE_OUTPUT_FILTERS) before and after, so join/unordered_list emitted them unescaped either way — what changes is what they emit, and Django emits the same bytes. Measured rather than reasoned: a two-build differential against a rebuilt origin/main over 37,621 cells — 124 newly agreeing, 0 regressions, 0 introduced live-payload leaks. That number required extending the corpus, and the extension is the finding: INPUTS carried no list holding a number, a Decimal or a map, and held l-nested/t-nested off the 2-chain axis citing #2324 itself as the reason — on the pre-extension corpus this same fix reported 2 newly agreeing cells. New l-scalars and l-dict rows (the Decimal/Float spelling is unmeasurable without one), and the two nested shapes join the chain axis together, since a list-only addition is exactly how #2317's tuple gap stayed invisible. Three adjacent tests move in the agreeing direction: the TestKnownAdjacentDivergences pin this issue was filed from is deleted (its own failure message said to) and {{ p|safeseq }} joins the nested-sequence agreement table, while test_context_item_safety_2287.py and test_custom_filter_safedata_2290.py each asserted that a safeseq output holding an int reaches a custom filter unmarked — Django's answer is list[True,True], measured, so those cells were pinning this divergence and now agree. New cases in TestTheRowsFromTheIssue, TestTheSpellingIsPythonStrNotTheRenderForm, TestNotMorePermissiveThanDjango, TestRandomisedSweep (11 chains × 1,046 shapes against live Django, with a guard that the generator still produces container items) and TestOneStringifyMechanism, plus py_str_is_cpython_str_not_the_render_form, py_str_is_display_for_every_variant_but_float_and_decimal and py_str_and_py_repr_differ_exactly_where_python_does in djust_core. Gate-off ran six mutations, each asserted found-and-unique, each rebuilt, each restored byte-identically, and all six red: dropping the per-item stringify 17 failed, to_string instead of py_str 5, py_str losing its Float arm 4, losing its Decimal arm 6, the coercion no longer calling py_str 5, and the sink pin no longer scanning djust_core 1 — so the spelling, each arm, both call sites and the pin extension are independently reachable rather than shadowing one another.

  • One producer for a serialized model's identity map, so "__model__" means something (#2322). Six sites stamped "__str__": str(...) onto a map standing in for a Django model, and only two also stamped "__model__"_serialize_model_safely and jit.py's identity-only subset. The depth-limited FK, the max-depth shorthand and both template/rendering.py fallbacks emitted {"id", "pk", "__str__"} alone, so which shape a consumer received depended on prefetch depth, on whether the template referenced a field, and on whether serialization happened to raise — none of it visible from the consuming side. A consumer keying on the marker was correct in development and wrong in production the moment a relation crossed max_depth, silently, by taking the wrong branch. The fix is not the one the issue proposed. Its three options — document the split, stamp the key at all six, delete the key — all take the six hand-rolled dict literals as given and argue about their contents. Each of those four bare sites is an independent attempt to write "the minimal identity representation of a model", and _IDENTITY_KEYS (the frozenset the field filter already treats as always-allowed) already says what that is: six copies of one concept, differing, which is parallel-path drift (#1646) with the marker gap as its symptom. So the map now has exactly one producer — serialization.model_identity — that all six call, and the marker is universal as a consequence of there being one place to answer the question rather than as six coordinated edits that can drift again on the next key. Deleting the marker instead would have removed information __str__ cannot carry (the class name), for a key docs/SECURE_DEFAULTS.md documents and out-of-repo consumers may read, and would have left the four literals in place. Driving the producers instead of grepping them found a second divergence none of the options would have: the two sites that did stamp the marker disagreed about key ORDER (id, pk vs pk, id), and dict order survives json.dumps and msgpack, so two producers of "the same" map were emitting two wire shapes. Not more permissive: the added key is obj.__class__.__name__, no Django field name may contain __ so it cannot collide with data, and a sweep over every filter in Django's live registry with the key present and absent — against hostile values in both__str__ and __model__, since type("<img src=x>", (), {}) is legal Python and worth sweeping rather than arguing inert — gains zero live fragments; {{ obj }} and {{ obj|length }} are byte-identical either way, because the engine's model predicate is object_str(), which keys on __str__. The two-build differential cannot see this fix and says so: it renders literal dicts through both engines and never calls djust's serializer, so over 33,336 cells against a rebuilt origin/main0 djust outputs changed, 0 newly agreeing, 0 regressions, 42 → 42 live-payload leaks (0 introduced) — its "identical agreement counts" guard fires correctly, and the numbers are computed with the tool's own load/_leaks logic from its own dumps. Its corpus still gains d-model, because the model-map shape had zero coverage — no input carried the "__str__" key Value::object_str branches on, so no cell could reach the model arm of {{ p }} or {{ p|length }} at all — and it deliberately gets no LIVE_FRAGMENTS entry, measured rather than assumed: with one it reports 65 cells identically on both builds, none of them a leak the tool can judge, since the two engines disagree about what that value is (the residual already pinned as known-wrong in #2294). Sixty-five permanent false leaks would drown a real one; same precedent as l-marked/s-marked. The #2294 count pin is replaced rather than updated — it cannot survive its own fix, since with the split closed it can only read 6/0 and a seventh hand-rolled literal carrying the key would pass it while re-opening the drift (#1859) — and now asserts that exactly one site builds the map. New cases in TestTheHelperIsTheDefinition, TestEverySiteIsExercised (all six producers driven and their identity sub-maps compared as ordered key/value pairs, the behavioural half the #2294 pin explicitly did not have), TestAddingTheMarkerIsNotMorePermissive and TestOneProducerNotSix. Gate-off ran eight mutations, each asserted found-and-unique with __pycache__ cleared between iterations and a byte-identical restore, and all eight red: max-depth shorthand 6 failed, depth-limited FK 5, main path 3, helper drops __model__ 15, helper emits the old key order 7, JIT identity-only 5, and the two rendering fallbacks 5 each — so all six call sites, the marker and the ordering are independently reachable.

  • wordwrap is textwrap.TextWrapper, not a greedy re-joiner (#2293).django.utils.text.wrap delegates to textwrap.TextWrapper; djust's word_wrap was split_whitespace() re-joined on single spaces — a different algorithm that happens to agree on "one two three", and diverged four ways at once: it flattened every existing line break into a space, collapsed runs of spaces and dropped leading indentation, measured widths in BYTES, and returned the text unchanged at width=0 where Django raises ValueError. The byte defect could not be fixed on its own, which is why #2279 filed this rather than folding it in: swept alone it fixed 21 differential cells and REGRESSED 6, every regression a U+2028 string, because Django's splitlines() breaks a line there while the re-joiner emitted a space — the byte overcount had been putting a break at that position by accident. Two bugs cancelling (the #2272 pattern), so the pair moves together. crates/djust_templates/src/textwrap.rs ports _munge_whitespace / _split / _wrap_chunks / _handle_long_word for the flag set Django constructs (break_long_words=False, break_on_hyphens=False, replace_whitespace=False, drop_whitespace=True, expand_tabs=True) plus wrap's own splitlines / whitespace-line-restore / trailing-newline wrapper. The load-bearing discovery is that the algorithm needs three different whitespace sets and they are pairwise different: str.splitlines() decides what a line is (ten boundaries, including U+2028 and a \r\n that counts as ONE), textwrap._whitespace decides where a chunk boundary is (six ASCII characters — \xa0 is not one), and str.isspace() decides which chunks drop_whitespace discards (so a lone \xa0 is a word the splitter never breaks on that is nonetheless thrown away at a line break). Every known defect in this filter lived in a gap between two of them, so each is named rather than spelled inline: py_is_line_break is factored out of pprint::py_splitlines_keepends so the keepends and no-keepends forms cannot drift (#1646), and truncate::py_is_space is reused for chunk.strip() == ''. A parsed width <= 0 now raises Django's own message; an UNPARSEABLE argument still falls back to 75, because a bare-identifier filter argument that does not resolve arrives at the filter as its own NAME and apply_filter_full_safe documents that class as a deliberate non-raise — pinned as a decision rather than left as a surprise, and filed as #2328. Nothing here was reasoned about: a transcription of the intended algorithm was swept against livedjango.utils.text.wrap over 76,437 cells (a randomized corpus × 19 widths including 0 and negatives) at 0 mismatches before a line of Rust was written, and every expectation in the Rust unit tests is Django's live answer — one of them was wrong on first writing (wrap("a \xa0 b", 3) is "a \nb", not "a\nb": only ONE trailing chunk is dropped) and the oracle corrected it. Two-build differential against a rebuilt origin/main at aa71903b, 31,867 cells: 57 newly agreeing, 0 regressions, 0 introduced live-payload leaks. Getting that number at all required widening the tool, and the first run is the finding worth recording: every s- corpus entry was a single line of ASCII-ish text with single spaces, so the differential could not construct one cell in which this filter differed from Django, and it reported agree BEFORE == agree AFTER over a fix that moved four behaviours — not "0 regressions" but no movement at all, which the tool correctly refuses as a non-baseline only because the counts happened to be identical; a corpus one cell luckier would have printed REGRESSIONS: 0 over an unmeasured change. That is the dictsort failure shape (#2296) on the INPUT axis instead of the filter-NAME axis, so the instance fix comes with a coupling: s-lines (indentation, a run of spaces, \n, a tab, U+2028, \xa0, \x1f, multi-byte words, a live payload, and every remaining py_is_line_break boundary) joins INPUTS and INPUTS_2, wordwrap joins HOT2 because it is the one built-in that INSERTS newlines into a string that later filters then read, and test_every_whitespace_boundary_the_engine_branches_on_is_in_the_corpus now parses the whitespace predicates out of the Rust (pprint::py_is_line_break, textwrap::is_textwrap_space) and fails when the corpus cannot reach one of their characters — the same shape as the dictsort coupling one axis over, so the next blind spot is mechanical rather than discovered. It is deliberately corpus-GLOBAL rather than per-filter: a sound "which filters read this axis?" derivation is not available (a filter that merely passes a character through is indistinguishable, by output, from one that branches on it), and inventing one would be a pin that looks mechanical and answers the wrong question — the global form is strictly stronger anyway. Gated against 8 mutations, including one that ADDS a boundary to the Rust predicate, without which the check would be a snapshot rather than a coupling; one survivor was treated as a question and was a genuine overlap — the leading indentation was itself a run of spaces, so the two properties could not be removed independently, and the corpus now indents by ONE space with the run elsewhere. The docstring's input count is dropped rather than corrected: it read "Sixteen" over a dict of 21, and #2327 and #2293 both found that independently within a day, which is the argument for len(INPUTS) being the only place it is stated. Gate-off ran 17 mutations against the Rust and 2 against the suite's own source pins, with 0 survivors: 15 turn cases red, and 2 (removing the long-word branch, and <= width< width) make the algorithm non-terminating, which is the termination argument in wrap_chunks's doc comment being load-bearing rather than decorative. New cases in TestTheCancellingPair, TestWhatTheRejoinerDestroyed, TestTheThreeWhitespaceSets, TestWidthArgument, TestRandomizedDifferential (3,536 cells, with a sibling that requires the harness to be able to REPORT a disagreement), TestThisFileCannotBeFlattened and TestEscapingIsUnchanged (python/tests/test_wordwrap_parity_2293.py), plus 11 in textwrap.rs. TestThisFileCannotBeFlattened exists because it happened: an edit silently replaced two U+2028 literals in this suite with ordinary spaces, and the two cases they were the whole point of went GREEN for the wrong reason — the characters this filter is about are invisible, so nothing in the diff would have shown it. Every one is now an escape, and a test fails if a literal returns.

  • {{ list }}, {{ dict }} and {{ x|pprint }} now escape non-ASCII non-printable code points, and a fixed Rust table turns out to be the right way to do it (#2292).djust_core::py_repr_string escaped \, the active quote, \t, \n, \r, the rest of C0 and DEL, and stopped — so U+00A0, U+200B, U+2028, U+2029, U+FEFF and every private-use code point rendered LITERALLY where CPython writes \xa0 / \u200b / \u2028. It stopped there because CPython's rule is str.isprintable(), which is Unicode-version data that disagrees across the CI matrix — the striptags situation (#2273), where the reference moves and the port cannot follow. The premise is true and was understated; the conclusion does not follow. Re-measured across the whole supported matrix rather than a 3.12-vs-3.14 pair, python3.103.14 carry FIVE Unicode versions (13.0, 14.0, 15.0, 15.1 — the issue had 3.13 at 15.0 — and 16.0) and disagree about 11130 code points, not 5812 (which is exactly right for the pair the issue measured). But of those 11130, 11130 became printable and 0 became non-printable, and that holds on all ten ordered pairs, not merely end to end: every disagreement is an unassigned (Cn) code point becoming assigned (9473 Lo, 945 So, 183 Mn, …). So not isprintable() splits into a STABLE part — the seven categories Cc, Cf, Cs, Co, Zl, Zp, Zs, which over 13.0 → 16.0 gained exactly ONE already-assigned member (U+0020 SPACE, which Python calls printable anyway) — and Cn, which is the entire moving part. Escaping the seven categories and treating Cn as printable reproduces str.isprintable()exactly for every assigned code point on all five interpreters, verified against the table as committed rather than as generated, and is version-INDEPENDENT so djust answers identically everywhere; pinning a Unicode version instead would be exact on one runner and wrong by up to 11130 code points on another. No dependency was needed: the set is 139769 code points but only 28 ranges, private use being three contiguous blocks. Escape width now follows CPython's \xNN / \uNNNN / \UNNNNNNNN choice by magnitude. Documented residual: an unassigned code point is emitted literally — unreachable in real template data, and the one place no fixed table can be right. The table is not asserted but RECOMPUTED from the running interpreter's unicodedata on every run, as is every code-point count quoted in the doc comment, so both go red on whichever runner they stop being true for. 27 test cases in python/tests/test_py_repr_isprintable_table_2292.py (58 collected, with parametrization) including a 3000-case randomized differential, plus the inverted pins in TestKnownResidualDivergences. Six gate-off mutations, all red.

  • truncatechars / truncatechars_html now normalize NFC, truncatechars stops counting combining marks, and slugify folds NFKD to ASCII (#2319).django.utils.text.Truncator.chars opens with unicodedata.normalize("NFC", text); _text_chars then skips characters whose canonical combining class is non-zero, and calculate_truncate_chars_length applies the same skip to the truncation text. slugify opens with unicodedata.normalize("NFKD", value).encode("ascii", "ignore"). The port had none of the three, so a decomposed ábcdefg truncated one character early and {{ p|slugify }} left café alone where Django gives cafe. This is the half of the Unicode-tables question that DOES need a dependency, and the difference from #2292 is measurable rather than a matter of taste: canonical combining class and canonical decomposition are covered by the Unicode Character Encoding Stability Policies, and across CPython 3.10–3.14 zero already-assigned code points changed either — against 11130 that changed printability. (The 11172 canonical decompositions that appear to arrive in Unicode 16.0 are algorithmic Hangul, a CPython reporting change rather than a data change; only 20 are genuinely new.) But NFC needs canonical decomposition, canonical ordering AND the composition-exclusion set, and slugify needs NFKD on top; no hand-rollable subset exists the way it did for the isprintable table. Adds unicode-normalization 0.1 (unicode-rs, MIT/Apache-2.0, one transitive dependency tinyvec). Measured with the crate added AND actually called, since an unused dependency is stripped by LTO: the release wheel goes 8,035,284 → 8,102,434 bytes, +67,150 (+0.84%); the compiled extension +132,112 raw and +66,672 compressed — about half the compressed cost of the already-accepted chrono-tz (+136 KB), and far below what the crate's 625 KB of table source suggests. Two premises stated in the issue were wrong and are now pinned as such, having been checked against live Django rather than assumed: TruncateCharsHTMLParser.process counts with a plain len(data) and does NOT skip combining marks, so truncatechars_html needed NFC and nothing else — adding a skip there would have created a fresh divergence; and Truncator.words reads self._wrapped, so the words filters must NOT normalize, kept as a negative control. Moving the NFKD fold AHEAD of the separator pass also changes slugify's answer for non-ASCII whitespace, in Django's direction and NOT uniformly: U+00A0 and U+3000 decompose to an ordinary space and stay separators, while U+2028, U+200B and U+1680 have no ASCII fold and vanish — two characters that are both str.isspace() sent opposite ways by the decomposition table rather than by any whitespace predicate. a\u2028b slugged to a-b before and ab now, which is Django's answer; enumerated rather than reasoned about, next to the whitespace-boundary corpus coupling #2293 added. 35 test cases in python/tests/test_truncate_nfc_slugify_fold_2319.py (61 collected) including eight randomized differentials against live Django, new cases in truncate.rs's test module for the truncation-text skip that no template can reach, and the inverted pins in TestKnownRemainingDivergences and TestItem4IsStillOpen. Seven gate-off mutations, all red across pytest and cargo test — including an INVERSE mutation that ADDS the combining skip to the HTML path, which is what proves the premise-correction pin is load-bearing rather than decorative. The first gate-off run reported INVALID for the whole cargo column because cargo test prints error: test failed on a test FAILURE and the harness read that as a compile failure; the one mutation whose coverage lives only in Rust therefore looked uncovered, which is precisely the "a gate-off that reports zero because it broke the build looks like evidence" failure mode — re-measured with the detector fixed.

  • first / last / random now honour a mark_safe'd item, and so does a custom filter handed a TUPLE that carries the grant. Two consumers of the item-level grant #2287 seeded from the context, both over-escaping — djust escaped where Django does not. The extractors are a different mechanism from the two filters #2287 repaired: join / unordered_listconditional_escape per element inside their own body, while first / last / random hand back the ELEMENT OBJECT, so the grant has to become the RESULT's container safety. builtin_produced_safe — the per-call channel that already answers for join/cut/default/add — grows the arm, with no shape narrowing of its own, because every producer of items already guarantees every element is safe. container is deliberately not a term: Django's SafeString[0] is a plain str, and last/random already get Django's is_safe=True arm. Measured through the real RustLiveView + mark_safe_keys channel (render_template has no context-safety channel and cannot construct a single cell of this surface), 12 extractor templates × 14 shapes: 74 of 168 cells differed from Django before, 14 after — 12 of those the MIXED/NESTED shapes the grant's producers refuse (the same one-bool narrowing #2287 documents, still over-escaping), 2 the pre-existing tuple→list collapse in the measurement helper. Separately, mark_input_safety handled PyList only: #2290 deleted its PyTuple arm as unreachable and was right on the evidence it had, but #2287's Context::items_are_safe accepts Value::Tuple, so the claim expired the moment the two changes met. The arm is back, rebuilding a real PyTuple so a filter branching on type(value) keeps Django's answer, sharing one mark_item helper with the list arm so the str-only policy cannot drift between them. Verifying that reachability rather than trusting it found a second entry point the issue does not name — RustLiveView.update_state + mark_safe_keys — and a fourth framework path that is safe for the documented reason (TemplateMixin's page-shell render normalizes first, like rust_bridge, SimpleLiveView and the template backend). Two-build differential against a rebuilt origin/main, 29,005 cells: 285 newly agreeing, 0 regressions, 0 introduced live-payload leaks. 80 of those 285 are on a new marked-TUPLE corpus input, which is what made the second bug visible to the tool at all — and nothing pinned that axis, the exact shape the dictsort XSS shipped in, so two couplings now close it: every name builtin_produced_safe grants must be on the differential's hot sets (random exempt, because NONDET collapses its cells to a marker on both sides and composing it would add ~480 blind agreeing cells — worse than absent), and every shape Context::items_are_safe accepts must have a marked input in the corpus, parsed from the Rust rather than transcribed. Gate-off ran 7 mutations; two SURVIVED the first round and were treated as questions rather than passes — one was an equivalent mutation (dropping first from HOT2 while HOT3 still carried it), the other was the genuine finding that the input axis had no pin at all. New cases in TestTheExtractorsConsumeTheItemGrant, TestTheNarrowingsTheExtractorsInherit, TestRandomIsCoveredByCapabilityNotByBytes, TestEverySeedSiteReachesTheExtractorArm (the renderer seeds items_safe at three sites and each decides for itself whether to consume a filter's reported safety, so a {{ … }}-only test covers one of three — #1104) and TestACustomFilterSeesContextSourcedItemSafety, plus test_the_per_call_safety_channel_is_swept_too and test_the_differential_sweeps_every_shape_the_context_grant_accepts.

  • unordered_list nests the <ul> at the parent's indent, matching Django (#2301). Fixed in #2306 by @alexsmolya — a one-line change dropping sub_indent, so the nested <ul>/</ul> sit at the parent's depth and only the <li>s inside step in, which is where Django's list_formatter puts them (all four %s of its sublist wrapper are the parent's indent). Whitespace only: the <li> content was already byte-identical, and it reproduced with nothing mark_safed. Coverage is added on top of that fix here, because the divergence is a recursion bug and the question it raises is not whether the reported cell matches but whether every nesting shape does: python/tests/test_unordered_list_indent_2301.py compares against live Django across the reported cell, Django's own docstring example, 15 curated nesting shapes and a 3,000-case randomised sweep — which reads 2164/3000 before the fix and 3000/3000 after, and which surfaced the one remaining unordered_list shape divergence (Django accepts a tuple as a sublist, djust matches Value::List only — filed as #2317 rather than folded in). New cases in TestTheReportedCell, TestNestingShapes and TestRandomisedShapes; #2287's nested-refusal case keeps @alexsmolya's plain-shape assertion and gains the markup-carrying cell beside it, so the refusal and the indentation are told apart rather than asserted twice. Gate-off verified by reverting #2306's hunk against this suite: 15 cases go red, @alexsmolya's own assertion among them, so the coverage and the fix are each load-bearing for the other. One premise of #2301 did not survive measurement: its coverage note predicted #2287's nested assertions could be tightened to assert_agrees, but those cells still differ for an unrelated, deliberate reason — the nested item-safety grant is refused, an over-escape.

  • {{ n|safe }} stringifies a scalar the way Django's mark_safe does (#2303). Django's mark_safe(obj) is SafeString(str(obj)) — it does not merely mark the value, it changes its type before the rest of the chain sees it — while djust's |safe was a no-op for a scalar, so {{ n|safe|my_filter }} handed the filter an int where Django hands it '42', and reported SafeDataFalse where Django reports True. The container half landed in #2283; this is the same edit one variant over. Two spellings it had to get right, and neither needed a special case: str() and not the RENDER form (Display is numberformat.format(), which expands an exponent — 1e20 renders 100000000000000000000 but str(1e20) is 1e+20, and Decimal("1E-9") renders 0.000000001 but str() is 1E-9), which is exactly the @stringfilter coercion #2250 already computes, so safe joins STRING_FILTERS rather than growing a second stringify in its own arm that would shadow it; and Value::Missing as "" and not"None", since Django substitutes string_if_invalid before the chain runs and Display for Missing is already "" (#2203), so a blanket stringify putting the literal text None on the page never arises. This closes #2257's residue 1 for safe — a Decimal behind |safe is now Django's 1E-9 and no longer localizes to 0,000000001 — though not for escape, which still stringifies through Display. divisibleby is the one filter the type change bit, and the two-build differential found it rather than inspection: Django is int(value) % int(arg), so a numeric string has always worked there while djust matched Value::Integer alone, and {{ n|safe|divisibleby:"2" }}True before and in Django — would have started answering False; it is widened to what int(str) accepts unambiguously and still fails soft where Django raises. Measured across two builds against a rebuilt origin/main baseline that already contains #2306 and #2316, so the figures isolate this change alone: 29,662 cells through scripts/filter-parity-differential.py — 221 newly agreeing, 0 regressions, 0 introduced live-payload leaks — plus a wider 16,472-cell scalar sweep carrying Decimal, BigInt, exponent floats and the MISSING variable, 245 newly agreeing and 0 regressions. divisibleby joins the differential's HOT2: it is in no safety set so the enforcing test does not require it, but it reads the input's type, which is the axis this change moves, and its absence is why the first sweep reported clean over a real regression. New cases in TestTheReportedTable, TestEveryScalarVariant, TestItIsStrAndNotTheRenderForm, TestTheAbsentVariableIsEmptyAndNotNone, TestDivisiblebyReadsTheValueNotTheType, TestTheBuiltInChainIsNotWorseOff and TestTheStringifyIsComplete (python/tests/test_safe_stringifies_scalars_2303.py), plus safe_stringifies_every_variant_the_way_python_str_does in filters.rs. Gate-off verified against each mechanism separately — the "safe" arm (15 red), STRING_FILTERS membership (9) and the divisibleby parse (8) — so none shadows another. Two existing pins were updated deliberately, each of which had asked for it in prose: #2290's non-string pass-through case, which pinned exactly this behaviour, and #2250's NAMED_EXCLUSIONS (27 → 28 covered filters). Worth recording for the milestone retro: both defects this change had to account for were found by a randomised sweep and neither by the curated tabledivisibleby by the two-build differential once every registry filter was composed behind |safe, and the unordered_list tuple-sublist gap (#2317) by the 3,000-case nesting sweep. The curated tables in both files were written first, are careful, and found neither.

  • stringformat:"Ns" honours the width, center uses Python's odd-margin tie-break, and {{ dict|length }} answers its length. Three of the four measuring-filter divergences #2294 found by grepping the length-measuring sink; each is a separate Python semantic, none of them the byte-vs-char defect of #2279, and all three were re-measured on current main before being touched. (1) Django's stringformat is ("%" + arg) % value, and CPython honours [flags][width][.precision] for the s conversion exactly as it does for %d — the arm read only the conversion character, so {{ p|stringformat:"10s" }} rendered 'ab' where Django renders ' ab', .3s did not truncate and -10s did not left-align. The grammar is ported from CPython's unicode_format_arg_parse and then checked against it, over every grammar-valid prefix up to length 4 crossed with a value corpus, which settles five things a curated table would not have thought to include — the 0 flag is IGNORED for s (spaces, not zeros, unlike %d), a bare . is precision ZERO, 0 leads as a flag so %0s is width 0 while %010s is width 10, +/ /# are accepted no-ops that may repeat, and width and precision have limits five orders of magnitude apart (Py_ssize_t vs int), both bisected against the interpreter rather than assumed. (2) center used Rust's {:^}, which always puts the smaller half of an odd margin on the left; CPython is left = marg // 2 + (marg & width & 1), biasing left only when the width is odd too, so 'ab'.center(5) is ' ab '. The two agree on every even margin — 'a'.center(4) and 'abc'.center(6) are identical either way — so an exhaustive (length × width) grid replaces a table. ljust/rjust were already right. (3) length fell to _ => 0 for every Value::Object, which is two Python things wearing one shape: a dict, whose length is len(dict), and any non-dict object the serializer flattened into a map, whose length is 0 because len(model) raises TypeError and Django's filter catches it. Returning o.len() would have traded one wrong answer for another — a model spelling its field count. The marker is now Value::object_str(), the "__str__" predicate {{ obj }} already uses (#968), promoted from two open-coded copies in the Display impls to one definition with a source pin. "__model__" looks like the more specific marker and is unusable: four of the six model-serialization sites omit it — only _serialize_model_safely and jit.py's identity-only subset stamp it, while serialization.py's depth-limited-FK and max-depth shorthands and both template/rendering.py fallbacks emit __str__ alone — so a depth-limited model would have started answering its key count. Filed on its own as #2322, since a marker four of six producers omit misleads anything that reaches for it. Measured over 29,662 cells against a rebuilt origin/main baseline at 1fa46c33: 30 newly agreeing, 1 regression, 0 live-payload leaks introduced (38 → 38) — re-run from scratch after #2316 and #2318 both landed in filters.rs and grew the corpus, and the three numbers are unchanged from the first run's 27,684 cells. The single regression is {{ dict|add:"1"|length }} and it is not this filter's — add's documented third-branch divergence (Django returns "" for a value it can neither sum nor concatenate; djust returns it unchanged) was being cancelled by the length bug, and four twins (l-plain, l-mixed, l-marked and the marked tuple t-marked#2316 added) already diverged identically on the baseline; pinned and explained rather than papered over. Gate-off verified against 11 mutations, every one red, with a harness that asserts the mutation matched exactly once, that the source changed, that a pytest collection error is reported as INVALID rather than as a number, and that the restore is byte-identical. New cases in TestStringformatS, TestCenter, TestLengthOfAnObject and TestItem4IsStillOpen; the #2294 known-wrong pin in TestKnownResidualDivergences is converted rather than deleted — renamed, inverted, and joined by the model-instance half that explains why the pin existed. #2294's fourth item (truncatechars counting combining marks) is re-measured and unchanged, and is filed separately: it needs NFC normalization plus canonical combining classes, i.e. a Unicode-tables dependency, which is a decision that also closes slugify's NFKD fold and belongs with it.

  • htmlparser.rs's header now says CPython 3.12.10+ / 3.13, and a test keeps it true. It read "a transcription of CPython 3.12's html/parser.py" — imprecise in a way that misleads, because the HTML5-spec rewrite landed in 3.12.10, so 3.12.9 is a CPython 3.12 and djust differs from it on a quarter of a 4000-value corpus. A reader on 3.12.9 taking that at its word would expect a match; requires-python is >=3.10, so 3.10 and 3.11 carry the same pre-rewrite parser. The body of the file always got this right (16 separate 3.12.10 citations) — only the header generalised. It now also states the #2286 decision that makes the divergence acceptable: this is djust's pinned behaviour on every host, not a claim about the running interpreter, because a filter whose output changes when ops bumps the base image is a worse property than a documented fixed divergence. Docs-only, no behaviour change — but a corrected header is worth little if nothing keeps it correct, so test_htmlparser_header_accuracy_2289.py recomputes the figures from striptags_reference_2273.json and fails when they drift. Gate-off verified against four mutations: generalising the header back, deleting the pinned-behaviour claim, staling a figure, and silently dropping a table row. Two of the issue's own premises were corrected in passing — it says "ten citations" where there are 16, and its figures did not reproduce (measured: 3.12.9 differs on 992 / 24.8%, 3.14.6 on 231).

  • A custom @register.filter could not see that its input was SafeData (#2290).Value — the enum that crosses the PyO3 boundary — is safety-blind, so into_pyobject handed every project filter a bare str: {{ p|safe|probe }} gave the filter ('str', False, True) where Django gives ('SafeString', True, True). That makes Django's canonical needs_autoescape opening line, autoescape = autoescape and not isinstance(value, SafeData), an expression whose second branch is unreachable — and the scope is wider than filters registered needs_autoescape, since conditional_escape(value), format_html("{}", value) and a filter that simply returns its input all read the same marker. The renderer already computes the answer (InputSafety, #2284 widened by #2283); it just stopped at the boundary. Both granularities drive a wrap, because they are two different Django states: container marks the VALUE (|safe, |escape, a mark_safe context variable, measured through the real mark_safe_keys channel), while items marks each ELEMENT and leaves the sequence plain — exactly what safeseq/escapeseq build, since [mark_safe(o) for o in value] never marks the list. Answering container for a safeseq output would grant a safety Django withholds; answering only container leaves every item cell diverging. Only a str is wrapped. Django's mark_safe stringifies a non-str (mark_safe(42) is SafeString('42')) and following it there would change the TYPE an existing filter receives — a pre-existing |safe-on-a-non-str SHAPE divergence with its own blast radius, distinct from the safety gap, and one that would turn {{ absent|safe|f }} into the literal text None where Django's string_if_invalid had already made it "". The residue is djust reporting SafeData False where Django reports True, which is the escaping direction and unchanged by this fix. Over-escaping only, and measured as such: scripts/filter-parity-differential.py over 20,824 cells against a rebuilt baseline reports 534 newly agreeing, 0 regressions, 0 introduced live-payload leaks. That script grew a custom-filter corpus here — four probes registered on both engines — because no built-in cell dispatches through apply_custom_filter, so this entire path was previously invisible to the tool that found two shipped XSSes. The tuple arm of the item wrap was written and then deleted: the gate-off reported it SURVIVED, because safeseq is a list comprehension and a tuple input is already a list by the time any item grant exists, and an unreachable branch is decorative rather than defensive (#1859). 23 regression cases in python/tests/test_custom_filter_safedata_2290.py (52 with parametrisation), including a registry-wide sweep asserting no chain through a custom filter out-permits Django and two source pins (the single apply_custom_filter call site must forward the real InputSafety; mark_input_safety must never consult the filter's own metadata, the autoescape flag, or the value's content). Six gate-off mutations, one per mechanism, each reddening 1–20 named tests with no survivor.

  • A list whose ELEMENTS a view mark_safed reached join and unordered_list escaped (#2287). They are the two needs_autoescape=True built-ins whose body applies conditional_escape PER ELEMENT, so {"p": [mark_safe("<b>x</b>"), mark_safe("<i>y</i>")]} renders <b>x</b>, <i>y</i> in Django and rendered &lt;b&gt;x&lt;/b&gt;, &lt;i&gt;y&lt;/i&gt; here — the list itself is not SafeData, mark_safe was never called on it, and only the items were marked. The issue's premise that this "needs safety tracked inside the container" was stale by the time it was picked up: #2283 had already shipped InputSafety{container, items}, ITEM_SAFE_OUTPUT_FILTERS and ITEM_SAFETY_PRESERVING_FILTERS, and both filters already read input_safety.items. What was missing was one seed — all three renderer sites opened let mut items_safe = false, so the only producer of item safety was a safeseq/escapeseq earlier in the same chain; safety arriving from the CONTEXT had no route in, even though _collect_safe_keys had been putting p.0/p.1 into safe_keys on every render all along. The channel existed and nothing read it at this granularity. Context::items_are_safe reads it, and all three sites seed from it — each verified reachable by the template syntax that reaches it ({{ }}, an inline conditional, {% firstof %}/{% cycle %}), plus the loop-variable alias, because a list rendered inside {% for %} is the shape a real template uses and its marks are recorded under the iterable's path. Four narrowings keep it from ever out-permitting Django, three of them security properties rather than conveniences: List/Tuple only (a dict records its safe paths by NAME while the filters iterate its KEYS, and a string's "items" are characters nothing can mark); every index present (Django answers per element, so a partially-marked list is escaped whole rather than granted whole); each element is a Value::String (mark_safe_keys only ever extends, so a stale path must not grant safety to a shape never marked — #2300); and non-empty (a vacuous grant is a claim no test can falsify). Nested containers fall out of the String narrowing and that is load-bearing: join stringifies a sublist and Django escapes that repr, so a recursive "all leaves are safe" grant would emit raw < where Django emits &lt;. escapeseq moves to Django's conditional_escape in the same change — that branch was unreachable before, since nothing could hand it pre-safe items, and without it {{ p|escapeseq|join:", " }} double-escapes. Measured through the real RustLiveView + mark_safe_keys channel, because render_template has no context-safety channel and cannot construct a single cell of this surface: on {join, unordered_list} × 11 value shapes, 12 cells differed from Django before and 6 after, and all 6 remaining are djust escaping where Django does not (4 partially-marked, 2 nested). On the two-build filter-parity differential, measured against a baseline that already includes #2302, 786 of 27,684 cells newly agree, 0 regressed, 0 new live-payload leaks. The differential was itself blind to this surface and grows a context-safety axis routed through render_template_with_dirs, the only Python entry point carrying safe_keys, with those inputs on the length-2 and length-3 chain axes because the real risk is what a SECOND filter does with a grant the first preserved (slice) or minted (join). Two further premises corrected: the issue's linenumbers row said it was "already correct (now pinned by TestLinenumbersWasAlreadyCorrect)", which #2291 falsified — it was a live XSS, and that pin is exactly what let it survive; and its closing note that |safe on a list "does not iterate that string as a character sequence" was superseded by #2296. #2284's TestSequenceShapeIsOutOfScopeAndStillDiverges asserted this divergence was still present and instructed whoever closed the follow-up to move the names out of SEQUENCE_SHAPE and delete it; it goes red on this fix (verified before removal), so it is deleted and the bucket renamed. 76 cases in python/tests/test_context_item_safety_2287.py — including a registry-wide permissiveness sweep through the context-safety channel, the first in the suite to run through it, asserting djust grants no capability Django withholds for fully-marked, unmarked and partially-marked inputs — plus 11 unit cases in context::tests pinning the narrowings at the bool level, where the Python suite structurally cannot see them. Nine gate-off mutations, nine killed; two are killed only by cargo test -p djust_core, which is the honest reading rather than a pass, since both are bool-level facts that render identical bytes. Interacts with #2302, which wraps a sequence's items as SafeString before a custom filter gated on input_safety.items: this is what lets items be seeded from the context, so a project @register.filter can now receive SafeString items sourced from mark_safe_keys rather than from safeseq — a path neither change exercised alone, measured to agree with Django and pinned in both directions. It also expired a comment: #2302 deleted a PyTuple arm from mark_input_safety as unreachable because items could then only originate at safeseq (a list comprehension), and Context::items_are_safe accepts Value::Tuple. The arm is reachable now; what keeps it harmless is a different fact than the one written down — normalize_django_value collapses a Python tuple to a list before it crosses into Rust, and SimpleLiveView passes no safe_keys at all — so every framework path is unaffected and it takes a direct four-argument render_template_with_dirs call. Over-escaping, exactly as that comment's last sentence anticipated; the comment now states the reason that holds and the bytes are pinned (#2305). Four gaps this measurement surfaced are filed rather than fixed (#1079): first/last/random need the grant to become the RESULT's container safety, a different mechanism (#2299); mark_safe_keys never clears, so a stale grant survives into a later render (#2300, pre-existing and an under-escape, carried here as a strict-xfail forward pin that turns red when it is fixed); nested unordered_list indents its <ul> one level deeper than Django (#2301, pre-existing, whitespace only); and the tuple arm above (#2305).

  • {{ p|length }} counted BYTES, not characters (#2279).str::len() in Rust is a byte count and Python's len() is a code-point count, so every non-ASCII string measured long: {{ "中<b"|length }} gave 5 where Django gives 3. Code points, not graphemes -- Python's len of a skin-toned thumbs-up is 2 and of a three-person ZWJ family is 5, and a grapheme count would answer 1 to both and be a different wrong answer; Rust's char is a Unicode scalar value, so chars().count() is Python's answer exactly. The bug had been masked in the #2273striptags sweep, where the old striptags deleted the tail after a lone < and the byte count of what survived matched Django's char count of the whole value -- two bugs cancelling. Grepping the sink cleared the neighbours: slice, first, last, make_list, truncatechars, truncatewords and ljust/rjust/center all already measure code points. wordwrap does not and is deliberately left alone -- the char fix was implemented and measured, and it fixes 21 differential cells while regressing 6 (every one a U+2028 string, where the byte overcount had been putting a line break at the position Django's splitlines breaks); djust's wordwrap is not textwrap.TextWrapper at all, so the pair goes with that port. {{ dict|length }} still answers 0, which is a missing Value::Object arm rather than this bug. New cases in TestLengthCountsCodePoints.

  • {{ p|pprint }} never wrapped, where pprint.pformat wraps at width 80 (#2277). Django's filter is pprint.pformat(value) and pformat breaks a structure across lines with hanging indentation past 80 characters, so [1.5] * 40 was 39 newlines in Django and 0 here. It is a real line-breaking algorithm, not a width check: CPython's _format / _format_items / _format_dict_items / _pprint_str are ported in crates/djust_templates/src/pprint.rs, together with a Python-faithful str.splitlines(keepends=True) (Rust's lines() splits on \n alone; Python breaks on eight more, U+2028 included). Scalars now go through the one djust_core::py_repr_string the {{ list }} path also uses (#1646) -- pprint had a second, bare '{s}' spelling that escaped nothing -- and that helper grew the ASCII control escapes it was missing. That last part also changes {{ list }} and {{ dict }}, the helper's other caller: {{ ['a\tb'] }} rendered a literal tab and now renders ['a\\tb'], which is Django's answer. Covered by TestContainerReprUsesTheSameEscaper across list, dict-value, dict-KEY, tuple and nested positions, because a mutation inside a shared helper must redden a test on each side of it (#1195). Measured: 0 of 4000 randomized values differ from real pformat; on a 13,751-cell differential across every measuring filter, 2421 disagreements before and 1998 after, 423 fixed and 0 regressed. The randomized differential caught a defect the port itself introduced and no curated table reached -- dict keys sorted by the rendered pair rather than by the key, which the old filter got away with only because it quoted every key identically (6.4% of a 4000-value corpus). Known residual: a non-ASCII non-printable code point (U+00A0, U+200B, U+2028, U+FEFF) renders literally where CPython escapes it. CPython's rule is str.isprintable(), Unicode-version data that disagrees across this project's CI matrix -- 3.12/3.13 carry Unicode 15.0 and call 148998 code points printable, 3.14 carries 16.0 and calls 154810 -- so no fixed table in Rust is green on every runner, the same situation the striptags port hit (#2273). Pinned in TestKnownResidualDivergences. New cases in TestPprintWraps.

  • linebreaks, linebreaksbr, urlize and urlizetrunc no longer escape a SafeData input (#2284). Django registers these needs_autoescape=True and each body opens autoescape = autoescape and not isinstance(value, SafeData), skipping its own internal escape for a value that was already safe. djust escaped unconditionally, so markup a view deliberately marked safe was escaped away from inside the filter: {{ p|safe|linebreaks }} rendered <p>&lt;b&gt;x&lt;/b&gt;</p> where Django renders <p><b>x</b></p>. Only one of the expression's two terms is reachable in djust — there is no {% autoescape %} block (the parser rejects the tag), so the first is pinned true and its false branch cannot be entered. Implementing the tag was considered and declined: it is block-scoped policy through every render arm whose only effect is to let templates turn escaping off, and nothing diverges today for want of it; the SafeData half is what diverges on every build, so that is what is implemented, hard-wired to the pinned policy. The flag is threaded from the renderer's runtime_safe — the same state filter_output_is_safe already reads as Django's input term (#2274) — so both halves of the SafeData reading come off one value at all three render arms rather than two that can drift. The four keep their unconditional SAFE_OUTPUT_FILTERS membership and the reason widens: output is safe under both arms, either because the filter escaped its input (every value nothing marked safe, i.e. all hostile input) or because the caller had already declared it safe. urlize's href escape stays unconditional, as Django's does, because it lands in an attribute and a conditional one is an XSS Django does not have. Two premises in the issue are corrected: the needs_autoescape registry is seven names, not four — linenumbers carries the same clause and was already correct (never escaped internally, so the renderer's output escape lands on Django's answer; now pinned rather than asserted in a comment), and join/unordered_list use a per-element conditional_escape that one whole-value bool cannot express, filed as #2287 and pinned as still-diverging; and the four are not equally clean afterwards, because urlize/urlizetrunc keep a separate pre-existing URL-detection gap (regex vs Django's word-split + smart_urlquote) that is identical before and after and in all three columns, which is what shows it orthogonal to the escape decision. Over a 4000-value adversarial corpus × 4 filters × 3 columns (plain / |safe / context mark_safe through the real mark_safe_keys channel), differing comparisons fell from 33,035 to 12,177 against a rebuilt origin/main baseline — 20,858 fixed, 0 new regressions, the plain column byte-identical, and linebreaks/linebreaksbr at zero on all three. New cases in python/tests/test_needs_autoescape_2284.py, plus the_needs_autoescape_filters_skip_the_escape_only_when_told_to and urlize_escapes_the_href_even_when_autoescape_is_off in filters.rs — because the #2259 test that pins the SAFE_OUTPUT_FILTERS contract proves only that the escape happens, which is now one of two arms. Ten gate-off mutations, one per mechanism on each side, each reddening named tests with no survivor.

  • join, safeseq, escapeseq, unordered_list and random did not iterate a string as its characters (#2283). Python iterates a str as a sequence of characters, so Django's {{ p|unordered_list }} on "<b>x" is one <li> per character and {{ p|join:", " }} is &lt;, b, &gt;, x. Each of the five matched only Value::List | Value::Tuple and fell through to the input for everything else — the same question asked five times, so the fix is one iter_values sink rather than five correct copies (#1646), and a structural test pins the caller SET. It also answers the input-shape axis the five shared: a dict iterates its KEYS, and an absent variable is Django's string_if_invalid (""), not a TypeError. first, last and slice were named alongside them and were already correct — a premise the issue's own five-filter list had right and the surrounding discussion did not.

    Django's per-ITEM safety came with it. safeseq is [mark_safe(obj) for obj in value]: it marks the ITEMS and never the sequence, so {{ items|safeseq }} escapes the list's repr in Django while djust — which had safeseq in SAFE_OUTPUT_FILTERS — emitted it raw, more permissive than Django on the list path the issue described as correct. The grant now lives in ITEM_SAFE_OUTPUT_FILTERS, is read only by join and unordered_list (the two built-ins that conditional_escape per item), and is dropped when Django's mark_safe(list) would have collapsed the sequence to a SafeString of its repr — which is also why {{ l|safe|slice:":3" }} is ['< in Django and now in djust.

    #2285's escape on the non-sequence branch is kept and is now a no-op for every reachable input: every markup-carrying Value variant moved to the iterating side, so only numbers, booleans, None and Decimal/BigInt digit strings still reach it. It stays because a future non-iterable variant that can carry markup makes it load-bearing again; every_non_iterable_variant_is_markup_free enumerates the enum so that variant has to be classified rather than silently slipping past.

  • dictsort had no failure branch, and that became an XSS once a sequence filter could grant safety (#2283 review). Django's dictsort is try: sorted(value, key=_property_resolver(arg)) except (AttributeError, TypeError): return "". djust had the sort and not the except, returning the input UNCHANGED where Django discards it — harmless until something downstream could mark items safe, which this change gave safeseq/escapeseq. {{ hostile|dictsort:"x"|safeseq|unordered_list }} then emitted raw markup on a list Django had already thrown away, on data nothing marked safe.

    The point fix was to keep dictsort out of the item-safety-preserving set; that closes safeseq|dictsort and leaves dictsort|safeseq, the same class one step over. The failure branch closes both orders at the root. Two premises had to be corrected on the way: the resolver's discriminator is the argument's Python type, not whether it looks numeric — dictsort:0 passes an int, so itemgetter(0) indexes and sorts strings by first character, while dictsort:"1" passes a str and raises — and djust's numeric path never actually sorted, because its comparator resolved every non-dict item to Missing and saw every pair as equal.

  • join escaped its separator, which Django does not (#2283 review). Django applies conditional_escape(arg), and a quoted filter argument is SafeData (Variable.__init__ does self.literal = mark_safe(unescape_string_literal(var))), so {{ l|join:"<br>" }} renders a real <br>. A bare identifier resolved from the context is not SafeData and is still escaped. This was a regression on 34 cells, not merely a mis-documented one: the previous join joined raw and let the render escape the result, which lands on Django's bytes whenever a later |safe suppresses that escape.

  • Four filters reported safety by NAME where Django reports it per CALL (#2281 fallout). Making escape produce a SafeString turned a pre-existing quiet over-escape into 104 measurable double-escapes — a latent correctness gap that only became measurable because of an adjacent fix. The needs_autoescape half of it was fixed independently and concurrently in #2288, which this builds on; what remains here is the per-call half. join, cut, default/default_if_none and add report safety per CALL through the existing produced_safe channel, because their answer depends on which branch ran: join is mark_safe(data) on success and the value untouched on TypeError (returning an escaped string instead changed the TYPE, which {{ n|join:", "|length }} measured as 2 against Django's 0); cut re-marks safe unless the argument is ";"; default hands back the input object when it is truthy; add's concatenation branch is SafeString.__add__. And {{ l|safe }} now stringifies a container, as mark_safe(list) does — the rendered bytes were always identical, which is why it stayed invisible until the sequence filters started iterating.

    Measured as a set comparison against a rebuilt origin/main over 18,600 differential cells (57 live-registry filters × 16 input shapes, plus every length-2 and length-3 chain over a hot subset): agreement 6,956 → 15,714, zero cells that agreed before and disagree after, and — asserted as its own check — zero cells that emit a live fragment of a hostile payload that Django does not, down from 1,783 to 4. The harness is checked in as scripts/filter-parity-differential.py; its single-build half — the registry-wide sweep asserting djust grants no capability Django does not — runs in CI as a test.

  • |safe now survives an is_safe=True filter (#2274). Django marks a filter's output safe on two terms — getattr(func, "is_safe", False)andisinstance(obj, SafeData), where obj is the filter's input. djust modelled only the second case (a filter that marks its own output, via SAFE_OUTPUT_FILTERS) and was missing the input term entirely, so {{ p|safe|lower }} came out escaped: |safe was undone by the very next filter. filter_output_is_safe now takes the input's safety, and all three call sites seed it from the context's own mark_safe flag and feed each result forward — which also makes that flag re-taintable, so {{ marked_safe|upper }} is escaped as Django escapes it (upper is registered is_safe=False precisely because upper-casing &lt; yields &LT;). The new IS_SAFE_FILTERS list is Django's registry set verbatim, all 36, pinned against the live registry in both directions so a Django release that flips a flag is a red test rather than silent drift; it is a different property from SAFE_OUTPUT_FILTERS and the two must not be merged. Measured against Django on <b>x</b>: {{ p|safe|X }} went from 28 of 36 diverging to 9, and {{ p|X }} is unchanged at 5 — the issue's "24 of 27" predates #2259/#2272. The 9 remaining diverge in both columns, so they are not |safe-related, and are filed as #2283 and #2284. Over a 44,610-cell differential (chains of length 1-3, hostile payloads, capabilities compared by parsing both outputs rather than substring-matching an entity-decoded string) cells more permissive than Django fell from 1154 to 491. 16 cases in python/tests/test_safe_survives_is_safe_filter_2274.py (138 with parameterization) plus 10 in test_xss_prevention.rs for the context-safe path, which normalize_django_value makes unreachable from Python; 9/9 gate-off verified, including a mutation that makes the rule too permissive.

  • A tuple came back a list across a state round trip (#2276). Both of the issue's claims turned out false, and checking them is what changed the fix. Value::Tuple is NOT unreachable — {{ (1.0,) }} renders (1.0,) exactly as Django does, which is what #2203 added the variant for; and normalize_django_value does flatten a tuple, but so does Django's own DjangoJSONEncoder, because json.dumps has no tuple type — both emit [1.0], so that is parity rather than a divergence. The real defect was narrower and unnamed: msgpack has no tuple either, so Value::Tuple serialized as an array and came back a list — a view attribute changed type across a reconnect and (1, 2) rendered [1, 2] after one and not before. Same class as the Decimal loss #2214 fixed with a binary tag, and fixed the same way with TUPLE_TAG — third instance of that mechanism after DECIMAL_TAG and BIGINT_TAG (#2260). The human-readable arm deliberately stays an array, because matching Django there means staying an array; only the binary arm is tagged, and that asymmetry is documented at the constant rather than left to be rediscovered. The tag's payload is a list rather than a string, which is also what keeps it from colliding with the other two — pinned, along with three near-misses. The collision hazard it does share with them (a user dict of exactly that one-key shape is misread) is asserted rather than claimed away. New cases in python/tests/test_tuple_roundtrip_2276.py; gate-off reds all three mechanisms.

  • striptags deleted everything after a lone <, and a lone > too (#2273). The filter was a 12-line scanner: < set in_tag, > cleared it. So "a < b" rendered "a " — every character from a mathematical or comparison < to the end of the input was silently dropped — and "a > b" rendered "a b", which the issue does not name. Django runs an html.parser.HTMLParser (MLStripper) in a loop until the tag count stops falling, and neither half is reachable by patching the scanner: a < not followed by a letter / / / ! / ? is data, which is a fact about tag-open syntax the scanner cannot represent, and one pass over "<<b>script>" yields a live <script> only the loop removes. Measured: wrapping Django's loop around the old scanner still leaves 1,915 of 4,013 cases divergent. Also fixed, and reported: MLStripper runs convert_charrefs=False and re-emits handle_entityref(name) as &name;, so "&one two<b>x</b>" is "&one; twox" — the tag is load-bearing, which is why a sweep over plain strings alone never found it. Reuse rather than a second tokenizer:#2272's goahead port is lifted behind a Sink trait into crates/djust_templates/src/htmlparser.rs, with TruncateSink and MLStripper as its two implementations — one state machine, not the parallel-path drift a second copy would be. The reference moves and the port does not. CPython rewrote html/parser.py for HTML5-spec alignment in 3.12.10 and changed it again in 3.14, so the three interpreters CI runs disagree with each other (3.12.9 vs 3.12.13: 1,108 of 4,000 corpus values; 3.12.13 vs 3.14.6: 224). The first version of this work targeted 3.12.9 — the repo .venv — and computed its reference at run time, so it asserted a different contract on every runner: green locally, red in CI. The tokenizer now tracks 3.12.10+/3.13 — every currently-shipping CPython, and the safer direction, since an unterminated attacker-controlled construct is now discarded rather than re-emitted as page text. Ported: goahead's incomplete-construct dispatch and tail flush, commentclose --!?> plus the abrupt -?>, locatetagend, the rewritten parse_endtag, the CDATA interesting regex, the RAWTEXT/RCDATA element sets with _escapable, HTML5 whitespace for \s, and <![CDATA[ replacing the parse_marked_section port — which also removes the AssertionError Django used to raise on <![name[. Porting only the end-of-input half was tried first and was wrong: it left the tokenizer matching no CPython on 174 values, caught by this PR's own test_version_dependent_values_track_a_supported_cpython. Also fixed a panic: parse_html_declaration indexed s[i..i+9] for the <!doctype probe, which is not a char boundary when a multi-byte character sits there (<![中) — unreachable until the <![ arm stopped swallowing those inputs. The DoS guard is ported with exact bounds (49 inner < in a 1000+ character unclosed tag; 50 passes allowed, 51 refused), but a filter has no channel to Django's SuspiciousOperation — raising would 500 the whole render — so a refused value renders as the empty string with a tracing::warn!, the only refusal that stays safe under {{ v|striptags|safe }}. Two port defects survived the parity table and were found only by the randomized differential: feed() + close() is genuinely twogoahead passes (the &#-bail is the one break that advances before stopping, so the next pass resumes the loop — and a second bail inside pass 2 has no pass 3), and entityref's name class [-.a-zA-Z0-9] overlaps its own trailing [^a-zA-Z0-9] on - and ., so re's backtracking is load-bearing (&amp- is the entity amp). Result: djust matches CPython 3.12.13 and 3.13.7 on 1283 of 1283 version-dependent corpus values and 2715 of 2717 stable ones (the two exceptions are the DoS guard, where the reference raises); zero values match no supported CPython, against 742 such orphans on main. Non-regression against a rebuilt main, scored on the same interpreter against the same version-neutral fixture: 2,349 values fixed, 0 broken. The two chain divergences that remain are not striptags#2279 (length counts bytes) and #2281 (escape|X passes X the unescaped value); #2280 (is_safe=True not propagated) was fixed by #2285 while this was open, and this suite's own "now AGREES — delete this row" assertion is what reported it. New: python/tests/fixtures/striptags_reference_2273.json (the reference captured across all four interpreters, split stable/version-dependent), scripts/gen-striptags-reference.py and scripts/check-striptags-version-stability.py (re-runs all 70 literal expectations through each supported CPython, so a version-dependent expectation cannot be written down by hand again). 20 cases in crates/djust_templates/src/htmlparser.rs::tests and new cases in TestReportedCells / TestUnreportedDivergences / TestChains / TestPinnedDifferential / TestPinnedReferenceIsHonest / TestKnownRemainingDivergences; 14/14 gate-off verified, after five mutations SURVIVED their first form — the loop cap (that input's uncapped fixpoint was also "") and four ported mechanisms that no test could observe, one of which the re-strip loop repaired until it was asserted on strip_once instead.

  • pprint and json_script spelled a float with Rust's {} — the last two of the five #2258 sinks (#2270).#2258 routed Display, the @stringfilter boundary and py_repr through Python's repr; pprint_value and value_to_json predate it, are neither of those things, and each kept its own format!("{f}") arm. The issue's table names 1e20, NaN and inf, and the ordinary case is none of them — Rust's {} drops a float's trailing .0, so {{ 1.0|pprint }} rendered 1 and json_script put a JSON integer on the wire where Django puts a float, for every whole-numbered value. The two sinks do not share a spelling, so one helper could not serve both: pprint.pformat(f) is repr(f) exactly (python_float_repr), while json.dumps(f) is repr(f) for a finite value and NaN/Infinity/-Infinity otherwise (a new json_float_body) — and on main the coincidences ran opposite ways, Rust's NaN matching json.dumps but not pprint and its inf matching pprint but not json.dumps, which is why half of each sink was accidentally right and neither read as a whole-filter failure. The Infinity spelling is a decision, taken explicitly.json_script calls json.dumps(value, cls=encoder or DjangoJSONEncoder) and DjangoJSONEncoder overrides only default(), so allow_nan stays True and Django emits Infinity into a <script type="application/json"> body. That is not valid JSON — JSON.parse('{"x": Infinity}') throws — and djust matches Django anyway: null (what JSON.stringify writes) is valid and silently lossy, since a client cannot tell an infinity from a None, whereas Infinity fails loudly at the parse site, Python's own json.loads accepts it, and answering null would make djust the one that changed the data. Deliberately not#2241's outcome, and the two differ in both halves — there Django emitted VALID JSON and djust did not (parity and validity agreed), and the mechanism was structure INJECTION from an attacker-reachable key, where Infinity is a fixed token chosen from the float's own class that injects nothing. The consequence is asserted rather than implied (test_infinity_is_django_parity_and_is_not_parseable_json). The structural half: the grep that finds these is format!("{f}") over crates/djust_templates/src/, and the only reason they were found is that someone re-ran it, so the new mod float_sink_set pins the SET of float→string sinks from Rust's own token stream — following #2249's cure for the same pin done as text, since both filters.rs and floatformat.rs carry format!("{f}") inside doc comments explaining why it is wrong, which a text grep counts. A SET and not a floor (#1125/#2233); the DIRECTORY is read at test time so a new file in the crate is covered the day it is added; and the rule is about the OPERATION rather than the literal text, because Value::Float(x) => format!("{x}") is the same defect and the same grep misses it. Verified as a set comparison against a rebuilt main: 648 cells (36 floats × 6 container shapes × 3 templates), AGREE 273 → 576, 0 regressions, 303 moving DIFF→AGREE. The 72 remaining divergences are all the tuple shape — normalize_django_value flattens a Python tuple to a list at the PyO3 boundary, so Value::Tuple is unreachable from a view context; pre-existing, unrelated to floats and filed separately (#1079). New cases in python/tests/test_pprint_json_script_float_2270.py (435 parametrized), plus 3 Rust unit tests and 4 in mod float_sink_set. 6/6 gate-off verified, one mutation per mechanism, each reddening a named test — the two arms (which also redden the structural pin, proving it load-bearing), the two non-finite spellings, and the pin's own detection mechanics; the harness asserts each mutation was found and changed the source, and treats a missing cargo test result: line or a pytest N error as INVALID rather than green (#2129/#2135).

  • filesizeformat, floatformat's u/gu and linebreaks computed the right value and emitted the wrong bytes (#2264, #2266, #2259). One change because they are one failure class — the number, the paragraph and the size were all correct and the shape they were written in was not — and two of the three are invisible to a test that compares strings by eye. filesizeformat diverged on EVERY value, for five causes, not the three the issue named. Django's avoid_wrapping joins the number to its unit with U+00A0, so EVERY cell differed by at least that one byte and a test written with an ordinary space passes while shipping the wrong one — exactly the trap #2228 recorded for timesince, and exactly what test_filesizeformat_filter had been doing since the filter was written. ngettext says 1 byte, not 1 bytes. The two the issue did not list are the ones an ordinary page hits: Django takes the absolute value, formats that and re-signs, so -1024 is -1.0 KB where the signed bytes < KB comparison sent every negative into the bytes branch and rendered -1024 bytes; and Django's first statement is int(bytes_) with a TypeError/ValueError/UnicodeDecodeError fallback to 0 bytes, so "1024" is 1.0 KB and None is 0 bytes — the old filter returned the value UNCHANGED for every non-numeric type, so {{ p|filesizeformat }} rendered the literal None. A fifth no cause covered: the KB-and-up branch is localized through number_format(round(v, 1), 1), so de gives 1,5 GB and USE_THOUSAND_SEPARATOR gives 1,024.0 KB. The as_f64 parse the issue was filed against is real but needs a >2^53 value to see it — Decimal('12345678901234567890.123456789') saturated to 8192.0 PB against Django's 10965.2 PB — and is now an exact i128 truncation through the shared decimal::to_i128_trunc. Still divergent, stated rather than left to be discovered: Django gettexts the unit NAMES (fr renders 1,5 Gio), which is the {% trans %} gap and not this filter's; int(float('inf')) raises OverflowError in Django, which filesizeformat does not catch, and a filter here cannot reproduce a 500 — it lands on 0 bytes, which is at least saner than the 8192.0 PB it used to fabricate. floatformat's u/gu ignored overridden number settings, and the residue did not move under #2263 — that PR rewrote the quantization and never touched finish's use_l10n arm. Django's u means use_l10n=False, and get_format short-circuits on that flag before it consults the active language (if use_l10n is False: return getattr(settings, format_type)), so the raw and the localized triples cannot be derived from each other: under de with no overrides the localized separator is , and the raw one is ., and under DECIMAL_SEPARATOR="!" with English it is the other way round. So a secondNumberFormat is resolved on the Python side and pushed alongside the first, and finish selects on use_l10n. Its use_grouping is false by construction rather than by simplification — Django's use_grouping is False whenever use_l10n is, and only then ORs in force_grouping — so u never groups and gu groups iff the RAW NUMBER_GROUPING is non-zero, which means that at its default 0 Django renders 6666.67 for "2gu" and not 6,666.67. test_the_u_suffix_ignores_overridden_number_settings went red on the fix, which is what it was written to do, and is kept flipped to the agreeing direction rather than deleted. linebreaks HTML-escaped its own markup, so any page using it showed the literal text <p>hello</p>. The filter now escapes its input internally, which is what earns it a place in SAFE_OUTPUT_FILTERS — Django's is_safe=True on a markup-producing filter is always paired with an escape() in the body, and marking the output safe without that inner escape turns {{ comment|linebreaks }} into an XSS sink, so the two halves are one change and the Rust unit test asserting it names that contract. Four more defects came with it: Django splits on \n{2,} (so a\n\n\nb is ONE separator), joins with \n\n, KEEPS empty paragraphs ('' is <p></p>, which djust rendered as '') and normalizes \r\n first. The issue asked for two neighbours to be checked rather than assumed, and both answers were surprises: linebreaksbr diverges the same way on both axes and is fixed with it, while linenumbers turned out escape-EQUIVALENT (it escapes the whole output where Django escapes per line, and everything it adds is escape-invariant) so it stays deliberately OUT of the safe list — but it zero-pads in Django where djust space-padded, a defect the issue did not mention and that only appears past ten lines. Adding a name to SAFE_OUTPUT_FILTERS first required curing a drift among its three consumers.get_value_safe applied the safe-name check per filter — Django's rule, since FilterExpression.resolve marks safe only when the filter it just ran is is_safe, which is why upper is registered is_safe=False — while the Node::Variable and Node::InlineIf arms applied it as any() over the WHOLE chain, and get_value_safe's own comment claimed all three matched. So {{ p|urlize|upper }} and {{ p|safe|upper }} already diverged from Django on an unmodified build, and adding linebreaks would have widened that to a fourth name. Found by the non-regression set comparison, not by reading the diff: exactly 396 cells agreed with Django on a main build and disagreed on the first version of this branch, every one of them {{ p|linebreaks|upper }}. All three sites now call one filter_output_is_safe helper (#1646), which can only ever mark FEWER values safe and so fixes urlize/safe/unordered_list in the same pass. Verified as a set comparison against a main build rather than a spot check: 45,936 cells (59 filter invocations x 55 values x 6 locale configurations), 0 of the 32,761 that agreed with Django on main disagree now, and 3,054 that disagreed now agree; the 110 that moved without agreeing are the two documented gaps above. Per-filter differentials against a live Django cover every unit boundary and both signs, the full u/g suffix matrix against six DECIMAL_SEPARATOR/THOUSAND_SEPARATOR/NUMBER_GROUPING overrides x grouping x language, and randomized sweeps, because a curated table samples the axis you thought of. New file python/tests/test_output_shape_parity_2264_2266_2259.py (286 collected), which spells the nbsp as an escape and asserts it is not a plain space so the byte cannot be normalised away, and whose XSS probe drives <img src=x onerror=alert(1)> and </script><script> through both filters asserting the payload is escaped AND the generated markup is not; new cases in TestLastFilterWinsForSafeness and, in crates/djust_templates/src/filters.rs, linebreaks_escapes_its_input_which_is_what_makes_marking_it_safe_safe plus filesizeformat_joins_with_a_non_breaking_space_not_a_plain_one, with test_filesizeformat_filter, test_linebreaks_filter and test_linenumbers_filter_alignment corrected from the wrong bytes they had encoded. The #2259 row in test_string_filter_stringification_2250 flips to agreement and linebreaks leaves UNCOMPARABLE, now that it is byte-diffable. 17/17 gate-off verified, one mutation per mechanism, each rebuilt and each reddening a NAMED test — including the plain-space mutation, which must redden or the assertion was comparing visually rather than by bytes. Two of the seventeen were caught mid-run by the harness's own guards (#2129/#2135): one broke the build and was refused a number rather than reported as 0 failed, and one was a valid mutation that was a semantic no-op for the values under test — use_grouping: false -> true cannot change anything while NUMBER_GROUPING is 0 — which is a missing assertion, not a passing test, and the two rows that distinguish it were added.

  • Three numbers that did not survive djust's Value representation (#2260, #2258, #2265). The same shape at three layers, and none of the three fixes subsumes another. #2260, the boundary:Value::Integer is an i64 and a Python int is arbitrary-precision, so past 2**63 - 1 the i64 arm of FromPyObject failed and the next arm that matched was extract::<f64>()12345678901234567890 reached the renderer as a binary double and {{ p }} printed 12345678901234567000. Every string filter inherited it because the value was already lossy, which is what distinguishes it from a filter-boundary bug. #2258, the rendering:Display for Value::Float was Rust's {}, which is neither of Django's two steps — it never uses exponent notation and spells the non-finite values NaN/inf where Python gives nan/inf. #2265, the filter:stringformat:"d" computed an i64 through as_f64(), so it was off by one from 2^53 up and saturated past 2^63, printing 9223372036854775807 — a fabricated constant — for an id or a money column. Fixing only the boundary leaves stringformat:"d" saturating; fixing only the filter leaves {{ p }} truncated. #2260 takes a new Value::BigInt(String) variant, following #2214's Decimal precedent, and the blast radius was measured by adding the variant and compiling rather than estimated: six exhaustive match sites. Sharing Decimal would have cost nothing structurally and is wrong twice — py_repr renders Decimal('123') where an int renders 123, and IntoPyObject returns a decimal.Decimal, so a view attribute holding a big int stops being an int to every isinstance after a state round trip. A wider Integer was the cheap option and is still finite: i128 reaches 39 digits and a 40-digit hash is not exotic. The loop-cache hash_value tag is 10, distinct from Decimal's 9 (sharing it serves one variant's cached fragment for the other, which render alike but do not serialize alike), and the __djust_bigint__ msgpack tag round-trips in both directions — #2214 shipped an encode-only assertion that stayed green through exactly that gap (#2135). #2258's premise needed checking before it could be built on. The issue says Django renders 1e300 as 1e+300 — true; python_float_repr's own doc-comment says {{ 1e20 }} renders 100000000000000000000 — also true. Django's rule is the digit count, not the exponent form, so rendering repr verbatim (the obvious reading) would have regressed every float between 1e16 and 1e200. The fix reuses expand_decimal_exponent, the same cut-off the Decimal arm uses, because Django reaches it by turning the float into a Decimal. Two further sites of the same str/repr split are closed with it: the @stringfilter boundary (Django's string filters consume str(value), so {{ f|upper }} legitimately disagrees with {{ f }}) and py_repr (a float nested in a list is spelled by Python's list repr). pprint and json_script are the remaining two and are filed as #2270 rather than folded in — neither is Display and neither is a @stringfilter. #2265's framing needed one correction too: it calls this "the #2253 defect, one filter over", which is right about the Decimal path and incomplete — the same arm saturated a plain float, and "%d" % 1e300 is the exact binary expansion, neither i64::MAX nor 10**300. Its group-3 question is decided against CPython rather than reasoned about: %d raises TypeError for a str, a numeric one included, so {{ "42"|stringformat:"d" }} is empty in Django and the old parse::<i64>() fallback disagreed in both directions. The real ceiling is CPython's sys.get_int_max_str_digits() (4300), which is also what bounds the allocation — Decimal('1E+400000000') is twelve bytes that hang CPython. Three consequences the differential caught and inspection did not:numeric_pair admitted only {Integer, Float, Decimal}, so {% if p > 10 %} on a big int returned 0 — "equal" — and both > and < were false (the #2244 hole, one variant over); add's width is gone entirely, because 9x60 |add:1 was correct on main only by coincidence (the value had arrived as the double 1e60, whose expansion is exactly the sum the filter was declining to compute); and get_digit read the rendered string where Django indexes str(int(value)), which was invisible while Display expanded every float and became wrong the moment {{ 1e-200 }} started rendering 1e-200. int_digits_of is now one definition of Python's int() shared by add, get_digit and stringformat — the three filters that had each re-derived it and disagreed (#1646). Verified as a set comparison against a main build, not a spot check: of 4,400 cells (numeric spectrum x 40 templates), 0 of the 2,799 that agreed with Django on main disagree now, and 814 that disagreed now agree. The 22 cells whose output changed while staying divergent are all shapes where Django raises (int(inf), str(int) past 4300 digits, int('-')) and djust renders rather than 500ing. An earlier pass had 22 real regressions, every one found by the set comparison and none by inspection. New files: crates/djust_core/tests/test_bigint_value_2260.rs (8), crates/djust_core/tests/test_float_display_2258.rs (4), crates/djust_templates/tests/test_bigint_loop_cache_2260.rs (2), crates/djust_templates/tests/test_stringformat_int_2265.rs (5), python/tests/test_big_int_value_2260.py (65), python/tests/test_float_display_2258.py (376), python/tests/test_stringformat_int_2265.py (195), plus new cases in decimal::tests. Four existing tests went red and are corrected rather than deleted — three TestKnownRemainingDivergences entries whose own contract says a closing gap turns the file red, and test_get_digit_filter, which had pinned djust's pre-fix out-of-range behaviour that a live Django render disagrees with. 13/13 gate-off verified, one mutation per mechanism, each reddening a named test; the harness asserts the mutation applied and treats a build break as INVALID, which caught one invalid mutation and one genuinely uncovered mechanism (#2129/#2135).

  • Seven filter algorithms diverged from Django, and three of the seven cells were not reachable by adjusting the existing code (#2262, #2261). Each filter had been written against Django's documentation rather than differentialed against its behaviour, so each was correct-looking and wrong in a detail the docs do not mention. Ported the references into a new crates/djust_templates/src/truncate.rsdjango.utils.text.Truncator, the html.parser.HTMLParser subclasses that drive its HTML variants, slugify, Python's str.title() and urllib.parse.quote. The reported cells are all exact now: truncatechars_html:8 on "Infinity" (Infinit…Infinity) and on {'a': 1} (truncated → whole); truncatewords_html:2 (escaped once → twice); truncatewords:2 on " spaced " (padding kept → dropped); urlencode on "<b>x</b>" (%2F/); slugify on "3.5" and "-1.5e+300" (3-535, 1-5e-30015e300); title on " spaced " and "<b>x</b>" (stripped → kept, bB). truncatechars_html's two cells are one branch, not two bugs: TruncateCharsHTMLParser.process emits its input raw and unescaped and stops when the whole input is one run of text of exactly length characters — which is why this is a port and not an off-by-one fix. Reproducing the reported cells found nine more divergences the issues do not name: comments, doctypes and processing instructions are deleted rather than escaped (Django does not override handle_comment, so the base no-op runs); an unterminated construct at the end of the input discards everything after it (Truncator calls reset() before close(), so "trailing &amp" really does render empty); <script>/<style> switch to CDATA mode; character references round-trip through html.unescape; frame and spacer are void elements; length <= 0 is the empty string; a negative filter argument parses at all (the old usize parse silently fell back to the default); urlencode's argument was ignored entirely (so the urlencode:"" behaviour docs/RUST_TEMPLATE_API.md already documented now actually happens); and title needed the real titlecase mapping (ß is Ss, not SS), Nd-only \d in Django's \d([A-Z]) fixup (so ½ cup titlecases the c), the real Cased set, and a Case_Ignorable-skipping final-sigma lookahead (so Σ.Ζ is σ). markup5ever becomes a direct dependency but is not a new cratehtml5ever already pulls it in for djust_vdom, so Cargo.lock gains one edge and no package; its NAMED_ENTITIES is byte-identical to CPython's html.entities.html5 (2231 entries, asserted). Three classes stay open and are pinned rather than left to be discovered. Two are unicodedata: slugify's opening normalize("NFKD").encode("ascii", "ignore") and Truncator.chars's normalize("NFC") + combining skip need Unicode normalization tables this workspace does not carry, and their residue is confined to non-ASCII input (45 and 30 cells of a 13,500-cell unchained sweep). The third is the CHAIN: a 24,300-cell non-regression set comparison against a real origin/main build found 1,070 unchained cells fixed and 0 unchained regressions (upper|, lower| and striptags| also 0), but 243 regressions on escape| and safe| — and every one of the 243 is a pre-existing chain-link gap rather than the port. Those cells had agreed for the wrong reason, two bugs cancelling: djust's escape returns its input unchanged (#2257 residue 1), and its safe rule is a name whitelist where Django's is is_safe=Trueand the input was already safe, so a truncator that now escapes its text the way Django's does escapes twice. The proof is executable and in the suite — feed the port exactly what Django hands the filter and it reproduces Django's answer for 243 of 243. Measured breadth of the safe-rule half: 24 of Django's 27 is_safe=True filters diverge on {{ p|safe|X }} on origin/main (lower, capfirst, wordwrap, urlize, … — none of them touched here) versus 23 on this branch; without the |safe it is 5 on main and 1 here, so this PR strictly improves both axes. Also verified with a randomized differential against real Django per filter, an exhaustive single-codepoint differential for title over 4,448,256 cells whose only residue is Unicode-data-version skew between CPython 15.0 and Rust std, and a 15-mutation gate-off matrix in which every fix reddens a named test. 19 test cases in python/tests/test_truncate_slugify_parity_2262.py (79 node IDs after parameterization). test_string_filter_stringification_2250.py's UNCOMPARABLE set is now empty: its characterization test existed to go red when these closed, and did, so the six filters it parked joined linebreaks (closed by #2269 in the same release) in the compared set.

  • Django's @stringfilter built-ins saw a Decimal's numberformat rendering instead of str(Decimal) (#2250). Django decorates 29 of its built-ins with @stringfilter, which runs them on str(value); djust's ran on Display, which for a Decimal is the rendered form — numberformat.format's "{:f}".format(number) expansion, correct for {{ d }} (#2214) and wrong as a string-filter input. Decimal('1E-9') is the smallest case: Django's truncatechars sees 1E-9, djust's saw 0.000000001, and make_list|first gave 0 where Django gives 1 — nine digits, so not confined to the >200-digit cutoff #2242's comment predicted. The coercion is free: Value::Decimal already carriesstr(Decimal), built from ob.str() at the PyO3 boundary, and Display is what expands it — so the fix hands the filter the raw payload rather than deriving a second string. Placed at apply_builtin_filter, the one dispatch table every built-in funnels through, rather than in the ~30 arms that call value.to_string(); N correct copies is the #1646 shape and this issue's own family (#2203#2216#2227#2228) is four links of it. Custom filters need nothing — apply_custom_filter hands Python a real Decimal, so Django's own decorator applies. Two of the 29 are excluded, measured rather than assumed. djust's escape/safe are no-ops returning the value (auto-escaping is decided by filter NAME at the render site), so their divergence has a different mechanism — the value stays a Decimal and the renderer localizes it — and coercing them changes the type flowing down the chain, which floatformat cannot absorb: 1,168 cells of {{ d|escape|floatformat }} regressed. Teaching floatformat to parse a numeric string was tried and is worse — an f64 cannot reproduce Django's >200-digit passthrough or its NaN/inf handling, and it broke 538 cells of {{ d|upper|floatformat }} while fixing 1,168. Both residues tracked in #2257. The locale axis is where the issue's framing is wrong: djust's string filters never saw a localized form. localize_if_number runs only at the render site on the FINAL value and every stringfilter returns a Value::String, which is never localized — under de, truncatechars saw 1234567.89 exactly as Django does. The divergence was purely Display-vs-str(); localization is involved only for escape/safe, which is the second reason they are a different fix. Verified by a differential against a live Django rather than the issue's four-row table: 129,360 cells (63 single + 90 chained filter expressions × 81 values × 5 locales × 2 grouping flags), 10,855 moving DIFF→AGREE. 28 move the other way — 6 are {{ p|random }}, nondeterministic and moving in both directions, and 22 are {{ d|upper|floatformat }}, where upper now correctly yields 1E+1 and floatformat cannot parse it. That is #2257 becoming reachable, not a new defect: {{ "1E+1"|upper|floatformat }} already diverges on main with no Decimal anywhere. The same sweep surfaced five unrelated whole-filter divergences, each reproducing on a plain string and each filed rather than folded in (#1079): #2258 (Display for Value::Float on 1e300/NaN), #2259 (linebreaks escapes its own markup), #2260 (a Python int past i64 loses precision), #2261 (slugify, title), #2262 (truncatechars_html ×2, truncatewords_html, truncatewords, urlencode). Each is excluded from the parity tables only with its plain-string reproduction cited and pinned, and all 27 covered filters are still asserted Django-independently by test_every_covered_filter_treats_a_decimal_as_its_str — so none is silently dropped. 9 cases in python/tests/test_string_filter_stringification_2250.py, which re-derives the filter set by introspecting the live defaultfilters registry so a filter Django adds to the decorator fails the test rather than drifting, plus 4 unit tests at the dispatch table in crates/djust_templates/src/filters.rs; the #2242 characterization test flips from asserting the divergence to asserting the parity, as it was written to. Gate-off: 3 mutations, each rebuilt and re-run, each reddening a named test — the coercion (7 red), the set contents (5 red), the escape/safe exclusion (1 red, and only that one).

  • floatformat was float formatting where Django's is decimal arithmetic, and add silently did nothing past i64 (#2253). The issue reported four cells and named one cause for all four — Value::Decimal's digit string parsed through f64. Reproducing them first and then widening the differential corrected the premise twice. floatformat was wrong on far more than Decimal: a sweep of 21 argument forms x 475 values measured 302 divergent cells of 825, and precision is one of four independent causes. Django's floatformat converts EVERY input — float, int, str, Decimal alike — to a Decimal and quantizes it ROUND_HALF_UP, so the other three are about every input type. Rust's {:.n$} rounds the binary double half-to-even (2.675|floatformat:2 was 2.67, Django says 2.68); Django's default argument is -1 and a negative argument means "at most", which "-3".parse::<usize>() cannot express, so every negative argument silently became one place and Decimal('0.00')|floatformat kept a .0; and the g suffix was stripped from the argument and then ignored, so 6666.6666|floatformat:"2g" never grouped. Fixing only the two cited cells was not possible without the p <= 0 branch and exact quantization, which is most of the algorithm — so the whole of django/template/defaultfilters.py::floatformat is ported to exact decimal-string arithmetic in crates/djust_templates/src/floatformat.rs, with no new dependency: the algorithm is a quantize with carry on a digit string, str(text)'s two give-up paths, and Django's own 200-digit cut-off. add's cited cell was not the f64 parse:12345678901234567890 does not fit an i64 however exactly it is computed, so checked_add overflowed and the filter returned its input unchanged. The f64 parse is a real second defect — Decimal('9007199254740993')|add:1 gave back 9007199254740993, off by one from 2^53 up — and widening the truncation alone would not have closed the reported cell. Both are fixed: int() truncates the exact digits into an i128, and a sum outside i64 is carried as Value::Decimal's exact digits rather than discarded. Non-finite floats are refused rather than saturated, so {{ inf|add:1 }} returns inf instead of a fabricated i64::MAX. #2214's contract is upheld, not overturned — as_f64() is still what {% if %} compares through; what moved is formatting, the half that contract already puts on the exact side. The decimal parse expand_decimal_exponent grew inline is lifted into djust_core::decimal so the renderer and both filters share one definition of what a decimal is (#1646). Order within the port is load-bearing and was measured, not reasoned: Django parses the VALUE before the ARGUMENT and their give-up paths differ ("" vs the input back), so the first pass rendered abc where Django renders nothing. Still divergent, stated rather than left to be discovered: past i128add gives up (Python's ints are unbounded and nothing here is); add's third branch still returns the value where Django returns "" (pre-existing, deliberate); {{ p|floatformat:"" }} raises IndexError in Django 5.2 and djust does not reproduce crashes; a Python int wider than i64 is already lossy at the Value boundary, which {{ p }} alone shows; Value::Float's Display still writes NaN where Python writes nanfloatformat builds its own str(text) and agrees with Django, so the asymmetry is inside Display, not the filter; and the u/gu suffixes emit Django's DEFAULT DECIMAL_SEPARATOR/THOUSAND_SEPARATOR/NUMBER_GROUPING rather than a project's overrides, because only the LOCALIZED format is pushed to Rust. That last one is a run result, not a reasoned one (#1867): with DECIMAL_SEPARATOR="!" Django gives 6666!67 and djust gives 6666.67, pinned by test_the_u_suffix_ignores_overridden_number_settings alongside the localized forms, which agree — so the gap is bounded to u rather than being a general locale failure. Verified as a set comparison against a main build, not a spot check: of 23,750 cells (curated plus 400 randomized values), 0 of the 12,458 that agreed with Django on main disagree now, and 7,441 that disagreed now agree. New file python/tests/test_floatformat_parity_2253.py (50 collected) — the reported table asserted against a live Django first, the full argument x value grid, and 3,000- and 2,000-case randomized sweeps; KNOWN_FILTER_DIVERGENCES in test_decimal_converters_2239.py is now EMPTY and still asserted as a set, so a regression re-populates it. 9/9 gate-off verified, one mutation per mechanism, each reddening a named test.

  • A Decimal stored in the session or a signed snapshot came back a float (#2252).#2239 gave normalize_django_value's three destinations the representation each needs and named the third — a round trip back onto the view — as the one it could satisfy neither way: Django's session serializer is json.dumps with no encoder (and the signed snapshot a bare json.dumps), so both refuse a raw Decimal; and the exact digit string is refused one hop later, because whatever is stored is safe_setattr-ed back onto the view and reaches the template on the very next render, where a string stops |floatformat rounding — the #2214 regression. It kept float on the grounds that this was "today's behaviour exactly, today's loss exactly". Measuring what that costs is what changed the answer, and the issue's own framing was wrong. The residue is not precision loss "past ~15 significant digits" — float is wrong for ordinary four-digit money too, in two ways needing no precision loss at all: the type changes, so self.price + Decimal('1') raises TypeError after a reconnect and not before one; and trailing zeros are gone, so Decimal('19.90') renders 19.9 where Django renders 19.90. Measured across {19.90, 0.00, 100.00, 2.50, 19.99} × {{{ p }}, |floatformat, |floatformat:2, |stringformat:'s'}: 8 of 20 cases disagree with Django through the float round trip against 0 of 20 through the tagged one. (Both were higher when this landed — 10 and 2 — the residual 2 being the separate #2253floatformat gap; PR #2263 closed that gap for every input type later in the same drain, taking the tagged column to 0 and, since two float cells were floatformat cells too, the float column to 8.) So destination 3 now takes a tagged round trip — the encode_private_model_refs shape (#1994), under the same tag name the Rust binary encoding already uses for the same job (DECIMAL_TAG, #2214, pinned against crates/djust_core/src/lib.rs so the two halves cannot drift). decimal_for_state_roundtrip writes {"__djust_decimal__": "19.99"} and decode_state_roundtrip restores a real Decimal; an untagged float from a session written by an older release passes straight through. The restore sites were found by grepping the SINK (safe_setattr plus the _restore_* hooks), not by mirroring the twelve write sites — and the issue's read-side list was wrong in both directions: it names mixins/rust_bridge.py, which has no restore path at all, and omits three that do (runtime.py's _restore_snapshot call for the signed back-navigation snapshot, time_travel.py's replay restores, and _restore_component_state). Eight decode points cover all of them, and TestTheDecodeSiteInventory pins the set (#1125) plus a mechanical check that every module applying restored state also decodes. The decode is mandatory rather than defensive: an undecoded tag is strictly worse than the float it replaces — a dict in the template rather than a wrong number, 20/20 disagreements — which is why runtime.py decodes at the caller of _restore_snapshot (a documented subclass-override hook, so an override never sees the tag shape) and why time_travel.py's to_dict — the display view of the same capture — renders the bare digit string the debug panel expects. Collision hazard, deliberately the one the Rust side already documents: a user dict that is exactly {"__djust_decimal__": <digit string>} is misread; the guard is the same three rules visit_map applies (exactly one key, that key, a str payload) plus a fourth Python needs because Decimal() raises where Rust just stores the string — an unparseable payload stays a dict rather than crashing a reconnect. Non-regression measured as a set comparison against a main build rather than asserted: 2828 decimal-free corpus rows over 14 types are byte-identical through both adapters, 172/172 decimal-bearing rows change. New cases in TestTheRoundTripIsLossless, TestWhatTheFloatRoundTripCost, TestTheDecodeIsMandatory, TestTheCollisionHazard, TestEveryOtherTypeIsUntouched, TestTheRealHTTPPostRoundTrip, TestTheSignedSnapshotRoundTrip, TestTheStickyChildRoundTrip, TestTheComponentRoundTrip, TestTheTimeTravelRoundTrip and TestTheDecodeSiteInventory (86 in python/tests/test_decimal_state_tag_2252.py), plus 5 real-WebsocketCommunicator cases in python/djust/tests/test_decimal_state_tag_runtime_2252.py for the two runtime.py sites; 11/11 gate-off verified, and every mutation reddens a behavioural test that reddens for it alone rather than only the inventory pin (#2129/#2135).

  • The Python Decimal converters were still lossy after #2214 (#2239).DjangoJSONEncoder.default and normalize_django_value both returned float(o), so Decimal('12345678901234567890.123456789') still arrived as 1.2345678901234567e+19 — and not on a rare path: mixins/jit.py calls the normalizer at seven sites and its fallbacks are ordinary (JIT unavailable, no paths extracted, or the Rust serializer not capturing an @property, which sends the whole model down it). #2214 deferred the pair because three verified constraints pulled against each other; the consumer audit that closes it found the pair is one function with three destinations, and gave each the representation its destination needs. The template context keeps the Decimal, which Rust already carries as Value::Decimal and renders identically to Django. The client wire takes the exact digit string — byte-for-byte what Django's own DjangoJSONEncoder returns, and what the Rust serialize_context has emitted since #2214. A round trip back onto the view — the ten Django session writes plus the two signed-snapshot captures — keeps today's float through one new chokepoint, decimal_for_state_roundtrip, reached by normalize_django_value(..., state_roundtrip=True) and StateRoundtripJSONEncoder. That last one is a documented residue, not an oversight: Django's session serializer is json.dumps with no encoder so it cannot take the Decimal, and it cannot take the string either, because whatever is stored is restored onto the view and lands in the template context on the next render, where a string stops |floatformat rounding — the #2214 regression one hop later. Lossless there needs a tagged round trip plus a decode at every restore site, tracked in #2252. Note the issue named runtime.py's json.dumps(public_state, ...) as the blocking consumer; it is in fact already safe (its state is JSON-round-tripped by _capture_snapshot_state first), and the session writes are the real ones. TestParityWithJSONRoundtrip keeps holding with its premise restated: it now pins the composition dumps(normalize(x)) == dumps(x), which is the property callers rely on when they skip the round trip, still covers Decimal, and is stronger than the raw equality that only held while both converters flattened to the same float. New cases in python/tests/test_decimal_converters_2239.py — a 1,000-value randomized encoder differential against real Django, a 7-idiom x 4-value template differential, the full matrix asserted as a non-regression claim, and an AST inventory pinning the session-write call set; 8/8 gate-off verified.

  • A scientific-form Decimal's coefficient was not localized (#2242). Past Django's >200-digit cutoff a Decimal renders in scientific form, and localize_number_with bailed on any string containing an e — so under deDecimal('1.230E-250') rendered 1.230e-250 where Django gives 1,230e-250. Not a regression: before #2214 the value was an f64 and rendered further from Django than either, so this is a residual gap inside a strict improvement. Fixed by mirroring django/utils/numberformat.py's scientific branch rather than approximating it from the issue's table — split on the exponent marker, localize the coefficient through the SAME path (grouping included, so the two arms cannot drift, #1646), rejoin with the exponent verbatim. Reading Django settles two things guessing would not: the exponent is never localized, never grouped and keeps its sign, and the coefficient takes the full path rather than a decimal-separator swap. An exponent-shape guard preserves the old pass-through for an e not followed by a signed integer, so 1.5exyz and abcE+5 still come back byte-exact. Verified by a randomized differential against a live Django, not a curated table: 6,496 cases across 8 locales x {grouping on, off} x both exponent signs x negatives x both sides of the cutoff, 2,928 of them scientific — 0 mismatches, with ordinary values pinned unchanged in the same sweep. Not fixed, and now measured rather than assumed: #2242's comment folds in truncatechars and make_list|first on the premise that one fix covers all three. It does not — those filters never reach localize_number (Django's are @stringfilter and consume str(Decimal); djust's consume the numberformat rendering) and their divergence is not confined to the cutoff either, so Decimal('1E-9') diverges at nine digits. Filed as #2250 and pinned here as a characterization test. 9 cases in crates/djust_core/tests/test_scientific_localization_2242.rs and 7 in python/tests/test_scientific_localization_2242.py; 3/3 gate-off verified.

  • The #1817 render-send structural pin counted prose, not call sites (#2238).test_every_client_checked_send_path_uses_next_version matched _next_version_armed( with a regex over the RAW source of websocket.py, which is wrong in both directions. False positive, observed in #2237: a docstring explaining the argument-evaluation defect contained the literal version=self._next_version_armed(html), the pin went red with "expected 13 ... found 14", and the fix applied there was to reword the prose — backwards, since the code was correct and the checker was not. False negative, latent and worse: a call site inside a commented-out block still matched, so deleting a render-send path left the pin green; nothing caught this half. Counts and greps now run over prose-stripped source. The stripper is lifted from tests/test_reset_fixture_hygiene_2234.py::_code_only (#1077) into one shared python/djust/tests/_source_scan.py both callers use, rather than a third copy of the same tokenize walk (#1646). It exposes two functions, because prose and "not executable" are different lines: without_prose drops comments and docstrings only — a string literal is CODE, and _arm_recovery reaches its attribute through getattr(self, "_last_sent_version", 0), so the sibling assertion greping for that name asserts nothing if strings are blanked (found by running the first version, which dropped them); code_only also drops string literals, for #2234's guard where naming deactivate_all() in a message must not count. Both preserve layout exactly, which the pin depends on — it tells an inline kwarg version=self.f( from an assignment x = self.f( by the spacing, and a token-joining strip would collapse the two and count one as the other. _hotreload_broadcast_suppressed gets its natural wording back and the note explaining the contortion is deleted; that docstring is now the standing dogfood case, since the real websocket.py counts 14 raw and 13 code-only. Two empirical canaries against the real module (#1459), each asserting its mutation applied before reporting anything (#2129/#2135): one appends prose naming every counted shape and asserts the raw counts rise while the pin's do not; the other comments out a genuine call site (the whole statement via ast, plus a pass, so the source still parses — an unparseable source is returned unchanged and would measure nothing) and asserts the raw count is unchanged while the pin's drops to 12. New file python/djust/tests/test_source_scan_2238.py (25 collected) plus the two canaries in test_ws_send_version_1788.py; gate-off across all three affected files: 12 failed with the stripper neutered, 10 with docstrings unblanked, 4 with comments unblanked, 12 with code_only no longer blanking strings — so each mechanism has a test that reddens for it alone.

  • Two more structural pins were counting prose, one of them in a language the #2238 stripper cannot read (#2249, #2246). Same class as #2238, and the direction of the blindness follows the assertion's shape, which the issue's first draft had backwards and a one-minute run corrected: a negative assertion (".replace(" not in body) false-alarms on a comment merely explaining the ban, while a positive count (count("json_string_body(") == 3) false-passes when a real call site is deleted and its text left in a //. Measured on the real filters.rs before choosing an approach — prose naming .replace( → ban RED; prose naming the helper → count 4, RED; an arm deleted with its text left behind → count 3, GREEN, the latent half and the #1817 bug verbatim in Rust; the same arm deleted cleanly → count 2, RED. #2249's two pins move INTO Rust, as a #[cfg(test)] mod value_to_json_structure counting over proc_macro2's token stream. Wiring them to djust.tests._source_scan would have looked like a fix and been a no-op: it runs CPython's tokenize, so a .rs file comes back unchanged and silently — which test_rust_source_is_NOT_stripped_and_comes_back_unchanged pins from the other side and which this change deliberately leaves passing. The alternative, a Rust-aware stripper in Python, is the #1646 shape: a second lexer to keep correct through raw strings, byte strings, nested block comments, and lifetimes that look exactly like unterminated char literals. Rust's own lexer has nothing to maintain, drops // and /* */ before the pin sees them, and makes each string literal one opaque token — so ".replace(" inside an error message is not a call either, which the text pin also got wrong. proc-macro2 is test-only and already in the lockfile via pyo3-macros, so the Cargo.lock delta is one line and no new package. It also cannot silently no-op, where the text version could three ways: the source is include_str! (a moved file is a compile error, not a skipped test), the lex result is asserted, and the function is located in the token tree rather than by str::index(.., "\nfn "). That last one is not hypothetical — adding the test module immediately after value_to_json moves the old text terminator, and the old slice reads 18json_string_body( instead of 3, so had the pins stayed in Python this change would have broken them. #2246 is the Python half: test_bug_capture_views::_code_only_source stripped only the leading module docstring and now delegates to without_prose — not code_only, because every assertion in that file is a ban and blanking string literals weakens a ban, importlib.import_module("djust.tenants.middleware") being caught by one and missed in silence by the other. Its issue text is corrected in place too: all three assertions there are negative, so prose false-alarms and there is no false-pass direction — the old stripper reddened all three bans on a per-function docstring, which is the gap actually closed. Two matchers were collapsed to one on the way (#1859): a name match and a call-shape match were two mechanisms and only one could ever be reached from a test, so the (-suffix rule was decoration — after the lexer has run there is nothing left for it to exclude. Empirical canaries in both directions on both real trees (#1459), each asserting its mutation applied (#2129/#2135). Gate-off, each mechanism reddening a named test and no other: dropping the group recursion reddens a_call_nested_inside_a_macro_group_is_still_counted, breaking the body locator reddens the_body_is_the_function_s_brace_group_and_nothing_after_it, the pre-#2246 stripper reddens test_prose_below_the_module_docstring_does_not_trip_the_bans alone, code_only reddens test_a_string_mediated_tenants_import_still_reddens_the_ban alone, and no stripping at all reddens four including both real bans. New cases in value_to_json_structure and TestSourcePinCanaries.

  • {% if <float> == <int literal> %} was always false, diverging from Django (#2243).values_equal in crates/djust_templates/src/renderer.rs had explicit arms for (Integer, Integer) and (Float, Float) and nothing for the mixed pair, so {% if x == 0 %} answered "not zero" for 0.0 and {% if x == 19 %} answered "not equal" for 19.0. Python compares 0.0 == 0 as true, so Django does. Note the asymmetry that hid it: compare_values, twenty lines below in the same file, has carried explicit (Integer, Float) and (Float, Integer) arms all along — ordering was correct the whole time and only equality diverged, so a template doing {% if x > 0 %} next to {% if x == 0 %} got one right and one wrong. Compared exactly, which is the whole of the fix. An absolute f64::EPSILON tolerance here — the obvious mirror of the arms compare_values already has — makes {% if delta == 0 %} true for 0.1 + 0.2 - 0.3 (5.55e-17), a float residue silently taking the wrong branch, which is a worse bug than the one being fixed. That shipped briefly in #2240 and was reverted at round six; every residue float arithmetic actually produces is now asserted non-zero against Django on both engines, one assertion away from the case that made them equal. Also not a as f64 == b, which #2243's own fix shape proposed and which is the same trade one step over: the cast rounds above 2^53, so 9007199254740993 as f64is9007199254740992.0 and the comparison answers true for two values Python calls different — a pair that agreed with Django before this change and would have started disagreeing. Converting the float instead (whole, finite, in i64 range) is exact whenever it succeeds, which is Python's own rule; the range guard is load-bearing because b as i64 saturates rather than wrapping, so without it 1e300 compares equal to i64::MAX. (Float, Float) keeps its epsilon and the Decimal arm keeps its widening — both are separate questions (#1079), and the Decimal one is pinned as a stated limit rather than assumed unchanged. A bool against a number is still always false where Django says True == 1, since bool subclasses int; compare_values has no Bool arm either, so {% if flag > 0 %} is false as well. Out of scope here, filed as #2244, and pinned as-is rather than as correct. Answers are measured against real Django rather than asserted from a hand-written table: a 15,540-case differential over every combination of a numeric context value and a numeric literal, both operand orders (a two-sided guard pinned on one side is half a guard, #1859), both == and !=, which disagreed 48 times before and 0 after and is what caught the as f64 rounding. The sweep also found that {% if x == 1e-17 %} is a TemplateSyntaxError in Django — its FilterExpression regex has no place for a sign inside an exponent — so that literal is excluded as a harness artefact rather than measured. {% if needle in seq %} shares the same sink and is fixed with it. 13 regression cases in python/tests/test_float_int_equality_2243.py plus four unit tests at the function in renderer.rs; seven gate-off mutations, each rebuilt and re-run and none breaking the build, with every mechanism the fix introduces — each arm, the exactness, the range guard, the fract/finite guard — reddening a test that only it reddens (#2129).

  • {% if <bool> == <number> %} and {% if <bool> > 0 %} were always false, diverging from Django (#2244, the case #2243 left).bool subclasses int in Python — Trueis1 numerically — so True == 1, False == 0 and True > 0 are all true and Django says so. djust said false to all three. values_equal in crates/djust_templates/src/renderer.rs had a (Bool, Bool) arm and no mixed Bool/numeric arm, so a bool against a number fell to _ => false; compare_values had no Bool arm at all, so a bool reached numeric_pair — which admits only {Integer, Float, Decimal} — got None, and yielded 0, "equal". That is why the ordering half looked half-correct: 0 makes > and < both false and>= and <= both true, so {% if flag >= 1 %} on True agreed with Django by accident while {% if flag > 0 %} next to it did not. The fix is a substitution, not four more pairwise arms per function. A new bool_as_int replaces a bool operand with Value::Integer(0 | 1) and re-enters, which is exactly what Python does and routes a bool through the same arm its integer value takes — so the two cannot drift (#1646), and it inherits #2243's exact int_eq_float comparison by going through it rather than around it. The two traps that helper documents cannot bite a value that is only ever 0 or 1 (no float residue near it to mistake for zero, nothing near 2^53 to round), which is stated here rather than inherited unexamined. values_equal's (Bool, Bool) arm is deliberately not substituted — same answer, and skipping it keeps that arm live and bounds the recursion at one substitution per side; compare_values has no such arm to defer to, so the substitution covers two bools there as well, and {% if a > b %} on True/False — 0, "equal", before — is now true as Django says. values_identity (is / is not) must NOT widen and is untouched: True is 1 is false in Python, both engines already agreed, and the pin stays green under every gate-off mutation rather than being assumed. {% if needle in seq %} shares values_equal as its sink and is fixed with it. The load-bearing assertion is an equivalence, not a Django table: for every operator and every other operand, a bool now answers exactly as its integer does. That holds even where djust still disagrees with Django — NaN ordering ({% if 1 > nan %} is true here and false there) and sub-epsilon Decimal equality (Decimal('1E-30') == 0) are pre-existing (Integer, *) divergences (#1079) that a bool now inherits verbatim, so fixing them for integers fixes them for bools and this suite needs no edit; both are pinned as inherited, not as correct. Answers are measured against real Django rather than asserted from a hand-written table (v1.1.1-2 retro): 10,440 differential cases over 142 operands (ints, floats, Decimals, bools, strings, None, sequences, NaN, ±inf, the 2^53 boundary, seeded random samples) × six operators × both operand orders × both bools. Django parity went 1128 → 88 divergences, and all 88 remaining are cases where the same integer disagrees too — 0 divergences on the subset where the integer agrees. The bool ≡ integer equivalence went 1056 → 0 violations. 18 regression cases in python/tests/test_bool_numeric_comparison_2244.py plus five unit tests at the functions in renderer.rs. Four gate-off mutations, each rebuilt and re-run and none breaking the build: the two substitutions redden disjoint named tests (M1 alone 2 rust + 9 python, M2 alone 1 rust + 9 python) and their violation counts are exactly additive (72 + 984 = 1056), so neither shadows the other (#2129/#2135).

  • A dict key with a newline made json_script emit a <script type="application/json"> body that does not parse (#2241).value_to_json had converged its String and Decimal arms onto json_string_body, but the object-KEY path kept its own partial chain — backslash and quote only, no \n/\r/\t — so {{ d|json_script:"x" }} over {"a\nb": "v"} wrote a raw control character and json.loads raised Invalid control character at char 3. A key is a JSON string with exactly the same grammar as a value and is exactly as attacker-reachable; there was no reason for it to have its own escaper, and the previous PR's comment had already named the gap rather than closing it — which is why the fix here is a structural pin (the json_string_body call-site count inside value_to_json is pinned at 3, and an inline .replace( there fails the suite) and not a third correct copy (#1646/#1859). Second, wider defect, in every arm rather than just the key: the control characters with no short form were never escaped at all, so {"k": "a\x00b"} did not parse either — RFC 8259 forbids all of 0x000x1F unescaped inside a string. json_string_body now covers the whole range as \u00XX, plus the two remaining short forms json.dumps uses (\b, \f). Deliberately NOT escaped here: <, >, &, U+2028 and U+2029, which json_escape_for_script already claims on the assembled document — json_script composes the two, and adding them would be the double-application the single-helper shape exists to avoid; and 0x7F, which JSON permits raw and json.dumps(ensure_ascii=False) emits raw, so escaping it would be a divergence rather than a fix. The assertion is the round trip, json.loads(body) == original, never a substring of the escaped output: it fails both when an escape is missing (a parse error) and when one is wrong (a value mismatch), which a '\\n' in body check does neither of. Coverage enumerates the variants rather than sampling them — all 32 control characters in a key and in a value, and the named hostile set (newline, CR, tab, backspace, form feed, backslash, quote, 0x00, 0x1F, 0x7F, U+2028, U+2029, </script> and a JSON-injection payload) in key, value, bare-string and nested positions — plus the tagged-Decimal arm, whose #2214 case stayed green against a raw 0x00. Finally, a 12,000-case differential against json.dumps(ensure_ascii=False), which is what keeps the five short forms independently reachable: the generic \u00XX arm SHADOWS them, so dropping '\n' still yields valid, round-tripping \u000a and every round-trip test stays green (#2129/#2135). Their reason to exist is byte-parity with Python's encoder, so that is what pins them. New regression cases in python/tests/test_json_script_escaping_2241.py and three in crates/djust_templates/src/filters.rs; 10/10 gate-off, every escape reverted individually with the mutation text asserted present and a broken build reported as INVALID rather than green.

  • Decimal reached the client, and the Rust template engine, as a lossy binary float (#2214).serialize_python_value's type_name == "Decimal" branch was dead code: extract::<f64>() ran above it, and PyO3's f64 extraction goes through PyFloat_AsDouble, which honours Decimal.__float__. Every Decimal became a double before the branch could see it, so Decimal('12345678901234567890.123456789') arrived as 1.2345678901234567e+19. DecimalField is Django's money type and a binary double is exactly what it exists to avoid. UUID shared the branch and was never affected — not float-convertible, so it reached the check. Not the one-line branch move the issue proposed, which was measured and regresses two template behaviours: the serialized value is written back into the template context, so the Rust renderer sees what the wire sees, and as a plain string {{ p|floatformat }} stops rounding and {% if p > 10 %} compares lexically. Instead a Value::Decimal(String) variant carries the exact digits, with a single as_f64() for arithmetic and comparison so the ~8 numeric consumption sites cannot drift (#1646) — exact rendering and transport, with arithmetic unchanged from before rather than claiming a precision it does not have. Adding the variant made rustc enumerate the exhaustive matches; the _ => fallbacks it cannot see were audited by hand, which is where the floatformat and {% if %} regressions would otherwise have landed silently. Six converters, not the one the issue names.FromPyObject for Value (the template-context path), python_to_value (the actor path — it carried the "both must agree (#1646)" comment while still extracting f64), python_to_json_value, python_to_json, and model_serializer's, whose two exported model serializers disagreed with each other. All now share one is_decimal() that tests isinstance rather than a type name, so a Decimal subclass is claimed and an unrelated class merely namedDecimal is not; the type resolves once per interpreter, since re-importing per call cost 18-24% on context conversion. Binary encodings carry a tag.Value is #[serde(untagged)], so a Decimal encoded as a bare string and came back a String — and SerializableViewState.state round-trips through msgpack on every read of the default InMemoryStateBackend, so one cache hit undid the fix and reproduced both regressions it exists to prevent. JSON still emits the bare string; only binary formats are tagged. {{ p }} expands exponent form. Django renders through "{:f}".format(...), not str(), and Decimal('1')/Decimal('1000000000') is 1E-9 — so str() verbatim gave 1E-9 where Django gives 0.000000001, a regression against the previous release. repr keeps the exponent form, as Python does. Breaking: a Decimal now arrives at the browser as a JSON string rather than a number, matching DjangoJSONEncoder; client code doing arithmetic on it needs Number(). A JSON number cannot carry the precision, so no fix keeps both. The Python-side normalize_django_value/__default__ pair deliberately stays on float and is documented at both branches: it has a tested parity invariant and a template consumer of its own, and a first pass that changed one half split that invariant — caught by the parity suite. Its exposure is NOT small, and an earlier version of this entry said it was: mixins/jit.py calls it at seven sites, and a model with a @property in the template sends the whole object down that path. Deferred at its true size in #2239. Template cases are a differential against real Django rather than a hand-written table, and dictsort gained a second, unrelated improvement in passing: its numeric fallback also fixed MIXED int/float columns, which previously compared all-Equal and so did not sort at all (sort_dicts_by_key has no (Integer, Float) arm). Against Django, an all-permutations sweep of a mixed pool agreed 938/2184 before and 2184/2184 after — a strict improvement, deliberately left unguarded unlike values_equal's wildcard, where the same widening changed answers for the worse. Finally, the three f64-precision limits that remain ({% if p == 19.99 %}, two Decimals differing beyond f64, and — new here, Decimal-only — {% if p == 0 %} below f64::EPSILON) are pinned as stated limits rather than left to be discovered. New cases in python/tests/test_decimal_precision_2214.pycrates/djust_core/tests/test_decimal_value_2214.rs, and crates/djust_templates/tests/test_decimal_loop_cache_2214.rs; Also: binary-format Decimal values are escaped through the same helper as strings — the tag lets the variant hold an arbitrary string, so the "a Decimal is only digits" reasoning that justified skipping escaping is true of the values and false of the type; hash_value keys the loop-render fragment cache on the digits, without which a {% for %} over distinct prices served row 1's fragment for every row; and expand_decimal_exponent implements Django's >200-digit scientific fallback, without which Decimal('1E-10000000') expanded to a ten-megabyte string. The strict=True xfail in test_dead_special_case_converters_2214.py did its job as a tripwire and is dropped, its set-pin now empty.

  • A hot-reload broadcast consumed a wire version without sending anything — the #1882/#2215 flake, found at last (#2215).hotreload called _send_update(patches=patches, version=self._next_version_armed(html), ...). Python evaluates arguments before the call, and _send_update then suppressed the broadcast on its empty-patch guard (#763) and returned. The version was spent and recovery armed for a frame that never left the socket. An unrelated file re-renders to zero patches, which is the common case, so this fired on most hot-reload broadcasts in dev. Two consequences. The client's clientVdomVersion falls one behind, so the next real diff fails its version - 1 check and costs a request_html recovery round-trip — the class #1788 and #1817 exist to prevent, reintroduced by argument-evaluation order. And it is silent: nothing reaches the socket, so the only evidence is a version that jumped. That is why #2215 was sighted repeatedly and reproduced never, and why every hunt for a stray frame — including this PR's own first draft, which shipped "produces no frame at the socket" as a narrowing result — came back empty. The fix asks the suppression question before allocating, through one predicate _hotreload_broadcast_suppressed that both the call site and the guard consult, so the two cannot drift (#1646). #1882, #1883 and #2215 were one bug: test_gate_off_without_reset_reproduces_1882_drift, written months ago to reproduce the drift, reproduced it through this defect — it required jump == 4 and now reads 3. It is flipped rather than deleted, as the end-to-end proof that the root cause is cured rather than contained; the channel-layer reset fixture stays justified for strays whose re-render produces non-empty patches, a case this harness cannot construct and which is therefore stated rather than pinned. Gate-off (#1468) reverts both new assertions to red. The #763 suppression moves entirely to the call site rather than staying as a backstop: the two placements fail differently, and only the call-site one can fail loudly if a future caller repeats the mistake (#2233). Cases, named rather than counted per-file (#1106): new test_a_suppressed_hotreload_broadcast_consumes_no_wire_version (two-arm — the same mount/event sequence with and without a broadcast) and test_an_idle_connection_receives_no_unsolicited_frames (which also checks the mount->event window, matching the event's echoed ref rather than the frame type — a type allowlist admitted a sending hot-reload broadcast, since one rides type: "patch", and the review falsified the docstring that claimed otherwise, #1867 — the same ref filter was then needed in test_global_isolation_1883's harness, where mistaking the stray for the arming patch had silently disarmed the gate-off below, #1859); and the flipped test_a_stale_layer_stray_no_longer_drifts_the_version_2215. test_time_travel_jump_recovery_version_is_current's two scaffolding assertions are relaxed from == v + 1 to >: they were incidental to what that test guards — gating off the #1817 fix shows the final v_recovery == v_jump catches the regression on its own — so a stray bump failed it for a reason it was not about. The invariant itself stays exact.

  • Django's active language and timezone now reset between tests, and a live TEMPLATES leak is closed (#2234). An audit of reset fixtures for over-broad resets, following the instance fixed in #2233. It found a leak nobody knew about: the template-inheritance test in tests/unit enabled an override_settings(TEMPLATES=...) context and never disabled it, so the template loader kept pointing at a pytest tmp directory belonging to a finished test for the rest of the worker — live but wrong within the session, and gone entirely across sessions. The settings fixture in that test's signature does not undo it — it restores only the settings it was itself asked to change, and this override went through a separate context manager it never saw. Verified with a probe test running immediately after, which read a DIRS entry pointing at a path that no longer existed. The systemic half: reset_djust_globals covered djust's own process-globals, while Django keeps two of its own in thread-locals (translation._active, timezone._active) that nothing reset — so a test calling activate() changed how every later test in that worker rendered. Both are now normalised with deactivate(), which restores the settings default. Not deactivate_all(): that reads like the thorough reset and is the one that leaks, leaving get_language() as None so get_format falls back to global_settings where NUMBER_GROUPING is 0 — the exact shape that shipped in #2222 and poisoned a test two PRs later. Three structural guards (no deactivate_all() in tests; every .enable() matched by a disable; a file that activates must reset), plus one that pins the premise — that deactivate_all() really does zero NUMBER_GROUPING — so if a future Django makes it harmless the ban gets reconsidered rather than persisting as folklore. Stage 11 review added four more: the guard now catches both aliasing shapes (from … import deactivate_all was the one most likely to reintroduce #2222) and spares docstrings deliberately rather than by luck; the two resets get one try each so a failure in the first cannot skip the second; the documented "does not touch state a test configures via its own fixtures" constraint now states the exception this creates (#1867); and python/tests/ had no conftest at all, so the autouse reset the other two roots have had since #1883 never ran for its 133 files — the guards scanned a root nothing protected. 9 regression cases in tests/test_reset_fixture_hygiene_2234.py; 9/9 gate-off; 3 consecutive clean full-suite runs per the pollution-class gate.

  • djust.simple_live_view could not render at all (#2219).get_context_data walked dir(self) and getattr-ed every name, which reaches Django's View.as_view — a classonlymethod whose __get__ raises on an instance. So every render failed before a template was reached, for any subclass, always; and render_template's except Exception turned the crash into a generic An error occurred rendering this view. The class is also renamed SimpleLiveView: it was called LiveView, the same name as djust.LiveView which it is not, which is why grepping for SimpleLiveView found nothing and the module read as unused when it was merely unfindable — it went two PRs (#2209, #2223) without anyone noticing it was a live render path. LiveView stays as a module-level alias. The fix is the AttributeError guard, not a name exclusion: a name in dir(self) is never a promise that getattr will succeed, and this is a method whose entire job is reading attributes it does not know about. as_view is deliberately not in the plumbing-exclusion set, because listing it in both places made the two mechanisms shadow each other — re-introducing the original bug left the whole suite green, since the guard silently covered for it (#2129). A view with no template now says so instead of claiming the Rust backend is unavailable, which was false and unactionable. Also fixes a cross-test leak this release introduced: test_number_localization_2221.py's reset fixture called translation.deactivate_all(), which leaves get_language() as None so get_format skips the locale modules and falls back to global_settings, where NUMBER_GROUPING is 0 — silently disabling grouping for every later test in the same worker. deactivate() restores settings.LANGUAGE_CODE, which is what resetting the language should mean. 14 regression cases in python/djust/tests/test_simple_live_view_2219.py; 7/7 gate-off verified.

  • 168 of RETRO.md's 197 unchecked Open Items referenced issues that had all closed (#2200). Anything reading those boxes to judge outstanding work — a person scanning for what is left, or /pipeline-retro synthesising a milestone — got a number wrong by roughly an order of magnitude. Including, at RETRO.md:533, the row asking for exactly this automation: the row about closing rows was itself a stale row. Fixed by extending scripts/check-action-tracker.py rather than adding a sibling — it already owned the batched issue fetch, the --fix flag, the make check-tracker target and the test file, and RETRO.md's two structures (the tracker table's Status column and the per-milestone - [ ] checklists) drift the same way for the same reason. Two tools would have meant two fetches and two ideas of what "closed" means. A collision nearly wrote a wrong citation into the document: RETRO.md carries two numbering schemes on one line (Action Tracker #329 (GitHub #2142)) and tracker numbers collide with real issue numbers — 38 of the 94 unchecked items naming a GitHub issue also carry a tracker number resolving to a different, real issue. A first pass took the first #NNN and reported #2142 as closed by PR #362, which is issue #329's closer: plausible, verifiable-looking and wrong (#1197). A GitHub #NNNN reference is now authoritative for the decision as well as the citation, since ANDing over a colliding number can mask an open issue as easily as invent a closed one. Every one of the 117 PR citations written was verified against GitHub's closedByPullRequestsReferences before the change was committed. Where an item cites several closed issues, all distinct closers are listed rather than an arbitrary one — a gate-off mutation swapping first for last survived the suite precisely because that choice is arbitrary, so the choice was removed rather than pinned. Items with no issue reference (28 of them), items citing a still-open issue (5), and items a human has already annotated **resolved/**deferred are all left alone — the last because RETRO.md:682 is correct, precise, and would otherwise be a permanent false alarm. 15 new cases, taking tests/test_action_tracker_drift_2143.py to 34 regression cases; 12/12 gate-off verified, including one that forces a boundary mis-parse to prove the post-condition guard still fires.

  • Nine date format codes rendered as their own letter, and a tenth was wrong (#2217).b c f L o r S t u w W z were unimplemented and fell through the formatter's catch-all, so {{ v|date:"jS F Y" }} produced 22S August 2026. Quiet for a structural reason: rendering an unknown character as itself is also the correct behaviour for a literal, and Django does the same — so an unimplemented code is indistinguishable from an intentional one by inspection, and only a differential against Django separates them. All 38 codes Django recognises now match its own output, pinned as one table rather than as the nine that were missing, because a table with a hole in it looks exactly like a table without one. The tenth was already implemented and already wrong: N is Associated Press style (django.utils.dates.MONTHS_AP), not %b plus a period — AP does not abbreviate the short months at all (March, April, May, June, July are spelled out) and September is Sept., so half the year rendered incorrectly. It survived because the parity table's three sample values are January, August and February, all months where %b + . happens to be right; a randomized sweep found it in seconds. That lesson repeated within this change — gate-off mutations replacing W with a day-of-year division and o with the calendar year both survived the 38-code table, because ISO week arithmetic only diverges at year boundaries and none of the three values sits near one. Both now have discriminating cases (2027-01-01 is ISO week 53 of 2026), and all twelve months are pinned for b/M/F/N. Verified by a 3,000-case randomized differential across fifteen years, every code and four microsecond values: 0 diffs. 8 regression cases in crates/djust_templates/tests/test_all_date_format_codes_2217.rs; 12/12 gate-off verified.

  • timesince/timeuntil output diverged from Django on every input (#2228).#2227 fixed the parse; the output was wrong even for the aware values that always parsed. Three defects at once: the count/unit separator is Django's avoid_wrappingU+00A0 (so the pair never breaks across a line) where djust used an ordinary space; Django shows up to two adjacent units (3 days, 5 hours) where djust showed one; and Django's smallest unit is the minute — it ignores seconds entirely, so a fresh value reads 0 minutes where djust read 30 seconds. Now ported from django/utils/timesince.py rather than approximated, including the calendar-aware year and month arithmetic that an approximation cannot reach: Django's own docstring notes there is exactly "1 year, 1 month" between 2013-02-10 and 2014-03-10 and between 2007-08-10 and 2008-09-10, though the deltas are 393 and 397 days — dividing by a fixed 2629746 seconds gets both wrong. Django's MONTHS_DAYS quirk is reproduced deliberately (February is 28 with no leap-year case, so a pivot clamps to the 28th even in a leap year): parity is the point, and correcting it here would make djust disagree with Django on exactly those dates. The "adjacent" rule is load-bearing and easy to miss — the walk stops at the first zero, so a value exactly one year and five days old is 1 year, never 1 year, 5 days, which Django's docstring calls out as impossible output. The two filters previously carried near-identical 30-line formatting blocks, so every change had to be made twice (#1646); they now share one function, with timeuntil swapping its arguments the way Django's reversed=True does. Verified by a 1,600-case randomized differential against django.utils.timesince across aware and naive values and durations from seconds to a decade: 0 diffs. 14 regression cases in crates/djust_templates/tests/test_timesince_shape_2228.rs, pinned against the pure two-argument function rather than through the filters, so every expectation is an exact string rather than a bucket that could flake near a boundary (#1795); 8/8 gate-off verified plus two extra mutations after the first February one turned out to be semantically a no-op for the tested inputs.

  • {{ v|timesince }} on a naive datetime printed the raw timestamp into the page (#2227).timesince and timeuntil called DateTime::parse_from_rfc3339 and nothing else, so a naive datetime — the normal shape under USE_TZ = False — did not parse and the filter returned its input verbatim: 2026-08-25T12:16:36.074891 where Django renders 2 hours. A DateField was equally affected. Third instance of one class in three releases: date/time learned datetimes in #2203 and bare times in #2216, each time by extending the parse list of the filter in front of us while the neighbouring filters with their own parse went unchecked. Cured by one shared parse_serialized_datetime rather than a third correct copy (#1646), with a flag for the one place the callers genuinely differ — a bare time is formattable but has no instant, so timesince against its epoch anchor would have confidently reported the decades since 1970. Reproducing it first (as the issue itself asked) also surfaced a second defect the fix would otherwise have shipped: a naive value was being compared against Utc::now(), while Django compares it against datetime.now()naive local time — so a datetime two hours old reported six hours in a UTC-4 zone. Plausible enough to survive review, and visible only against Django's own answer. Both are gate-off verified, including a mutation that flips the comparison baseline and one that lets the duration filters accept a bare time. The output shape is a separate defect, filed as #2228 and not fixed here: Django joins with U+00A0, shows two adjacent units (3\u{a0}days, 5\u{a0}hours), and never reports seconds, all of which diverge even on the values that always parsed. 12 regression cases in crates/djust_templates/tests/test_duration_filters_2227.rs and filters.rs::parse_shape_tests_2227.

  • {{ v|time:"H:i" }} on a TimeField echoed its input instead of formatting it (#2216). No parse branch matched a time-only string, so date/time returned the serialized value verbatim — 23:30:00 where Django renders 23:30. Exactly the class #2203 fixed for datetimes, still live for a different type: the format list carried four datetime shapes and one date-only shape and no time-only shape at all. It hid well, because for H:i:s the echoed input equals the correct output — the most obvious test one would write passes against the broken code, which is why that case is kept in the suite labelled as a reminder rather than as coverage. Django's rules were enumerated by running all 38 format characters against a datetime.time through its own engine, because they do not follow from the docs and split three ways that look alike: a A c f g G h H i P s u format normally; the timezone codes e T O Z render empty in place and leave the rest of the format intact; and any date code empties the entire render — {{ v|date:"H:i Y" }} is '', not '23:30 '. Conflating the last two is the easy mistake. The timezone rule also differs from the naive-datetime rule one line away, where the default zone is reported (#2209), so suppressing both would have silently undone that fix for every naive datetime — pinned in both directions, and both gate-off verified. An escaped \Y stays a literal and does not empty the render. Also fixes lowercase a, which emitted am/pm where Django emits a.m./p.m. (only uppercase A is bare) — outside this issue's scope strictly, found by the same differential on datetimes as well as times, and two lines in the match arm being edited. The bare-date-object half of #2216 is deliberately unchanged: djust renders midnight where Django raises TypeError, and Rust cannot tell a date from a midnight datetime because the serializer discards the type — that wants a documented decision, not a patch. 12 regression cases in crates/djust_templates/tests/test_time_only_filters_2216.rs and 4 in python/djust/tests/test_time_only_render_2216.py, the latter existing to prove the serializer emits the shape the new parse branch accepts — a filter-level test takes a string and would stay green if it did not.

  • The Django-template backend rendered UTC timestamps and unseparated numbers (#2223).#2209 and #2221 each wired their setting into Rust from the two Python render paths a structural test pinned — and that set was wrong. A plain Django template rendered through DjustTemplateBackend goes through template/rendering.py, which pushed nothing: on a fresh worker thread it rendered 1234567|23:30 where Django renders 1,234,567|19:30. The same page could render a number correctly inside a LiveView and incorrectly in a template beside it. The gap survived two PRs because the thread-local persists — a worker that has already served a LiveView render carries a correct environment into any later backend render, so the bug only shows on a thread that has not: the first request a worker handles, or a process whose traffic is all plain templates. Wired at the top-level entry rather than inside _rust.render_template*, and the difference was measured rather than assumed: the push costs ~12µs against ~15µs for a small render, so pushing on every call — including the many nested component renders that already inherit a correct thread-local from their enclosing render — would be ~78% overhead for no gain. The pinned caller set grows to three and now records which nested paths are deliberately excluded. Two claims in the first draft of the tests were corrected after gate-off contradicted them: the fresh-thread fixture is not what lets these tests see the bug (override_settings moves the timezone and language away from any stale value anyway), and one of the three cases cannot detect this defect at all — with USE_TZ off and no thousand separator, doing nothing is the right answer — so it is labelled as guarding the opposite defect instead of being counted as coverage. 3 regression cases in python/djust/tests/test_backend_render_env_2223.py.

  • Guarded the dead-special-case class that produced both #2212 and #2214.serialize_python_value has a branch intending to stringify Decimal and UUID; it is dead for Decimal, because extract::<f64>() above it honours Decimal.__float__. So a DecimalField reaches the client as a binary float, and Decimal('12345678901234567890.123456789') arrives as 1.2345678901234567e+19. Same shape as #2212a permissive extraction placed above a narrower special case — and invisible to every tool the repo runs: not a compile error (the arms have different types, so not unreachable_patterns) and not a clippy lint, both verified against mutated builds. #2214 is deliberately not fixed here, because the one-line move the issue suggests was measured to regress two template behaviours: this value goes back into the template context, not only onto the wire, so {{ p|floatformat }} renders 19.99 instead of 20.0 and {% if p > 10 %} takes the false branch once the value is a string. It needs a decision — a Decimal-aware Value variant, or an accepted precision limit — not a patch. What ships instead is the half the issue itself called the more valuable one: a structural sweep for the general rule, replacing #2212's i64/bool-specific one. Its capture table is measured, not declared — for each type the Rust source special-cases, an instance is built and the coercion PyO3 actually performs is run, so the guard re-derives its own premise every time (#1459). That caught an error in the guard's first draft: PyO3's i64 goes through __index__, not int(), and int(Decimal(...))/int(UUID(...)) both succeed while operator.index rejects both — modelling it as int() reported two reachable branches as dead. Three canaries prove the guard is load-bearing: fixing the bug makes it XPASS, injecting a second instance turns the set-pin red (which an xfail alone would have hidden, since one failure satisfies it), and a non-float-convertible special case is not reported. Also corrects three prose claims that were false about the code they sat on (#1867): Rust's doc-comment said Decimal converts to a string, and both Python converters said float "matches DjangoJSONEncoder.default" when that encoder returns str(o) with full precision. 5 regression cases in python/tests/test_dead_special_case_converters_2214.py.

  • Rendered numbers ignored the active locale — including in English (#2221). Django localizes a number on its way into the page; the Rust engine used Rust's defaults. The ROADMAP row that became this issue framed it as floatformat under LANGUAGE_CODE="de"; probing it against Django's own engine widened it twice. It is not a non-English problemUSE_THOUSAND_SEPARATOR applies regardless of language, so Django renders 1,234,567 where djust rendered 1234567in the default configuration. And it is not confined to floatformat — bare {{ n }} is affected, which is every rendered number in every template. Fixed for both, matching Django across en-us / de / fr including French's U+00A0 thousands separator. The fix shape is the inverse of #2209's, and that was the load-bearing decision: the timezone fix put a self-contained database in Rust and passed only a zone name, but locale formatting is defined by django/conf/locale/*/formats.py, so deriving it in Rust would fork Django's data rather than use it — Python resolves three values per render and Rust only applies them, reusing the per-render push #2209 built (now djust.render_env.apply_render_env, renamed since it carries two settings). Django's interval walk is ported faithfully rather than assumed to be groups of three: Indian grouping ([3, 2, 0]) yields 12,34,567, and a 0 entry keeps the previous width instead of ending grouping. USE_L10N is deliberately not read — verified inert across the full USE_L10N × USE_THOUSAND_SEPARATOR × language matrix, since Django 5.0 removed it as a toggle. USE_THOUSAND_SEPARATOR=False still localizes the decimal point, and floatformat:"2u" remains Django's documented opt-out. The localization is applied at the variable-output site, not in impl Display for Value where the number rendering lives: Display is also the lookup key for {% if x in dict %} (#2203), so a separator there would silently break every such lookup — pinned by a test rather than left as a comment. 11 regression cases in python/djust/tests/test_number_localization_2221.py and 12 in crates/djust_core/tests/test_number_localization_2221.rs; 8/8 gate-off verified. Month and day names ({{ d|date:"D" }}Sa in German) and per-locale DATE_FORMAT remain unfixed and are tracked as pieces 2 and 3 of #2221.

  • Synthesized live_redirect requests ignored SESSION_ENGINE (#2210). Four places built a request for the live_redirect / url_change paths, and each imported django.contrib.sessions.backends.db.SessionStoredirectlysettings.SESSION_ENGINE appeared nowhere in the package. A project on a cache-backed engine got a store reading a django_session row that does not exist. The issue was filed from a grep and said so; reproduced at runtime first, which showed two failure shapes rather than the one reported: with the sessions migration run, the store finds no row and hands the view an empty session silently; without it — which a cache-only project has no reason to run — the store raises OperationalError: no such table: django_session, and because Django's stores load lazily that lands wherever the view first reads the session, far from the code that built it. Both are now pinned, the second by forbidding database access outright rather than by dropping a table, which also covers the round trip a cache-backed project configured its way out of. All four sites — runtime.py, websocket.py and both in testing.py, which the issue did not list — now resolve through one djust.utils.build_session_for_request, the way SessionMiddleware itself does; LiveViewTestClient was included deliberately, since a test client on a different session engine than the production paths it stands in for is its own quiet trap. A structural test pins that no module imports the DB store directly, so a fifth copy cannot appear. signed_cookies diverges from the issue's suggestion, after probing what the engine actually does: it proposed a logged refusal, but the "session key" for that engine is the signed payload, so reads work — strictly better than the empty session it got before — and only writes cannot persist, because saving mints a new key that only a Set-Cookie can deliver and a WebSocket has no response to put one on. Refusing would have discarded the working half to prevent the broken one, so it warns once per process and keeps the reads. Every case asserts a value written through the configured engine rather than that a session merely exists — the hardcoded store also produced a session object, so the weaker assertion passes either way. 6 regression cases in python/djust/tests/test_session_engine_2210.py; 4/4 gate-off verified.

  • Every rendered timestamp was off by the UTC offset (#2209). Django applies timezone.localtime() to an aware datetime before formatting it; the Rust engine did no timezone conversion at any layer, so it formatted whatever offset the serializer handed it — UTC, under USE_TZ=True. A New York project rendered 2026-08-22 23:30 where Django renders 19:30. Four hours out, in the configuration djust new generates, since the scaffold sets USE_TZ = True. Confirmed through the real LiveView.render() path, not just the filter. Django's rules were taken from a live 5.2 render rather than the docs, and the naive row is the one that is easy to get wrong: an aware value is converted, a naive one is not (it is already understood to be local, and shifting it would move every timestamp in a USE_TZ = False project) — but a naive value still reports the default zone's abbreviation and offset. chrono-tz is now a dependency, and a fixed per-render offset was rejected rather than not considered: America/New_York is -0500 in January and -0400 in August, so any table of timestamps spanning six months needs both, and a single offset would be right for one row and wrong for the next. Measured before adopting — +1.16 MB raw on the extension, +136 KB compressed, the compressed figure being what a wheel actually ships. The zone is a thread-local set per render, not a process global set at startup: timezone.activate() is per-request (the documented way to give each user their own zone) and the RustLiveView is session-cached, so a zone captured at ready() or per view instance would be stale for exactly the case users care about; and djust renders run in sync_to_async worker threads, where two connections can hold different zones concurrently. This mirrors Django, whose own timezone._active is a Local(). It also fixes the five timezone format codes, which had no zone to report and so fell through to the catch-all and rendered as their own letter — {{ v|date:"H:i T" }} produced 19:30 T; T, e, O, Z and I now match Django, as does U. A TIME_ZONE the bundled database does not know is logged once and left unconverted rather than raised. The handoff itself lives in a new djust.timezone_bridge rather than on the mixin, because there are two Python render paths that share no base class — RustBridgeMixin and simple_live_view — and a method would have fixed the first while silently leaving the second in UTC; a structural test pins the caller set so a third path cannot appear unwired, and a private copy in either turns it red. 12 regression cases in python/djust/tests/test_timezone_render_2209.py and 17 in crates/djust_templates/tests/test_timezone_parity_2209.rs; every expectation pinned against a live Django 5.2 render of the same value, and all nine mechanisms gate-off verified. Surfaced #2216 (bare date/time objects still diverge), #2217 (nine non-timezone format codes remain unimplemented) and #2219 (simple_live_view cannot render at all — get_context_data raises on every instance).

  • A bool in LiveView state reached the client as 1 (#2212). PyO3 0.29 extracts a Python True as i641 — its own test_i64_bool asserts this — so any converter trying i64 before bool has a dead bool arm. serialize_python_value did, and serialize_context({"flag": True}) returned 1, False returned 0, nested and in-list alike. serialize_context is a public #[pyfunction] feeding JIT state serialization, so this was user-visible: client code doing x === true saw false, and after #2203 a Value::Integer(1) renders 1 where a Value::Bool(true) renders True. The issue undercounted the surface — it claimed three converters extract both types; a sweep of every fn under crates/ found six. Review then found the sweep's own parser had the same weakness one level up: it did not match pub(crate) fn / const fn / unsafe fn (three such functions already exist in the tree), ended a body at the next signature match rather than at a balanced brace — so a legal pub(crate) rename silently absorbed one converter into its predecessor while the count still read six — and did not strip comments, so a correct converter whose comment merely mentionedextract::<i64>() was reported as broken. All three are fixed, and the self-check now pins the exact expected set rather than a floor (#1125), because a floor tolerates exactly the silent disappearance that was demonstrated. The other three were already correct, but enumerating by hand had missed half of them, which is why the guard added here is structural rather than a fourth hand-written case: a dead if let arm is not a compile error, clippy does not flag it (the arms have different types), and four of the six converters have no behavioural test at all — so the guard is not a strict subset of one, and the #2167 objection to source-grep pins does not apply. It also guards itself, asserting the sweep still finds at least five converters so a parser that stops matching goes red rather than silently protecting nothing. 3 behavioural cases in TestBoolRoundTrip2212 plus 9 structural in the new python/tests/test_bool_before_int_converters_2212.py; gate-off reverting the arm order reddens both, and the two parser canaries above now behave correctly (a pub(crate) rename keeps the converter under its own name; a comment mentioning the wrong order no longer fails correct code). Review also found a second instance of the same class in the same functionextract::<f64>() sits above the Decimal/UUID stringify branch, so every DecimalField reaches the client as a lossy binary float; filed as #2214 (#1079), along with the suggestion to widen this guard from the i64/bool pair to the general rule it is an instance of.

  • date and time silently ignored their format string for any datetime — plus Django's truncation ellipsis and add's real semantics (#2203). Three parity gaps that persist with a literal argument, so unrelated to #2202. The headline is not what the issue was filed about.format_date accepted RFC3339 or a bare %Y-%m-%d, and format_time delegates straight to it — but a Python datetime arrives T-separated — djust serializes with .isoformat() (python/djust/serialization.py:311), not str() — and with microseconds, matching neither, so the parse failed and the filter returned its input verbatim. Review caught the first pass having this backwards: it accepted two space-separated shapes that never occur on this path while carrying no fractional-seconds directive at all, so datetime.now().isoformat() — every auto_now_add timestamp — was still broken. {{ post.created_at|date:"Y-m-d" }}, the commonest use of this filter, rendered a raw datetime string. It survived because a DateField stringifies to 2026-08-22 and takes the date-only branch, so the failure is invisible unless the value is a datetime. Fixed at the one shared parse chokepoint rather than in two filters (#1646), accepting both separators with and without seconds; the fail-soft contract (unparseable input returns unchanged) is preserved and pinned. truncatewords/truncatechars used ... where Django uses (U+2026) — not cosmetic for truncatechars, because Django reserves one character for the ellipsis inside the limit, so truncatechars:5 is abcd… where reserving three gave ab.... add implemented only a partial first branch of Django's three (int(value) + int(arg), else value + arg, else ""): it parsed the argument as i64 and defaulted to 0 on failure, so {{ n|add:1.5 }} silently added nothing, and with no concatenation branch {{ "a"|add:"b" }} returned "a". Branch order is load-bearing — int first, so {{ "4"|add:"3" }} is 7, not "43" — and so is quoting: int("1.5")raises in Python, so Django concatenates ({{ "1.5"|add:"1.5" }} is "1.51.5"), while an unquoted 1.5 is a float literal that truncates. A first pass coerced both and returned 2 — a fabricated number where Django produces text, worse than the inert wrong value it replaced; arg_was_quoted now separates them. Value::Bool coerces too (int(True) is 1). {{ 1.5|add:2 }} now renders 3, not 3.5 — Django's answer, but a real change to existing output. Two divergences from Django are deliberate and documented: its third branch returns "" where djust returns the value unchanged (turning a rendered value into silent emptiness on upgrade is the exact silent-wrong-output class this engine keeps fixing), and overflow returns the value unchanged rather than wrapping — Python's ints are arbitrary-precision so Django cannot overflow, but i64 can, and plain +panics in a debug build while silently wrapping in release ({{ max|add:1 }} returned a negative number). Self-review caught two defects in the first pass, both by disconfirming its own comments rather than by a failing test: the no-seconds fallback was justified by a false claim (Python always emits seconds — str(datetime(...,14,30)) is "2026-08-22 14:30:00") and covered a shape that never occurs while missing the one that does, an HTML <input type="datetime-local"> submitting YYYY-MM-DDTHH:MM; and widening add's coercion to floats and numeric strings is what made overflow reachable. Out of scope, tracked separately: divisibleby (true/True), slice ([List]) and a Null argument (""/None) are not filter bugs — they are impl Display for Value, which governs every {{ var }}; 5 of the 7 Value variants diverge, including dict ([Object]), which #2203 does not even mention. It also carries a concrete back-compat hazard: var flag = {{ v }}; renders valid JS today and would be a ReferenceError under Django's True. That is a design decision, not a drive-by fix (#1079). 16 cases in the new crates/djust_templates/tests/test_filter_django_parity_2203.rs, written failing first (8 red / 5 green) and gate-off verified per mechanism — removing the datetime parse, the ellipsis, add's int coercion, or checked_add each reddens a distinct set, the last by reproducing the original overflow panic. Review added four more fixes: truncatechars:0 returned where Django returns ""; the truncatechars_html/truncatewords_html twins still emitted ..., so the same filter disagreed with itself on one page (#1646) — fixed via .chars().count(), because "…".len() is 3 bytes exactly like "..." and swapping the constant alone silently preserves the three-character reservation; Value::Bool was missing from add's coercion; and the timezone test used +00:00, which the naive branch's .and_utc() also produces, so it passed whether the offset was honoured or discarded — decorative per #1859, now +05:00. Review also found add's float coercion was gated by no test at all: neutering it left the suite green while silently changing {{ 1.5|add:2 }}. Four legacy ... assertions (2 Rust, 2 Python) were updated to values taken from Django itself, not from this implementation.

  • Built-in template filters ignored a bare-identifier argument, rendering the identifier's own text instead of the value it names (#2202). Django resolves a filter argument as a variable unless it is quoted — {{ x|default:fallback }} looks up fallback, and only {{ x|default:"fallback" }} is the literal. djust's built-in filters used the raw argument text and never consulted the context. The failure is silent: the template renders, nothing raises, and the output looks plausible. Found on djust.org, where {{ post.featured_image_alt|default:post.title }} had been shipping alt="post.title" on every post with an empty alt — an accessibility defect that had been live for months without anyone noticing, because a broken alt attribute is invisible unless you read the HTML. The fix applies to all 26 arg-taking built-ins, since it lands once ahead of the dispatch table; ten are verified fixed with tests: default, default_if_none, add (integer-valued arguments only — see below), join, cut, yesno, floatformat, pluralize, stringformat, date. join and cut are the sharpest — they did not merely ignore the argument, they spliced the identifier text into the output ({{ a|join:sep }} rendered pvq, using the variable's name as the separator) or silently no-opped. Custom filters were already correct (filter_registry.rs resolves bare identifiers via Context::resolve and has done since #1121), so this is #1646 parallel-path drift on the filter-argument axis: two implementations of "resolve a filter argument", one right and one wrong. The fix routes built-ins through the same resolution rather than adding a second correct copy — resolved once in apply_filter_full_safe ahead of the dispatch table, not in each of the ten arms, which would have been ten more places for the next filter to drift from. apply_builtin_filter already received context and simply never consulted it for the argument. Three behaviours are deliberately preserved. A quoted argument is never looked up (gated on the existing arg_was_quoted hint the renderer already computes), so a literal cannot become a lookup when a context key happens to share its name. The classic apply_filter_with_context call site passes arg_was_quoted=true and is untouched. And an unresolvable identifier still falls back to its raw text — a deliberate divergence from Django, which raises VariableDoesNotExist — because {{ n|pluralize:es }} works today only by that accident, and raising would convert a silent wrong-output bug into a site-wide 500 on upgrade. The two resolution outcomes are treated differently, which the first pass got wrong: a lookup miss is Ok(None) and falls back as described, but an Err — raised only by a method auto-called during resolution (ADR-024) — now propagates. The first pass used .ok() and swallowed it, which would have left {{ x|default:obj.raising_method }} rendering the literal text obj.raising_method into the page: the exact silent-wrong-output failure this fix exists to remove, reintroduced on the error branch. Django propagates it, and so do the custom-filter path (filter_registry.rs) and the main variable path (renderer.rs) — converging the resolver but not its error policy would have been #1646 drift, twenty lines from the code it converges with. Scope was corrected twice during review, both times upward in rigour: date was initially excluded because the probe fed it a string, which Django cannot format either, so the divergence looked pre-existing rather than fixable — with a real datetime it is a tenth affected filter; and the raw-text fallback was initially described as matching Django when it does the opposite. Out of scope, filed as #2203 (#1079): divisibleby, slice, truncatewords, truncatechars, time, add with a non-integer argument, and Value::Null as an argument all diverge from Django even with a literal argument, so they are separate pre-existing bugs and folding them in would blur what this fix is verified to do — and three are really one Value-rendering issue (Display for Value emits true, [List], "") that would be the same parallel-path mistake if patched inside the filters. time is the instructive one: it is date's structural twin, and review proposed adding it here on that basis — the literal-argument control showed it fails with a literal too, so including it would have made this entry claim a fix it does not deliver. That control is what corrected the scope in both directions. 17 cases written failing first (10 red / 3 green) and gate-off verified — 13 in the new crates/djust_templates/tests/test_builtin_filter_arg_resolution_2202.rs, 2 for date in TestBuiltinFilterArgResolution2202 (Python-side, because Value has no date variant and an ISO string passes through unformatted, so a Rust-level case would assert nothing), and 2 for the error policy in TestFilterArgErrorPolicy2202 (needs a real auto-called method that raises). Each mechanism is independently reachable: dropping the quoted gate reddens only the quoted-literal guard, dropping the raw-text fallback reddens only the miss guards, and reverting ? to .ok() reddens only the propagation test with DID NOT RAISE.

Security

  • A safe-key grant no longer outlives the value it was granted for (#2300).RustLiveView accumulated safe keys and nothing ever revoked them, so a key marked safe once stayed safe for the lifetime of the view — which spans every event on a WebSocket connection. A view that rendered trusted markup into p and later rendered an attacker-controlled p emitted it live. This one needs no filter chain and no |safe anywhere in the template — a bare {{ p }} is the whole reproducer, which makes it the broadest of the escaping bugs fixed in this release. update_state now revokes a key's grant when it replaces that key's value, so a grant lives exactly as long as the value it was granted for, regardless of who drives the API. Scoped per key rather than wholesale, because update_state is a partial merge: updating p drops p and its p.0 descendants and leaves an untouched q alone. The first attempt was caller discipline — mark_safe_keys replacing rather than extending, plus an unconditional call from the bridge — and #2287's forward pin stayed red against it, because that pin drives the Rust API directly and never makes the second call. Gate-off then showed the replace half had become redundant once revocation existed, and mildly wrong besides (a render updating only p would drop a still-valid grant on an untouched q), so both halves were dropped for the single structural rule. New cases in python/tests/test_stale_safe_grant_2300.py; all three facets gate-off verified with unique-anchor assertions (2 / 2 / 1 failures).

  • linenumbers now escapes inside the filter, so a trailing |safe cannot expose its input.{{ p|linenumbers|safe }} rendered 1. <img src=x onerror=alert(1)> live, where Django renders it escaped. djust's linenumbers never escaped anything itself and relied on the render-time auto-escape; |safe suppresses exactly that, and then nothing had escaped the input at all. renderer.rs documented the exclusion from SAFE_OUTPUT_FILTERS deliberately, on the argument that per-line and whole-output escaping are byte-identical "because everything it adds is escape-invariant" — true, and beside the point, since the argument holds only while the render-time escape actually runs. The escape now happens per line inside add_linenumbers (conditionally, as Django's is, so an already-safe input is not double-escaped) and the name joins SAFE_OUTPUT_FILTERS; the two halves are one change, and either alone is a bug in opposite directions. The surface is wider than the |safe shape: any downstream filter that reads the output as markup was a live cell, {{ p|linenumbers|truncatechars_html:"5" }} among them, with no |safe anywhere in the template. Second shipped XSS found by the registry-wide probe written for #2281 rather than by inspection — and the reason it survived is instructive: TestLinenumbersWasAlreadyCorrect (#2284) ran the prose invariant across three columns and found them in agreement, but never sampled the trailing-|safe column. A leading|safe was sampled, which is a different question entirely, and sampling it read as coverage of the safety axis. That class is renamed, narrowed to the claim it supports, and given the column it missed. New cases in TestTheAxesTheSafeShapeDoesNotCover and python/tests/test_linenumbers_escaping_2291.py, both mechanisms gate-off verified independently (11 and 6 failures).

  • {{ p|escape|safe }} emitted attacker markup — escape was a no-op that deferred to render-time auto-escaping (#2281). Django's escape_filter is conditional_escape(value): EAGER, returning a SafeString, so the next filter in the chain sees the ESCAPED text. djust's returned the value unchanged and let the render site escape it, which is indistinguishable for {{ p|escape }} alone and wrong for every chain — {{ p|escape|upper }} upper-cased the raw value where Django upper-cases &lt; to &LT;, and {{ p|escape|striptags }} stripped tags Django's escape had already turned into inert text. The security cell is {{ p|escape|safe }}: |safe suppressed the deferred escape that was, by then, the only escaping left, so an idiom that reads as "escape it, then it is safe to emit" — exactly what Django's semantics make true — was a bare |safe on attacker input. A probe over every {{ p|escape|X }} and every length-3 chain containing escape found 104 live-markup cells on main, every one an escapesafe pair; the same probe reports zero now. escape joins SAFE_OUTPUT_FILTERS, a grant it earns by escaping its own input, and stays distinct from force_escape: escape is conditional_escape (a SafeString passes through), force_escape is escape (a SafeString is escaped again), which {{ p|safe|escape }} vs {{ p|safe|force_escape }} pins.

  • unordered_list and safeseq no longer hand a non-sequence input back unescaped under a safe grant. Both sit in SAFE_OUTPUT_FILTERS — an unconditional "emit this without escaping" grant, earned because they escape every item they emit. On a non-sequence they emitted nothing and returned the input verbatim under that same grant, so {{ hostile_string|safeseq }} was an exact synonym for |safe with no mark_safe anywhere in the template, and {{ user_bio|unordered_list }} rendered attacker markup live. Fixed alongside #2274 rather than filed, because #2274 makes it worse: once an is_safe filter preserves the safety it is handed, an unearned grant survives arbitrarily far down the chain (|unordered_list|lower was escaped before and would not have been after). The list path — the shape the filters are actually for — is untouched and still agrees with Django byte for byte. Only the safety half is fixed here; the output shape for a string input is still wrong (Django iterates it as characters) and is filed as #2283, pinned by a landmark test so closing it turns red deliberately. New cases in TestUnearnedSafeGrant and in test_xss_prevention.rs, both directions and both gate-off verified.

  • SimpleLiveView render failures no longer put exception detail in the response (CodeQL py/stack-trace-exposure, alert #2596; CWE-209).render_template's except rendered f"<div>Template error: {e}</div>" whenever DEBUG was on. Two separate defects, and CodeQL named only the second. (1) CWE-79: the message was interpolated unescaped, and template errors routinely echo the offending value, so an exception carrying a < injected markup straight into the page. websocket.py:549 fixed exactly this shape with escape(str(e)) — and its comment says it "mirrors the DEBUG gate in simple_live_view", so the copy was fixed and the original it was modelled on was not (#1646). (2) CWE-209: the detail reached the response at all. Escaping fixes (1) and leaves (2) untouched, so copying the sibling's fix verbatim would have closed the real bug and left the alert open — pinned by a gate-off mutation that applies exactly that fix and still reddens. Now logger.exception plus a static string in both modes: no DEBUG branch, so there is no mode-dependent leak to reason about and nothing for a misconfigured production DEBUG=True to expose. Strictly better for the developer the branch was written for — a full traceback in the log beats a one-line str(e) in a div. 5 new cases in python/djust/tests/test_simple_live_view_2219.py (24 in the file), asserting on a message carrying a script tag, an attribute-breaking quote, an ampersand and an event handler so a partial escape fails too; 4/4 gate-off verified.

Documentation

  • A non-finite Decimal renders here where Django 5.2 500s the page — decided, and left that way (#2460).Template("{{ p }}").render(Context({"p": Decimal("Infinity")})) raises TypeError: bad operand type for abs(): 'str' on Django. No filter is involved: render_value_in_contextlocalizenumber_formatnumberformat.format, which reaches _, digits, exponent = number.as_tuple() and then if abs(exponent) + len(digits) > 200. Decimal("Infinity").as_tuple().exponent is the string'F' ('n' for NaN, 'N' for sNaN), so abs('F') raises. djust renders Infinity / -Infinity / NaN / sNaN. No behaviour changes here. What lands is the decision, its four measurements, and the argument recorded at the code.

    Django's behaviour here is a crash, not a considered refusal, and each of the four facts that say so is a test rather than a sentence (#1867). (1) It is not a policy about non-finite numbers: float("inf") renders inf on Django perfectly happily — the same mathematical value, refused only on the Decimal branch, because that is the only branch that calls as_tuple(). (2) The line that raises is the >200-digit scientific-notation cutoff, a performance guard whose own comment says "to avoid high memory usage" — and a special has one digit. (3) "{:f}".format(Decimal("Infinity")) is "Infinity", the else arm one line below the guard, and it is byte-identical to what djust emits: djust is not inventing a rendering, it is producing the one Django's own code computes and then fails to reach. (4) Django itself puts those characters on the page one filter over — floatformat, stringformat:"s", safe, escape, force_escape, title and linebreaks all render Infinity for the same value. The characters are not the objection.

    Against that, matching would turn a rendered page into a 500 for a value an ordinary DecimalField aggregate can hold — an outage bought with parity against a crash. Decided the way #2429 decided json_script, and more easily: json.dumps' refusal there is at least a documented contract, where abs('F') is documented nowhere. Reporting it upstream is the remaining half and is out of this repo's scope; the decision does not depend on the answer.

    Both counts in the issue are wrong, and the reason for one of them is the interesting part.#2460 says "12 of the 17 surviving single-filter {{ }} cells", from "6 filters × 2 Decimal specials". Measured on the 353,909-cell differential: the whole class is 57 cells (26 on dec-inf, 31 on dec-nan), reaching the with, cycle, firstof, firstof-as, @path and @ctag axes as well as the bare filter one; and the bare single-filter position is 11, not 12 — five on Infinity (cf_ident, default, default_if_none, join, slice) and six on NaN (those five plus get_digit). The asymmetry is not noise: int(Decimal("NaN")) raises ValueError, which get_digit's except ValueError catches, so the Decimal is handed back and the render raises abs(); int(Decimal("Infinity")) raises OverflowError, which that except does not catch, so get_digit raises first and the render is never reached. Both engines model the split identically since #2435, so it is a fact about Django's exception taxonomy rather than a djust artefact. A further 195 cells reach the same abs() raise in Django and are NOT this class, because djust refuses them too, for its own reason.

    Self-retiring, and controlled in both directions.TestTheDecisionCloses_Itself_IfDjangoFixesIt asserts that Django still refuses, so an upstream fix reddens the file and names what to revisit rather than leaving a stale divergence in the docs. The sweep over ten shapes is paired with two controls — the same shape over float("inf") and over a finite Decimal("1.5") must AGREE — so a shape that diverged for its own reason could not be counted as this one. And because the decision lives in the localising arm, TestNoLocaleCanCorruptTheSpelling pins that localize_plain's digits-and-a-point guard still rejects Infinity, since a grouping locale that treated it as digits would quietly turn the permissive answer into a different one.

    18 cases in python/tests/test_decimal_special_render_decision_2460.py (97 collected, parameterized over the four specials × ten shapes), and the argument is also carried at crates/djust_templates/src/renderer.rs's number arm — a decision recorded only in a test file is invisible to the next person editing the code (#1197), so the test asserts the code comment is still there. TestTheResidueThisDoesNotTouch::test_the_decimal_special_cells_are_the_bare_RENDER_not_the_filter keeps its assertions and gains the decision.

  • json_script stays PERMISSIVE where json.dumps refuses, in both the key and the value position — decided, not left open (#2429).#2425 closed the spelling half of the typed-key question and deliberately left the refusal half, because refusing an unserialisable KEY alone would make the two positions disagree. No behaviour changes here. What lands is the decision, the measurements behind it, and a corrected code comment.

    The divergent set, re-derived over 21 key-position and 26 value-position types against live Django — and it is wider and differently shaped than the issue's table. #2429 says {{ p|json_script:"d" }} over {"a": b"k"} emits {"a": "b'k'"}; it emits {"a": [107]}, a JSON array, because PyO3 extracts bytes as a sequence long before any str() fallback. An object carrying a populated __dict__ emits a nested JSON object ({"a": {"name": "n"}}), not a string — the issue samples only the __dict__-less shape. range(2) emits [0, 1]; a generator emits its repr and is consumed on the way. And the key/value asymmetry the issue notes for date is seven types widetuple / Decimal / date / datetime / time / timedelta / UUID are all refused by Django as KEYS and accepted by it as VALUES, because DjangoJSONEncoder.default never sees a key (CPython coerces keys before the encoder hook). Django is itself inconsistent between the positions, so "match Django" does not mean "treat the two alike".

    The convenient premise is false, and was measured rather than assumed. "djust never raises because of a context VALUE, only because of a template-source error" would have made this decision easy. Running it: {% for x in p %} over an int raises 'int' object is not iterable here exactly as it does in Django (#2382), and a __str__ that raises propagates on both engines. A data-driven raise is established djust behaviour, so this decision does not lean on its absence.

    What decides it is that the VALUE position cannot see the type at all. For every value Django refuses, djust's output is byte-identical to its output for an ordinary serialisable stand-in: {"a": Obj()} and {"a": "OBJ"} both render {"a": "OBJ"}; {"a": frozenset({1})} and {"a": "frozenset({1})"} both render the same string; {"a": b"k"} and {"a": [107]} both render the array. FromPyObject for Value converts an arbitrary object to its __dict__ (an Object) or its str() (a String) at the boundary — deliberately, because that is what makes {{ obj.name }} work — so by the time any filter runs, the Python type Django refuses on no longer exists, and a value-position refusal would have to refuse the stand-in too: an ordinary dict of ordinary strings. The key position IS decidable (ObjectKey keeps the type, #2339), which is exactly why refusing there alone is the disagreement #2425 declined. Recovering the type means a new Value variant threaded through the ~460 Value::String sites in crates/**/src and every filter, renderer and serializer that matches on Value — an architectural change to that boundary, bought so one filter can turn a rendering page into a 500 that only a djust-native template can reach, since a template that ran under Django's engine never carried these values. The output is escaped in both positions (json_string_body, pinned since #2241), so this is a correctness divergence and not an injection.

    A prose invariant that running it falsified (CLAUDE.md #1867): filters.rs claimed the json_script arm "refuses the whole filter before reaching here" for a dict view. True only of the ATTRIBUTE route — {{ d.keys|json_script:"i" }} renders empty — while a view bound in Python (ctx = {"p": d.keys()}) never becomes a Value::DictView at all and emits its repr as a JSON string. Same object, two routes, two answers; the same type erasure seen from the other side. Both comments corrected and the route-dependence pinned.

    Two defects the sweep surfaced are filed rather than folded in (#1079): #2448json_script spells a datetime / timedelta VALUE with str() instead of DjangoJSONEncoder's isoformat / ISO-8601 duration ("2020-01-01 03:04:05" vs "2020-01-01T03:04:05", "0:01:30" vs "P0DT00H01M30S") — the EMITTING direction, where both engines render and disagree on the bytes; and #2449unordered_list and first emit where Django raises on a scalar, which unlike this one IS decidable, because it turns on a Value's shape rather than on an erased Python type.

    22 cases in python/tests/test_json_script_refusal_decision_2429.py (TestTheDivergentSetReDerived, TestDjustDoesRaiseWhereItCanSeeTheShape, TestTheValuePositionCannotSeeTheTypeAtAll), and TestTheRefusalHalfIsNotClosedHere becomes TestTheRefusalHalfIsADecidedLimit. Five gate-off mutations — each rebuilding the crate and asserting the .so mtime advanced — redden 8 / 5 / 1 / 10 / 1 tests with no survivors: a key position that genuinely refuses reddens the divergent-set pin and the both-positions-consistent pin; a boundary that carries a marker reddens the byte-identity pins, which is the reopen signal for this decision; a {% for %} that stops raising reddens the precedent pin; and removing the DictView arm reddens the route-dependence pin. A sixth was recorded INVALID rather than reported as evidence — it swapped one emitted string for another instead of modelling a refusal, so startswith("<script") stayed true and it measured nothing.

All releases · Atom feed