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 thedjust.V014check 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-suppliedscrub, 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 existingscrub_fields(), which already handles absent keys and already carries forwardscrubbed_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 theV012the issue names — that ID has been taken since #1803 (sticky child declares its owndj-view), and V001–V013 are all in use.V014 warns when a view sets
time_travel_enabled = Trueand 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 —emailis on almost every user model, and a check that fires on every project teaches people to ignore it (#1060). Three gates:time_travel_enabledis a deliberate dev-only opt-in almost no view sets, which does the heavy lifting; token matching rather than substring matching, sotelephone_poledoes not contain the tokenphone; and field TYPE, soemail_verified(Boolean) andphone_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-levelmodelattribute, so an enumerate-the-names scan would be reliably one short. NotDEBUG-gated:manage.py check --deployis 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 inTestExcludedFields,TestNoParallelSink,TestV014Firing,TestV014StaysQuietandTestTokenMatching; 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 viawebbrowser.open();--inspectprints the decoded capture as ONE JSON document (event_name,scrubbed_fields,state_before,state_after,vdom_patches) so it pipes straight intojq;--diffprints a unified diff ofstate_beforeagainststate_after. Identical states write nothing to stdout and say so on stderr, sodjust replay --diff … > patchstill 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 "rundjust 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 withdjbug1.(sodjust replay https://phishing.example/never reacheswebbrowser.open, and the URL handed to the browser is always one the command built itself), and--base-urlis restricted tohttp/httpssince its scheme reaches the browser directly.The replay path comes from
reverse("djust:bug_capture_replay")when a URLconf is available, so a project mountingdjust.urlsunder 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, elsehttp://127.0.0.1:8000.New cases in
TestExtractBlob,TestReplayUrl,TestInspectMode,TestDiffMode,TestBrowserMode,TestErrorPathsandTestSubparserWiring— driving the realmain()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. ConfigureLIVEVIEW_CONFIG['bug_capture_store']and any payload overbug_capture_inline_limit(default 1536 base64 characters) travels by reference instead. New: theSnapshotStoreABC the iter-C issue assumed an earlier iteration would introduce (neither did), plusInMemorySnapshotStoreandRedisSnapshotStore.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.
Nonealso keeps the feature zero-cost when unused (#246/#1446): a sub-threshold payload never reads the store config, and nothing indjust.bug_capture_storeis 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".RedisSnapshotStorerefuses 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, andredis://:@host,redis://host?password=xandredis://u:p%40ss@hostcan all point at a server with norequirepass. 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=Falseis 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-charactersecrets.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 underdjust: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,TestRedisAuthRefusalandTestReplayViewWithStore. The Redis refusal is exercised against REALredis-serverprocesses the fixtures start (one open, onerequirepass), skipped whenredis-serveris 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 thereset_djust_globalsinventory (#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).
INPUTSinscripts/filter-parity-differential.pywas 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@cmpaxis DEEP-COPIES the second operand — correct, and load-bearing, because two structurally-equal operands that are not the same object is the whole of whatvalues_equal/try_compareneeded (#2335) — but all three ofdict_keys/dict_values/dict_itemsraiseTypeError: cannot pickleunderdeepcopy. So an emptydict_keyscould not be a corpus row at all, and was therefore swept nowhere: not on the@cmpaxis 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_LAZYis an additive mapping of zero-argument factories thatINPUTSis updated from, andfresh(key)is the single chokepoint the@cmpaxis 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 adict_keyswhere copying does not.INPUTSstays 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 fromvalue-truthinesson the grounds that its only inhabitant would be a user-defined class and "a class instance cannot be a row here at all".evalinjects__builtins__into a globals mapping that has none, so the AST reader evaluatestype("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"), ando-falsy-iter(falsy,__iter__, no__len__— the shape #2466's own doc-comment DECLINED, and the only one that reaches the terminalValue::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 madenormalize_django_valueraise 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 --jsonfrom 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 toinput-shape. The regression gate re-verified across two genuinely different builds (89f219710de14da8→054bc92b2bdd954d,falsy_opaque's first arm reverted to the pre-#2466 shape):--compareexits 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 movesValue::Encoded, the carrier two of the new rows reach) rather than carried forward — same numbers on build0749837799e818f9.What the new rows surface is FILED, not fixed (#1079).
first/last/phone2numericover an emptydict_keysrender on the LiveView path where Django refuses, becausenormalize_django_valueflattens the view to the string"dict_keys([])"— #2477's class one type over and a degree worse, since asetflattens to a sorted LIST while a view flattens to TEXT; three rows added toNORMALIZER_FLATTENED, whose existing non-vacuity test covers them. The same three overdv-keys-plainando-falsy-iterrender 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 answering15where Django says0as 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 fromFalsetoTrue— a mutation that leaves the row, the corpus and the script intact — puts exactlyvalue:str-fallback:falsyandarg:str-fallback:falsyback inmissing, and removing only theARG_CONTEXTbinding puts back exactly theargone, so each channel has a test that goes red when only its own row is removed. New cases inTestTheRowThePreviousHarnessCouldNotHold,TestFreshIsOneChokepointAndNotTwo,TestTheASTReadersSeeTheWholeCorpus,TestTheExemptionsStatedReasonWasFalseandTestTheFalsyIterableRowIsTheShape2466Declinedin the newpython/tests/test_lazy_corpus_rows_2482.py, plusTestItWouldHaveCaughtTheHistoricalBlindSpotsintest_differential_reachability_manifest_2345.pyandTestTheReferenceTableIsRunNotTranscribedintest_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 thesetpair 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, soget_digitanddivisiblebyrefuse it as Django does (#2473). #2366 established the rule for the ARGUMENT position —int(datetime)raises TypeError, whichget_digit'sexcept ValueErrordoes not catch, so Django raises — and #2448 gave djust theValue::Encodedvariant it needs to see the type. The VALUE position did not follow:python_int_valuehad noEncodedarm, so a datetime fell to the wildcard and answered ValueError, the one exception those bodies DO catch.{{ p|get_digit:"1" }}over atimedelta(seconds=90)rendered0:01:30onto the page where Django 500s, andget_digit's echo arm carries a per-call safety grant (#2403), so the value reached the page live.divisiblebyrefused either way but its message named the wrong exception.One arm at the chokepoint, not one per filter.
python_int_valueis THEint(value)reading (#2435);get_digit,divisiblebyand{% 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_digitordivisiblebyto adatetime,date,timeortimedeltanow 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_valuespelled a datetime as itsDjangoJSONEncoderstring in Python before the conversion — so the engine really was handed astrthere andint("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 sameValue::Encodedand 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 inTestBothPathsCarryTheSameEncoded.The differential's
value-truthinessaxis enumerates conversion OUTCOMES, notValuevariants — 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 theValuevariants 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 emptydict_keys, a zero-__len__class, a__bool__-False class — has no variant, and the absence IS the defect.td-zerosuppliedEncoded:falsy, so the axis reported0 MISSINGover 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 Valuedid 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 terminalValue::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 areturn 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 theDecimalordering, so a raw scan finds seven arms for six exits.Corpus:
set-empty/set-plaininINPUTS,known_set_empty/known_setinARG_CONTEXT. Asetis 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 reachesfalsy_opaque; the payload-carrying one is truthy, is declined by that arm's own gate, and falls to the terminalstr()— the residueSTRINGIFIED_AT_EXTRACTIONnames intest_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:truthycannot exist — the arm opens withif ob.is_truthy() { return None };str-fallback:falsyneeds 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, andvalue-truthinessgoes 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 (
f15dc3ac223b24c1→a82f6d35625359cb,falsy_opaque'struthybit flipped — the pre-#2466 shape):--compareexits 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_FLATTENEDintest_sequence_op_chokepoint_2451.pyholdsfirst/lastover both set rows: they render on the LiveView path where Django refuses, becausenormalize_django_valuehas no arm for the class #2466 closed at the conversion and turns asetinto 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 morejson_scriptcells joind-typed-keyas the same declined refusal direction (#2429), and that test now asserts the direction per cell instead of pinning names alone.New cases in
TestItWouldHaveCaughtTheHistoricalBlindSpotsandTestTheReferenceTableIsRunNotTranscribed. Two follow-ups filed rather than folded in (#1079): the corpus harness cannot hold an unpicklable or non-literal row, which is what keeps adict_keysand a zero-__len__class out of it; andValue::Encodedcarries 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
exceptclause that catches nothing relevant — so the operation's exception IS the filter's answer.first/lastarevalue[0]/value[-1]underexcept IndexErroralone;randomisrandom.choice(value), which isvalue[i];escapeseq,safeseqandunordered_listare bare comprehensions overvalue;phone2numericis"".join(… for c in phone.lower())and is@keep_lazy_textrather than@stringfilter, so Django never coerces its input. djust failed soft on all seven:{{ p|first }}over anintrendered''where Django raisesTypeError: 'int' object is not subscriptable,{{ p|escapeseq }}rendered42, and{{ p|phone2numeric }}overNonerendered6663— 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_errormirror #2435'sIntValueError+int_value_error, because the question is the same one: Django'sexceptclauses catch different subsets, so "did it raise" is not enough. Three thin probes name the operations —python_iter(which wraps the EXISTINGiter_valuessink so itsNonecan carry the name of the exception Python raises there),python_getitemandpython_lower— and all three share onepython_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_unificationpins 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 a0key and answer its VALUE; four do not and raiseKeyError; none carries-1, solastraises on all seven wherefirstraises on four. A rule saying "a mapping refuses" would have been permissive on three cells and strict on one.ObjectKeyalready conflates numeric keys the way Python'shashdoes (#2339), so{True: 'b'}[1]finds'b'here as it does there. A serialized MODEL is told apart from a genuine dict by the sameobject_str()markerpython_lenuses (#2294) and refuses as not-subscriptable, which is what a real model does.joinkeeps the raw sink andrandomleaves it. Django'sjoinhasexcept TypeError: return value, so it needsiter_values'Nonerather than a raise — the one filter of the six that must still fail soft.randommoved off the iteration sink entirely, becauserandom.choice(seq)isseq[i]: it belongs withfirst/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.TestRandomOverAMappingpins 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::Missingis Django'sstring_if_invalidand therefore astr, 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 wasNonein 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 omitsrandom's 17 and includes eleven belonging to other classes.join/slice/default/default_if_none/cf_identare not this class at all — all ten of those cells are{{ p }}overDecimal("Infinity")orDecimal("NaN")raisingTypeError: bad operand type for abs(): 'str'in Django's ownnumberformat.format, with no filter involved (filed as #2460). And 15 of the 17 surviving cells areget_digitreturning a one-character STRING where Django returns anint— a wrong SUBJECT type rather than a wrong consumer (filed as #2459). The issue's explicit exclusion,get_digitover adatetimeat the PyO3 extraction boundary, is re-run and still excluded.Measured over 353,909 cells, two genuinely different builds (
668494847e16b33c→4e5cde758a0519f9): the{% widthratio %}bucket goes 1,088 → 12;django REFUSES & djust RENDERSgoes 14,058 → 6,187 across every tag shape;djust REFUSES & Django RENDERSis 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
argumentaxis — correctly, and from the wrong axis._ARG_ERROR_MARKkeeps every literal naming a filter, on the reasoning that "nothing else in these modules does"; the value-side constructors name one too. #2435'sint_value_errorhid the break becauseget_digitanddivisiblebytake an argument, soarg_cells()reaches it by coincidence. #2451's cannot: every filter that raises it takes NO argument. Split into avalue-opaxis 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,TestTheResidueThisDoesNotTouchandTestTheCorpusDeclaresItReachesThisErrorClassinpython/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 claimedcapfirst/lower/title(all@stringfilter, which coerces first) and missedunordered_list. Eight gate-off mutations, each rebuilding the crate and asserting the.somtime 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 infilters::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.filterif the template were parsed first. Four facts, each run rather than reasoned, close it. (1) The registry is complete at the END ofdjango.setup():DjustConfig.ready()warms the Django→Rust bridge, andhas_custom_filter('field_value')is alreadyTruebefore any request — measured in a subprocess byTestTheRegistryIsPopulatedBeforeAnythingCanBeParsed. (2) djust never parses a template outside a render call —Template::newis reached fromrender,render_with_diff,render_binary_diffandrender_template*, all of which are renders — so there is no window in which a user template is parsed with an empty registry. (3) Django'sEngine.template_librariesis filled fromINSTALLED_APPSat 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_CACHEandPARSED_TEMPLATE_CACHEare 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:{{ … }}reachesparser::parse_filter_specsthroughparse_tokenand every tag operand reaches it throughvalidate_tag_operand, so the lookup went intoparse_filter_specsand nowhere else.TestOneSiteClosesBothShapespins that the call appears exactly once inparser.rsand that both entry points reach it.The oracle is the dispatch table, not a copy of it.
filters::is_known_filterasksfilter_arity::builtin_arityfor 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 inapply_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 —TestTheOracleIsTheDispatchTableextracts the match arms fromfilters.rsand 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 inTestAnUnknownNameRefusesWhereverItAppears— 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'sInvalid filter: '<name>', because that substring is a published contract (template/rendering.pykeys 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 andInvalid filterthere, becausesplit_filter_specis 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 throughDjustTemplateBackend— now arms the filter bridge itself, as_initialize_rust_viewalready does for the LiveView path. A project settingfilter_bridge_warm = Falsepreviously 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'smasked-refusalaxis carriednosuchfilteralready, 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, andInvalid filterwas 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 (879c6fbd→e55e96cd): 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. Zeronosuchfiltercells 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 intest_tag_operand_parse_time_2411.pyandtest_filter_arity_2400.pynow assert the refusal, and two fixtures intest_template_edge_cases.pythat 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.soand 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_xBOUND,{{ 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_nameis that rule, and it is called from the three places djust turns a NAME into a lookup: the{{ … }}head, every filter ARGUMENT inparse_filter_specs, and every TAG OPERAND's head invalidate_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).TestNoNumericSpellingIsRefusedchecks 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 }}reportsupper'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.TestABindingIsNotALookupuses 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 sharedvalidate_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.renderinjected the key as_component_keyand its own docstring told authors to read it as{{ _component_key }}— a template that never compiled on the Django engine_render_template_with_fallbackfalls back to, so it only ever worked on the Rust path. The same value is now also injected ascomponent_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, andTestSidecarSerializationFloor::test_underscore_prefixed_refusednow 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 waspand 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 reported0 MISSINGon every axis. Adds avariable-nameaxis — one spelling per arm of Django's ordering (_x,p._priv,"_x",_("_x")) × seven positions — whoserequiredset is read out ofparser.rs'svalidate_variable_namecall sites, so a fourth position is reported MISSING until a cell exists for it; three of those spellings also joinARG_SPELLINGS, which crosses the argument position with every argument-taking built-in rather than withdefaultalone. The manifest reportedmasked-refusalMISSINGcycle/firstof/widthratio/includethe 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 remainingTemplateSyntaxErrorbucket overall is{% widthratio %}coercing a non-numeric first operand to0(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 inTestTheTwoRulesThisDoesNotClose(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.soand 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_filterover 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, inrenderer::get_value_safe— and{% if %}legitimately absorbs aVariableDoesNotExist. 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 andTemplateSyntaxErrorthere. 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.renderwraps the wholecondition.eval(context)— filter ARGUMENTS included, whichFilterExpression.resolvedoes not protect — inexcept 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.TestDjangoSwallowsResolutionFailuresToopins that, because it is the premise the whole fix shape rests on.parser::validate_tag_operandsplits an operand on its unquoted pipes and hands the chain toparse_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 forNode::If, which a fix wired only into the"if"arm would miss),{% for %}'s iterable and each{% with %}assignment.TestTheCallerSetIsPinnedpins 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-renderis unchanged at 940 — so it is not stricter than Django anywhere the sweep can see. Thearity,remainderandsome-charactersbuckets 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
TestTheTwoRulesThisDoesNotClosepins each as open with the evidence that it is a SEPARATE defect rather than part of this one.Invalid filteris 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 underscoresisVariable.__init__'s rule, which djust has NOWHERE: with_xBOUND 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. Itsarityaxis writes{{ p|name:"x"… }}only and itstagaxis gives each filter one VALID argument out ofFILTER_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 reported0 MISSINGover ~345,000 cells while 1,227{% if %}templates diverged. A cross of two covered axes is its own axis. Adds amasked-refusalaxis: six refusal classes (each PROBED against Django rather than declared) × nine positions a refusal can hide in × the payload inputs, with itsrequiredset read out ofparser.rs's validator call sites so a fifth operand-bearing tag is reported MISSING until a cell exists for it. Empirically canaried: dropping thedead-branch-forposition makes the manifest report1 MISSING.New cases in
TestDjangoSwallowsResolutionFailuresToo,TestAnUnresolvableArgumentNoLongerMasksTheRefusal,TestShapesNoRenderTimeFixCouldReach,TestItIsNotStricterThanDjango,TestTheCallerSetIsPinnedandTestTheTwoRulesThisDoesNotClose(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'sN errorapart fromN failed. One mutation was dropped rather than tested around: a first pass carried anIF_OPERATORSset mirroringsmartif.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.renderrunsconditional_escapeover asimple_tag's return unless it carries__html__;renderer.rs'sNode::CustomTagarm inserted it VERBATIM. So a handler as ordinary as@register.simple_tag def greet(name): return f"Hello {name}"emittedHello <img src=x onerror=alert(1)>LIVE where Django rendersHello <img …>. The fail-OPEN half of the asymmetry #2290 found on the way IN, reaching everyregister_tag_handler/register_block_tag_handleruser — 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_handlerfunctions and triggering every registration path — and then CALLED, never read (amark_safeon 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 themmark_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 plainstr. Those six arecall/component(one class),dj_suspense,djust_markdown,slotandtoast_container, each marked at its ONE exit rather than at its N returns (#1104):CallTagHandler.renderandSuspenseTagHandler.renderbecame thin wrappers over_render_component/_render_state.toast_containeris the sharpest of the six — its empty-container early return was the single return out of ~190 inrust_handlers.pythat 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_taghands the handlernodelist.render(context)— already-rendered, already-escaped markup, and thereforeSafeData. djust passed it across PyO3 as a barestr, 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&lt;img …&gt;where Django gives<img …>. The bridge now marks the block body safe on the way in — the block-path twin of #2290's marker loss — and fails SOFT ifdjango.utils.safestringis not importable, so a pure-Rust embedding keeps rendering.What changes for you. A handler you registered through
register_tag_handlerorregister_block_tag_handlerthat returns HTML must now say so:return mark_safe(...), which is what Django requires of asimple_tagtoo. 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__").TestEveryRegisteredHandlerIsAccountedFordoes 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_stringis #2290's own predicate, EXTRACTED rather than copied (#1646) — one function, three callers, and the security half of it stated once: requiringstrsubclass-ness and not just__html__, becauseValue'sFromPyObjectstringifies an arbitrary object via__str__and a non-strimpostor advertising__html__would otherwise reach output unescaped. That half is load-bearing on the FILTER path and shadowed on the TAG path (whereextract::<String>()refuses a non-stroutright), 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 barestr(#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@ctagarm 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 thect-condprobe 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 inTestKnownDivergencesOnTheCustomTagPathgo 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 inTestAHandlersPlainReturnIsEscaped,TestAMarkedReturnIsStillLive,TestTheBlockBodyIsSafeData,TestTheSharedPredicateRefusesAnImpostor,TestEveryRegisteredHandlerIsAccountedFor,TestKnownDivergencesThisUnmasksandTestTheDifferentialCanTELLThatUnmaskingApart(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.renderdecides an operand's fate in three steps and not one:if values is Nonebecomes[], thenif not hasattr(values, "__len__")runslist(values)— which RAISES for anything that is not iterable — and only then doesif len_values < 1reach 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 %}overTrue,42,1.5orDecimal("2.5")renderedEwhere Django raisesTypeError. It is not about bools and not about falsiness:None— and an operand that does not resolve, whichignore_failures=Trueturns intoNone— reaches the empty branch in Django too, and every other non-iterable raises, so0,0.0andDecimal("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 aDEBUG-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 aRuntimeErrorcarrying CPython's own wording and contained byLiveViewConsumer.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 aNoneone still take the empty branch, in their own arm —Value::Missingis Django'signore_failuresanswer, 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::BigIntis a Pythonint, and aDecimalis spelleddecimal.Decimalbecausedecimalis 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 deleted —
TestIteratingANonIterableIsNamedNotFixed(#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 thedjango-raisedresidue 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), andset(residue)is what makes its absence mechanical. Its replacement is #2387'sboth-raisedpredicate, which compares MESSAGES rather than exception classes, so a djust failure that merely coincided with a Django one still gates.Corpus:
for-bareexisted and every scalarINPUTSentry was anintor afloat, 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. Addsb-true,b-false,i-biganddec-plain—b-falseas well asb-truefor the reasonARG_SPELLINGScarries 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 afor-operand-outcomeaxis whose three members (empty-branch,refused,iterated) are PARSED out of Django's ownForNode.rendersource, 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'sTypeErrorand djust'sRuntimeErrorcannot compare equal however faithful the message. Every one of the 1,811 is classified mechanically: 1,141 move from "djust renderedE" 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 (452ValueError, 204TypeError, 8KeyError, 6OverflowError— 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,setor bareobject()has noValuevariant and reaches the renderer as itsstr(), so it is a SEQUENCE by the timeNode::Forsees 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: anIntFlagmember is iterable in Python 3.11+ and Django iterates it, but it arrives as a plainValue::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 inTestWhichShapesDjangoRefuses,TestBothEnginesRefuseANonIterable,TestTheAnswersThatMustNotMove,TestTheWireResidueIsNamed,TestTheOneShapeDjustIsNowSTRICTERAboutandTestTheCorpusGapThatHidTheShapesFromTheDifferential(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, andFilterExpression.__init__requires the regex matches to TILE the token, so a second:argisTemplateSyntaxError: Could not parse the remainder. djust split on the FIRST colon and kept everything after it as one argument, so{{ p|cut:"a":"b" }}handedcutthe 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 whyupperagreed (two arguments folded into one, then refused as one) whilecut,defaultandtruncatewordsall diverged.The same blindness sat one character over, on the pipe. Django's
constant_stringadmits 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 raisedUnknown 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{{ … }}) andrenderer::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 newcrate::filter_lexerrather 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 aRuntimeErrorcarrying Django's own wording, and is contained byLiveViewConsumer.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_COUNTSwas(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), anddjango_refuses_aritychecks 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. Thearityaxis grows 48 → 105 required members. A newseparator-in-constantaxis requires the corpus to carry a quoted argument containing each of Django's two separators, read fromdjango.template.base.FILTER_SEPARATOR/FILTER_ARGUMENT_SEPARATORrather 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 at0b44d747, 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 %}'sVariableDoesNotExistarm — the arm that matches Django'signore_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 inTestDjangosLexerBound,TestATwoArgumentCallIsRefused,TestAQuotedSeparatorIsPartOfTheArgument,TestBothSitesRefuseandTestTheCorpusGapThatHidThisFromTheDifferential(58), plus 10 incrates/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 raisesTemplateSyntaxError. djust's dispatch readarg: 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 saysupper requires 1 arguments, 2 provided, and{{ p|default }}was'abc'where Django saysdefault 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 aRuntimeErrorrather than Django's class, carries Django's own wording verbatim, and is contained byLiveViewConsumer.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
TemplateSyntaxErroron an extra argument; 23 do. The other five —linebreaks,linebreaksbr,linenumbers,unordered_list,urlize— areneeds_autoescape=Trueandargs_checkreads the RAW argspec, soplen = 2 <= alen = 2COMPILES and the failure is a render-timeTypeError: got multiple values for argument 'autoescape'. That is why the table carries two upper bounds and the fix has two sites:parser::parse_filter_specstakes the COMPILE bound (the only site that can see{% if False %}{{ p|upper:"x" }}{% endif %}, which Django refuses even though the node never renders) andfilters::apply_filter_full_safetakes the CALL bound, first, before the argument is resolved — Django's order, so{% if p|upper:missingvar %}is an arity error rather than aVariableDoesNotExist. 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 andTestTheTableIsDjangosOwnArityre-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 inpython/tests/test_filter_arity_2400.pyandcrates/djust_templates/src/filter_arity.rs. The differential grew an eleventh axis for this — it reported0 MISSINGon 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.pygrows 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 writesp|<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 onlyp, so it could not construct a comparison at all. A corpus gap is silent by construction, soTestTheCorpusGapsThatHidTheseFromTheDifferentialpins all three additions. The existing coincidental-agreement and escaped-text classifiers are untouched and both still fire.scripts/filter-parity-differential.pygained 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 ascoincidentalrather 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); andlive()substring-matches, so a fragment such asonerror=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 Valuediverged from Django for 5 of the 7Valuevariants, and not only cosmetically:Displayis the lookup key for{% if x in dict %}. All 19 bare-variable types now render byte-identically to Django. Gated onLIVEVIEW_CONFIG['django_value_repr'], default ON; set itFalseto 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 aReferenceErrorunder 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::Nullsplit intoMissingandNone. Django distinguishes an absent variable ("", itsstring_if_invalid) from a presentNone("None"); djust collapsed both.resolve(..)?.unwrap_or(Value::Null)folded missing into it — and so didCallOutcome::Empty, which is analters_datarefusal or a serialization-floor denial. Mapping the oldNullto"None"without splitting would have made every missing variable render the literal textNone, and put text where a refusedpasswordfield rendered nothing. Both variants stay falsy and both still satisfyis None, verified against Django for present-None, absent,0and"x". (2)Objectis now anIndexMap. Rust randomisesHashMapiteration 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_cachehashed 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 forNoneandTuple. (3) ATuplevariant so(1, 2)is distinguishable from[1, 2]— and this is where the real risk was. About 20Value::Listmatches have a_fallback, so the compiler cannot flag a missing twin: tuples silently vanished from{% for %},|length,|joinand|first. That was caught by a security test whose loop over a tuple of models stopped running at all. All 18 accessor arms now matchList(x) | Tuple(x), whileDisplay,value_to_json,pprintandhash_valuedeliberately still distinguish them — clippy'sunreachable_patternscaught a blanket sweep that had madepprint's tuple arm dead code. Also fixed in passing: a dict with non-string keys used to degrade gracefully (the oldextract::<HashMap<..>>()simply failed and fell through), and hand-iteration turned that into a hardTypeError; it falls through again and now renders{1: 'x'}/{True: 1}, matching Django. 13 cases in the newcrates/djust_core/tests/test_display_django_parity_2203.rs, plus 4 incrates/djust_templates/tests/test_loop_cache_value_keys_2203.rspinning 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-testsfixed,python-testsbecame the critical path (339s), and it invoked pytest twice:tests/ python/tests/, thenpython/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 overridepyproject.toml'stestpaths, so listing two roots silently drops the third, with no error and a green run — and a REDTestSetattrChokepointCWE-915 guard sat undetected onmainbecause 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 newtests/test_ci_python_test_roots.pypin all three roots against the CI invocation itself, not against pytest's discovery (a test checkingtestpathswould pass while CI ran a subset, which is the original bug). Gate-off verified: droppingpython/djust/tests/, splitting back into two invocations, and removing-n autoeach redden a test.CI drops LTO for the Rust test build, cutting
rust-testsfrom 407s to 187s. TheTestsrun is effectively one job — jobs run in parallel, andrust-testswas 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]carrieslto = 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 = 3buys the speed; LTO andcodegen-units = 1buy almost nothing at run time and cost 2.4× at compile. Applied through per-jobCARGO_PROFILE_*env vars rather than by editingCargo.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 --releasereads[profile.release]directly.) Scoped torust-testsalone, because the measurement said so. It was applied topython-teststoo 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-testsis compile-dominated, whilepython-testsis 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-threadedshowed no signal (86s → 85s). Not applied tobenchmarks, 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 newtests/test_ci_cargo_profile_overrides.pymake it mechanical:rust-testsmust carry the override, the test-dominated jobs must not,benchmarksmust not, no workflow-levelenv:may leak it to every job, andCargo.tomlmust still shiplto = true. Each gate-off verified against a distinct mutation. Verified on CI:rust-tests407s → 187s,benchmarksunchanged 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+systotal only ~210s of that 330s, so roughly 120 seconds was spent blocked rather than computing — most of it the eleven tests inpython/tests/test_deploy_cli.py, each standing up a real loopbackHTTPServerfor 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 declaresxdist_groupor a serial marker, the FAILED-id parsing is unaffected by sharding, andtest.ymlalready runs-n autoover these same paths. What serial still provided was undocumented, and is preserved.pytest-randomlyis 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-healthalready 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 ofmainrather than of the branch being pushed, so blocking a push on one tells the pusher nothing actionable. xdist is probed, not assumed: passing-n autoto a pytest without it does not degrade, it aborts withunrecognized 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 oftest_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 newtests/test_suite_ordering_coverage_2187.pymake it mechanical — the suite must be exercised in both orderings across the two runners, and dropping the parallelism, dropping the xdist probe, or parallelisingmain-healtheach redden a different, specific test.
Fixed
Two opaque
Value::Encodedvalues compare by Python's CONTRACT, so{% if p == q %}on twoset()s answersY(#2480).opaque_valuesetcmp_key: None, soEncoded::python_partial_cmpansweredNonefor 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 NEight shapes, and "widened from four to eight" is the honest count. Before #2476 a
set()had no variant and landed on the terminalOk(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 inTestTheComparisonAxisThisWIDENSin 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; andset() == {}.keys()is True whileset() == {}.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 everycollections.abc.Setregistration a user writes, and it claims any user class merely namedset, becausetype(o).__name__is unqualified. Both halves are run intest_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:arm measured equality ordering EqClass::Setisinstance(o, collections.abc.Set)the carried items, both containmentsa real SUBSET partial order EqClass::Numberisinstance(o, numbers.Number), ascomplex(o)the two components none EqClass::Identitydefault __eq__and default__repr__the reprtokennone Noneeverything else never equal never ordered Arm 1 is the one that answers the hard direction: the ABC defines
__eq__aslen(self) == len(other) and self <= otherand__le__as containment, soset() == frozenset() == {}.keys() == {}.items()falls out ACROSS type names andset() != {}.values()falls out for free — adict_valuesis not aSet. Arm 3 is a restoration rather than a new hazard: before #2476 aLenZero()crossed asValue::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()isY(subset order) whilecomplex(0) <= complex(0)isN(<RAISES, and Django'ssmart_ifswallows it to False). An implementation that reaches equality by handing these values a comparison key gets the first right and flips the second fromNtoY— eight cells bought, a new divergence sold. Sorenderer::encoded_partial_cmpis the ONE wrapper all three comparison sinks read (values_equalviaencoded_equal,try_compare,dictsort'scompare_sort_values); it carries the Set order and answersNonefor the two equality-only classes, andEncoded::python_partial_cmpkeeps the datetime family unchanged with exactly one caller. The Set order lives in anOption<Ordering>because Python's is partial —{1}and{2}are incomparable, andNoneis already rendered as "false for all four operators", which is Django's answer.Cross-carrier, closed too.
complex(0) == 0is True in Python andYin 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 + 1is False even though the float cast rounds, andcomplex(1e300) == 2**63 - 1is False even thoughas i64saturates 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__: adict_valuesis the builtin case, and two DISTINCT empty ones share the spellingdict_values([]), so using the token would call them equal where Python says they are not — a NEW wrong answer rather than an unfixed cell. ADecimalor a bigintagainst a complex, both exact types anf64cannot answer. And two sets pastSET_COMPARE_CAP(1,000 items a side): containment without a hash is quadratic and asetstates its own length, soopaque_valueenumerates 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 reopeningENCODED_TAGexists to prevent. It is a MAP, nevernil— 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, soan_interior_insert_is_refused_rather_than_silently_misreadcould 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 ornil) or the intruder itself into this position, never a map. Writingnilfor 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_misreadinserts 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 theValue::Objectpattern, an insert at slot 6 decodes as anEncodedwithrepr: "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) restoreseq_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 theattrsmap instead was rejected: that map is whatcontext::lookup_segmentresolves{{ 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.pyover 380,484 cells —origin/mainate5d499a0against 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
@cmpcells 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 intest_encoded_wire_positions_2471_2472.rs(includingan_insert_into_the_ten_slot_payload_is_refused_by_the_last_slots_type, the one a gate-off proved was missing); andTestTheComparisonAxisThisWIDENS(now…WIDENED),TestAFalsyOpaqueEncodedIsNotComparableand the@cmpcorpus row intest_lazy_corpus_rows_2482.pyare 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 inOk(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__ ['<img …'] ['F', 'a', 'l', 's', …] {{ p|first }} {}.keys() <<TypeError>> d {{ p|phone2numeric }} {"…": 1}.keys() <<AttributeError>> dials the repr{{ p|length }}being15where Django says0is 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 aValue::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_opaquebecomesopaque_valueand the gate stops asking about the sign ofbool(o)— a{'a'}is the same kind of object as aset(), and was declined only because the carrier had no way to say what it contains.Encodedgainsitems(list(o), enumerated at the conversion) and itssized_empty: boolwidens tolen: Option<usize>: Django's|lengthreads__len__underexcept TypeError: return 0while its iterating filters are comprehensions that calliter(), so a falsy__iter__class with no__len__answers 0 and one item at the same time, which a single bit cannot carry.Why
Encodedand 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 bedisplay;{{ p|first }}RAISES for a set, so the refusal needstype_nameto name'set';{% if p %}isFalsefor a__bool__-False collection with two items;{{ p|pprint }}wantsreprand{{ p.a }}wantsattrs.Value::DictView— the one existing variant with a collection's shape — is documented as built ONLY byContext::dict_viewduring 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 soset()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, azip, amap): 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_conversionis the assertion that justifies it rather than merely restating it. An unsized iterable pastOPAQUE_ITEM_CAP— a class whose__iter__returnsitertools.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 andEncoded::attrskeeps{{ obj.a }}resolving either way.normalize_django_valuestopped flattening the same class, which is the #2477 half and is needed for either fix to be visible on a page. Asetbecame 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 itsstr(), so an emptydict_keyswasTfor{% if p %}on one path andFon 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": abytesand acollections.dequesatisfyopaque_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 }}overb"ab"went from Django'sb'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 rawQuerySetandManagerand 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 asopaque_gate, which measuresbool(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, socrosses_as_encoded_by_conversionis exported alongside it and swept against it over every shape; that sweep immediately found the probe answeringtrueforNone,True,7,1.5,"ab"and aDecimal— 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 itsstrict_serializationraise, deliberately — that signal is about LiveView STATE, whose paths passstate_roundtrip=Trueand 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.pyover 380,484 cells —origin/mainagainst 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 : 12The 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
@cmpwidening below, and two are{{ p|timesince:obj }}/{{ p|timeuntil:obj }}, where Django raisesAttributeErroron 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 asetbuilt ZERO groups where Django builds one — the operand reaches the handler throughvalue_channel_arg_string, and anEncodedfell 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 aValue::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@ctagcells the wrong way.{% if tag in tags %}answeredN, and it had worked by a worse accident:inover aValue::Stringis a SUBSTRING match, so'a' in {'ab'}was true and so was any character of the repr's punctuation. Andinto_pyobjectgoes BACK to the display — handing the items back looked conservative, but measured againstmainthe premise was false (a truthy set was declined by the pre-#2477 gate and already came back asstr(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, aCounteris a dict, a generator is the decline.What it costs, measured and pinned (#2480). An
Encodedfrom this arm carries no comparison key, sopython_partial_cmpanswersNonefor every pair either side of which came from it: never equal, never ordered.{% if p == q %}over two equal sets isNwhere Django saysY. #2466 already did that to the falsy half —set(),complex(0), an emptydict_keys— and filed #2480; this widens it from four shapes to eight. AsValue::Stringthese 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 intest_encoded_value_position_2471_2472_2473.pygoing 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|pprintover an awaretimeBETTER: it gives the constructor form Django spells, where aValue::Stringgave the quoted display.Wire. The
ENCODED_TAGpayload grows to a tenth positional slot: slot 5 widens from #2466'ssized_emptyboolean tolen(o)itself (a bit cannot saySome(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 %},|joinor|lengthafter one msgpack round trip, the reopeningENCODED_TAGhas now prevented five times. The 9 / 8 / 6 widths restorelenfrom the boolean they carry, which is EXACT rather than approximate — the pre-fix gate declined every object whoselenwas not 0.into_pyobjectis UNCHANGED and returns the display, which is #2458's filed decision — widening it cost 20 custom-filter cells and its premise (that asetin state used to come back a list) was false againstmain.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.rsgains two:an_interior_insert_is_refused_rather_than_silently_misreadinserts 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 whileattrsdecoded asrepranditemsascmp_key; andthe_slot_that_grew_inside_itself_did_not_take_a_positiongrows 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 realserialize_msgpackfor six shapes rather than inferred from either description.Pins deleted on their own stated terms:
NORMALIZER_FLATTENED(#2477) andSTRINGIFIED_AT_CONVERSION(#2482) intest_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'sRESIDUE/ITERABLE_RESIDUEand #2366'sSTRINGIFIED_AT_EXTRACTIONempty 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 theLenTwoBoolFalseWithAttrsrow in #2478 are kept with their assertions INVERTED rather than deleted, because each names the exact cell the decline cost.value-truthinessloses one exemption and gains one.("opaque_value", "truthy")was exempt with the stated reason "the arm opens withif ob.is_truthy().ok()? { return None }" — true of the code, and the defect;set-plainanddv-keys-plaininhabit the slot now.str-fallbackbecomes exempt on both answers, and the reason is checked: every shape still reaching the terminalstr()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 intest_encoded_wire_positions_2471_2472.rs,test_falsy_conversion_2466.py,test_for_non_iterable_2382.pyandtest_int_argument_type_2366.py.{{ dt.isoformat }}renders — aValue::Encodedcarries the auto-called half of Django's lookup (#2485). Django'sVariable._resolve_lookupAUTO-CALLS a callable attribute (ADR-024), so{{ p.isoformat }}is an EVALUATION where{{ p.year }}is a lookup. #2481 gaveValue::Encodeda 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 — socontext::lookup_segmentstays the ONE reader ofEncoded::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 agetattrand 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 omitsisoweekdayand includes three names carrying them would not close:name Django renders the result renders as isoformat2026-03-04T05:06:07.000008the same CARRIED 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, plusnow/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 theTypeErrorand rendersstring_if_invalid, which is the empty string djust already renders, so those cells agree today.The calls fail soft, per name. A
getattrthat misses, a call that RAISES, or a result that will not convert is SKIPPED rather than stored, leavinglookup_segmentansweringNone— 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: atzinfosubclass decides whatutcoffset()/tzname()/dst()do, andtimestamp()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 (
isoformat0.4 µs,ctime0.35 µs,weekday/toordinal/utcoffset/tzname/dst~0.02–0.1 µs each); the rest is the nine extra map entries.utcoffsetin particular costs nothing new —comparison_keyhas called it on everydatetimeand everytimesince #2471 to build theCmpKey. 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/resolutionstay 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 saysresolution(atimedelta) andtimedelta's ownmin/maxWOULD agree if a depth bound existed, whiledatetime/date/time'smin/maxwould not — Django localizes those too.Regression coverage: new cases in
python/tests/test_nullary_autocall_2485.py— every carried name through BOTHrender_templateentry points and through a msgpack state round trip, the aware andZoneInfosubjects 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_DIFFERENTLYis 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.somtime asserted to advance,__pycache__cleared, andcargo test --no-fail-fastfor the Rust side: neutering the producer (122 red), making the merge find nothing (122), removingisoformatfrom the datetime row (16), removingtotal_secondsfrom the timedelta row (5), and the CROSSED mutation that ADDSdateto 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 RENDERS4992 → 4992 anddjust REFUSES & Django RENDERS39253 → 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: itspathaxis (3,392 cells) is the dict-view path axis from #2334 and builds nodatetime × method-namecell, 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 unguardedtype(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 whateval(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 warningThe 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) andchecks/configuration.py(the ASGI middleware walk inmanage.py check, where the pre-existingor ""covered a__module__that isNonebut not one that is ABSENT).templatetags/live_tags.pyreads the same pair inside its ownexcept AttributeErrorand 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'straceback.format_exceptionmakes the same unguarded read inTracebackException.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 (anINTERNALERROR, 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, andcheck_configurationdriven through a realASGI_APPLICATIONsetting rather than by calling the expression directly). Gate-off verified against four independent reverts — the serialization guard (5 red), the tracebacksgetattr(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 neitherfailednorerror, only a short pass count — a harness that reads the wrong instrument, rerun after teaching it to comparepassed + failed + errorsagainstcollected.{{ p }}on aNonerendersNoneand keeps rendering it — a state round trip no longer turns everyValue::Noneinto aValue::Missing(#2484).Value::NoneandValue::Missingare deliberately DISTINCT (#2203):Nonerenders"None"asstr(None)does,Missingrenders""as Django'sstring_if_invaliddoes. The msgpack codec collapsed them —impl Serialize for Valuewrote both as onenil, andvisit_unitread everynilback asMissing:{{ 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.stateround-trips through msgpack on EVERY read of the defaultInMemoryStateBackendand 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: aNoneat the top level, in a dict, in a list, or in anEncoded's attribute map (#2481) was affected equally. It predates #2481 and #2448; #2203 gaveMissing | Noneone serializer arm and the round trip has collapsed them ever since.Blast radius, measured rather than reasoned about. Over Django's LIVE
defaultfiltersregistry withp = 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,|truncatecharsand every bare display cell. In a plausible LiveView state blob, 13 of 27 leaf values wereNone. 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 reads rendered OLD payload ( nil), NEW readerValue::None"None"— fixedNEW payload ( nil), OLD readerValue::Missing""— unchanged, today's behaviourThe tag goes on
Missing, the rare variant, so the COMMON value's bytes do not move: aNoneis still one msgpacknil. The OLD-payload direction is a fix rather than a guess becauseFromPyObjectmaps PythonNonetoValue::Noneand has NO arm producing aMissing— aMissingis a render-time sentinel (renderer.rs'sresolve(...)?.unwrap_or(Value::Missing)) andRustLiveView::stateis filled only through that conversion — so anilin a pre-fix blob can only have come from a PythonNone. Measured, not asserted:test_a_missing_cannot_enter_state_through_the_python_conversionsweeps 18 Python shapes throughset_stateand checks the tag never appears in the blob.Tagging
Noneinstead — 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-keyValue::Objectwhere it used to seenil, giving a dict spelling from{{ p }}and the TRUE branch from{% if p %}. Strictly worse than the defect. Dropping the tag entirely — just readingnilasNone— 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 aMissingdid 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 (onenullfor both), exactly asTUPLE_TAG's arm is —json.dumpshas onenulltoo; what did change there is that anullnow reads back asNone, which is whatjson.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 PLAINValue::Objectkeeping it, with noEncodedin reach) andtest_encoded_attributes_2481.py::test_a_None_attribute_survives_the_state_round_trip_since_2484. #2481's two-row round-trip exemption (the naivetzinfos) 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.rsandpython/tests/test_none_missing_state_round_trip_2484.py. The wire pin is literal —Value::Nonemust encode as exactly[0xc0](the compatibility statement itself, written as the byte rather than as "whatever we emit") andValue::Missingas the byte-for-byte tagged map. Gate-off verified against three independent reverts — the serializer arm (5 Rust cases red), thenilreader (6 Rust, 27 Python), the tag reader (3 Rust, 1 Python) — each asserting the mutation matched exactly once, the source changed, the.somtime 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 aMissing, which is exactly why the tag was put on that variant. The first counting pass under-reported every row (1 / 2 / 1) becausecargo testis fail-fast across binaries — a harness that reads the wrong instrument, rerun with--no-fail-fastfor the numbers above.{% if p %}isFalsefor a falsy object WITH attributes — it reaches the carrier instead of the__dict__bulk dump (#2478). #2466 closed the falsiness class that lands onFromPyObject for Value's finalOk(Value::String(ob.str()?))— aset, afrozenset,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-emptyValue::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_opaquewas placed after the__dict__arm deliberately: routing an attribute-carrying object through theEncodedcarrier would have fixed{% if %}and broken{{ obj.a }}, because anEncodedhad no attributes. #2481 gave it an attribute map, so the objection is answered rather than worked around —falsy_opaquemoves 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 onValue::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.TestTheIssuesOwnRemedyWouldNotHaveReachedmeasures 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-Modelarm (#1986, and its vector 7) are ordered BEFOREfalsy_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 sharedpublic_dict_attrswith 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 emitsstr(o)rather than a JSON object of the attribute VALUES, so strictly less of the object reaches the page), and|dictsortover an empty iterable, which is unrelated to the carrier.Regression coverage: 383 cases in
python/tests/test_falsy_with_attributes_2478.py; new cases intest_falsy_conversion_2466.py::TestWhatThisDeliberatelyDoesNOTClose.{{ post.published.year }}renders2026instead of nothing — aValue::Encodedcarries its attributes (#2481). Django'sVariable._resolve_lookuptries three things at every dotted segment: mapping item access, thengetattr, then an integer index.context::lookup_segmentimplemented steps 1 and 3, and said so in as many words — "attribute access — see the note above; aValuehas none". So every dotted lookup on adatetime/date/time/timedeltaresolved to nothing, on every path with no raw-Python sidecar — which is everyDjustTemplateBackendrender:{{ 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
datetimewasValue::String(str(o)), which has no attributes either. The LiveView path had a partial escape —crates/djust_live/src/lib.rsattaches araw_py_objectssidecar, so{{ dt.year }}could resolve throughgetattrthere — 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 whyTestBothPathsAgreeasserts the two now answer the same.21 cells, measured against live Django, through BOTH entry points
python/djust/template/backend.pybinds.lookup_segmentis 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_segmentitself has exactly one caller, pinned the same way. Over the swept attribute surface the mismatch count goes 64 → 41, and nothing moves intodjust 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/resolutionlook like they belong and cannot: their values are values of the same family, anddatetime.min.min is datetime.min— measured, intest_the_class_attributes_would_not_terminate— so a collector that carried them would not terminate.test_the_name_list_is_the_whole_of_the_policyfails 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;DecimalisValue::Decimal, a different carrier with no attribute slot, and is filed as #2486.Wire. The
ENCODED_TAGpayload 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 choicecmp_keymakes 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.stateround-trips through msgpack on every read of the defaultInMemoryStateBackend, so without the slot{{ dt.year }}would answer once and go empty afterwards, the reopeningENCODED_TAGhas now prevented four times.crates/djust_core/tests/test_encoded_wire_positions_2471_2472.rsgrows 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 derivedPartialEqbecomes a hand-written one. The map holdsValues andValuedeliberately has noPartialEq: Django's==for a template value isrenderer::values_equal, which equates1with1.0and askspython_partial_cmpfor this family. Deriving a second==ontoValuewould 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, asvalues_structurally_equal, withtest_every_variant_is_structurally_equal_to_its_own_cloneso a new variant cannot land on its wildcard unnoticed.Surfaced rather than folded in (#1079).
Value::NoneandValue::Missingare deliberately distinct (#2203) and share ONE msgpacknil, so everyNonein state comes back asMissingand renders''after one cache hit. Pre-existing and general — pinned with a plainValue::Objectlosing 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 intest_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.somtime advanced and__pycache__was cleared, and counting collection errors apart from failures.Two
Value::Encodedvalues now compare as Python compares them — a datetime was not equal to ITSELF, on every operator (#2471).values_equalenumeratedMissing|None,Bool,Integer,Float, the mixed int/float pair,Stringand same-kind sequences, then_ => false;try_comparehad the matching hole. So twoEncodeds were never equal and never ordered:{% if p == q %}on the SAMEdatetimetook 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 twotimedeltas 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::Encodedarrived 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 differentstr().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_stringleaves 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).Encodedgrows aCmpKey—(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)isFalsein CPython even thoughdatetimeIS adatesubclass,date < datetimeRAISES, 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 becausetimedelta.maxis ~8.64e19 µs andi64::MAXis ~9.22e18; Python normalises atimedeltato(days, 0 ≤ seconds < 86400, 0 ≤ microseconds < 10⁶), so the pair orders lexicographically exactly as the delta does, negatives included. An awaredatetimeis normalised to UTC, which is what makes the cross-zone equality right.One function, three readers.
values_equal,try_compareanddictsort'scompare_sort_valuesall callEncoded::python_partial_cmp, and equality isSome(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.dictsortover aDateTimeFieldcolumn 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
timenever becomes aValue::Encodedat all:DjangoJSONEncoder.defaultraisesValueError: JSON can't represent timezone-aware times.for it, so the conversion fails closed and the value stays theValue::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 inTestAnAwareTimeIsNotAnEncodedAtAll.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_valueshad 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(f15dc3ac223b24c1→89f219710de14da8, two genuinely different builds on the identical corpus): thecmpaxis moves 12 — the number #2471 predicted —filter9,tag63. 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 RENDERSis 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
mainbefore #2476/#2475 and again after, the movement was identical (cmp12,filter6,tag42, +40 collapsed) — because neither of those PRs moves a single cell here:INPUTSheld noset(), so nothing reachedfalsy_opaque, and the differential renders through rawrender_template, whichnormalize_django_valuewas never on. Then #2483 (#2477) putset()andfrozenset()INTOINPUTS, and the same two engines answered 3,942 more cells:filter6 → 9 andtag42 → 63, every one of them asetreaching|pprintor anint(value)refusal. The engine did not change between the second and third measurements; only what the corpus could ask did.cmpstayed at 12 across that widening, and that is the #2480 measurement rather than an argument. The newsetrows added 1,372 comparison cells and moved none of them: both builds answerNwhere Django answersY, 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.sois not byte-identical to the previous one, with__pycache__cleared andN errorcounted apart fromN 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_opaqueintroduced a secondob.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_opaquecopiesdisplayintorepr) reddens exactly 1, which is a question rather than a pass: a crossed run named the single failing test, andtest_that_one_test_is_the_COMPLETE_set_of_distinguishersnow asserts why one is the whole set — every builtin the widening carries hasstr(o) == repr(o), so only a user class can tell the two implementations apart.{{ p|pprint }}over a datetime spellsrepr(o), and so does a datetime NESTED in a list or dict (#2472).pprint::flat_reprspelledpy_repr_string(&e.display)— the repr of the display string,'0:00:00', quotes and all — andValue::py_reprdelegated toDisplay, which isstr(o). Django spells the constructor form:datetime.timedelta(0). The comment on the pprint arm said the real answer was "out of reach becauseEncodedcarriesstr()and the encoder's JSON and notrepr()", which is what this fix answers by puttingrepr()on the variant.The nested position is the one that matters more, and the issue did not name it. A container's
strcallsrepron 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" }}areValue::py_reprtoo and moved with it. Four sinks, one field.Carried rather than derived, because
reprfor this family is not a format string.repr(timedelta(0))isdatetime.timedelta(0)whilerepr(timedelta(seconds=90))isdatetime.timedelta(seconds=90)— the KEYWORD is chosen by the value — andrepr(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'ssized_empty/iterable, thenreprand 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, anddisplayas 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: neitherEncodednorCmpKeyderivesSerialize(the encoding is the hand-writtenimpl Serialize for Value), and no field anywhere indjust_corecarriesskip_serializing_if—cmp_keyis written unconditionally asnilor[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::MAXand negative-hi, every domain constant, all four accepted payload widths, the two widths that must NOT forge anEncoded, 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::Encodedis no longer only the datetime family, and the merge with #2476 turned two of its assumptions into checks. #2466'sfalsy_opaquebuilds anEncodedforset(),frozenset(),complex(0)and any falsy user object, so both new fields had to be answered there too:repris measured (ob.repr()), not cloned fromdisplaythe wayjsonis. For every builtinfalsy_opaquewas written for the two spellings coincide — so adisplay-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_keyisNonethere, sopython_partial_cmpanswersNonefor any pair either side of which came fromfalsy_opaque— byte for byte the_ => falsethose values already got. That leaves{% if p == q %}on twoset()s answeringNwhere Django answersY: 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 literalfalse, leaves every case inTestAFalsyOpaqueEncodedIsNotComparablegreen). No carried spelling can decide it —set() == frozenset()is True ACROSS type names whileLenZero() == 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|pprintdivergence (whose stated premise — "out of reach becauseEncodedcarriesstr()and the encoder's JSON and notrepr()" — is what this fix moved),test_filter_arm_parity_2399_2401_2403.py'sget_digit-over-a-datetime row, andtest_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
datetimeto Rust instead of flattening it, so djust's two paths answer the same (#2467).normalize_django_valueconverted adatetime/date/time/timedeltato a string in Python, soValue::Encoded(#2448) was never constructed on the LiveView path and every downstream decision was made on text. #2456 fixed the rawDjustTemplateBackendpath and declared this bound for itself in its CHANGELOG, its docstring and aTestWhichPathThisFixIsOnclass; this is the other side of it. Measured on a real mount + render rather than throughrender_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 atimedeltain 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 }}emitted7038004006007where Django raisesTypeError: '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 %}overtimedelta(0)answeredThere andFthere, because a non-empty string is truthy.The
Decimalbranch verbatim (#2239): carried through UNCONVERTED for the renderer, converted only at thestate_roundtrip=Trueboundary. The consumer audit the issue asks for, run per consumer rather than assumed — the template context takes it; the wire encoders arejson.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); everyrequest.session[...]write already passesstate_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, becauseValue::Encodedhas 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 rendersstr(o)now —2020-01-01 03:04:05where it rendered2020-01-01T03:04:05, and…+00:00where 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 fordatetime/date/time, including timezone conversion (#2216), and{{ p|time:"H:i" }}over atimedeltaimproves from empty to00:01.What this NARROWS, said out loud. #2252's
state_roundtripflag was documented as having "ONLY theDecimalbranch" as its effect, and itsTestEveryOtherTypeIsUntouchedswept 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 asDecimalis. 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 baredatetime, 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 itsdateleaf: 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,TestTheRustStateBackendRoundTripandTestWhatThisCostsinpython/tests/test_liveview_path_carries_datetime_2467.py, plustest_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_placeandtest_a_randomized_corpus_of_CARRIED_types_still_reaches_the_sessioninTestEveryOtherTypeIsUntouched. 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-timerow stays because #2429 is genuinely still open), #2451'sTestTheLiveViewPathNormalizesBeforeRustSeesIt(renamed with its claim toTestTheLiveViewPathCarriesTheTypeSince2467) and its twelve-cell #2467 pin, and every remaining assertion that spelled anormalize_django_valueoutput as a string —TestDateTimeTypesintests/unit/test_normalize_django_value.py,test_normalize_date/test_normalize_dict_with_complex_valuesintest_serialization_hardening.py,test_succeeded_recurses_into_resultintest_async_result_serializer.py,test_the_timedelta_gap_is_CLOSEDintest_decimal_converters_2239.py, andtest_normalize_then_rust_update_state_no_quote_wrapintest_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())isFalseon both engines — the falsiness rule now reaches the CONVERSION (#2466).{% if p %}over aset()or afrozenset()rendered the TRUE branch where Python and Django render the false one. Asethas noValuevariant, soFromPyObject for Valuelanded it on its finalOk(Value::String(ob.str()?))and it arrived as the non-empty string"set()", whoseis_truthyis!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 asetnever becomes anEncoded.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 emptydict_keys/dict_values/dict_items(theDictViewvariant exists, but only the template's ownd.keysaccess ever built one — the conversion never did), and any user class with a__len__returning 0 or a__bool__returningFalse. The last two are user classes, so the set cannot be enumerated — which is the argument for carryingbool(o)over givingseta 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::Encodedalready IS one — a Python object held by itstype_name/display/json/truthyspellings because the object itself cannot cross. #2448 built it for the fourDjangoJSONEncodertypes and #2458 added the truthiness bit;falsy_opaquewidens the set of objects that use it and adds no mechanism. A newValuevariant would be a second carrier for one question (#1646) and would have to be classified at every wildcardmatcharm in the workspace.jsonstaysstr(o)for these — exactly what theValue::Stringpath already wrote — sojson_scriptdoes not move; Django REFUSES{{ p|json_script:"x" }}over aset, 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.renderreads__len__when the object has one and callslist()only when it does not, whilejoin/safeseq/escapeseq/unordered_listare comprehensions and calliter()unconditionally. So a class with a zero__len__and no__iter__renders the{% empty %}block AND raises from|safeseq— on Django.Encodednow carriessized_empty(len(o) == 0) anditerable(iter(o)succeeds) separately;python_lenand the{% for %}arm read the first,iter_valuesreads 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__returningFalseand 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 onitertools.count(). Both keep their previousValue::Stringpath, 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 thestr()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 emptyset/frozenset/ dict view and for a falsy user object.{{ p }}is byte-identical — it wasstr(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 iterableinstead of a message about astr.Corpus, stated rather than swept.
scripts/filter-parity-differential.pycannot construct a single cell of this class, and the reason is structural rather than an omission: itsvalue-truthinessaxis (#2469) reads theValueVARIANTS out ofcrates/djust_core/src/lib.rsand requires a falsy and a truthy inhabitant of each — and the values this fix is about are exactly the ones with NO variant. Adding asettoINPUTSwas tried and reverted: it surfaces fourdjango REFUSES & djust RENDERScells (first/last) that belong to a DIFFERENT defect —normalize_django_valueturns a set into a sorted list on the LiveView path, so subscript-refusing filters render there — and a corpus row cannot carry thedeepcopyadict_keysneeds. 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 — theEncodedfamily included, since #2469 added atimedelta— 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 withbool()CALLED rather than transcribed, the nineteen already-right shapes, the{% for %}/ filter split with itsLenZerorow, 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 hasset(),frozenset()andcomplex(0)in the sweep proper. In Rust,every_variant()gained theEncodedsamples it never had (the variant was added in #2448 and neither iterability probe named it for two releases) andpython_len_agrees_with_iter_valuesgained the one exemption Python itself requires. Twelve gate-off mutations, each rebuilding the crate and asserting the.somtime 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|lengthanswers 0 through its ownunwrap_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 saysgot 0.and the un-armed engine would saygot 1.The filter-parity differential can build a cell where an argument is FALSY, and holds a
timedelta(#2469).ARG_CONTEXTbound 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 whatArgType::is_falsy's first arm answers. And no entry ofINPUTSwas atimedelta: the one member of theValue::Encodedfamily with a falsy inhabitant, and the only way to reach that variant from the value corpus at all. So #2458, whose entire subject isbool(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.truthyreverted to its pre-fix!e.display.is_empty()— compared under each corpus. Pre-#2469:0 movedon every axis,REGRESSIONS: 0, exit 0. This corpus: 43 moved,REGRESSIONS: 38, exit 1, every one of the 38 aknown_td_zeroargument or atd-zerovalue reachingdefault/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-truthinessaxis, so this is the last time.Value::is_truthyis amatchoverValue, so the ENGINE does name the set: the variants are read out ofcrates/djust_core/src/lib.rsand each must have a falsy AND a truthy inhabitant, in the value channel and the argument channel. The four uninhabitable combinations areexemptwith a written reason —MissingandDictViewnever arrive from Python,Value::Nonehas one inhabitant and it is falsy, aBigIntis never zero — so a stale exemption is reported rather than silent.input-shapestays UNVERIFIED for everything else: this closes the one slice of it the engine names, and says so.Corpus:
INPUTSgrows a falsy inhabitant of the five variants that had only truthy ones (i-zero,f-zero,dec-zero,t-empty,d-empty) plustd-zero/td-plain;ARG_CONTEXTandARG_SPELLINGSgrow one resolved binding per variant in both answers, plusknown_str_zero— a TRUTHYstrspelling0, 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::Encodeddivergences 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);pprintspellsstr(o)where Django spellsrepr(o)(#2472); andget_digitechoes its input where Django raisesTypeError(#2473, the value-position twin of #2366).New cases in
TestItWouldHaveCaughtTheHistoricalBlindSpotsreconstruct 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_unreachablenow removes two spellings:known_bigreachespad_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 bareintfailed soft to an empty grouping where Django raisesTypeError: '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 —regroupis a Python assign-tag handler (djust.template_tags.regroup) dispatched from Rust, and the fail-soft wasRegroupTagHandler._decode_source'sexcept 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_itermodels —iter(x)— is directly available to it:list(decoded), whose message is CPython's verbatim rather than a reconstruction. OnlyNonestill answers "no groups", which is Django's own single guard (if obj_list is None) and covers the unresolved operand too, sinceignore_failures=TrueproducesNonefor it. Django'sgroupbycallsiter()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
boolsource still answered[0], becausevalue_channel_arg_stringhanded the handler Python'sTrue— which is not valid JSON, sojson.loadsraised and the handler took its "this must be an unresolved bare name" branch.42and1.5ARE valid JSON and so were already refused correctly; the doc comment claiming "every other scalar'sDisplayform (42,True,None,1.5) is unambiguous against a bare name" was false of exactly one of the four. ABoolis now encoded as JSONtrue/false— the same type-tag argument #2385 made for theStringarm.Nonekeeps itsDisplayspelling: its mis-decode is harmless, because the fallback answersNone, 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_sourceis the only one in the wholedjust/template_tags/package, and it is the one fixed. Rust:filters::iter_valuesis the sink,filters::python_iternames itsNonefor the refusing filters (#2451), andrenderer.rs's{% for %}arm writes the message directly through the sharedpython_type_name. Exactly two production sites emit'X' object is not iterable, andTestNoSecondIterabilityCheckWasAddedpins 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 bareobject()still renders one group, because it never reaches the handler as an object —Value's conversion has no variant for it and it arrives asValue::String(str(o)). That is the #2466 conversion gap, one tag over, and guarding it insideregroupwould be a fix at the consumer for a defect at the source; pinned in the diverging direction inTestWhatThisDeliberatelyDoesNOTClose.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) andTestTheDivergenceThatIsNotClosedHere(#2385), both of which said in as many words that they would redden the dayregrouprefused. Three gate-off mutations — restoring the swallow, dropping theNoneguard, and reverting theBoolencoding — redden 43 / 12 / 9 tests, each rebuilding the crate and asserting the.somtime advanced; no survivors.A
datetimereaching a client through the LiveView path or the wire encoder is spelled the wayDjangoJSONEncoderspells it (#2462). Django's encoder truncates microseconds to milliseconds (r[:23] + r[26:]for adatetime, the DIFFERENTr[:12]for atime) and rewrites a trailing+00:00toZ. Three djust sinks spelled itisoformat()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 RAWDjustTemplateBackendpath withValue::Encoded; this is the same defect on the other one, in Python, which the Rust variant cannot reach because the value is already astrby then.The issue's own measurement names the wrong encoder, and that is where the fix goes. It reports
normalize_django_valueviolating its docstring identity for 4 of 10 datetime shapes.DjangoJSONEncoderindjust/serialization.pyis djust's own subclass, notdjango.core.serializers.json's — and djust's spelled a datetime with a bareisoformat()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 onlynormalize_django_valuewould have created the violation the issue describes — a pre-pass spelling.123while the encoder it feeds spelled.123456.Three sinks, one helper, found by grepping the SINK (#1646).
djust/serialization.py's encoder, itsnormalize_django_value, anddjust/template/serialization.py::serialize_value— the third was not in the issue and was found by greppingisoformat()rather than by listing the callers already known. All three now calldjango_json_datetime, which callsDjangoJSONEncoder.defaultrather 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 thedatetimeslice pair as thetimerule, andr[:23] + r[26:]is a no-op on atime—"03:04:05.123456"is 15 characters, so nothing is truncated.timedeltajoins djust's encoder's branch as well, which had raisedTypeErrorwhere Django's has always answeredduration_iso_string; that is whynormalize_django_valuedocumented 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
TestParityWithJSONRoundtripand diagnoses its 17-value list as sampling onlymicrosecond == 0with notzinfo. That is true and it is not the load-bearing half: the test importsDjangoJSONEncoderfrom 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. Meanwhiletest_decimal_converters_2239.py::TestEncoderMatchesRealDjangohad 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)); andtest_template_serialization.py::TestDjangoJSONEncoderTypesasserted 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 branchesdefault()actually has (o.microsecondtruthiness,r.endswith("+00:00"),is_aware(o), andduration_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 nottimezone.utcbut formats identically, and+00:01, which ends in0:00without 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'sdefault()raisesValueError: JSON can't represent timezone-aware times., keeps emitting itsisoformat()— the more-permissive direction djust takes for every unserialisable value (#2429), and the directiondjango_json_encodedtakes by failing closed. It is an explicit branch rather than a bareexcept, so it cannot swallow a different failure. And the LiveView path still FLATTENS adatetimeto astrin Python, soValue::Encodedis 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 ofnormalize_django_valuereceives — 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…Zwhere it rendered…+00:00on 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,TestThreeTestsWereBlindOnComplementaryAxesandTestWhatThisDeliberatelyDoesNOTCloseinpython/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-timeresidue.tests/unit/test_normalize_django_value.py::TestParityWithJSONRoundtripis 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 itstest_timedelta_is_a_known_pre_existing_gapinverted;test_template_serialization.py's hand-written literal now asserts Django's answer rather than a third one; andtest_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_digitanswers anint, as Django's docstring says it always does — not a one-character string (#2459). Django's body endsreturn int(str(value)[-arg])and itsexcept IndexErrorarm answers0; its docstring says "output is always an integer". djust's arm answeredValue::String((*b as char).to_string())andValue::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 astriterates, subscripts, has alen()and is truthy at"0"where anintdoes 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 plusrandom(random.choiceislenthenvalue[i]) andphone2numeric(.lower()on anint), and{% for %}over the digit; (2) renders on both, DIFFERENT text —pprint(2vs'2'),length(Django'slen(int)raises into its ownexceptand answers0; a string answers1),stringformat:"d"("%d" % "2"is aTypeErrorDjango'sexceptswallows, so djust answered""); (3) silently takes the wrong branch —{% if p|get_digit:"9" %}and{{ p|get_digit:"9"|yesno }}, because theIndexErrorexit is0, 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. Adjango-refuses / djust-renderscount — 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_errorchokepoint was already right about every refusal cell — anintis not iterable and not subscriptable, and it refuses when it is given one. It was being handed astr. So the diff is one arm's return type:Value::Integer(i64::from(b - b'0'))andValue::Integer(0).int_value_ofis deliberately not called — it exists to parse an arbitrary digit string and widen pasti64, 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
TestTheOutputIsIndistinguishableFromDjangosOwnReturnasserts, for every filter Django registers and each ofget_digit's four exits, that{{ p|get_digit:<n>|F }}over the subject equals{{ q|F }}overdjango.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 ownFILTER_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 (
adac3068f0802d1d→a0923abeeb3c9c90):django REFUSES & djust RENDERSgoes 6,106 → 6,009, anddjust REFUSES & Django RENDERSis 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 namingget_digit: 97 become a refusal, 40 now agree while still rendering (thefirstof/{% if %}truthiness class,0vs"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 is4in Django and0here on BOTH builds — a pre-existingpython_lendivergence about the model-vs-dict marker (#2294) — and the branch used to match only because djust's0arrived as the truthy string"0". Two different numbers agreeing on one boolean; pinned intest_one_cell_stops_agreeing_and_it_agreed_by_COINCIDENCEwith thelengthcontrol, 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 arerandom'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 plainintwith no filter in the template, which already diverges;{% for %}, the same question one tag over, agrees. #2451 wired the type-named refusal into theforarm and theregrouparm kept its old fail-soft — parallel-path drift (#1646). Pinned live inTestTheTagOperandPositions::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), plustest_get_digit_answers_an_int_on_both_numeric_exitsincrates/djust_templates/src/filters.rs— the first assertion at that arm to read theValueVARIANT rather than.to_string(), which is the shape that hid this. The residue pin inTestTheResidueThisDoesNotTouchinverts as its own docstring instructed. Four gate-off mutations, each rebuilding the crate with the.somtime asserted to have advanced,__pycache__cleared, the mutation text asserted found exactly once, andN errorcounted apart fromN failed.bool(timedelta(0))isFalse, as it is in Python and Django (#2458). Every other member of the datetime family is truthy for every value — a midnighttimehas been truthy since 3.5 — so a zerotimedeltais the whole of the divergence, and it was live in the plainest possible template:{% if p %}T{% else %}F{% endif %}renderedThere andFthere. It predates #2448: atimedeltacrossed the PyO3 boundary asValue::String("0:00:00"), which is non-empty and therefore truthy under the string rule, and #2448'sValue::Encodeddeliberately 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.
Encodedgrows a fourth field set from Python's ownbool(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-TRUTHYstr"0:00:00"— one text, two answers.TestTheBitIsPythonsAndNotADerivationruns both counterexamples: a NON-zerotimedeltasubclass whose__bool__saysFalse(every spelling-derivation answersTrue) and a ZERO one whose__bool__saysTrue(every spelling-derivation answersFalse).The
timesincehalf was a second, text-shaped copy of a rule the codebase already answered value-typed (#1646).{{ p|timesince:q }}withq = timedelta(0)measures from now in Django and RAISED here, becausetimesince_arg_is_falsy(&str, bool)recovered Python'sif not now:from the argument'sDisplaytext — whileArgType::is_falsy(#2413), computed from the RESOLVEDValuetwo frames up, was already sitting there with the right answer. The copy is deleted and the filter reads the shared bit; #2448's owntest_every_display_arm_that_can_be_falsy_is_handledhad 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
strspelling a falsy object —"0","None","False","0.0","[]","{}"— was read as the object it spells and measured from now, where Python calls every non-emptystrtruthy and Django raisesAttributeErroron.year; it now raises on both. And underlegacy_display, which renders EVERY sequence as the literal[List], an empty list was indistinguishable from a full one and both raised; theValueis not, so both modes now agree with Django. The four rows that used to beTestTheFalsinessResidueIsNamed's residue are down to one, and the survivor is genuinely about the wire format rather than about truthiness: a date-SHAPEDstris still read as the datetime it spells, because a Pythondatetimecrosses into Rust as a string and has no other spelling.The state round trip carries the bit, because the
Decimalversion of this shipped once without it (#2135).SerializableViewState.stateround-trips through msgpack on EVERY read of the defaultInMemoryStateBackend, 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_itdecodes 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
sethas noValuevariant at all, so it lands on the conversion's finalValue::String(str(o))and arrives as the non-empty"set()"—bool(set())isFalseand{% if q %}rendersT. 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 inTestWhatThisDeliberatelyDoesNOTCloseand filed separately.New cases in
TestPythonsOwnAnswerForTheWholeFamily,TestTheBitIsPythonsAndNotADerivation,TestTheStateRoundTripKeepsTheAnswer,TestTheConvergenceDividend,TestTheSinkHasExactlyTheCallersItClaimsandTestWhatThisDeliberatelyDoesNOTCloseinpython/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,Zand+HH:MMand negative offsets, zero/positive/negative/sub-second/multi-day durations — withbool()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 andpython/tests/test_json_script_datetime_value_2448.py's zero-timedeltaexemption is inverted to agreement rather than deleted. Four gate-off mutations, each rebuilding the crate and asserting the.somtime advanced, redden 9 / 10 / 3 / 30 tests; no survivors, no build breaks.json_scriptspells adatetime/date/time/timedeltaVALUE the wayDjangoJSONEncoderdoes, notstr()(#2448).django.utils.html.json_scriptisjson.dumps(value, cls=DjangoJSONEncoder)and that encoder'sdefault()is notstr()— it isisoformat()with the microseconds truncated to milliseconds and a trailing+00:00rewritten toZfor adatetime, andduration_iso_stringfor atimedelta. 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", andtimedelta(seconds=90)went out as"0:01:30"rather than"P0DT00H01M30S". Not cosmetic: neither is parseable byDate.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
timeas AGREEING (✓); it agrees only atmicrosecond == 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 adatetimecarrying microseconds. Andtimedelta(seconds=-90)is"-P0DT00H01M30S"against astr()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.dateis 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.dumpsREFUSES) 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 Valuelanded adatetimeon its finalOk(Value::String(ob.str()?))fallback three arms below aDecimalarm that reads its type with anisinstance. So the fix stops discarding the type rather than reconstructing it downstream.Value::Encodedcarriesstr(o),DjangoJSONEncoder.default(o)and CPython'stp_name;value_to_jsonis 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, theZrewrite andduration_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), whichtemplate/backend.pytakes, so a plain Django view rendering throughDjustTemplateBackend— hands Rust the Python object, and that is the pathValue::Encodedexists for and the one this closes. The LiveView path runs its context throughnormalize_django_valuefirst, which flattens adatetimeto an ISO string in Python, soValue::Encodedis never built there. That path was already mostly right — and only mostly: the normalizer violates its own documentedDjangoJSONEncoderidentity for 4 of 10 datetime shapes (it applies neither the millisecond truncation nor theZrewrite), 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
Decimalversion of this was shipped without it once (#2214/#2135).SerializableViewState.stategoes through msgpack on every read of the default backend, so an untaggedEncodedcomes back as aValue::Stringholding the display spelling and the whole defect reopens after one cache hit.ENCODED_TAGcarries[type_name, display, json];TestTheStateRoundTripKeepsTheEncoderSpellingexercises 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 iterableinstead of iterating the display string CHARACTER BY CHARACTER (#2382's residue, closed for four of its five shapes — a bareobject()is still on thestr()path), and adatetimefilter ARGUMENT now raises as Django'sint()does, so{{ p|floatformat:dt }},|get_digit,|truncatecharsand|truncatewordsagree. #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 stillstr(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 awaretime(whose encoder RAISES, so the helper fails closed to the pre-fix path),bool(timedelta(0))(#2458) and|pprint. Each has a test inTestWhatThisDeliberatelyDoesNOTCloseso 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.somtime advanced, and counts pytesterrorapart fromfailed(#2129/#2135).{{ nope|random }}renders""as Django does, instead of refusing with a message that is false of everystr(#2449 reconciliation). #2461 landed the sequence-filter refusal onmainfirst —first/last/random/unordered_list/safeseq/escapeseq/phone2numericthrough oneValueOpErrorchokepoint — 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::Missingis Django'sstring_if_invalid, which is"": typestr, length 0, and subscripting it is anIndexError.python_type_nameandpython_getitemboth said so — the latter with the comment "string_if_invalidis"", and""[0]is an IndexError" — whilepython_lenansweredNone("len()of the thing that was not there is not a number"), reasoning about ABSENCE where the other two reason about the substituted string.randomis the one caller that distinguishesNonefromSome(0), so{{ nope|random }}refused with'str' object is not subscriptable— a self-contradicting message, since everystris subscriptable — where Django renders"". Stricter than Django, on the most ordinary shape a template has. Fixed by givingValue::Missinga 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
pon every cell and skipsrandomas nondeterministic — two exclusions meeting on the one filter that had the bug.TestAnAbsentVariableIsStringIfInvalidOnEveryOneOfTheSevenis the missing axis, andTestTheDatetimeFamilyReachesTheChokepointWithItsRealTypeNameis the other one its corpus cannot reach; both are added totest_sequence_op_chokepoint_2451.pyrather than to a parallel table. Thed[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_missingincrates/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 (
4e5cde758a0519f9→588bb3f544eb9c3d), 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 RENDERSis 38,105 before and 38,105 after — 0 cells became stricter than Django, which is the number thepython_lenfix 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_tagdiagnoses a failure by returning<!-- djust: <tag> render failed (check server logs) -->, and returned it as a plainstr. Since #2379 the Rust tag bridge ESCAPES a handler's return unless it carries__html__(Django'sSimpleNode.renderrule), so the comment reached the page as the visible text<!-- djust: djust_pwa_head render failed (check server logs) -->— 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.exceptionas 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 incomponents/function_component.pyis ALREADY marked, by #2379's own single-exitsafe_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'smark_saferule, 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.renderreturns aSafeString— 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_VECTORSintest_custom_tag_return_escape_2379.pynow carries a kwarg whose value holds a double quote, which breaks_build_django_tag'skey="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:77andfunction_component.py:302as the only comment-returning exits behind the bridge, and the second was already marked by #2379.New cases in
TestTheFailureDiagnosticReachesThePageAsAComment,TestTheSiblingDiagnosticAgrees,TestTheMarkerCannotBeWidenedByItsArgumentandTestEveryPwaHandlerRoutesThroughTheOneExit(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
SafeDatamarker, and a quoted literal loses its quotes (#2416). Django'sSimpleNode.rendercompiles each operand withparser.compile_filter(bit)and resolves it withFilterExpression.resolve(context), handing the handler the resolved object. djust flattened every operand to aStringthroughvalue_to_arg_string, which lost two things Django keeps. (1) The marker:{% ct_cond p %}overp = mark_safe("<img src=x onerror=alert(1)>")— a handler whose body is the ordinary defensiveconditional_escape(value)— is a no-op in Django and the markup renders; djust handed it a barestr, 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 withself.literal = mark_safe(unescape_string_literal(var)), so a quoted literal loses its surrounding quotes AND arrives asSafeData; 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"<b>". 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 itpost, 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 theSafeStringin ONE place (registry::build_py_args, shared by all three registries), andNode::CustomTagresolves throughget_value_safe— which ends atdjango_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 aValue::String— Django's own rule, sinceSafeStringis astrsubclass. 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 akey=valuecomposite (the transported text iskey=<value>, so marking it would mark thekey=bytes too — left over-escaping and otherwise unchanged), and not an operand that failed to resolve.TestTheGrantDoesNotWidenasserts 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 it5. It does not: marking a stringSafeDatadoes not make it anint. The two are different halves of the same flattening and only the safety half moved; the type half would rework thevalue_to_arg_stringcontract every handler decodes against —RenderSlotTagHandler's JSON round-trip among them. Pinned inTestTheArgumentTYPEIsStillAStringand in #2356'stest_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 fromcontext.is_safe(var_name), and a literal is not a name — so{% firstof "<B>"|lower %}came out ESCAPED where the{{ }}arm, seeded fromdjango_literal's own bool, was already right.loweris registeredis_safe=True, so a safe input stays safe; anuppercell cannot tell the two seeds apart, which is why the corpus's existingct-filteredshape never moved.Unquoting has one fallout, and it needed a guard of its own.
TagHandler._resolve_arg— whichurl,static,djust_markdown,live_render,dj_flashand 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 ashome, which matches the variable-token regex, so a context variable namedhomeSHADOWED the URL name — the #2041 footgun one channel over, introduced by this very change and measured before it shipped:{% t "home" %}withhome = "/SHADOWED/"resolved to/SHADOWED/, and{% t "post.slug" %}walked apostdict. The guard is the marker this PR adds:SafeDatameans 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, andtest_a_PLAIN_resolved_string_is_still_re_resolvedpins 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_POSITIONSpolicy into the very block this replaces, so git could not combine them — its policy branch sits inside the code this deletes.resolve_custom_tag_argsstates 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 throughresolve_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_literalhands back the unescaped text and those exact bytes reach the page, which is why Django marks it (Variable.__init__ends its quoted branch withmark_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.SafeDataasserts "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 characterp, which is the class #2379 and #2421 closed on the one handler that made it framework-reachable with no|safeand nomark_safe. Django has no rule to copy here, because Django never hands asimple_tagan 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_slotnever reads its argument's marker; it resolves the path itself and marks its own RETURN at the one exit terminating in a slot entry'scontent.Both harnesses were measuring the wrong thing.
test_custom_tag_return_escape_2379.py::bothandtest_tag_operand_axis_2355_2356.py::djust_rendercalled_rust.render_template, which has nosafe_keysparameter — onlyrender_template_with_dirscarries the context-safety channel (#2287). So no row in either file could grant anything, and theirmark_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-literalalone could not separate the two stacked defects.ct-literal-plain({% ct_ident "post" %}) is the quotes alone;ct-literal-condis the grant alone;ct-literal-filtered({% ct_ident "<B>"|lower %}) is the literal crossed with a FILTER, which neither the name-basedct-filterednor the barect-literalcould construct.unmasked()'s@ctagarm is DELETED: it excused a cell whose new output was Django's escaped once more while thect-condtwin 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@ctagregression is now always reported. Still unreachable, and said rather than left silent: the built-in-tag axis writesp|<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_slotsitting 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.TestTheEnumerationReachesTheMarkupBranchesTooasserts 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
ctagaxis (189 of 492); the one that moved without newly agreeing israndom, nondeterministic by construction. New cases inTestAMarkedContextValueArrivesAsSafeData,TestAQuotedLiteralLosesItsQuotesAndIsSafeData,TestTheGrantDoesNotWidenandTestTheArgumentTYPEIsStillAStringandTestTheHandlerBaseClassDoesNotReResolveALiteral(41 inpython/tests/test_tag_argument_safedata_2416.py), inTestTheDivergencesThisUnmaskedAreNowCLOSED,TestTheDifferentialHasNoCtagExemptionAnyMoreandTestTheEnumerationReachesTheMarkupBranchesToo, and incrates/djust_templates/src/renderer.rs(tag_arg_marks_only_a_safe_string_value, threeresolve_custom_tag_arg_*cases andevery_handler_arg_construction_site_is_accounted_for, which pins the construction-site SET rather than a floor) pluscrates/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 pytestN errorcounted apart fromN 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, becauserender_slotis 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 bytest_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 theValue::Stringnarrowing reddened NOTHING, because_collect_safe_keysdescends to theSafeStringleaves and never emits a container path, so no row could reach the branch.render_template_with_dirs'ssafe_keysis 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 atRenderSlotTagHandler._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.
RenderSlotTagHandlerdeclaresRESOLVE_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.contentterminates at thecontentkey of a{"name", "attrs", "content"}slot entry,pterminates at a bare context value. Both registries now read the policy through ONEread_resolve_positionsrather than a hand-copy, because the rule's two halves — a missing attribute and an explicitNone— 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 literallycontentand the segment before it resolves to a dict with EXACTLY the key set_extract_slotsbuilds — 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.contentspelling;test_the_slot_shaped_dict_grant_is_the_one_2421_ALREADY_givesrenders 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, acontentkey on a dict with an extra or a missing key, a top-levelcontentvariable, and_render_value's trailingstr(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,TestTheGrantIsNotWidenedandTestTheDiscriminatorsPremises(python/tests/test_render_slot_scalar_path_2423.py). #2421's owntest_the_scalar_spelling_is_over_escaped_which_is_a_LIMIT_not_a_leakwent 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_scriptspells a dict KEY the wayjson.dumpsspells it —true/null/Infinity, and1e+16(#2425).json.dumpsdoes not callstr()on a non-strdict key; it has its own five-entry table, in CPython'sc_make_encoderorder:strunchanged, the three JSON literals forTrue/False/None,float.__repr__,int.__repr__, andTypeErrorfor anything else. djust routed the key throughObjectKey::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 ofjson_float_body(#2270),json_key_body, called from the one object-key site invalue_to_json— the count pinvalue_to_json_escapes_every_string_through_the_one_helperstill sees its fourjson_string_bodysites, 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
intandfloatarms agree by coincidence, becausestr(0)andstr(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 neverstr().to_display_string()is the key's TEMPLATE display — what{% for k in d %}{{ k }}{% endfor %}writes — and it parts company withfloat.__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/nan→Infinity/NaN) were found by the key-type sweep; the exponent band was found by the gate-off, which failed1e16and1e-05when theFloatarm 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.dumpsREFUSES, and they are deliberately not closed here (#1079): djust emits a key'sstr()where Django raisesTypeError: 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 raisesObject of type Obj is not JSON serializableon 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 inTestTheRefusalHalfIsADecidedLimit, whose second method asserts the VALUE position is permissive and is the reason the first is left alone. Thedatekey is the sharpest argument for taking them together:DjangoJSONEncoder.defaultserialises 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 notjson.dumps's bare table.scripts/filter-parity-differential.py's corpus stays at exactly one divergent argument-lessjson_scriptcell before and after —d-typed-key, now held there by its(1, "t")key rather than by itsTrue/Noneones — 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'stest_a_bool_or_None_KEY_is_spelled_Python_not_JSONflipped totest_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 — thetrueandfalsearms 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'sN errorapart fromN failed._rust.pyi'sregister_tag_handlerexample now runs, and documents the real contract (#2417). The stub showed a bare function (def handle_custom_tag(args, kwargs)), which the runtime rejects withTypeError: Handler must have a 'render' method— so the first thing a project author writing a custom tag would copy raises on paste. All twelveExample::blocks in the stub were executed: six raise, but five of those are ordinary placeholders (an undefinedArticle, a fictional template path);register_tag_handleris the only one that supplies every name it uses and still fails. Its siblingsregister_block_tag_handlerandregister_assign_tag_handlerdocumentrender(...)correctly, and all 221 registered handlers follow that contract, so only the stub disagreed. It survived becausescripts/check-doc-snippets.pydoes not read.pyi. Writing the replacement surfaced a second inaccuracy:argsarrive already resolved against the context, so a natural-lookingcontext.get(args[0])would look up the resolved value as a variable name —{% custom p %}withp="<b>hi</b>"givesargs == ['<b>hi</b>']. The example now also shows the post-#2379 escaping contract. New cases inTestTheStubExampleRuns, 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 themark_safe1, dropping theescape1, 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-strreturn get escaped — right in general, and wrong for the one valueRenderSlotTagHandlerechoes that the PARENT already rendered.{% render_slot p %}over{"content": "<strong>rendered</strong>"}gave<strong>rendered</strong>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&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 trailingstr(value)is a bare value straight out of the render context. Marking the whole return restores a shipped vulnerability —render_slotis 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, nomark_safeand 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 — atv1.1.0registry.rshad noescape_handler_returnat 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<img …>in the entry while literal markup written beside it survives raw, and the sentinel'shtml.escape/html.unescaperound-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 asimple_tagreturn'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.mdand 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.
SlotTagHandlerreturns a<!--DJUST_SLOT_V1:…-->sentinel whose payload_emit_slot_sentinelhas alreadyhtml.escape-d, so nothing in it is left to escape and escaping the comment would break slot collection outright;CallTagHandlerreturns 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) andtest_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 andrender_slotreturns""there, so its markup branch was in the "13 return the empty string" bucket and the audit never reached it. 27 cases inpython/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 reddensTestASlotsMarkupRendersLive(9 failed), widening it to the trailing return reddensTestABareContextStringStaysEscaped(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_valueat 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_scriptmatchesjson.dumps'sensure_ascii, and omits theidattribute for a falsyelement_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'sTestKnownDivergencesOnTheSamePathso they would go red the day they were fixed — which is how this landed.ensure_ascii:django.utils.html.json_scriptcallsjson.dumps(value, cls=encoder or DjangoJSONEncoder)and passes noensure_ascii, so it takes the default ofTrue(DjangoJSONEncoderoverrides onlydefault(), 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 runningjson.dumpsover every codepoint rather than read off the C encoder: what comes back raw is exactlyU+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_escapeswrites UPPERCASE — both spellings are Django's, from the two steps it composes — and0x7F(DEL) escapes, which the Rust helper's own doc had argued against by citingensure_ascii=False, true about a call Django does not make. Escaping lives injson_string_body, the ONE helper every quoted string invalue_to_jsongoes through (#2241), so keys, values and every depth get it from one place.json_escape_for_script'sU+2028/U+2029arms 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. Theid: the issue reported it as a MISSINGelement_id; running Django says the premise is narrower than the defect. The source isif element_id:— a TRUTHINESS test on the resolved Python OBJECT, notis not None— soNone,"",0,0.0,False,[]and{}all omit the attribute WHOLE, and djust wroteid="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&strcannot answer the question; the resolved value's truthiness is threaded as a thirdArgTypebit — computed once at the resolution site for BOTH argument channels, the context one and the literal one, becauseFilterExpression.resolveproduces 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-lessjson_scriptcell in the corpus already diverged on theid, 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 explicitid. Of the 41 plainjson_script <value>cells, 41 diverged before and 1 does now:d-typed-key, wherejson.dumpsspells abool/NoneKEYtrue/nulland REFUSES a tuple,bytesor object key while djust emits itsrepr— filed as #2425 and pinned inTestTheDivergenceTheUnMaskingRevealed, 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-settest_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 passedensure_ascii=Falseand so pinned the wrong encoder; and #2347's_KNOWNlist, 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'smismatchedassertion 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 inTestEnsureAscii,TestAFalsyElementIdEmitsNoIdAttribute,TestRandomizedDifferentialAgainstDjangoandTestTheDivergenceTheUnMaskingRevealed(80 total intest_json_script_ensure_ascii_and_element_id_2413.py), plustest_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_2413and the four-testjson_script_arm_structuremodule incrates/djust_templates/src/filters.rs. Six gate-off mutations — with the crate rebuilt per iteration, the mutation text asserted found exactly once, and pytestN errorcounted apart fromN failed— redden 34 / 17 / 19 / 18 / 26 / 6 Python tests and 4 / 1 / 2 / 2 / 2 / 1 Rust tests; no survivors. The twoidmechanisms are independently reachable (#2135): dropping theis_falsyguard reddenstest_every_falsy_resolved_argumentwhiletest_no_argument_at_allstays GREEN, and restoring the default reddenstest_no_argument_at_allandtest_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 countsid=-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.dumpsdoes (#2405).{% for x in p %}{{ forloop|json_script:"d" }}{% endfor %}over[1]putcounterfirst where Django putsparentloop— same keys, same values, different order, andjson.dumpspreserves 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 inNode::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, andTestTheForNodeDictWasAlreadyRightpins that as the premise the real diagnosis rests on. The order was destroyed one layer down, invalue_to_json'sObjectarm, which ranparts.sort(). So it was never aforloopdefect: every dictjson_scripttouched came out alphabetized — top level, nested, and inside a list — and aforloop-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 seventeenforloopshapes and not one could see this, because every one renders a MEMBER or the REPR and both engines already agreed on the repr; aforloop-jsonshape closes it, with an explicitidbecause{{ p|json_script }}diverges on theidATTRIBUTE on both builds and would mask the body — a gate-off that drops theidreddens 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 overd-plainandd-modelDID carry the divergence, and nothing attributed it. Left alone and pinned rather than folded in (#1079):json.dumpsdefaults toensure_ascii=Trueand djust emits raw UTF-8 (548 of the 1,600 fuzz values, unchanged by this fix), and{{ p|json_script }}writesid="data"where Django omits the attribute — both are named inTestKnownDivergencesOnTheSamePathso they go red the day they are fixed. Two-build differential against a baseline pinned at0b44d747, over 346,020 cells: 75 newly agreeing, 0 regressions, 0 introduced live-payload leaks (0 escaped / 0 live), 0 panics. New cases inTestTheForNodeDictWasAlreadyRight,TestASerializedMappingKeepsInsertionOrder,TestEveryOrderObservableFilterAgrees,TestTheCorpusGapThatHidThisFromTheForloopCellsandTestKnownDivergencesOnTheSamePath(18). Three gate-off mutations — re-introducingparts.sort(), deleting the corpus shape, and dropping its explicitid— 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 toyes/no/maybewhere Django'sif len(bits) < 2: return valuehands the VALUE back ({{ True|yesno:"only" }}wasonly, Django saysTrue); a falsy-but-not-Nonevalue took themaybearm that Django reserves forNonealone ({{ ""|yesno }}wasmaybe, Django saysno— and an ABSENT variable is falsy too, since Django substitutesstring_if_invalidbefore the filter runs, so{{ absent|yesno:"a,b,c" }}isb); a four-part argument readbits[2]forNonewhere Django's unpack raises for any length that is not exactly three and falls back tobits[1]; andValue::Bool(false)had its own arm answeringnowhile every other falsy shape answeredmaybe, 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 — thelen(bits) < 2exit returns the INPUT OBJECT, so{{ p|yesno:"only" }}over amark_safed value emits live<b>x</b>in Django, and that grant is now inbuiltin_produced_safebesidedefault's.timesince/timeuntil(#2399) had noif not value: return ""guard at all and ECHOED the input for every value they could not read —{{ p|timesince }}over"abc"renderedabcwhere Django raises, and over0rendered0where Django renders nothing. Django'stimesince()reachesvalue.yearon its first line, so a truthy non-datetime raisesAttributeError, which neither of itsexcepts catches; mirroringdate'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 asRuntimeErrorrather 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 tworeturn valuestatements and they are not the same answer:value = int(value)runs inside thetry, BEFORE thearg < 1test. So anint()that raised returns the INPUT OBJECT,SafeDataand all —{{ p|get_digit:1 }}overmark_safe("<b>x</b>")was escaped where Django emits it live — whilearg < 1returns the CONVERTED int, which djust returned unconverted ({{ False|get_digit:0 }}wasFalsewhere Django says0, and{{ 1.5|get_digit:0 }}was1.5where Django says1). It comes back as a NUMBER, so the rest of a chain does arithmetic rather than concatenation, and it carries no safety grant, because anintis neverSafeData. New cases inTestYesnoIsDjangosBody,TestTimesinceRefusesWhereDjangoRefuses,TestGetDigitsPassThroughBranch,TestNoArmIsMorePermissiveThanDjango,TestTheResiduesThisPRDoesNotTouchandTestTheIssuesOwnClaims— 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;yesnowas the last row of #2328'sOUTPUT_DIVERGES_FOR_ANOTHER_REASON, now empty. One moreyesnorow came out of the two-build differential rather than out of the issue:if arg is Noneis an IDENTITY test andstr(None)is"None", so a bareNoneliteral, a variable bound toNone, and the STRING"None"reach the dispatch table as the same four characters while Django answersmaybefor the first two andNonefor the third — the argument's resolved TYPE is now threaded (ArgType, which also carries #2366'sint(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 ateb7d89cd, 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.resolvemarks a CONSTANT argument safe (if not lookup: arg_vals.append(mark_safe(arg))), sodefaulthands theSafeStringback unchanged andconditional_escapeleaves 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_safehas takenarg_was_quotedsince #2202 and theaddarm already reads it — the change is two arms and oneif), 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 aSafeStringseparator alone) andjson_script, which the list does not name and whoseformat_htmlinterpolation puts a literalidin raw.yesnoandpluralize, which the list does name, are NOT members: theystr.split(",")the argument, and splitting aSafeStringyields plainstrs. The gate isarg_was_quoted, exactly Django'snot 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_escapespinned the old behaviour and is replaced by three tests, not deleted: the literal is live, and a variabledefaultargument and a variablejson_scriptid are both still escaped. Three premises of the tests' own first draft were also corrected by live Django and are recorded in the bodies:|upperLOSES the grant (is_safe=False), Django'sescapefilter isconditional_escapeand so is a no-op onSafeData, 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 makessafeseq's list collapse to its repr exactly as Django'smark_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 inTestAQuotedArgumentIsSafeData,TestTheBranchThatWasAlreadyRight,TestTheVariableChannelIsUntouchedandTestTheEnumerationIsMechanical; five gate-off mutations redden 11 / 3 / 3 / 2 / 2 tests, the last two being the over-permissive ones.{{ forloop.counter }}and every otherforloopmember rendered EMPTY (#2402). Django'sForNode.renderwritescontext["forloop"]— a dict carryingparentloop,counter0,counter,revcounter,revcounter0,firstandlast, updated on every iteration.Node::Forbound none of them, so all seven names missed and renderedstring_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.counteris 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_pathdeliberately uses the item index, and reading it here agrees on every forward loop while silently reversing the numbering on a reversed one.parentloopat 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 thelen(values) < 1early 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 namedforloopcarrying amark_safevalue grants the engine's own dict, and the whole repr — including the attacker-controlledparentloop— goes out UNESCAPED; gated off, that emits a live<script>alert(1)</script>where Django escapes it. The loop render cache'sforloopguard, whichloop_cache.rsdescribes as defensive because "the Rust renderer does not currently implementforloop", was protecting nothing until now — the members rendered empty, so a stale cached fragment was byte-identical to a fresh one, and the existingforloop_counterbattery 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 inPATH_SHAPES/TAG_SHAPES/BUILTIN_SHAPESreferenced aforloopname, 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 17forloopshapes (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 aloop-variableaxis whose requirement is parsed out of Django's ownForNode.rendersource rather than transcribed, so the day Django adds a member the manifest reports it MISSING.TestTheLimitTheManifestDoesNotCloseis sharpened accordingly: emptyingPATH_SHAPESis 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_templatewas the pin naming this bug; it is inverted in place, as its class docstring said it would be. Two-build differential againstc2d1405b: 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 areforloop-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 aforloopin it (test_a_forloop_free_if_in_a_loop_already_disagrees_on_the_markerpins that it is orthogonal). New cases inTestEverySevenMembers,TestArithmeticBoundaries,TestReversedUsesTheIterationOrdinalNotTheItemIndex,TestParentloopAndNesting,TestEmptyBranch,TestEveryOperandShapeTheLoopNormalises,TestUnpackingBothSpellings,TestCoordinatingTagsInsideTheLoop,TestFilterChainsOnForloop,TestShadowing,TestTheEscapingDirection,TestTheDjIfMarkerIsOrthogonalandTestTheCorpusGapThatHidThisFromTheDifferential, plus 4 Rust tests incrates/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.rendercomputeslen(item)(aTypeErrorcounts as 1) and raisesValueError("Need N values to unpack in for loop; got M. ")when it does not equal the loop-variable count. djust filled the extra names withValue::Missingand 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 aRuntimeErrorrather than Django'sValueError, as every djust render error does. The check alone was not the whole fix:zipITERATES the item rather than indexing it, so an item whose length DOES match unpacks by Python's iteration —["ab"]bindsa="a",b="b"and[{"x":1,"y":2}]bindsa="x",b="y"— where djust bound the whole item to the first name andMissingto the rest, rendering a dict's own repr into{{ a }}. Both shapes PASS the arity check, so both needed thezip; the new arm grants no safety to any component, because_collect_safe_keysspells a dict BY KEY NAME and a positional lookup there is the #2334 collision. The length rule is now stated once, infilters::python_len, which returnsOption<usize>—Nonewhere Python raises — because the two call sites disagree about the fallback (defaultfilters.lengthwritesreturn 0,ForNode.renderwriteslen_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).TestTheUnpackArityDivergenceIsNamedNotFixedmoved out oftest_for_unpack_comma_spelling_2377.pyas it said it would; what stays there is the #2377 half — every comma spelling raises the same message.TestDictIterationRandomised's residue classifier grew aboth-raisedrow 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 asdjango-raises-unpack / djust-rendered-before / djust-raises-the-same-message-after, and the corpus records a raise as<<EXC {type}>>, soValueErrorandRuntimeErrorcannot compare equal however faithful the message is. Every one moved in the less-permissive direction; none moved the other way. New cases inTestBothEnginesRefuseAnArityMismatch,TestAMatchingArityUnpacksByIteration,TestTheAnswersThatMustNotMove,TestTheLengthRuleIsStatedOnce,TestNoSafetyGrantSurvivesTheNewUnpackArm, pluspython_len_agrees_with_iter_valuesand two siblings incrates/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.renderrunsgroupby(obj_list, …)over whatever the target resolved to, so Django iterates with Python's own semantics — astryields its characters, adictyields its keys. djust's handler matchedlist/tupleand answered[]for everything else, so{% regroup s by k as g %}[{{ g|length }}]overs = "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 (abandnope), and Django's answer for the second is zero groups — iterating the text would have groupednopeinto four characters, MORE permissive than Django on a cell that already agreed. Soresolve_tag_value_argnow JSON-encodes a resolvedValue::Stringat a position the handler declared inRESOLVE_ARG_POSITIONS, and the quoting is the type tag that was missing;Decimal/BigIntdeliberately keep theirDisplayform, 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, sos = "q"grouped over the unrelated variableqwhenever one existed. Decided in the same pass per #2385's "check the other shapes": adictsource groups its keys. Left alone and pinned instead (#1079): a non-iterable source (int,bool,Decimal) renders an empty region where Django raisesTypeError— pre-existing and never more permissive.resolve_tag_operandis split intoresolve_tag_operand_valueplus 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 classifiedcoincidental(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.TestTheRegroupUnmaskingIsNamedsaid "if #2385 is fixed, delete this class"; it is flipped toTestTheRegroupUnmaskingIsCLOSEDinstead, because its evidence chain stays worth checking in the other direction. New cases inTestAStringSourceBuildsDjangosGroup,TestTheAnswersThatMustNotMove,TestTheDivergenceThatIsNotClosedHere,TestBothMechanismsAreReachableandTestTheWiringIsLoadBearing; 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 iscurrent[int(bit)], and Python subscripts astr— 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 everyContext::getcaller ("renderer.rsalone has 15") and was therefore a refactor. ButContext::resolvealready returns an ownedValueand is the door every operand site reaches —{{ }}calls it directly, and{% if %}/{% with %}/{% for %}/{% firstof %}/{% cycle %}reach it asget_value_safe's last arm — so the step is one helper besideContext::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 incurrent.get_item(idx)) while its value-stack twin did not. Recursive, because a character is itself astr({{ s.0.0 }}is'a'). Deliberately out of reach and measured rather than assumed: a NEGATIVE index (a Django parse error, already pinned), aValue::DictView(dict_itemsis not subscriptable;{{ d.items.0 }}stays empty on both), and the sidecar, which needs nothing. No new grant: a character sliced out of amark_safed string is a plainstrin Django (SafeStringoverrides__add__, not__getitem__), so both engines escape it — asserted throughrender_template_with_dirswith 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.TestTheStringIndexStepIsNamedNotFixednamed itself as the thing to move and is nowTestTheStringIndexStepIsCLOSED; #2371's 3,000-cell randomised sweep keeps its_walks_through_a_string_indexcount, which now bounds COVERAGE instead of an exclusion.{% regroup p.values by k as g %}silently misses: the assign-tag operand channel resolved throughget, notresolve(#2368).renderer::resolve_tag_operandhad two branches — a pipe-bearing one #2333 routed throughget_value, and a bare one still onContext::get. The dict views (d.items/.keys/.values) live inContext::resolve(dict_viewis 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 }}rendered0with 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 thingresolveadds beyondgetis 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, sinceget_value_safeends with acontext.resolvefallback); andtemplate_builtin, which is textually inert here becauseNone/True/Falseserialize back to the same bytes the raw token carried. The keyword-operand hazard #2041'sRESOLVE_ARG_POSITIONSexists to prevent is measured, not asserted: a handler that declares a mask (regroup declares{0}) never routes itsby/<attr>/as/<var>through this function, and the tests render with context entries deliberately namedk,by,asandgto 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_onlynamed itself as the thing to move the day this was fixed and is nowtest_a_view_reaches_a_tag_operand_through_BOTH_branches. New cases inTestTheBareDottedPathReachesTheHandler,TestTheControlsThatAlreadyAgreedandTestTheKeywordOperandsStayLiteral.{% for a,b in x %}— the comma WITHOUT a space — is tuple unpacking (#2377). Django'sdo_forjoins the tokens beforeinand splits that onre.split(r" *, *", …), soa,b,a, banda ,bare one three-name loop; djust split on WHITESPACE and only trimmed a trailing comma, soa,bbecame ONE variable literally spelleda,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 OWNdo_fordocstring ({% for key,value in dict.items %}). The split creates an empty-name case the whitespace split could not ({% for a, in p %}), sodo_for'sinvalid_charsrefusal comes with it verbatim — empty, space, either quote, or|— and NOTisidentifier(), 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 legacya,b,cform is gone andwith/firstof/regroup/ifchanged/widthratioall 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; fivePATH_SHAPESentries 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 inTestEverySpellingOfTheUnpackList,TestTheGrantStillTravelsUnderTheNewSpelling,TestTheInvalidArgumentRuleIsDjangos,TestTheUnpackArityDivergenceIsNamedNotFixedandTestTheCorpusGapThatHidThisFromTheDifferential.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_keyswrites a dict's marks atp.<key>— so nothing ever wroteq.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_mappingwas an ALIAS (is_saferewrites the dotted path through it) wherebindis a COPY, and it could express exactly one shape.loop_mappingsis now a plainname -> <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 NAMEpand the survivingq -> palias read it, emittingq's original hostile value RAW), and a MULTI-ASSIGNMENT tag (Django resolves every value against the OUTER context, sobin{% with a=p b=a %}binds the outerawhile an alias read the mark the same tag had just put ona). 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 inTestTheGrantReachesASubPath,TestTheGrantDoesNotLEAKSIDEWAYS,TestRebindingTheALIAS_TARGET_RetiresItToo,TestABindReplacesTheAliasToo,TestEachMechanismIsIndependentlyREACHABLE,TestTheAliasIsRefusedWhereTheCorrespondenceIsFalse,TestTheLoopMappingItReplacedStillWorksand a 3,240-cell generated sweep inTestNoBindingSHAPEEmitsAPayloadRaw.A quoted or numeric LITERAL resolves in
{{ }}, and a quoted one isSafeData(#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, whileNode::VariableandNode::InlineIfhad 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 withmark_safe(unescape_string_literal(var)), so{{ "<b>" }}renders LIVE markup in Django — resolving without the grant gives<b>, 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<B>(upperisis_safe=False) while{{ "<b>"|escape }}stays live (Django'sescapefilter isconditional_escape). Django's./egate beforefloat()is reproduced rather than simplified and is load-bearing —float()and Rust'sf64parser BOTH acceptinf/nan, and only the gate keeps a variable namedinffrom silently becoming a float. Two more cells fall out of deciding literals at COMPILE time as Django does: a context key spelled5no longer shadows{{ 5 }}, and an int pasti64renders 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-EXISTINGdate/timeunreadable-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 inTestTheEmitArmResolvesALiteralAtAll,TestTheLiteralCarriesDjangosGrant,TestTheTagOperandChannelAgreesWithTheEmitArm,TestTheInlineIfArmAgreesWithTheEmitArm,TestTheGateDjangoKeepsClosed,TestKnownNarrowerThanDjangoandTestTheCorpusGapThatHidThisFromTheDifferential.int(arg)is a TypeError for a non-strnon-number argument (#2366).truncatechars,truncatewords,get_digitandfloatformathave a Django source that catchesValueErroronly —try: length = int(arg) / except ValueError: return value— andint()raises TypeError for anything that is neither a string nor a number. So Django RAISES for a list, a tuple, a dict or aNoneargument, and djust returned its input: the more permissive direction, and inconsistent with djust's ownRaise-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 reachContext::resolveasValue::List/Value::Tuple/Value::Object, so their type is intact ONE LINE above whereto_string()discards it and the fix is to read one bit there rather than push a wholeValuethrough 57 filter arms; while adatetime, adate, atime, asetand an arbitrary object are alreadyValue::Stringby 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 bareNone;int_arg_is_type_errorasks it of the TYPE and subsumes it, sinceNoneresolves toValue::None(#2347). The rule is stated as whatint()ACCEPTS — CPython's own wording — so a newValuevariant 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 passesSome(context), so it could only ever answerfalse; the invariant that made it dead is pinned mechanically onrenderer.rsinstead. The differential could not construct any of this —ARG_CONTEXTbound 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 inTestTheFourReturnInputFiltersNowRaise,TestADictViewArgumentRaisesToo,TestEveryRendererCallSiteResolvesItsArgument,TestAnAcceptedArgumentStillWorks,TestOneMechanismNotTwo,TestTheExtractionBoundaryResidueIsNamedandTestARandomisedDifferentialOverTheArgumentAxis.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
_rustentry 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 %}answered0for every NON-NUMERIC operand where Django answers""(float(value)raises aValueErrorthatWidthRatioNode.rendercatches) — 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'sroundis half-to-EVEN, so{% widthratio 1 2 5 %}was3and Django's is2; it answeredi64::MAXfor a non-finite ratio whereround(inf)is anOverflowErrorDjango catches into""; and it answered0rather than raising for a non-numeric final argument, where Django raisesTemplateSyntaxError— that operand goes through Python'sint(), notfloat(), soint("100.6")raises wherefloat("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:asand 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 isrender_value_in_context(...)forfirstof— aSafeString, measured, so without the grant{{ v }}re-escapes an already-escaped string and renders&lt;b&gt;— and a plainstrforwidthratio, 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 withcompile_filterand a missing variable resolves tostring_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 thebychain and grouped every row underNone— one group where Django builds three, every{{ x.grouper }}empty. #2333 fixed the SOURCE operand;byis a filter expression too, sinceregroupcompiles<var>.<attr>, and the chain is now run through Django's ownFilterExpressionbecause that is literally what the Python engine does with it. The three hand-copiedNode::AssignTagarms in the sibling-aware render loops converge onto onesibling_updateshelper, 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: anas <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 onNode::AssignTag's dep arm, which states the reason in full.ifchangedandfilterstay 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, throughregister_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'sSimpleNode.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.TestKnownDivergencesOnTheCustomTagPathpins the current behaviour so that change cannot land silently. A harness bug theas-form shapes exposed:render_bothhanded Django the CALLER'S dict, andContext(d)keepsdasdicts[-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 assignedvwhen 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 rebuiltorigin/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 12ctagLIVE cells and the 42 oncycle/firstof/firstof-asare identical on both builds — the second set is the already-measuredadd:"1"|safedivergence reaching three more operands, not a new class. New cases inTestWidthRatio,TestFirstOf,TestCycle,TestRegroupByOperand,TestTheUnsupportedTwo,TestTheCustomTagPathIsReachableAtAll,TestKnownDivergencesOnTheCustomTagPathandTestTheCorpusReachesWhatTheseIssuesSaidItCouldNot(61 collected inpython/tests/test_tag_operand_axis_2355_2356.py), 3 incrates/djust_templates/src/renderer.rs::asvar_standalone_tests, and a newtest_2355_six_tags_took_a_filter_operand_and_were_exemptblind-spot canary inpython/tests/test_differential_reachability_manifest_2345.py— whose siblingtest_2325_no_tag_cell_existed_at_allgrew 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 (theint()-vs-float()case above), two shadowed bysibling_updateson 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 bycargo fmtand it refused withMUTATION TEXT FOUND 0 TIMESrather than reporting a green over a mutation that no longer applied.date/time/add/pluralizegive Django's own failure answer (#2359). Ten cells were reported, about the VALUE being aboolorNone—{{ 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/timeechoed the value for EVERY non-date (a string, an int, a float, a list, a dict, aDecimal),add's third branch echoed forNoneand every unsummable container, andpluralizehad anIntegerarm, 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 thetracing::debug!both date arms still emit.pluralizeis 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 texty,ies— and its twoexceptarms are NOT one arm: aValueError(a string that is not a number) falls straight to""and does not trylen(). 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.formatsplits 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_formatstates 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, becauseValuehas no date variant so a Pythondatearrives 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 againstmainafter #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.addwas the only ECHOING filter on the chain axis, andaddreaches its echo path only for a container, because"<img …>" + "1"succeeds and Django emits that concatenation live too.dateandtimeecho for EVERY non-date, so{{ p|date:"Y-m-d"|safe }}over a plain string is live onmainand empty in Django — and the corpus built zero chains beginning withdate, so no cell could say so. Adding the two toHOT2takes 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 againstmainat 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 ownUNESCAPED_TAGrule. #2376 made a quoted literalSafeData, 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, acrossdate,timeandaddcomposed withsafe,linebreaks,linebreaksbr,join,unordered_listandjson_script; they are pinned inTestAnEchoingFilterComposedWithASafetyGrantIsNotLive, 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 noaddin the template at all, and is filed as #2385; the evidence chain is asserted inTestTheRegroupUnmaskingIsNamedrather than argued. New cases inTestTheReportedCells,TestDateAndTimeRenderNothingForANonDate,TestAddsThirdBranchRendersNothing,TestPluralizeIsDjangosFourAnswers,TestIteratingANonIterableIsNamedNotFixed,TestTheArgumentTypeResidueIsNamed,TestTheDateWireResidueIsNamedandTestARandomisedDifferentialOverTheFourFilters.stringformatis CPython's%-format grammar, not a last-character switch (#2358).apply_stringformatdispatched onspec.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 bareTrue. 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%sfollowed by the letters, so Django answers'42s'). Group 3 —%ewrites 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 newcrates/djust_templates/src/stringformat.rsthat scans"%" + specthe 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 isPyMapping_Check; the mapping key RESOLVES immediately after it is parsed, so"%()" % {'a': 1}is aKeyErrorand not incomplete format; and Python's0flag is not C's —"%08.5d" % 42is'00000042'and"%05.2f" % infis'00inf'. Base conversion is long division on the exact decimal digits rather than a cast, so"%x" % 2**70is exact (anas u64here 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/%con 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 inTestTheRaiseResidueIsNamed. Two stale exclusions elsewhere went red and named themselves, exactly as built to, and both rows are removed rather than relaxed (TestOnlyAddWasBrokenByTheBareLiteralin #2347's file, andOUTPUT_DIVERGES_FOR_ANOTHER_REASONin #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's1.23e3and #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 againstmainafter #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'slive-payload leaksmetric 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<img src=x onerror=alert(1)>). Measured by splitting the closed set with the tool's ownUNESCAPED_TAGrule: 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 inTestGroupOneSpecsCPythonRejects,TestGroupTwoConversionsCPythonSupports,TestGroupThreeTheExponentFormat,TestTheFourGrammarRulesTheSweepFound,TestTheTupleIsStringifiedFirst,TestTheIntegerConversionsAreExactPastF64,TestTheAlternateFlagAndTheGeneralFormat,TestPrecisionMeansDifferentThingsPerConversion,TestTheWidthAndPrecisionLIMITSDifferFromEachOther,TestTheRaiseResidueIsNamedandTestARandomisedDifferentialAgainstLiveDjango.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 renders4. 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 throughlookup_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'sObjectKey, so{{ d.1 }}resolves against{1.0: …}and{True: …};int(bit)means{{ d.007 }}is the key7. AValue::DictViewis deliberately absent from the index arm, because Python'sdict_itemsis 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 astrtoo ({{ s.0 }}on"abc"is'a'), which needs an owned return across everyContext::getcaller; filed as #2373 and pinned inTestTheStringIndexStepIsNamedNotFixedso it goes red the day it is closed. The differential could not construct any of this — noPATH_SHAPESentry spelled a numeric segment and no input carried a numeric key — so the corpus grows ad-numeric-keyinput (holding0,"1"and1, the only shape that can measure the step order) and eight numeric-segment path shapes; the two-build sweep — re-measured againstmainafter #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 inTestTheReportedCells,TestTheThreeStepsAndTheirOrder,TestNumericKeysAreConflatedTheWayPythonConflatesThem,TestTheWalkIsPerSegment,TestTagOperandsResolveThroughTheSameWalk,TestTheMissesThatMustStayMisses,TestTheStringIndexStepIsNamedNotFixed,TestTheLexerLevelDivergenceIsNamedNotFixed,TestANewlyResolvableValueIsEscapedExactlyAsDjangoEscapesItandTestARandomisedDifferentialOverTheSegmentSurface(a 3,000-case randomised sweep against live Django whose own preconditions are asserted).timesince/timeuntilmeasure 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" whateverotherwas, 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:insidetimesince); a date or datetime is that instant, with a baredatetruncated to midnight; and anything else truthy raises AttributeError fromnow.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 makesnow - da TypeError, so Django renders the empty string, and so does this.arg_was_quotedis load-bearing, as it is foraddandfloatformat:{{ p|timesince:0 }}is the integer zero and measures from now, while{{ p|timesince:"0" }}is a non-emptystr, 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)istimesince(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 rulefloatformatcarries (#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 Pythondatetimecrosses 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 astrspelling"0","None","False"or a date is indistinguishable from the object it spells, andTestTheFalsinessResidueIsNamedasserts the divergence rather than claiming exactness — including a mechanical pin overValue'sDisplayarms, so a new variant with a falsy inhabitant has to be considered.Displayhas TWO modes and the rule knows both:django_value_repr(on by default, #2203) spells a boolTrue/Falsewhilelegacy_displayspells it Rust'strue/false, so a rule that knew only the default would raise for a resolvedFalseunder 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_displayrenders 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 fromRAISE_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 — becausetimesince/timeuntilare 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 inTestTheArgumentIsTheComparisonInstant,TestAFalsyArgumentMeansNow,TestATruthyNonDateRaises,TestAwarenessMixing,TestTheValueDecidesFirst,TestTheFalsinessResidueIsNamedandTestOneBodyForTwoFiltersinpython/tests/test_timesince_comparison_instant_2344.py. #2340'sValue::DictViewis handled too, and it was the mechanical pin that said so:TestTheFalsinessResidueIsNamedwalksValue'sDisplaymatch 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, whileDisplayspells an empty viewdict_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 everytimesince/timeuntilcell took the unreadable-value branch and the argument logic was never reached;s-datetimeis what it asked for. Then about the manifest itself:_swept_argument_errorsopen-coded the corpus product assorted(FILTER_ARGS) x ...whilearg_cellshad moved todjango_argument_filters(), andtimesinceis one of the four names in the second set and not the first — so the axis measured a narrower corpus than it ships. It iteratesarg_cells()itself now. A third followed:nondet_agreementcompared the two engines' raw output, which for a cell where BOTH raise compares Django'sAttributeErrortext against djust's wrappingRuntimeErrortext — strings that can never match — so a raise-bit fix read as unchanged and the differential reported zero moved cells on every axis._outcomereduces a raise to the fact of it while keeping a PANIC distinct (#2343). Measured with the two-build differential against a rebuiltorigin/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 byrust_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 %},|safeincluded, 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: amark_safevalue reached throughd.values/d.itemsloses its mark, because the collector spells a dict's paths BY KEY (p.a) while the loop's positional mapping looks forp.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 withpmarked 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::bindis the one door for every binding: it revokesnameand everyname.…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 theO(len(safe_keys))subtree revoke OUT of its iteration and calls theO(1)set_safetyper item — a COST decision, not a semantic one, andthe_loop_decomposition_of_bind_agrees_with_bindpins that the two spellings agree so the split cannot drift; without the hoist a loop over an N-element list of marked items paysO(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 theboolthat sat beside theValuethey already resolved (get_value→get_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 — itemicame from keyk, 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 plainValues across PyO3 with no safety channel, so the honest grant isfalseand 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 (sliceshifts indices,dictsortreorders — #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 whennormalised, the mapping only when not), so they can never disagree about one item. A key containing a.is refused outright —p.a.bis BOTH{"a.b": …}and{"a": {"b": …}}and no lookup can tell them apart — and a grant applies only to aValue::String, becausemark_safe_keysaccumulates and is never cleared (#2300). Two-build differential over 109,571 cells against a rebuiltorigin/main: 5,157 newly agreeing, 0 regressions, 0 panicking cells before or after; 6,559 cells moved on thetagaxis and 2 onpath, and 0 on every other axis —filter,chain,argument,argument-filter,cmp,customandbuiltinare untouched, which is the shape a binding-only change should have. 4 cells are flagged as newly-live and each isp|add:"1"|safe:add's third branch is a DOCUMENTED divergence (djust returns the value where Django returns""; the reasoning is infilters.rs), and the EMIT twin of all four was already live on the baseline build — measured on both builds, not argued.|safedoes 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:TestTheBindPathGrantsNothingTheEmitPathDoesNotasserts 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 markedp) 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 fromget_valuetoget_value_safeand are updated rather than widened, so the pin still reddens on a barecontext.get. 43 collected inpython/tests/test_safety_survives_a_binding_2361_2363.py(13 inTestTheGrantSurvivesAWithBinding, 12 inTestTheGrantReachesTheLoopVariableThroughADictView, 4 inTestTheGrantCrossesAnIncludeWithBinding, 5 inTestTheHostileKeyGateStillHolds, 7 inTestABindReplacesTheGrantRatherThanAddingToIt, 2 inTestTheBindPathGrantsNothingTheEmitPathDoesNot), plus 7 Rust cases incrates/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 fromerror: test failedand a pytestN errorcounted separately fromN 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 isUrlizer.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 - 3plus three dots is alsolimitcharacters long).{{ p|urlizetrunc:"5" }}onsee http://example.com/aaaa nowgaveht...where Django giveshttp…, and everyurlizetrunccell in the differential's sweep differed for this reason alone. It is the same ellipsis fix that landed fortruncatecharsin #2203 and never reachedurlize— parallel-path drift on a CONSTANT (#1646) — and the two deliberately still do not share a code path, becauseTruncator.charsnormalizes to NFC, skips combining characters and subtracts the truncation text's own visible length whiletrim_urlis a plain code-point slice; routing one through the other would be tidier and would not be Django.divisibleby's zero divisor: Django isint(value) % int(arg)andx % 0is aZeroDivisionError; djust guardeddivisor != 0and answeredFalse, a guard Django does not have. Reachable two ways and the second only recently —:"0"always, and:Falsesince #2328 madeint(False)be0as Python has it. The olddivisor != 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'sfloatformat, ahead of the value parse, so""raisesIndexErrorfor 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 ownNone-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, becauseisinstance(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_hereis INVERTED to assert agreement (its reasoning was wrong twice over — anIndexErroris not a crash, and "treated as the absent argument" was a silent different answer, which is worse than the raise), andtest_urlizetrunc_truncates_for_a_negative_limitdrops 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,|safeincluded, 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, sourlizetruncnever truncates there, which is theinput-shapeblind spot #2345's manifest declares UNVERIFIED, demonstrated rather than argued. New cases inTestUrlizetruncEllipsis,TestDivisiblebyZeroDivisor,TestFloatformatEmptyArgumentandTestTheEmptyArgumentIsAskedFirstinpython/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 redundantdivisor != 0).The parity differential DECLARES its axes, and reports what it cannot reach (#2345).
scripts/filter-parity-differential.pyhas 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 as0 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,SafeDatainvisible 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 inAXES, 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 fromfilters.rs's ownformat!strings),argument-filter,tag(every Django built-in tag),entrypoint(every_rustfunction that renders or changes how rendering works),grant-shape, andinput-shape— declared 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).--manifestprints what is and is not reachable; each results file carries its own manifest and the_rustbuild'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 pastisize; 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— becausearg_cellsiteratedFILTER_ARGS, the ESCAPING axis's table of one benign argument per filter and a different question with a 25/29 overlap. It iteratesdjango_argument_filters()now (8,700 argument cells over 29 filters), and theargument-filteraxis is what stops the two drifting again. A third, found the same way:render_both's newexcept BaseExceptionalso caught Ctrl-C, so a 95,275-cell sweep could not be interrupted — both engine arms re-raiseKeyboardInterrupt/SystemExitahead 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_rustbuild'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 inTestTheManifestIsCleanOnMain,TestItWouldHaveCaughtTheHistoricalBlindSpots,TestTheLimitTheManifestDoesNotClose,TestTheManifestAbsorbedRatherThanReplacedWhatLandedFirst,TestTheArgumentAxisCorpus,TestTheSameBuildGuardIsAnsweredandTestRenderBothSurvivesAPanicinpython/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'sContextlacked Django's three template builtins (#2347).django.template.context.builtinsis[{"True": True, "False": False, "None": None}], added to every DjangoContextatdicts[0]. The three names are NOT literals —Variable.__init__does not special-case them — they RESOLVE through the ordinary lookup, which is why{{ True }}rendersTrueand{{ True|yesno }}isyes. djust rendered''andmaybe. Two resolvers can reach a bare name and only one of them knew (#1646):renderer::get_value_safecarried inline arms, so{% if True %}and{% firstof None False True %}were always right, whileContext::resolve— the resolver{{ }}output, the built-in filter-argument channel and the custom-filter argument channel all use — had none.template_builtinis now the one statement of the rule, consulted inContext::resolveAFTERget, which is Django's own precedence (builtinsisdicts[0]and__getitem__walksreversed(self.dicts), so a user variable namedTrueshadows it — measured, not assumed). The renderer's arms were DELETED rather than repointed at the helper.get_value_safealready ends in acontext.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 lowercasetrue/false/nonespellings stay in the renderer; they are a djust extension Django does not have, andtemplate_builtinis exactly the Django set. The issue's own remedy was wrong, and that was measured rather than reasoned. It predicted the fix would makepython_int_arg's"True" => 1coercion redundant. It does not: the built-in argument channel isOption<&str>andapply_filter_full_safecalls.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 throughinto_pyobject, receives a realbool. Running each remaining cell against its NUMERIC control (the same cell with1/0) showed the argument-side defect was one filter:addhas its ownint()—int_digits_of, arbitrary-precision because a sum pasti64used to saturate (#2253/#2260) — and so never reached #2328's chokepoint where the bool rule lived. Both now callbare_bool_arg_as_int, andis_literal_filter_arg'sTrue | False | Nonearm 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 — sixdate/timeand 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 thestringformatspec family found alongside. The corpus grew a builtin-value axis (192 cells) and aFalseargument spelling — before them the tool boundpin 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 (mappingFalsetotrue) that reddens 17. New cases inTestTheValuePosition,TestUserVariablesShadowTheBuiltins,TestTheHalfThatWasAlreadyRight,TestTheArgumentChannel,TestKnownPreExistingDivergencesNotFixedHere,TestOnlyAddWasBrokenByTheBareLiteral,TestRandomisedDifferentialandTestOneStatementOfEachRule(154 collected inpython/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_stringformatread the conversion character asspec.chars().last().unwrap_or('s'), so an EMPTY spec entered the's'arm and reached&spec[..spec.len() - 1], where0usize - 1underflows. Debug traps it asattempt to subtract with overflow; release wraps tousize::MAXand the slice panics one line later withend byte index 18446744073709551615 is out of bounds— same blast radius, different message. Every arm (d/i,f/F,e/E) carries the samespec.len() - 1, so the guard sits ABOVE the dispatch rather than in the arm theunwrap_ordefault happened to select. Django's answer is"", measured on 5.2.16: its body is("%" + arg) % value, and a%that ends the format string isValueError: incomplete format, one of the two exceptions its ownexcept (ValueError, TypeError)catches. The severity is not the wrong answer — it is that a PANIC is not anException. PyO3 converts an unwind intopyo3_runtime.PanicException, whose MRO is[PanicException, BaseException, object], deliberately NOT underExceptionso a panic propagates likeKeyboardInterrupt.LiveViewConsumer.receivewraps its dispatch inexcept Exception→handle_exception→send_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_panicnow wraps the 16_rustentry points that run the engine — every one that executes template source, walks HTML or converts a user value — converting any unwind into aRuntimeError. 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'sMAX_PAD_WIDTHcaps are for, and apanic = "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_ARGSgave every filter exactly ONE argument and it was always VALID, so the differential's corpus could not constructstringformat:""at all — andrender_bothcaughtException, so when a panic did occur the sweep ABORTED rather than recording a cell. #2343 was found by that traceback.ARG_SPELLINGSnow 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;--comparereports newly-panicking cells on their own line and exits non-zero on any. That axis is what makes the number below real: onorigin/mainthe corpus now reports 15 panicking cells, and 0 after. Two-build differential against a rebuiltorigin/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 thestringformatguard reddens 13 Python cases and 1 Rust case; makingguard_panicstop 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 oneguard_paniclabel reddens only the 2 structural pins. Each mechanism reddens a test only it reddens (#2129/#2135). New cases inTestEmptySpecMatchesDjango,TestGuardCoversTheRenderSurfaceandTestNoReachablePanicAcrossTheFilterSurface(29 collected inpython/tests/test_panic_boundary_2343.py), 3 incrates/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 incrates/djust_live/src/lib.rs::panic_boundary_tests— which is where the premise is falsification-tested, because PyO3 createspyo3_runtimelazily 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 asif (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 itselseto "greater" for every NaN pair. Python answers False for all four ordering operators on any NaN, and True forinf == inf—float("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 thenumeric_pairwildcard aDecimalor aBigIntreaches) and two equality arms ((Float, Float)and theis_decimal_pairwildcard) — the "N similar sites need N tests" shape (#1104); all six now call oneorder_floatsor onefloats_equal, and a source pin asserts.abs() < f64::EPSILONappears in exactly two CODE lines so a seventh site cannot arrive with a seventh copy. The guard isis_nan, not!is_finite, and that distinction is the whole fix:-inf < 1 < infare 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 >= nanraises nothing, it evaluates toFalse), sotry_comparewas never asked the question #2338 taught it to answer — butNoneis still the right vehicle, because it means "all four operators are false". The NaN EQUALITY answer was right only by accident: the epsilon producedFalsefor the same undefined-comparison reason that madeinfwrong, so a future change to the tolerance would have flipped it silently with nothing failing;floats_equalmakes 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_floatsdeliberately 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 anda != b, soa - bfor a non-finite pair is always±infand never belowEPSILON. 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'stest_non_finite_floats_still_diverge("delete this test when #2349 is fixed") now asserts the same cells AGREE, and #2244'stest_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 gainedinf,-inf,nanand theDecimalforms ofInfinity/NaN: every numericINPUTwas 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 rebuiltorigin/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 aValuecarries no object identity; distinct NaN objects agree on every operator, andinfneeds no shortcut at all. 165 collected inpython/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.valuesare dict VIEWS, not lists (#2340). #2334 made the three methods resolve, to aValue::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[('a', 1)]where Django readsdict_items([('a', 1)]), and{{ d.items|first }}answered('a', 1)where Python raisesTypeError.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.
|slicedoes not raise — Django'sslicecatches theTypeErrorand 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.|dictsortdoes not raise either — it issorted(value, key=…), andsorted()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,|yesnoand|make_listall operate on the textdict_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, sod.keys in xmisses rather than matching by text),value_to_json,pprint::flat_repr, andloop_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_valuesyields the items so every iterating filter works from one sink, while|randomand|json_scriptguard 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::Tupleor-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:pluralizereturned the suffix unconditionally (right for a 2-entry view by luck, wrong for a 1-entry one), andvalue_to_arg_stringcollapsed a view toto_string(), handing an assign-tag handler the textdict_items([…])instead of the rows — the #2042[List]-collapse class one placeholder over. The other 15 were decided and left:first/last(Django raises;_ => Missingis 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_displaydeliberately 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 withdict_items([[List]])(CLAUDE.md #1867).legacy_displayis the pre-#2203 rendering where every container is a[List]placeholder, and before #2340 a view WAS aValue::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
TestTheDictViewModelIsAListand thedict-view-modelled-as-a-listresidue 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_claimsnow delimits each arm's own body instead of using a 400-char proximity window (the new guard's comment pushedrandom'siter_valuescall past it, and the pin reported a filter had stopped routing through the sink when it had not), andtest_every_bare_list_site_is_one_of_the_documented_list_always_fournow strips//comments before grepping — the rule its siblingtest_bool_before_int_converters_2212._strip_commentsalready states, since it reported a new construction site for a comment that merely namesValue::List(items).New cases in
TestTheIssueTable,TestAViewIsStillASequenceWherePythonSaysItIs,TestEveryFilterandTestNotMorePermissive, pluseach_kind_names_its_own_container_in_strincontext.rs,a_dict_view_names_its_container_only_on_the_django_parity_pathintest_display_django_parity_2203.rs, and two cache-collision cases intest_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 hidpluralizeanddictsortrespectively — 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/Nonebecame 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 namedTrueshadows the builtin.TestTheContextBuiltinsInteractioncarries the discriminating pair — the same template answeringNfor a string-keyed dict andYfor 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::Objectwas anIndexMap<String, Value>, and two bugs followed.{% if 0 in d %}comparedcontains_key(&needle.to_string())— a gate opening on a coincidence ofDisplayformatting, so0,1.0,NoneandTrueall 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 ownrepr—{% 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 realLiveView.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. WithObjectKeycarrying the type, both answers become Python's simultaneously. Pinned inTestThePremiseThatBlockedThisFix, because the whole design turns on it.Numerics are conflated the way CPython conflates them —
hash(1) == hash(1.0) == hash(True), so{1: "a"}[True]resolves — while the variant is kept for DISPLAY, sorepr({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::Strhashes EXACTLY as itsstrdoes and implementsEquivalent<ObjectKey>forstr/String, so all 232map.get("literal")call sites compile and behave unchanged; only 6 sites indjust_coreand 5 indjust_templatesneeded a semantic decision. Both Python→Valueconverters now share ONE key extractor rather than a second copy (#1646) —djust_live'spython_to_valueused 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 intoobject_key::dict_iteration_valuesbefore 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 inTestTheWireStillStringifies.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,TestTheWireStillStringifiesandTestRandomisedAgainstDjango. 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_valuesreturned-1 | 0 | 1and collapsed "these two cannot be ordered" into0. For>and<that reproduced Django exactly — Python raisesTypeError,{% if %}catches it, the branch resolves False, and0makes both of those false.>=and<=read the SAME0as equal and answered True for every pair with no ordering arm:{% if p >= q %}on"a"and1,{% if p <= q %}on[1]and(1,), a dict against anything, twoNones, 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 theOptionviais_some_and. There is deliberately noi32wrapper left: #2335 briefly carried one and removed it before merge precisely because, with every caller reading only thei32, theOptionwas observationally equivalent to0— 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'sNonerather than returningSome(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_equalcallsMissing/Noneequal, because Django'signore_failuresresolves an absent variable toNone, while Python'sNone < Noneraises — 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 saidYand Django saidN.TestSequenceComparisonRandomised.OPSgains<=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 theMissing/Nonearm 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 >= nanraises nothing, it evaluates to False), sotry_compareis never asked this question; the defect is the(a - b).abs() < f64::EPSILONidiom, undefined for NaN, at six sites, and the same idiom is whyinf == infis 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 inpython/tests/test_incomparable_ordering_2338.py(144 collected), plus four unit tests at the function inrenderer.rs.mark_item's non-strbranch is deleted — it had no producer left (#2337).filter_registry::mark_item, the helper both thePyListandPyTuplearms ofmark_input_safetycall, wrapped a sequence element inmark_safeonly if it was astrand 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 parallelPyTuplearm 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>"])isSafeString("['<b>']"), a string carrying a raw<that then bypasses escaping — the more-permissive-than-Django directionContext::items_are_safe's own doc-comment says this code must never take. Proven three ways. Analytically: every writer ofInputSafety.items = truecan only grant on a sequence whose elements areValue::String, whichIntoPyObjectturns into aPyString—Context::items_are_saferequiresmatches!(item, Value::String(_))for every element,safeseq/escapeseqCONSTRUCTValue::Stringelements unconditionally, andsliceonly preserves a grant already held. Empirically: the branch was replaced with apanic!and the extension rebuilt, and nothing reached it across the full suite, the 95,275-cellfilter-parity-differential.pycorpus, 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-strshape a sequence can carry — int, float, bool,None,Decimal, a bigint, a nested list, a nested tuple, a dict — through explicitsafe_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/escapeseqstringify every element themselves, and so does Django, whose[mark_safe(obj) for obj in value]turns['<b>', 2]into twoSafeStrings — verified against live Django rather than assumed. So on those paths the assertion is Django parity plus a structural pin on the constructors; onlyContext::items_are_safeis 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 therandomfilter's own nondeterminism).--comparerefuses 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: relaxingitems_are_safe'sStringnarrowing 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: lettingitems_are_safegrant on a dict changes nothing, becausemark_input_safetycasts toPyList/PyTupleand 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 astr" 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 isTestANonConvertingProducerRefusesANonStrSequenceplusTestTheProducerEnumerationIsComplete, 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 inpython/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/.valuesare Python METHODS, not keys, andContext::get's nested walk only ever doesobj.get(part), so the lookup missed; Django reaches them throughVariable._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 %}asget_value_safe's last arm) — placed AFTER theget, so a dict that has its own key nameditemsstill resolves to that key's value, which is Django's mapping-before-attribute order. Second,Node::Forhad noValue::Objectarm 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 theIndexMap'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'sstr()reads[…]rather thandict_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_equalhad 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_valueshad 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, sinceinis 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 throughRESOLVE_ARG_POSITIONSplus a JSON hop, not through the renderer'sget_value. So it asked for a variable literally namedcities|dictsort:"country", missed, and handed the handler the template's own source text —{{ groups|length }}rendered0and every{% for %}over the groups rendered nothing. Django compiles this operand withparser.compile_filter, and its ownregroupdocs open by noting the input usually needs sorting first, so the idiom is close to canonical. Oneresolve_tag_operandnow resolves a pipe-bearing operand throughget_value; a non-pipe operand keeps the plain lookup, because this channel's contract is "unresolved ⇒ pass the raw token" andget_value's literal arms have no way to say "unresolved" — they would turn regroup's ownby/<attr>/askeyword 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_keyswrites a dict's paths by KEY NAME (d.1), so a dict with a key spelled"1"whose value ismark_safe(…)putsd.1insafe_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_keyscollector.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 ownN,wordwrap's seventh spelling of the same thing, thetruncate_arghelper serving four more, andfloatformat::parse_int_likein its own module — and one more site inapply_filter_full_safefell 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 wordes. 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.receivealready catches a render error and sends a safe error frame (stack trace inDEBUG, 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 throughfilter_int_arg, which takes the policy Django's own source takes:Raisewhere Django writes a bareint(arg)(center,ljust,rjust,wordwrap,urlizetrunc,divisibleby) andReturnInputwhere it writesexcept ValueError: return value(the four truncates,get_digit,floatformat).floatformathad a secondint()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 broughtint()'s spellings that every scatteredparse::<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 noexcept ValueErrorcatches, soNoneraises under both policies — but only after the VALUE has parsed, because Django parses the value first and one that fails never reachesint(arg).{% if %}is the one construct that swallows the resolve failure, becauseIfNode.renderwraps its condition inexcept VariableDoesNotExist; the catch is deliberately narrow (it does NOT cover the unparseable-argumentValueError), which is why the miss carries its ownDjangoRustError::VariableDoesNotExistvariant. Two pre-existing defects fell out of the measurement.ljust/rjustpanicked — Rust's format spec holds its width in au16, soformat!("{s:<width$}")raises "Formatting argument out of range" at exactly 65536, one pastu16::MAX. That arrives in Python as aPanicException, whose MRO isBaseExceptiondirectly — it does not inherit fromExceptionat all, so it escapes the consumer'sexcept Exceptionand 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, pastusize::MAX, so the oldparse::<usize>()failed and fell back to width 0 — as do"x","-5","0"and"". One digit shorter isusize::MAXitself, which parses, and panicked. Both pad filters build their padding explicitly now, ascenteralways did. Second:urlizetruncrefused a negative limit and did not truncate at all, where Django'sTruncator.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_intsaturates pastisizerather than failing — right forslice, where a magnitude pastisizeselects 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 forisize::MAXspaces, which the allocator answers by aborting the process, not by raising.center/ljust/rjustnow cap the width atMAX_PAD_WIDTH(1,000,000, mirroringfloatformat::MAX_PLACES) and raise past it; Python's own answers there areMemoryErrorandOverflowError, which also fail the render.urlizetruncis deliberately uncapped — its limit is a comparison bound, never an allocation. The chokepoint is pinned mechanically byTestChokepointIsTheOnlyParser, which fails if a bare parse-and-default on the argument reappears; a comment would not (#1859). New cases inpython/tests/test_filter_argument_contract_2328.py, inTestWidthArgument, and incrates/djust_templates/tests/test_builtin_filter_arg_resolution_2202.rs. The first pass of this fix regressed 508 cells that the full suite ANDscripts/filter-parity-differential.pywere 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/timeuntilignore their argument entirely), #2346 (three non-int()divergences), #2347 (the missingTrue/False/Nonecontext 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 rendersab;{% 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 toValue::String(expression), echoing the template's own source into the page, so{% with q=p|upper %}{{ q }}{% endwith %}rendered the literal textp|upper,{% with q="lit" %}rendered"lit"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 callget_valuerather than each learning about filters separately, andTestEveryFilteredOperandSiteIsAccountedForpins the enumeration mechanically — a fifth tag that grows its own bare lookup fails it. Three supporting changes fall out:get_value_safegains theContext::resolvegetattr walk as its last arm ({% for %}calledresolve()directly for #806, and routing it throughget_valuewithout 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 sinceupper/join/first/lastall hand the loop a string; and the loop safe-key mapping is registered only for a bare operand, because it assertsitemis<iterable>.<index>and a filter falsifies that (sliceshifts indices,dictsortreorders). 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 %}.sliceimplements Python's slice semantics rather than approximating them (#2326). Django's filter isvalue[slice(*bits)]— a passthrough, so every Python rule applies — whileparse_slice_indicesread 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 isslice(stop)so"2"means[:2]and not[2:], a:stepwas 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_AdjustIndicesplus the walk) in oneslice_positionshelper 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_intis CPython'sint(), not Rust'sparse::<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 rejecting1_0returns the input unchanged and renders every element where Django renders none. Anythingslice(*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'sexcept (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, andslice's tworebuild_likeexits 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 —
sliceof a tuple is a tuple, andunordered_listaccepts 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'sslicefilter is a barevalue[bits]passthrough, so{{ p|slice:":2" }}rendered['a', 'b']where Django renders('a', 'b'); and Django'sunordered_listacceptsisinstance(x, (list, tuple, GeneratorType))as a sublist while djust matchedValue::Listalone, 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, andunordered_listfor the slice half) saw the same elements either way, which is exactly why the sequence-filter suites — which composeslicewithjoin— agreed for as long as they did. A third instance of the same rule shipped in #2316 (thePyTuplearm ofmark_input_safety), so this fixes the rule rather than the two cells: every production site infilters.rsthat 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 aresorted()(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 areslice's populated and empty branches, now routed through onerebuild_likehelper (#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 inTestEveryRebuildSiteIsAccountedForare the mechanical source) — the counterpart ofiter_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 matchedValue::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 rebuiltorigin/mainbaseline over 29,723 cells: 28 newly agreeing, 0 regressions, 0 introduced live-payload leaks (38 before, 38 after). The corpus gains at-nestedshape, becauset-plainputs a tuple at the top andl-nestedputs a list in the sublist slot, so between them it could not construct the one cellunordered_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):safeseqdoes not stringify its items the waymark_safedoes (#2324 —mark_safe(obj)isSafeString(obj), astrfor any input, and matching it needs aValue::py_str()becauseDisplayis deliberately Django'snumberformat.formatforFloat/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), andsliceclamps 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 inTestTheReportedCells,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 ownrepr(value[sl])— the shape's actual source),TestNotMorePermissiveThanDjango,TestEveryRebuildSiteIsAccountedForandTestTheNormalizationBoundary, plusrebuild_like_returns_the_container_it_was_givenandrebuild_like_does_not_touch_the_itemsinfilters.rs. Reproduction fidelity is load-bearing here and pinned rather than asserted:normalize_django_valuecollapses 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_listtuple 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 bareValue::List— which they catch, so "six rebuild sites" is a grep the suite enforces rather than a sentence in this entry.safeseqnow stringifies its items, becausemark_safedoes (#2324). Django's filter is[mark_safe(obj) for obj in value], andmark_safe(obj)isSafeString(str(obj))— it does not merely mark an item, it replaces it with the item'sstr(), 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 stringmark_safemade of the sublist. Identical for a tuple sublist, which is how #2317 found it. The spelling isstr(), not the render form, and it is the mechanism that already existed rather than a second one.Value'sDisplayis Django'snumberformat.format()forFloat/Decimal(#2214, #2258), sostr(1e20)is1e+20where{{ f }}renders100000000000000000000, andstr(Decimal("1E-9"))is1E-9where the render is0.000000001— a naiveitem.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@stringfiltercoercion at the top ofapply_builtin_filter, which is how #2303 fixed the scalar half (|safe) — by listingsafeinSTRING_FILTERSrather than growing a stringify inside the"safe"arm. This reuses it: the split moves intoValue::py_str()(djust_core, sibling ofpy_repr(), which is the same split one nesting level out), and both the coercion andsafeseqcall it — two.py_str()call sites infilters.rs, pinned mechanically byTestOneStringifyMechanismso 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_reprscanned onlydjust_templates/srcand its own doc noteddjust_corewas "out of this pin's reach", so moving a sink there would have silently un-pinned it — the scan now walksdjust_core/srctoo (three approved sinks,py_str/py_repr/Display, plus the one deliberateRUST_DISPLAY,legacy_display's frozen pre-#2203 arm) and its entries are crate-qualified, since both crates have alib.rs. Not a permissiveness change:safeseqgrants its items safety by name (ITEM_SAFE_OUTPUT_FILTERS) before and after, sojoin/unordered_listemitted 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 rebuiltorigin/mainover 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:INPUTScarried no list holding a number, aDecimalor a map, and heldl-nested/t-nestedoff the 2-chain axis citing #2324 itself as the reason — on the pre-extension corpus this same fix reported 2 newly agreeing cells. Newl-scalarsandl-dictrows (theDecimal/Floatspelling 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: theTestKnownAdjacentDivergencespin this issue was filed from is deleted (its own failure message said to) and{{ p|safeseq }}joins the nested-sequence agreement table, whiletest_context_item_safety_2287.pyandtest_custom_filter_safedata_2290.pyeach asserted that asafeseqoutput holding anintreaches a custom filter unmarked — Django's answer islist[True,True], measured, so those cells were pinning this divergence and now agree. New cases inTestTheRowsFromTheIssue,TestTheSpellingIsPythonStrNotTheRenderForm,TestNotMorePermissiveThanDjango,TestRandomisedSweep(11 chains × 1,046 shapes against live Django, with a guard that the generator still produces container items) andTestOneStringifyMechanism, pluspy_str_is_cpython_str_not_the_render_form,py_str_is_display_for_every_variant_but_float_and_decimalandpy_str_and_py_repr_differ_exactly_where_python_doesindjust_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_stringinstead ofpy_str5,py_strlosing itsFloatarm 4, losing itsDecimalarm 6, the coercion no longer callingpy_str5, and the sink pin no longer scanningdjust_core1 — 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_safelyandjit.py's identity-only subset. The depth-limited FK, the max-depth shorthand and bothtemplate/rendering.pyfallbacks 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 crossedmax_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 keydocs/SECURE_DEFAULTS.mddocuments 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, pkvspk, id), and dict order survivesjson.dumpsand msgpack, so two producers of "the same" map were emitting two wire shapes. Not more permissive: the added key isobj.__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__, sincetype("<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 isobject_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 rebuiltorigin/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 ownload/_leakslogic from its own dumps. Its corpus still gainsd-model, because the model-map shape had zero coverage — no input carried the"__str__"keyValue::object_strbranches on, so no cell could reach the model arm of{{ p }}or{{ p|length }}at all — and it deliberately gets noLIVE_FRAGMENTSentry, 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 asl-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 inTestTheHelperIsTheDefinition,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),TestAddingTheMarkerIsNotMorePermissiveandTestOneProducerNotSix. 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.wordwrapistextwrap.TextWrapper, not a greedy re-joiner (#2293).django.utils.text.wrapdelegates totextwrap.TextWrapper; djust'sword_wrapwassplit_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 atwidth=0where Django raisesValueError. 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 aU+2028string, because Django'ssplitlines()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.rsports_munge_whitespace/_split/_wrap_chunks/_handle_long_wordfor the flag set Django constructs (break_long_words=False,break_on_hyphens=False,replace_whitespace=False,drop_whitespace=True,expand_tabs=True) pluswrap'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, includingU+2028and a\r\nthat counts as ONE),textwrap._whitespacedecides where a chunk boundary is (six ASCII characters —\xa0is not one), andstr.isspace()decides which chunksdrop_whitespacediscards (so a lone\xa0is 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_breakis factored out ofpprint::py_splitlines_keependsso the keepends and no-keepends forms cannot drift (#1646), andtruncate::py_is_spaceis reused forchunk.strip() == ''. A parsedwidth <= 0now 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 andapply_filter_full_safedocuments 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.wrapover 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 rebuiltorigin/mainataa71903b, 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: everys-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 reportedagree BEFORE == agree AFTERover 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 printedREGRESSIONS: 0over an unmeasured change. That is thedictsortfailure 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 remainingpy_is_line_breakboundary) joinsINPUTSandINPUTS_2,wordwrapjoinsHOT2because it is the one built-in that INSERTS newlines into a string that later filters then read, andtest_every_whitespace_boundary_the_engine_branches_on_is_in_the_corpusnow 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 thedictsortcoupling 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 forlen(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 inwrap_chunks's doc comment being load-bearing rather than decorative. New cases inTestTheCancellingPair,TestWhatTheRejoinerDestroyed,TestTheThreeWhitespaceSets,TestWidthArgument,TestRandomizedDifferential(3,536 cells, with a sibling that requires the harness to be able to REPORT a disagreement),TestThisFileCannotBeFlattenedandTestEscapingIsUnchanged(python/tests/test_wordwrap_parity_2293.py), plus 11 intextwrap.rs.TestThisFileCannotBeFlattenedexists because it happened: an edit silently replaced twoU+2028literals 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_stringescaped\, the active quote,\t,\n,\r, the rest of C0 and DEL, and stopped — soU+00A0,U+200B,U+2028,U+2029,U+FEFFand every private-use code point rendered LITERALLY where CPython writes\xa0/\u200b/\u2028. It stopped there because CPython's rule isstr.isprintable(), which is Unicode-version data that disagrees across the CI matrix — thestriptagssituation (#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.10–3.14carry 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 (9473Lo, 945So, 183Mn, …). Sonot isprintable()splits into a STABLE part — the seven categoriesCc,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) — andCn, which is the entire moving part. Escaping the seven categories and treatingCnas printable reproducesstr.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/\UNNNNNNNNchoice 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'sunicodedataon 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 inpython/tests/test_py_repr_isprintable_table_2292.py(58 collected, with parametrization) including a 3000-case randomized differential, plus the inverted pins inTestKnownResidualDivergences. Six gate-off mutations, all red.truncatechars/truncatechars_htmlnow normalize NFC,truncatecharsstops counting combining marks, andslugifyfolds NFKD to ASCII (#2319).django.utils.text.Truncator.charsopens withunicodedata.normalize("NFC", text);_text_charsthen skips characters whose canonical combining class is non-zero, andcalculate_truncate_chars_lengthapplies the same skip to the truncation text.slugifyopens withunicodedata.normalize("NFKD", value).encode("ascii", "ignore"). The port had none of the three, so a decomposedábcdefgtruncated one character early and{{ p|slugify }}leftcaféalone where Django givescafe. 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, andslugifyneeds NFKD on top; no hand-rollable subset exists the way it did for theisprintabletable. Addsunicode-normalization0.1 (unicode-rs, MIT/Apache-2.0, one transitive dependencytinyvec). 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-acceptedchrono-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.processcounts with a plainlen(data)and does NOT skip combining marks, sotruncatechars_htmlneeded NFC and nothing else — adding a skip there would have created a fresh divergence; andTruncator.wordsreadsself._wrapped, so thewordsfilters must NOT normalize, kept as a negative control. Moving the NFKD fold AHEAD of the separator pass also changesslugify's answer for non-ASCII whitespace, in Django's direction and NOT uniformly:U+00A0andU+3000decompose to an ordinary space and stay separators, whileU+2028,U+200BandU+1680have no ASCII fold and vanish — two characters that are bothstr.isspace()sent opposite ways by the decomposition table rather than by any whitespace predicate.a\u2028bslugged toa-bbefore andabnow, which is Django's answer; enumerated rather than reasoned about, next to the whitespace-boundary corpus coupling #2293 added. 35 test cases inpython/tests/test_truncate_nfc_slugify_fold_2319.py(61 collected) including eight randomized differentials against live Django, new cases intruncate.rs's test module for the truncation-text skip that no template can reach, and the inverted pins inTestKnownRemainingDivergencesandTestItem4IsStillOpen. Seven gate-off mutations, all red across pytest andcargo 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 reportedINVALIDfor the wholecargocolumn becausecargo testprintserror: test failedon 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/randomnow honour amark_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_escapeper element inside their own body, whilefirst/last/randomhand 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 forjoin/cut/default/add— grows the arm, with no shape narrowing of its own, because every producer ofitemsalready guarantees every element is safe.containeris deliberately not a term: Django'sSafeString[0]is a plainstr, andlast/randomalready get Django'sis_safe=Truearm. Measured through the realRustLiveView+mark_safe_keyschannel (render_templatehas 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_safetyhandledPyListonly: #2290 deleted itsPyTuplearm as unreachable and was right on the evidence it had, but #2287'sContext::items_are_safeacceptsValue::Tuple, so the claim expired the moment the two changes met. The arm is back, rebuilding a realPyTupleso a filter branching ontype(value)keeps Django's answer, sharing onemark_itemhelper with the list arm so thestr-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, likerust_bridge,SimpleLiveViewand the template backend). Two-build differential against a rebuiltorigin/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 thedictsortXSS shipped in, so two couplings now close it: every namebuiltin_produced_safegrants must be on the differential's hot sets (randomexempt, becauseNONDETcollapses its cells to a marker on both sides and composing it would add ~480 blind agreeing cells — worse than absent), and every shapeContext::items_are_safeaccepts 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 (droppingfirstfromHOT2whileHOT3still carried it), the other was the genuine finding that the input axis had no pin at all. New cases inTestTheExtractorsConsumeTheItemGrant,TestTheNarrowingsTheExtractorsInherit,TestRandomIsCoveredByCapabilityNotByBytes,TestEverySeedSiteReachesTheExtractorArm(the renderer seedsitems_safeat three sites and each decides for itself whether to consume a filter's reported safety, so a{{ … }}-only test covers one of three — #1104) andTestACustomFilterSeesContextSourcedItemSafety, plustest_the_per_call_safety_channel_is_swept_tooandtest_the_differential_sweeps_every_shape_the_context_grant_accepts.unordered_listnests the<ul>at the parent's indent, matching Django (#2301). Fixed in #2306 by @alexsmolya — a one-line change droppingsub_indent, so the nested<ul>/</ul>sit at the parent's depth and only the<li>s inside step in, which is where Django'slist_formatterputs them (all four%sof its sublist wrapper are the parent'sindent). Whitespace only: the<li>content was already byte-identical, and it reproduced with nothingmark_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.pycompares 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 remainingunordered_listshape divergence (Django accepts atupleas a sublist, djust matchesValue::Listonly — filed as #2317 rather than folded in). New cases inTestTheReportedCell,TestNestingShapesandTestRandomisedShapes; #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 toassert_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'smark_safedoes (#2303). Django'smark_safe(obj)isSafeString(str(obj))— it does not merely mark the value, it changes its type before the rest of the chain sees it — while djust's|safewas a no-op for a scalar, so{{ n|safe|my_filter }}handed the filter anintwhere Django hands it'42', and reportedSafeDataFalsewhere Django reportsTrue. 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 (Displayisnumberformat.format(), which expands an exponent —1e20renders100000000000000000000butstr(1e20)is1e+20, andDecimal("1E-9")renders0.000000001butstr()is1E-9), which is exactly the@stringfiltercoercion #2250 already computes, sosafejoinsSTRING_FILTERSrather than growing a second stringify in its own arm that would shadow it; andValue::Missingas""and not"None", since Django substitutesstring_if_invalidbefore the chain runs andDisplayforMissingis already""(#2203), so a blanket stringify putting the literal textNoneon the page never arises. This closes #2257's residue 1 forsafe— aDecimalbehind|safeis now Django's1E-9and no longer localizes to0,000000001— though not forescape, which still stringifies throughDisplay.divisiblebyis the one filter the type change bit, and the two-build differential found it rather than inspection: Django isint(value) % int(arg), so a numeric string has always worked there while djust matchedValue::Integeralone, and{{ n|safe|divisibleby:"2" }}—Truebefore and in Django — would have started answeringFalse; it is widened to whatint(str)accepts unambiguously and still fails soft where Django raises. Measured across two builds against a rebuiltorigin/mainbaseline that already contains #2306 and #2316, so the figures isolate this change alone: 29,662 cells throughscripts/filter-parity-differential.py— 221 newly agreeing, 0 regressions, 0 introduced live-payload leaks — plus a wider 16,472-cell scalar sweep carryingDecimal,BigInt, exponent floats and the MISSING variable, 245 newly agreeing and 0 regressions.divisiblebyjoins the differential'sHOT2: 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 inTestTheReportedTable,TestEveryScalarVariant,TestItIsStrAndNotTheRenderForm,TestTheAbsentVariableIsEmptyAndNotNone,TestDivisiblebyReadsTheValueNotTheType,TestTheBuiltInChainIsNotWorseOffandTestTheStringifyIsComplete(python/tests/test_safe_stringifies_scalars_2303.py), plussafe_stringifies_every_variant_the_way_python_str_doesinfilters.rs. Gate-off verified against each mechanism separately — the"safe"arm (15 red),STRING_FILTERSmembership (9) and thedivisiblebyparse (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'sNAMED_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 table —divisiblebyby the two-build differential once every registry filter was composed behind|safe, and theunordered_listtuple-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,centeruses 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 currentmainbefore being touched. (1) Django'sstringformatis("%" + arg) % value, and CPython honours[flags][width][.precision]for thesconversion exactly as it does for%d— the arm read only the conversion character, so{{ p|stringformat:"10s" }}rendered'ab'where Django renders' ab',.3sdid not truncate and-10sdid not left-align. The grammar is ported from CPython'sunicode_format_arg_parseand 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 — the0flag is IGNORED fors(spaces, not zeros, unlike%d), a bare.is precision ZERO,0leads as a flag so%0sis width 0 while%010sis width 10,+//#are accepted no-ops that may repeat, and width and precision have limits five orders of magnitude apart (Py_ssize_tvsint), both bisected against the interpreter rather than assumed. (2)centerused Rust's{:^}, which always puts the smaller half of an odd margin on the left; CPython isleft = 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/rjustwere already right. (3)lengthfell to_ => 0for everyValue::Object, which is two Python things wearing one shape: adict, whose length islen(dict), and any non-dict object the serializer flattened into a map, whose length is 0 becauselen(model)raisesTypeErrorand Django's filter catches it. Returningo.len()would have traded one wrong answer for another — a model spelling its field count. The marker is nowValue::object_str(), the"__str__"predicate{{ obj }}already uses (#968), promoted from two open-coded copies in theDisplayimpls 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_safelyandjit.py's identity-only subset stamp it, whileserialization.py's depth-limited-FK and max-depth shorthands and bothtemplate/rendering.pyfallbacks 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 rebuiltorigin/mainbaseline at1fa46c33: 30 newly agreeing, 1 regression, 0 live-payload leaks introduced (38 → 38) — re-run from scratch after #2316 and #2318 both landed infilters.rsand 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 thelengthbug, and four twins (l-plain,l-mixed,l-markedand the marked tuplet-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 collectionerroris reported as INVALID rather than as a number, and that the restore is byte-identical. New cases inTestStringformatS,TestCenter,TestLengthOfAnObjectandTestItem4IsStillOpen; the #2294 known-wrong pin inTestKnownResidualDivergencesis converted rather than deleted — renamed, inverted, and joined by the model-instance half that explains why the pin existed. #2294's fourth item (truncatecharscounting 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 closesslugify'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'shtml/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-pythonis>=3.10, so 3.10 and 3.11 carry the same pre-rewrite parser. The body of the file always got this right (16 separate3.12.10citations) — 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, sotest_htmlparser_header_accuracy_2289.pyrecomputes the figures fromstriptags_reference_2273.jsonand 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.filtercould not see that its input wasSafeData(#2290).Value— the enum that crosses the PyO3 boundary — is safety-blind, sointo_pyobjecthanded every project filter a barestr:{{ p|safe|probe }}gave the filter('str', False, True)where Django gives('SafeString', True, True). That makes Django's canonicalneeds_autoescapeopening line,autoescape = autoescape and not isinstance(value, SafeData), an expression whose second branch is unreachable — and the scope is wider than filters registeredneeds_autoescape, sinceconditional_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:containermarks the VALUE (|safe,|escape, amark_safecontext variable, measured through the realmark_safe_keyschannel), whileitemsmarks each ELEMENT and leaves the sequence plain — exactly whatsafeseq/escapeseqbuild, since[mark_safe(o) for o in value]never marks the list. Answeringcontainerfor asafeseqoutput would grant a safety Django withholds; answering onlycontainerleaves every item cell diverging. Only astris wrapped. Django'smark_safestringifies a non-str(mark_safe(42)isSafeString('42')) and following it there would change the TYPE an existing filter receives — a pre-existing|safe-on-a-non-strSHAPE divergence with its own blast radius, distinct from the safety gap, and one that would turn{{ absent|safe|f }}into the literal textNonewhere Django'sstring_if_invalidhad already made it"". The residue is djust reportingSafeDataFalse 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.pyover 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 throughapply_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, becausesafeseqis 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 inpython/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 singleapply_custom_filtercall site must forward the realInputSafety;mark_input_safetymust never consult the filter's own metadata, theautoescapeflag, 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 reachedjoinandunordered_listescaped (#2287). They are the twoneeds_autoescape=Truebuilt-ins whose body appliesconditional_escapePER ELEMENT, so{"p": [mark_safe("<b>x</b>"), mark_safe("<i>y</i>")]}renders<b>x</b>, <i>y</i>in Django and rendered<b>x</b>, <i>y</i>here — the list itself is notSafeData,mark_safewas 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 shippedInputSafety{container, items},ITEM_SAFE_OUTPUT_FILTERSandITEM_SAFETY_PRESERVING_FILTERS, and both filters already readinput_safety.items. What was missing was one seed — all three renderer sites openedlet mut items_safe = false, so the only producer of item safety was asafeseq/escapeseqearlier in the same chain; safety arriving from the CONTEXT had no route in, even though_collect_safe_keyshad been puttingp.0/p.1intosafe_keyson every render all along. The channel existed and nothing read it at this granularity.Context::items_are_safereads 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 aValue::String(mark_safe_keysonly 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:joinstringifies a sublist and Django escapes that repr, so a recursive "all leaves are safe" grant would emit raw<where Django emits<.escapeseqmoves to Django'sconditional_escapein 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 realRustLiveView+mark_safe_keyschannel, becauserender_templatehas 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 throughrender_template_with_dirs, the only Python entry point carryingsafe_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'slinenumbersrow said it was "already correct (now pinned byTestLinenumbersWasAlreadyCorrect)", which #2291 falsified — it was a live XSS, and that pin is exactly what let it survive; and its closing note that|safeon a list "does not iterate that string as a character sequence" was superseded by #2296. #2284'sTestSequenceShapeIsOutOfScopeAndStillDivergesasserted this divergence was still present and instructed whoever closed the follow-up to move the names out ofSEQUENCE_SHAPEand delete it; it goes red on this fix (verified before removal), so it is deleted and the bucket renamed. 76 cases inpython/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 incontext::testspinning the narrowings at the bool level, where the Python suite structurally cannot see them. Nine gate-off mutations, nine killed; two are killed only bycargo 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 asSafeStringbefore a custom filter gated oninput_safety.items: this is what letsitemsbe seeded from the context, so a project@register.filtercan now receiveSafeStringitems sourced frommark_safe_keysrather than fromsafeseq— a path neither change exercised alone, measured to agree with Django and pinned in both directions. It also expired a comment: #2302 deleted aPyTuplearm frommark_input_safetyas unreachable becauseitemscould then only originate atsafeseq(a list comprehension), andContext::items_are_safeacceptsValue::Tuple. The arm is reachable now; what keeps it harmless is a different fact than the one written down —normalize_django_valuecollapses a Python tuple to a list before it crosses into Rust, andSimpleLiveViewpasses nosafe_keysat all — so every framework path is unaffected and it takes a direct four-argumentrender_template_with_dirscall. 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/randomneed the grant to become the RESULT's container safety, a different mechanism (#2299);mark_safe_keysnever 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); nestedunordered_listindents 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'slen()is a code-point count, so every non-ASCII string measured long:{{ "中<b"|length }}gave5where Django gives3. Code points, not graphemes -- Python'slenof 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'scharis a Unicode scalar value, sochars().count()is Python's answer exactly. The bug had been masked in the #2273striptagssweep, where the oldstriptagsdeleted 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,truncatewordsandljust/rjust/centerall already measure code points.wordwrapdoes not and is deliberately left alone -- the char fix was implemented and measured, and it fixes 21 differential cells while regressing 6 (every one aU+2028string, where the byte overcount had been putting a line break at the position Django'ssplitlinesbreaks); djust'swordwrapis nottextwrap.TextWrapperat all, so the pair goes with that port.{{ dict|length }}still answers 0, which is a missingValue::Objectarm rather than this bug. New cases inTestLengthCountsCodePoints.{{ p|pprint }}never wrapped, wherepprint.pformatwraps at width 80 (#2277). Django's filter ispprint.pformat(value)andpformatbreaks a structure across lines with hanging indentation past 80 characters, so[1.5] * 40was 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_strare ported incrates/djust_templates/src/pprint.rs, together with a Python-faithfulstr.splitlines(keepends=True)(Rust'slines()splits on\nalone; Python breaks on eight more,U+2028included). Scalars now go through the onedjust_core::py_repr_stringthe{{ list }}path also uses (#1646) --pprinthad 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 byTestContainerReprUsesTheSameEscaperacross 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 realpformat; 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 isstr.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 thestriptagsport hit (#2273). Pinned inTestKnownResidualDivergences. New cases inTestPprintWraps.linebreaks,linebreaksbr,urlizeandurlizetruncno longer escape aSafeDatainput (#2284). Django registers theseneeds_autoescape=Trueand each body opensautoescape = 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><b>x</b></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; theSafeDatahalf 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'sruntime_safe— the same statefilter_output_is_safealready reads as Django's input term (#2274) — so both halves of theSafeDatareading come off one value at all three render arms rather than two that can drift. The four keep their unconditionalSAFE_OUTPUT_FILTERSmembership 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: theneeds_autoescaperegistry is seven names, not four —linenumberscarries 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), andjoin/unordered_listuse a per-elementconditional_escapethat one whole-value bool cannot express, filed as #2287 and pinned as still-diverging; and the four are not equally clean afterwards, becauseurlize/urlizetrunckeep 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/ contextmark_safethrough the realmark_safe_keyschannel), differing comparisons fell from 33,035 to 12,177 against a rebuiltorigin/mainbaseline — 20,858 fixed, 0 new regressions, the plain column byte-identical, andlinebreaks/linebreaksbrat zero on all three. New cases inpython/tests/test_needs_autoescape_2284.py, plusthe_needs_autoescape_filters_skip_the_escape_only_when_told_toandurlize_escapes_the_href_even_when_autoescape_is_offinfilters.rs— because the #2259 test that pins theSAFE_OUTPUT_FILTERScontract 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_listandrandomdid not iterate a string as its characters (#2283). Python iterates astras a sequence of characters, so Django's{{ p|unordered_list }}on"<b>x"is one<li>per character and{{ p|join:", " }}is<, b, >, x. Each of the five matched onlyValue::List | Value::Tupleand fell through to the input for everything else — the same question asked five times, so the fix is oneiter_valuessink rather than five correct copies (#1646), and a structural test pins the caller SET. It also answers the input-shape axis the five shared: adictiterates its KEYS, and an absent variable is Django'sstring_if_invalid(""), not aTypeError.first,lastandslicewere 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.
safeseqis[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 hadsafeseqinSAFE_OUTPUT_FILTERS— emitted it raw, more permissive than Django on the list path the issue described as correct. The grant now lives inITEM_SAFE_OUTPUT_FILTERS, is read only byjoinandunordered_list(the two built-ins thatconditional_escapeper item), and is dropped when Django'smark_safe(list)would have collapsed the sequence to aSafeStringof 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
Valuevariant moved to the iterating side, so only numbers, booleans,NoneandDecimal/BigIntdigit 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_freeenumerates the enum so that variant has to be classified rather than silently slipping past.dictsorthad no failure branch, and that became an XSS once a sequence filter could grant safety (#2283 review). Django'sdictsortistry: sorted(value, key=_property_resolver(arg)) except (AttributeError, TypeError): return "". djust had the sort and not theexcept, returning the input UNCHANGED where Django discards it — harmless until something downstream could mark items safe, which this change gavesafeseq/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
dictsortout of the item-safety-preserving set; that closessafeseq|dictsortand leavesdictsort|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:0passes anint, soitemgetter(0)indexes and sorts strings by first character, whiledictsort:"1"passes astrand raises — and djust's numeric path never actually sorted, because its comparator resolved every non-dict item toMissingand saw every pair as equal.joinescaped its separator, which Django does not (#2283 review). Django appliesconditional_escape(arg), and a quoted filter argument isSafeData(Variable.__init__doesself.literal = mark_safe(unescape_string_literal(var))), so{{ l|join:"<br>" }}renders a real<br>. A bare identifier resolved from the context is notSafeDataand is still escaped. This was a regression on 34 cells, not merely a mis-documented one: the previousjoinjoined raw and let the render escape the result, which lands on Django's bytes whenever a later|safesuppresses that escape.Four filters reported safety by NAME where Django reports it per CALL (#2281 fallout). Making
escapeproduce aSafeStringturned a pre-existing quiet over-escape into 104 measurable double-escapes — a latent correctness gap that only became measurable because of an adjacent fix. Theneeds_autoescapehalf 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_noneandaddreport safety per CALL through the existingproduced_safechannel, because their answer depends on which branch ran:joinismark_safe(data)on success and the value untouched onTypeError(returning an escaped string instead changed the TYPE, which{{ n|join:", "|length }}measured as2against Django's0);cutre-marks safe unless the argument is";";defaulthands back the input object when it is truthy;add's concatenation branch isSafeString.__add__. And{{ l|safe }}now stringifies a container, asmark_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/mainover 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 asscripts/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.|safenow survives anis_safe=Truefilter (#2274). Django marks a filter's output safe on two terms —getattr(func, "is_safe", False)andisinstance(obj, SafeData), whereobjis the filter's input. djust modelled only the second case (a filter that marks its own output, viaSAFE_OUTPUT_FILTERS) and was missing the input term entirely, so{{ p|safe|lower }}came out escaped:|safewas undone by the very next filter.filter_output_is_safenow takes the input's safety, and all three call sites seed it from the context's ownmark_safeflag and feed each result forward — which also makes that flag re-taintable, so{{ marked_safe|upper }}is escaped as Django escapes it (upperis registeredis_safe=Falseprecisely because upper-casing<yields<). The newIS_SAFE_FILTERSlist 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 fromSAFE_OUTPUT_FILTERSand 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 inpython/tests/test_safe_survives_is_safe_filter_2274.py(138 with parameterization) plus 10 intest_xss_prevention.rsfor the context-safe path, whichnormalize_django_valuemakes 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::Tupleis NOT unreachable —{{ (1.0,) }}renders(1.0,)exactly as Django does, which is what #2203 added the variant for; andnormalize_django_valuedoes flatten a tuple, but so does Django's ownDjangoJSONEncoder, becausejson.dumpshas 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, soValue::Tupleserialized as an array and came back alist— a view attribute changed type across a reconnect and(1, 2)rendered[1, 2]after one and not before. Same class as theDecimalloss #2214 fixed with a binary tag, and fixed the same way withTUPLE_TAG— third instance of that mechanism afterDECIMAL_TAGandBIGINT_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 inpython/tests/test_tuple_roundtrip_2276.py; gate-off reds all three mechanisms.striptagsdeleted everything after a lone<, and a lone>too (#2273). The filter was a 12-line scanner:<setin_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 anhtml.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:MLStripperrunsconvert_charrefs=Falseand re-emitshandle_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'sgoaheadport is lifted behind aSinktrait intocrates/djust_templates/src/htmlparser.rs, withTruncateSinkandMLStripperas 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 rewrotehtml/parser.pyfor 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 rewrittenparse_endtag, the CDATAinterestingregex, the RAWTEXT/RCDATA element sets with_escapable, HTML5 whitespace for\s, and<![CDATA[replacing theparse_marked_sectionport — which also removes theAssertionErrorDjango 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 owntest_version_dependent_values_track_a_supported_cpython. Also fixed a panic:parse_html_declarationindexeds[i..i+9]for the<!doctypeprobe, 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'sSuspiciousOperation— raising would 500 the whole render — so a refused value renders as the empty string with atracing::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 twogoaheadpasses (the&#-bail is the onebreakthat advances before stopping, so the next pass resumes the loop — and a second bail inside pass 2 has no pass 3), andentityref's name class[-.a-zA-Z0-9]overlaps its own trailing[^a-zA-Z0-9]on-and., sore's backtracking is load-bearing (&-is the entityamp). 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 onmain. Non-regression against a rebuiltmain, scored on the same interpreter against the same version-neutral fixture: 2,349 values fixed, 0 broken. The two chain divergences that remain are notstriptags— #2279 (lengthcounts bytes) and #2281 (escape|Xpasses X the unescaped value); #2280 (is_safe=Truenot 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.pyandscripts/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 incrates/djust_templates/src/htmlparser.rs::testsand new cases inTestReportedCells/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 onstrip_onceinstead.pprintandjson_scriptspelled a float with Rust's{}— the last two of the five #2258 sinks (#2270). #2258 routedDisplay, the@stringfilterboundary andpy_reprthrough Python'srepr;pprint_valueandvalue_to_jsonpredate it, are neither of those things, and each kept its ownformat!("{f}")arm. The issue's table names1e20,NaNandinf, and the ordinary case is none of them — Rust's{}drops a float's trailing.0, so{{ 1.0|pprint }}rendered1andjson_scriptput 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)isrepr(f)exactly (python_float_repr), whilejson.dumps(f)isrepr(f)for a finite value andNaN/Infinity/-Infinityotherwise (a newjson_float_body) — and onmainthe coincidences ran opposite ways, Rust'sNaNmatchingjson.dumpsbut notpprintand itsinfmatchingpprintbut notjson.dumps, which is why half of each sink was accidentally right and neither read as a whole-filter failure. TheInfinityspelling is a decision, taken explicitly.json_scriptcallsjson.dumps(value, cls=encoder or DjangoJSONEncoder)andDjangoJSONEncoderoverrides onlydefault(), soallow_nanstaysTrueand Django emitsInfinityinto a<script type="application/json">body. That is not valid JSON —JSON.parse('{"x": Infinity}')throws — and djust matches Django anyway:null(whatJSON.stringifywrites) is valid and silently lossy, since a client cannot tell an infinity from aNone, whereasInfinityfails loudly at the parse site, Python's ownjson.loadsaccepts it, and answeringnullwould 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, whereInfinityis 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 isformat!("{f}")overcrates/djust_templates/src/, and the only reason they were found is that someone re-ran it, so the newmod float_sink_setpins the SET of float→string sinks from Rust's own token stream — following #2249's cure for the same pin done as text, since bothfilters.rsandfloatformat.rscarryformat!("{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, becauseValue::Float(x) => format!("{x}")is the same defect and the same grep misses it. Verified as a set comparison against a rebuiltmain: 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_valueflattens a Python tuple to a list at the PyO3 boundary, soValue::Tupleis 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 inmod 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 cargotest result:line or a pytestN erroras INVALID rather than green (#2129/#2135).filesizeformat,floatformat'su/guandlinebreakscomputed 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.filesizeformatdiverged on EVERY value, for five causes, not the three the issue named. Django'savoid_wrappingjoins 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 fortimesince, and exactly whattest_filesizeformat_filterhad been doing since the filter was written.ngettextsays1 byte, not1 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-1024is-1.0 KBwhere the signedbytes < KBcomparison sent every negative into the bytes branch and rendered-1024 bytes; and Django's first statement isint(bytes_)with aTypeError/ValueError/UnicodeDecodeErrorfallback to0 bytes, so"1024"is1.0 KBandNoneis0 bytes— the old filter returned the value UNCHANGED for every non-numeric type, so{{ p|filesizeformat }}rendered the literalNone. A fifth no cause covered: the KB-and-up branch is localized throughnumber_format(round(v, 1), 1), sodegives1,5 GBandUSE_THOUSAND_SEPARATORgives1,024.0 KB. Theas_f64parse the issue was filed against is real but needs a >2^53 value to see it —Decimal('12345678901234567890.123456789')saturated to8192.0 PBagainst Django's10965.2 PB— and is now an exacti128truncation through the shareddecimal::to_i128_trunc. Still divergent, stated rather than left to be discovered: Djangogettexts the unit NAMES (frrenders1,5 Gio), which is the{% trans %}gap and not this filter's;int(float('inf'))raisesOverflowErrorin Django, whichfilesizeformatdoes not catch, and a filter here cannot reproduce a 500 — it lands on0 bytes, which is at least saner than the8192.0 PBit used to fabricate.floatformat'su/guignored overridden number settings, and the residue did not move under #2263 — that PR rewrote the quantization and never touchedfinish'suse_l10narm. Django'sumeansuse_l10n=False, andget_formatshort-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: underdewith no overrides the localized separator is,and the raw one is., and underDECIMAL_SEPARATOR="!"with English it is the other way round. So a secondNumberFormatis resolved on the Python side and pushed alongside the first, andfinishselects onuse_l10n. Itsuse_groupingisfalseby construction rather than by simplification — Django'suse_groupingis False wheneveruse_l10nis, and only then ORs inforce_grouping— sounever groups andgugroups iff the RAWNUMBER_GROUPINGis non-zero, which means that at its default0Django renders6666.67for"2gu"and not6,666.67.test_the_u_suffix_ignores_overridden_number_settingswent red on the fix, which is what it was written to do, and is kept flipped to the agreeing direction rather than deleted.linebreaksHTML-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 inSAFE_OUTPUT_FILTERS— Django'sis_safe=Trueon a markup-producing filter is always paired with anescape()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,}(soa\n\n\nbis ONE separator), joins with\n\n, KEEPS empty paragraphs (''is<p></p>, which djust rendered as'') and normalizes\r\nfirst. The issue asked for two neighbours to be checked rather than assumed, and both answers were surprises:linebreaksbrdiverges the same way on both axes and is fixed with it, whilelinenumbersturned 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 toSAFE_OUTPUT_FILTERSfirst required curing a drift among its three consumers.get_value_safeapplied the safe-name check per filter — Django's rule, sinceFilterExpression.resolvemarks safe only when the filter it just ran isis_safe, which is whyupperis registeredis_safe=False— while theNode::VariableandNode::InlineIfarms applied it asany()over the WHOLE chain, andget_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 addinglinebreakswould 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 amainbuild and disagreed on the first version of this branch, every one of them{{ p|linebreaks|upper }}. All three sites now call onefilter_output_is_safehelper (#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 amainbuild 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 onmaindisagree 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 fullu/gsuffix matrix against sixDECIMAL_SEPARATOR/THOUSAND_SEPARATOR/NUMBER_GROUPINGoverrides 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 inTestLastFilterWinsForSafenessand, in crates/djust_templates/src/filters.rs,linebreaks_escapes_its_input_which_is_what_makes_marking_it_safe_safeplusfilesizeformat_joins_with_a_non_breaking_space_not_a_plain_one, withtest_filesizeformat_filter,test_linebreaks_filterandtest_linenumbers_filter_alignmentcorrected from the wrong bytes they had encoded. The #2259 row intest_string_filter_stringification_2250flips to agreement andlinebreaksleavesUNCOMPARABLE, 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 as0 failed, and one was a valid mutation that was a semantic no-op for the values under test —use_grouping: false -> truecannot change anything whileNUMBER_GROUPINGis0— 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
Valuerepresentation (#2260, #2258, #2265). The same shape at three layers, and none of the three fixes subsumes another. #2260, the boundary:Value::Integeris ani64and a Pythonintis arbitrary-precision, so past2**63 - 1thei64arm ofFromPyObjectfailed and the next arm that matched wasextract::<f64>()—12345678901234567890reached the renderer as a binary double and{{ p }}printed12345678901234567000. 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::Floatwas Rust's{}, which is neither of Django's two steps — it never uses exponent notation and spells the non-finite valuesNaN/infwhere Python givesnan/inf. #2265, the filter:stringformat:"d"computed ani64throughas_f64(), so it was off by one from 2^53 up and saturated past 2^63, printing9223372036854775807— a fabricated constant — for an id or a money column. Fixing only the boundary leavesstringformat:"d"saturating; fixing only the filter leaves{{ p }}truncated. #2260 takes a newValue::BigInt(String)variant, following #2214'sDecimalprecedent, and the blast radius was measured by adding the variant and compiling rather than estimated: six exhaustivematchsites. SharingDecimalwould have cost nothing structurally and is wrong twice —py_reprrendersDecimal('123')where anintrenders123, andIntoPyObjectreturns adecimal.Decimal, so a view attribute holding a big int stops being anintto everyisinstanceafter a state round trip. A widerIntegerwas the cheap option and is still finite:i128reaches 39 digits and a 40-digit hash is not exotic. The loop-cachehash_valuetag is 10, distinct fromDecimal'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 renders1e300as1e+300— true;python_float_repr's own doc-comment says{{ 1e20 }}renders100000000000000000000— also true. Django's rule is the digit count, not the exponent form, so renderingreprverbatim (the obvious reading) would have regressed every float between1e16and1e200. The fix reusesexpand_decimal_exponent, the same cut-off theDecimalarm uses, because Django reaches it by turning the float into aDecimal. Two further sites of the same str/repr split are closed with it: the@stringfilterboundary (Django's string filters consumestr(value), so{{ f|upper }}legitimately disagrees with{{ f }}) andpy_repr(a float nested in a list is spelled by Python's list repr).pprintandjson_scriptare the remaining two and are filed as #2270 rather than folded in — neither isDisplayand neither is a@stringfilter. #2265's framing needed one correction too: it calls this "the #2253 defect, one filter over", which is right about theDecimalpath and incomplete — the same arm saturated a plainfloat, and"%d" % 1e300is the exact binary expansion, neitheri64::MAXnor10**300. Its group-3 question is decided against CPython rather than reasoned about:%draisesTypeErrorfor astr, a numeric one included, so{{ "42"|stringformat:"d" }}is empty in Django and the oldparse::<i64>()fallback disagreed in both directions. The real ceiling is CPython'ssys.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_pairadmitted 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, because9x60|add:1was correct onmainonly by coincidence (the value had arrived as the double1e60, whose expansion is exactly the sum the filter was declining to compute); andget_digitread the rendered string where Django indexesstr(int(value)), which was invisible whileDisplayexpanded every float and became wrong the moment{{ 1e-200 }}started rendering1e-200.int_digits_ofis now one definition of Python'sint()shared byadd,get_digitandstringformat— the three filters that had each re-derived it and disagreed (#1646). Verified as a set comparison against amainbuild, not a spot check: of 4,400 cells (numeric spectrum x 40 templates), 0 of the 2,799 that agreed with Django onmaindisagree 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 indecimal::tests. Four existing tests went red and are corrected rather than deleted — threeTestKnownRemainingDivergencesentries whose own contract says a closing gap turns the file red, andtest_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.rs—django.utils.text.Truncator, thehtml.parser.HTMLParsersubclasses that drive its HTML variants,slugify, Python'sstr.title()andurllib.parse.quote. The reported cells are all exact now:truncatechars_html:8on"Infinity"(Infinit…→Infinity) and on{'a': 1}(truncated → whole);truncatewords_html:2(escaped once → twice);truncatewords:2on" spaced "(padding kept → dropped);urlencodeon"<b>x</b>"(%2F→/);slugifyon"3.5"and"-1.5e+300"(3-5→35,1-5e-300→15e300);titleon" spaced "and"<b>x</b>"(stripped → kept,b→B).truncatechars_html's two cells are one branch, not two bugs:TruncateCharsHTMLParser.processemits its input raw and unescaped and stops when the whole input is one run of text of exactlylengthcharacters — 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 overridehandle_comment, so the base no-op runs); an unterminated construct at the end of the input discards everything after it (Truncatorcallsreset()beforeclose(), so"trailing &"really does render empty);<script>/<style>switch to CDATA mode; character references round-trip throughhtml.unescape;frameandspacerare void elements;length <= 0is the empty string; a negative filter argument parses at all (the oldusizeparse silently fell back to the default);urlencode's argument was ignored entirely (so theurlencode:""behaviourdocs/RUST_TEMPLATE_API.mdalready documented now actually happens); andtitleneeded the real titlecase mapping (ßisSs, notSS),Nd-only\din Django's\d([A-Z])fixup (so½ cuptitlecases thec), the realCasedset, and aCase_Ignorable-skipping final-sigma lookahead (soΣ.Ζis σ).markup5everbecomes a direct dependency but is not a new crate —html5everalready pulls it in fordjust_vdom, soCargo.lockgains one edge and no package; itsNAMED_ENTITIESis byte-identical to CPython'shtml.entities.html5(2231 entries, asserted). Three classes stay open and are pinned rather than left to be discovered. Two areunicodedata:slugify's openingnormalize("NFKD").encode("ascii", "ignore")andTruncator.chars'snormalize("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 realorigin/mainbuild found 1,070 unchained cells fixed and 0 unchained regressions (upper|,lower|andstriptags|also 0), but 243 regressions onescape|andsafe|— 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'sescapereturns its input unchanged (#2257 residue 1), and its safe rule is a name whitelist where Django's isis_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 27is_safe=Truefilters diverge on{{ p|safe|X }}onorigin/main(lower,capfirst,wordwrap,urlize, … — none of them touched here) versus 23 on this branch; without the|safeit 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 fortitleover 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 inpython/tests/test_truncate_slugify_parity_2262.py(79 node IDs after parameterization).test_string_filter_stringification_2250.py'sUNCOMPARABLEset is now empty: its characterization test existed to go red when these closed, and did, so the six filters it parked joinedlinebreaks(closed by #2269 in the same release) in the compared set.Django's
@stringfilterbuilt-ins saw aDecimal's numberformat rendering instead ofstr(Decimal)(#2250). Django decorates 29 of its built-ins with@stringfilter, which runs them onstr(value); djust's ran onDisplay, which for aDecimalis 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'struncatecharssees1E-9, djust's saw0.000000001, andmake_list|firstgave0where Django gives1— nine digits, so not confined to the >200-digit cutoff #2242's comment predicted. The coercion is free:Value::Decimalalready carriesstr(Decimal), built fromob.str()at the PyO3 boundary, andDisplayis what expands it — so the fix hands the filter the raw payload rather than deriving a second string. Placed atapply_builtin_filter, the one dispatch table every built-in funnels through, rather than in the ~30 arms that callvalue.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_filterhands Python a realDecimal, so Django's own decorator applies. Two of the 29 are excluded, measured rather than assumed. djust'sescape/safeare 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 aDecimaland the renderer localizes it — and coercing them changes the type flowing down the chain, whichfloatformatcannot absorb: 1,168 cells of{{ d|escape|floatformat }}regressed. Teachingfloatformatto parse a numeric string was tried and is worse — anf64cannot reproduce Django's>200-digit passthrough or itsNaN/infhandling, 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_numberruns only at the render site on the FINAL value and every stringfilter returns aValue::String, which is never localized — underde,truncatecharssaw1234567.89exactly as Django does. The divergence was purelyDisplay-vs-str(); localization is involved only forescape/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 }}, whereuppernow correctly yields1E+1andfloatformatcannot parse it. That is #2257 becoming reachable, not a new defect:{{ "1E+1"|upper|floatformat }}already diverges onmainwith noDecimalanywhere. The same sweep surfaced five unrelated whole-filter divergences, each reproducing on a plain string and each filed rather than folded in (#1079): #2258 (DisplayforValue::Floaton1e300/NaN), #2259 (linebreaksescapes its own markup), #2260 (a Pythonintpasti64loses 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 bytest_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 livedefaultfiltersregistry so a filter Django adds to the decorator fails the test rather than drifting, plus 4 unit tests at the dispatch table incrates/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), theescape/safeexclusion (1 red, and only that one).floatformatwas float formatting where Django's is decimal arithmetic, andaddsilently did nothing pasti64(#2253). The issue reported four cells and named one cause for all four —Value::Decimal's digit string parsed throughf64. Reproducing them first and then widening the differential corrected the premise twice.floatformatwas wrong on far more thanDecimal: a sweep of 21 argument forms x 475 values measured 302 divergent cells of 825, and precision is one of four independent causes. Django'sfloatformatconverts EVERY input — float, int, str,Decimalalike — to aDecimaland quantizes itROUND_HALF_UP, so the other three are about every input type. Rust's{:.n$}rounds the binary double half-to-even (2.675|floatformat:2was2.67, Django says2.68); Django's default argument is-1and a negative argument means "at most", which"-3".parse::<usize>()cannot express, so every negative argument silently became one place andDecimal('0.00')|floatformatkept a.0; and thegsuffix was stripped from the argument and then ignored, so6666.6666|floatformat:"2g"never grouped. Fixing only the two cited cells was not possible without thep <= 0branch and exact quantization, which is most of the algorithm — so the whole ofdjango/template/defaultfilters.py::floatformatis ported to exact decimal-string arithmetic incrates/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 thef64parse:12345678901234567890does not fit ani64however exactly it is computed, sochecked_addoverflowed and the filter returned its input unchanged. Thef64parse is a real second defect —Decimal('9007199254740993')|add:1gave 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 ani128, and a sum outsidei64is carried asValue::Decimal's exact digits rather than discarded. Non-finite floats are refused rather than saturated, so{{ inf|add:1 }}returnsinfinstead of a fabricatedi64::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 parseexpand_decimal_exponentgrew inline is lifted intodjust_core::decimalso 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 renderedabcwhere Django renders nothing. Still divergent, stated rather than left to be discovered: pasti128addgives 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:"" }}raisesIndexErrorin Django 5.2 and djust does not reproduce crashes; a Pythonintwider thani64is already lossy at theValueboundary, which{{ p }}alone shows;Value::Float'sDisplaystill writesNaNwhere Python writesnan—floatformatbuilds its ownstr(text)and agrees with Django, so the asymmetry is insideDisplay, not the filter; and theu/gusuffixes emit Django's DEFAULTDECIMAL_SEPARATOR/THOUSAND_SEPARATOR/NUMBER_GROUPINGrather 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): withDECIMAL_SEPARATOR="!"Django gives6666!67and djust gives6666.67, pinned bytest_the_u_suffix_ignores_overridden_number_settingsalongside the localized forms, which agree — so the gap is bounded tourather than being a general locale failure. Verified as a set comparison against amainbuild, not a spot check: of 23,750 cells (curated plus 400 randomized values), 0 of the 12,458 that agreed with Django onmaindisagree 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_DIVERGENCESin 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
Decimalstored in the session or a signed snapshot came back afloat(#2252). #2239 gavenormalize_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 isjson.dumpswith no encoder (and the signed snapshot a barejson.dumps), so both refuse a rawDecimal; and the exact digit string is refused one hop later, because whatever is stored issafe_setattr-ed back onto the view and reaches the template on the very next render, where a string stops|floatformatrounding — the #2214 regression. It keptfloaton 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" —floatis wrong for ordinary four-digit money too, in two ways needing no precision loss at all: the type changes, soself.price + Decimal('1')raisesTypeErrorafter a reconnect and not before one; and trailing zeros are gone, soDecimal('19.90')renders19.9where Django renders19.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 #2253floatformatgap; 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 werefloatformatcells too, the float column to 8.) So destination 3 now takes a tagged round trip — theencode_private_model_refsshape (#1994), under the same tag name the Rust binary encoding already uses for the same job (DECIMAL_TAG, #2214, pinned againstcrates/djust_core/src/lib.rsso the two halves cannot drift).decimal_for_state_roundtripwrites{"__djust_decimal__": "19.99"}anddecode_state_roundtriprestores a realDecimal; an untaggedfloatfrom a session written by an older release passes straight through. The restore sites were found by grepping the SINK (safe_setattrplus the_restore_*hooks), not by mirroring the twelve write sites — and the issue's read-side list was wrong in both directions: it namesmixins/rust_bridge.py, which has no restore path at all, and omits three that do (runtime.py's_restore_snapshotcall for the signed back-navigation snapshot,time_travel.py's replay restores, and_restore_component_state). Eight decode points cover all of them, andTestTheDecodeSiteInventorypins 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 thefloatit replaces — a dict in the template rather than a wrong number, 20/20 disagreements — which is whyruntime.pydecodes at the caller of_restore_snapshot(a documented subclass-override hook, so an override never sees the tag shape) and whytime_travel.py'sto_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 rulesvisit_mapapplies (exactly one key, that key, astrpayload) plus a fourth Python needs becauseDecimal()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 amainbuild 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 inTestTheRoundTripIsLossless,TestWhatTheFloatRoundTripCost,TestTheDecodeIsMandatory,TestTheCollisionHazard,TestEveryOtherTypeIsUntouched,TestTheRealHTTPPostRoundTrip,TestTheSignedSnapshotRoundTrip,TestTheStickyChildRoundTrip,TestTheComponentRoundTrip,TestTheTimeTravelRoundTripandTestTheDecodeSiteInventory(86 in python/tests/test_decimal_state_tag_2252.py), plus 5 real-WebsocketCommunicatorcases in python/djust/tests/test_decimal_state_tag_runtime_2252.py for the tworuntime.pysites; 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
Decimalconverters were still lossy after #2214 (#2239).DjangoJSONEncoder.defaultandnormalize_django_valueboth returnedfloat(o), soDecimal('12345678901234567890.123456789')still arrived as1.2345678901234567e+19— and not on a rare path:mixins/jit.pycalls 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 theDecimal, which Rust already carries asValue::Decimaland renders identically to Django. The client wire takes the exact digit string — byte-for-byte what Django's ownDjangoJSONEncoderreturns, and what the Rustserialize_contexthas emitted since #2214. A round trip back onto the view — the ten Django session writes plus the two signed-snapshot captures — keeps today'sfloatthrough one new chokepoint,decimal_for_state_roundtrip, reached bynormalize_django_value(..., state_roundtrip=True)andStateRoundtripJSONEncoder. That last one is a documented residue, not an oversight: Django's session serializer isjson.dumpswith no encoder so it cannot take theDecimal, 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|floatformatrounding — 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 namedruntime.py'sjson.dumps(public_state, ...)as the blocking consumer; it is in fact already safe (its state is JSON-round-tripped by_capture_snapshot_statefirst), and the session writes are the real ones.TestParityWithJSONRoundtripkeeps holding with its premise restated: it now pins the compositiondumps(normalize(x)) == dumps(x), which is the property callers rely on when they skip the round trip, still coversDecimal, 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 aDecimalrenders in scientific form, andlocalize_number_withbailed on any string containing ane— so underdeDecimal('1.230E-250')rendered1.230e-250where Django gives1,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 mirroringdjango/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 anenot followed by a signed integer, so1.5exyzandabcE+5still 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 intruncatecharsandmake_list|firston the premise that one fix covers all three. It does not — those filters never reachlocalize_number(Django's are@stringfilterand consumestr(Decimal); djust's consume the numberformat rendering) and their divergence is not confined to the cutoff either, soDecimal('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_versionmatched_next_version_armed(with a regex over the RAW source ofwebsocket.py, which is wrong in both directions. False positive, observed in #2237: a docstring explaining the argument-evaluation defect contained the literalversion=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 fromtests/test_reset_fixture_hygiene_2234.py::_code_only(#1077) into one sharedpython/djust/tests/_source_scan.pyboth 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_prosedrops comments and docstrings only — a string literal is CODE, and_arm_recoveryreaches its attribute throughgetattr(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_onlyalso drops string literals, for #2234's guard where namingdeactivate_all()in a message must not count. Both preserve layout exactly, which the pin depends on — it tells an inline kwargversion=self.f(from an assignmentx = self.f(by the spacing, and a token-joining strip would collapse the two and count one as the other._hotreload_broadcast_suppressedgets its natural wording back and the note explaining the contortion is deleted; that docstring is now the standing dogfood case, since the realwebsocket.pycounts 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 viaast, plus apass, 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 filepython/djust/tests/test_source_scan_2238.py(25 collected) plus the two canaries intest_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 withcode_onlyno 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 realfilters.rsbefore 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_structurecounting overproc_macro2's token stream. Wiring them todjust.tests._source_scanwould have looked like a fix and been a no-op: it runs CPython'stokenize, so a.rsfile comes back unchanged and silently — whichtest_rust_source_is_NOT_stripped_and_comes_back_unchangedpins 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-macro2is test-only and already in the lockfile viapyo3-macros, so theCargo.lockdelta is one line and no new package. It also cannot silently no-op, where the text version could three ways: the source isinclude_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 bystr::index(.., "\nfn "). That last one is not hypothetical — adding the test module immediately aftervalue_to_jsonmoves 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_sourcestripped only the leading module docstring and now delegates towithout_prose— notcode_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 reddensa_call_nested_inside_a_macro_group_is_still_counted, breaking the body locator reddensthe_body_is_the_function_s_brace_group_and_nothing_after_it, the pre-#2246 stripper reddenstest_prose_below_the_module_docstring_does_not_trip_the_bansalone,code_onlyreddenstest_a_string_mediated_tenants_import_still_reddens_the_banalone, and no stripping at all reddens four including both real bans. New cases invalue_to_json_structureandTestSourcePinCanaries.{% if <float> == <int literal> %}was always false, diverging from Django (#2243).values_equalincrates/djust_templates/src/renderer.rshad explicit arms for(Integer, Integer)and(Float, Float)and nothing for the mixed pair, so{% if x == 0 %}answered "not zero" for0.0and{% if x == 19 %}answered "not equal" for19.0. Python compares0.0 == 0as 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 absolutef64::EPSILONtolerance here — the obvious mirror of the armscompare_valuesalready has — makes{% if delta == 0 %}true for0.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 nota 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, so9007199254740993 as f64is9007199254740992.0and 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, ini64range) is exact whenever it succeeds, which is Python's own rule; the range guard is load-bearing becauseb as i64saturates rather than wrapping, so without it1e300compares equal toi64::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 saysTrue == 1, sinceboolsubclassesint;compare_valueshas noBoolarm 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 theas f64rounding. The sweep also found that{% if x == 1e-17 %}is aTemplateSyntaxErrorin Django — itsFilterExpressionregex 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 inpython/tests/test_float_int_equality_2243.pyplus four unit tests at the function inrenderer.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).boolsubclassesintin Python —Trueis1numerically — soTrue == 1,False == 0andTrue > 0are all true and Django says so. djust said false to all three.values_equalincrates/djust_templates/src/renderer.rshad a(Bool, Bool)arm and no mixed Bool/numeric arm, so a bool against a number fell to_ => false;compare_valueshad noBoolarm at all, so a bool reachednumeric_pair— which admits only {Integer, Float, Decimal} — gotNone, 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 %}onTrueagreed 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 newbool_as_intreplaces a bool operand withValue::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 exactint_eq_floatcomparison 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_valueshas no such arm to defer to, so the substitution covers two bools there as well, and{% if a > b %}onTrue/False— 0, "equal", before — is now true as Django says.values_identity(is/is not) must NOT widen and is untouched:True is 1is 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 %}sharesvalues_equalas 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-epsilonDecimalequality (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 inpython/tests/test_bool_numeric_comparison_2244.pyplus five unit tests at the functions inrenderer.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_scriptemit a<script type="application/json">body that does not parse (#2241).value_to_jsonhad converged itsStringandDecimalarms ontojson_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 andjson.loadsraisedInvalid 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 (thejson_string_bodycall-site count insidevalue_to_jsonis 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 of0x00–0x1Funescaped inside a string.json_string_bodynow covers the whole range as\u00XX, plus the two remaining short formsjson.dumpsuses (\b,\f). Deliberately NOT escaped here:<,>,&, U+2028 and U+2029, whichjson_escape_for_scriptalready claims on the assembled document —json_scriptcomposes the two, and adding them would be the double-application the single-helper shape exists to avoid; and0x7F, which JSON permits raw andjson.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 bodycheck 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-Decimalarm, whose #2214 case stayed green against a raw0x00. Finally, a 12,000-case differential againstjson.dumps(ensure_ascii=False), which is what keeps the five short forms independently reachable: the generic\u00XXarm SHADOWS them, so dropping'\n'still yields valid, round-tripping\u000aand 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 inpython/tests/test_json_script_escaping_2241.pyand three incrates/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.Decimalreached the client, and the Rust template engine, as a lossy binary float (#2214).serialize_python_value'stype_name == "Decimal"branch was dead code:extract::<f64>()ran above it, and PyO3'sf64extraction goes throughPyFloat_AsDouble, which honoursDecimal.__float__. EveryDecimalbecame a double before the branch could see it, soDecimal('12345678901234567890.123456789')arrived as1.2345678901234567e+19.DecimalFieldis Django's money type and a binary double is exactly what it exists to avoid.UUIDshared 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 aValue::Decimal(String)variant carries the exact digits, with a singleas_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 maderustcenumerate the exhaustive matches; the_ =>fallbacks it cannot see were audited by hand, which is where thefloatformatand{% 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 extractingf64),python_to_json_value,python_to_json, andmodel_serializer's, whose two exported model serializers disagreed with each other. All now share oneis_decimal()that testsisinstancerather than a type name, so aDecimalsubclass is claimed and an unrelated class merely namedDecimalis not; the type resolves once per interpreter, since re-importing per call cost 18-24% on context conversion. Binary encodings carry a tag.Valueis#[serde(untagged)], so aDecimalencoded as a bare string and came back aString— andSerializableViewState.stateround-trips through msgpack on every read of the defaultInMemoryStateBackend, 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(...), notstr(), andDecimal('1')/Decimal('1000000000')is1E-9— sostr()verbatim gave1E-9where Django gives0.000000001, a regression against the previous release.reprkeeps the exponent form, as Python does. Breaking: aDecimalnow arrives at the browser as a JSON string rather than a number, matchingDjangoJSONEncoder; client code doing arithmetic on it needsNumber(). A JSON number cannot carry the precision, so no fix keeps both. The Python-sidenormalize_django_value/__default__pair deliberately stays onfloatand 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.pycalls it at seven sites, and a model with a@propertyin 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, anddictsortgained 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_keyhas 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 unlikevalues_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 %}belowf64::EPSILON) are pinned as stated limits rather than left to be discovered. New cases inpython/tests/test_decimal_precision_2214.pycrates/djust_core/tests/test_decimal_value_2214.rs, andcrates/djust_templates/tests/test_decimal_loop_cache_2214.rs; Also: binary-formatDecimalvalues 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_valuekeys the loop-render fragment cache on the digits, without which a{% for %}over distinct prices served row 1's fragment for every row; andexpand_decimal_exponentimplements Django's>200-digit scientific fallback, without whichDecimal('1E-10000000')expanded to a ten-megabyte string. Thestrict=Truexfail intest_dead_special_case_converters_2214.pydid 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).
hotreloadcalled_send_update(patches=patches, version=self._next_version_armed(html), ...). Python evaluates arguments before the call, and_send_updatethen 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'sclientVdomVersionfalls one behind, so the next real diff fails itsversion - 1check and costs arequest_htmlrecovery 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_suppressedthat 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 requiredjump == 4and 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): newtest_a_suppressed_hotreload_broadcast_consumes_no_wire_version(two-arm — the same mount/event sequence with and without a broadcast) andtest_an_idle_connection_receives_no_unsolicited_frames(which also checks the mount->event window, matching the event's echoedrefrather than the frame type — a type allowlist admitted a sending hot-reload broadcast, since one ridestype: "patch", and the review falsified the docstring that claimed otherwise, #1867 — the same ref filter was then needed intest_global_isolation_1883's harness, where mistaking the stray for the arming patch had silently disarmed the gate-off below, #1859); and the flippedtest_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 + 1to>: they were incidental to what that test guards — gating off the #1817 fix shows the finalv_recovery == v_jumpcatches 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
TEMPLATESleak 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 anoverride_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. Thesettingsfixture 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 aDIRSentry pointing at a path that no longer existed. The systemic half:reset_djust_globalscovered 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 callingactivate()changed how every later test in that worker rendered. Both are now normalised withdeactivate(), which restores the settings default. Notdeactivate_all(): that reads like the thorough reset and is the one that leaks, leavingget_language()asNonesoget_formatfalls back toglobal_settingswhereNUMBER_GROUPINGis0— the exact shape that shipped in #2222 and poisoned a test two PRs later. Three structural guards (nodeactivate_all()in tests; every.enable()matched by a disable; a file that activates must reset), plus one that pins the premise — thatdeactivate_all()really does zeroNUMBER_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_allwas the one most likely to reintroduce #2222) and spares docstrings deliberately rather than by luck; the two resets get onetryeach 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); andpython/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_viewcould not render at all (#2219).get_context_datawalkeddir(self)andgetattr-ed every name, which reaches Django'sView.as_view— aclassonlymethodwhose__get__raises on an instance. So every render failed before a template was reached, for any subclass, always; andrender_template'sexcept Exceptionturned the crash into a genericAn error occurred rendering this view.The class is also renamedSimpleLiveView: it was calledLiveView, the same name asdjust.LiveViewwhich it is not, which is why grepping forSimpleLiveViewfound 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.LiveViewstays as a module-level alias. The fix is theAttributeErrorguard, not a name exclusion: a name indir(self)is never a promise thatgetattrwill succeed, and this is a method whose entire job is reading attributes it does not know about.as_viewis 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 notemplatenow 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 calledtranslation.deactivate_all(), which leavesget_language()asNonesoget_formatskips the locale modules and falls back toglobal_settings, whereNUMBER_GROUPINGis 0 — silently disabling grouping for every later test in the same worker.deactivate()restoressettings.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-retrosynthesising a milestone — got a number wrong by roughly an order of magnitude. Including, atRETRO.md:533, the row asking for exactly this automation: the row about closing rows was itself a stale row. Fixed by extendingscripts/check-action-tracker.pyrather than adding a sibling — it already owned the batched issue fetch, the--fixflag, themake check-trackertarget and the test file, and RETRO.md's two structures (the tracker table'sStatuscolumn 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#NNNand reported #2142 as closed by PR #362, which is issue #329's closer: plausible, verifiable-looking and wrong (#1197). AGitHub #NNNNreference 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'sclosedByPullRequestsReferencesbefore 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/**deferredare all left alone — the last becauseRETRO.md:682is correct, precise, and would otherwise be a permanent false alarm. 15 new cases, takingtests/test_action_tracker_drift_2143.pyto 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
dateformat codes rendered as their own letter, and a tenth was wrong (#2217).b c f L o r S t u w W zwere unimplemented and fell through the formatter's catch-all, so{{ v|date:"jS F Y" }}produced22S 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:Nis Associated Press style (django.utils.dates.MONTHS_AP), not%bplus a period — AP does not abbreviate the short months at all (March,April,May,June,Julyare spelled out) and September isSept., 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 replacingWwith a day-of-year division andowith 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 forb/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 incrates/djust_templates/tests/test_all_date_format_codes_2217.rs; 12/12 gate-off verified.timesince/timeuntiloutput 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'savoid_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 reads0 minuteswhere djust read30 seconds. Now ported fromdjango/utils/timesince.pyrather 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'sMONTHS_DAYSquirk 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 is1 year, never1 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, withtimeuntilswapping its arguments the way Django'sreversed=Truedoes. Verified by a 1,600-case randomized differential againstdjango.utils.timesinceacross aware and naive values and durations from seconds to a decade: 0 diffs. 14 regression cases incrates/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).timesinceandtimeuntilcalledDateTime::parse_from_rfc3339and nothing else, so a naive datetime — the normal shape underUSE_TZ = False— did not parse and the filter returned its input verbatim:2026-08-25T12:16:36.074891where Django renders2 hours. ADateFieldwas equally affected. Third instance of one class in three releases:date/timelearned 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 sharedparse_serialized_datetimerather 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, sotimesinceagainst 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 againstUtc::now(), while Django compares it againstdatetime.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 incrates/djust_templates/tests/test_duration_filters_2227.rsandfilters.rs::parse_shape_tests_2227.{{ v|time:"H:i" }}on aTimeFieldechoed its input instead of formatting it (#2216). No parse branch matched a time-only string, sodate/timereturned the serialized value verbatim —23:30:00where Django renders23: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 forH:i:sthe 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 adatetime.timethrough 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 uformat normally; the timezone codese T O Zrender 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\Ystays a literal and does not empty the render. Also fixes lowercasea, which emittedam/pmwhere Django emitsa.m./p.m.(only uppercaseAis 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 raisesTypeError, and Rust cannot tell adatefrom a midnight datetime because the serializer discards the type — that wants a documented decision, not a patch. 12 regression cases incrates/djust_templates/tests/test_time_only_filters_2216.rsand 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
DjustTemplateBackendgoes throughtemplate/rendering.py, which pushed nothing: on a fresh worker thread it rendered1234567|23:30where Django renders1,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_settingsmoves the timezone and language away from any stale value anyway), and one of the three cases cannot detect this defect at all — withUSE_TZoff 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_valuehas a branch intending to stringifyDecimalandUUID; it is dead forDecimal, becauseextract::<f64>()above it honoursDecimal.__float__. So aDecimalFieldreaches the client as a binary float, andDecimal('12345678901234567890.123456789')arrives as1.2345678901234567e+19. Same shape as #2212 — a 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 notunreachable_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 }}renders19.99instead of20.0and{% if p > 10 %}takes the false branch once the value is a string. It needs a decision — aDecimal-awareValuevariant, 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'si64goes through__index__, notint(), andint(Decimal(...))/int(UUID(...))both succeed whileoperator.indexrejects both — modelling it asint()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 saidDecimalconverts to a string, and both Python converters saidfloat"matches DjangoJSONEncoder.default" when that encoder returnsstr(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
floatformatunderLANGUAGE_CODE="de"; probing it against Django's own engine widened it twice. It is not a non-English problem —USE_THOUSAND_SEPARATORapplies regardless of language, so Django renders1,234,567where djust rendered1234567in the default configuration. And it is not confined tofloatformat— bare{{ n }}is affected, which is every rendered number in every template. Fixed for both, matching Django acrossen-us/de/frincluding 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 bydjango/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 (nowdjust.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]) yields12,34,567, and a0entry keeps the previous width instead of ending grouping.USE_L10Nis deliberately not read — verified inert across the fullUSE_L10N×USE_THOUSAND_SEPARATOR× language matrix, since Django 5.0 removed it as a toggle.USE_THOUSAND_SEPARATOR=Falsestill localizes the decimal point, andfloatformat:"2u"remains Django's documented opt-out. The localization is applied at the variable-output site, not inimpl Display for Valuewhere the number rendering lives:Displayis 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 incrates/djust_core/tests/test_number_localization_2221.rs; 8/8 gate-off verified. Month and day names ({{ d|date:"D" }}→Sain German) and per-localeDATE_FORMATremain unfixed and are tracked as pieces 2 and 3 of #2221.Synthesized
live_redirectrequests ignoredSESSION_ENGINE(#2210). Four places built arequestfor thelive_redirect/url_changepaths, and each importeddjango.contrib.sessions.backends.db.SessionStoredirectly —settings.SESSION_ENGINEappeared nowhere in the package. A project on a cache-backed engine got a store reading adjango_sessionrow 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 raisesOperationalError: 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.pyand both intesting.py, which the issue did not list — now resolve through onedjust.utils.build_session_for_request, the waySessionMiddlewareitself does;LiveViewTestClientwas 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_cookiesdiverges 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 aSet-Cookiecan 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, underUSE_TZ=True. A New York project rendered2026-08-22 23:30where Django renders19:30. Four hours out, in the configurationdjust newgenerates, since the scaffold setsUSE_TZ = True. Confirmed through the realLiveView.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 aUSE_TZ = Falseproject) — but a naive value still reports the default zone's abbreviation and offset.chrono-tzis now a dependency, and a fixed per-render offset was rejected rather than not considered:America/New_Yorkis-0500in January and-0400in 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 theRustLiveViewis session-cached, so a zone captured atready()or per view instance would be stale for exactly the case users care about; and djust renders run insync_to_asyncworker threads, where two connections can hold different zones concurrently. This mirrors Django, whose owntimezone._activeis aLocal(). 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" }}produced19:30 T;T,e,O,ZandInow match Django, as doesU. ATIME_ZONEthe bundled database does not know is logged once and left unconverted rather than raised. The handoff itself lives in a newdjust.timezone_bridgerather than on the mixin, because there are two Python render paths that share no base class —RustBridgeMixinandsimple_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 incrates/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 (baredate/timeobjects still diverge), #2217 (nine non-timezone format codes remain unimplemented) and #2219 (simple_live_viewcannot render at all —get_context_dataraises on every instance).A bool in LiveView state reached the client as
1(#2212). PyO3 0.29 extracts a PythonTrueasi641— its owntest_i64_boolasserts this — so any converter tryingi64beforeboolhas a dead bool arm.serialize_python_valuedid, andserialize_context({"flag": True})returned1,Falsereturned0, nested and in-list alike.serialize_contextis a public#[pyfunction]feeding JIT state serialization, so this was user-visible: client code doingx === truesaw false, and after #2203 aValue::Integer(1)renders1where aValue::Bool(true)rendersTrue. The issue undercounted the surface — it claimed three converters extract both types; a sweep of everyfnundercrates/found six. Review then found the sweep's own parser had the same weakness one level up: it did not matchpub(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 legalpub(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 deadif letarm 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 inTestBoolRoundTrip2212plus 9 structural in the newpython/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 (apub(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 function —extract::<f64>()sits above theDecimal/UUIDstringify branch, so everyDecimalFieldreaches 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.dateandtimesilently ignored their format string for anydatetime— plus Django's truncation ellipsis andadd'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_dateaccepted RFC3339 or a bare%Y-%m-%d, andformat_timedelegates straight to it — but a PythondatetimearrivesT-separated — djust serializes with.isoformat()(python/djust/serialization.py:311), notstr()— 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, sodatetime.now().isoformat()— everyauto_now_addtimestamp — was still broken.{{ post.created_at|date:"Y-m-d" }}, the commonest use of this filter, rendered a raw datetime string. It survived because aDateFieldstringifies to2026-08-22and takes the date-only branch, so the failure is invisible unless the value is adatetime. 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/truncatecharsused...where Django uses…(U+2026) — not cosmetic fortruncatechars, because Django reserves one character for the ellipsis inside the limit, sotruncatechars:5isabcd…where reserving three gaveab....addimplemented only a partial first branch of Django's three (int(value) + int(arg), elsevalue + arg, else""): it parsed the argument asi64and 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" }}is7, 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 unquoted1.5is a float literal that truncates. A first pass coerced both and returned2— a fabricated number where Django produces text, worse than the inert wrong value it replaced;arg_was_quotednow separates them.Value::Boolcoerces too (int(True)is 1).{{ 1.5|add:2 }}now renders3, not3.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, buti64can, 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">submittingYYYY-MM-DDTHH:MM; and wideningadd's coercion to floats and numeric strings is what made overflow reachable. Out of scope, tracked separately:divisibleby(true/True),slice([List]) and aNullargument (""/None) are not filter bugs — they areimpl Display for Value, which governs every{{ var }}; 5 of the 7Valuevariants diverge, includingdict([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 aReferenceErrorunder Django'sTrue. That is a design decision, not a drive-by fix (#1079). 16 cases in the newcrates/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, orchecked_addeach reddens a distinct set, the last by reproducing the original overflow panic. Review added four more fixes:truncatechars:0returned…where Django returns""; thetruncatechars_html/truncatewords_htmltwins 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::Boolwas missing fromadd'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 foundadd'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 upfallback, 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 shippingalt="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.joinandcutare the sharpest — they did not merely ignore the argument, they spliced the identifier text into the output ({{ a|join:sep }}renderedpvq, using the variable's name as the separator) or silently no-opped. Custom filters were already correct (filter_registry.rsresolves bare identifiers viaContext::resolveand 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 inapply_filter_full_safeahead 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_filteralready receivedcontextand simply never consulted it for the argument. Three behaviours are deliberately preserved. A quoted argument is never looked up (gated on the existingarg_was_quotedhint the renderer already computes), so a literal cannot become a lookup when a context key happens to share its name. The classicapply_filter_with_contextcall site passesarg_was_quoted=trueand is untouched. And an unresolvable identifier still falls back to its raw text — a deliberate divergence from Django, which raisesVariableDoesNotExist— 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 isOk(None)and falls back as described, but anErr— 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 textobj.raising_methodinto 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:datewas 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 realdatetimeit 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,addwith a non-integer argument, andValue::Nullas 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 oneValue-rendering issue (Display for Valueemitstrue,[List],"") that would be the same parallel-path mistake if patched inside the filters.timeis the instructive one: it isdate'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 newcrates/djust_templates/tests/test_builtin_filter_arg_resolution_2202.rs, 2 fordateinTestBuiltinFilterArgResolution2202(Python-side, becauseValuehas no date variant and an ISO string passes through unformatted, so a Rust-level case would assert nothing), and 2 for the error policy inTestFilterArgErrorPolicy2202(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 withDID NOT RAISE.
Security
A safe-key grant no longer outlives the value it was granted for (#2300).
RustLiveViewaccumulated 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 intopand later rendered an attacker-controlledpemitted it live. This one needs no filter chain and no|safeanywhere in the template — a bare{{ p }}is the whole reproducer, which makes it the broadest of the escaping bugs fixed in this release.update_statenow 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, becauseupdate_stateis a partial merge: updatingpdropspand itsp.0descendants and leaves an untouchedqalone. The first attempt was caller discipline —mark_safe_keysreplacing 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 onlypwould drop a still-valid grant on an untouchedq), so both halves were dropped for the single structural rule. New cases inpython/tests/test_stale_safe_grant_2300.py; all three facets gate-off verified with unique-anchor assertions (2 / 2 / 1 failures).linenumbersnow escapes inside the filter, so a trailing|safecannot expose its input.{{ p|linenumbers|safe }}rendered1. <img src=x onerror=alert(1)>live, where Django renders it escaped. djust'slinenumbersnever escaped anything itself and relied on the render-time auto-escape;|safesuppresses exactly that, and then nothing had escaped the input at all.renderer.rsdocumented the exclusion fromSAFE_OUTPUT_FILTERSdeliberately, 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 insideadd_linenumbers(conditionally, as Django's is, so an already-safe input is not double-escaped) and the name joinsSAFE_OUTPUT_FILTERS; the two halves are one change, and either alone is a bug in opposite directions. The surface is wider than the|safeshape: any downstream filter that reads the output as markup was a live cell,{{ p|linenumbers|truncatechars_html:"5" }}among them, with no|safeanywhere 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-|safecolumn. A leading|safewas 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 inTestTheAxesTheSafeShapeDoesNotCoverandpython/tests/test_linenumbers_escaping_2291.py, both mechanisms gate-off verified independently (11 and 6 failures).{{ p|escape|safe }}emitted attacker markup —escapewas a no-op that deferred to render-time auto-escaping (#2281). Django'sescape_filterisconditional_escape(value): EAGER, returning aSafeString, 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<to<, and{{ p|escape|striptags }}stripped tags Django'sescapehad already turned into inert text. The security cell is{{ p|escape|safe }}:|safesuppressed 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|safeon attacker input. A probe over every{{ p|escape|X }}and every length-3 chain containingescapefound 104 live-markup cells onmain, every one anescape…safepair; the same probe reports zero now.escapejoinsSAFE_OUTPUT_FILTERS, a grant it earns by escaping its own input, and stays distinct fromforce_escape:escapeisconditional_escape(aSafeStringpasses through),force_escapeisescape(aSafeStringis escaped again), which{{ p|safe|escape }}vs{{ p|safe|force_escape }}pins.unordered_listandsafeseqno longer hand a non-sequence input back unescaped under a safe grant. Both sit inSAFE_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|safewith nomark_safeanywhere in the template, and{{ user_bio|unordered_list }}rendered attacker markup live. Fixed alongside #2274 rather than filed, because #2274 makes it worse: once anis_safefilter preserves the safety it is handed, an unearned grant survives arbitrarily far down the chain (|unordered_list|lowerwas 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 inTestUnearnedSafeGrantand intest_xss_prevention.rs, both directions and both gate-off verified.SimpleLiveViewrender failures no longer put exception detail in the response (CodeQLpy/stack-trace-exposure, alert #2596; CWE-209).render_template'sexceptrenderedf"<div>Template error: {e}</div>"wheneverDEBUGwas 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:549fixed exactly this shape withescape(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. Nowlogger.exceptionplus a static string in both modes: noDEBUGbranch, so there is no mode-dependent leak to reason about and nothing for a misconfigured productionDEBUG=Trueto expose. Strictly better for the developer the branch was written for — a full traceback in the log beats a one-linestr(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
Decimalrenders here where Django 5.2 500s the page — decided, and left that way (#2460).Template("{{ p }}").render(Context({"p": Decimal("Infinity")}))raisesTypeError: bad operand type for abs(): 'str'on Django. No filter is involved:render_value_in_context→localize→number_format→numberformat.format, which reaches_, digits, exponent = number.as_tuple()and thenif abs(exponent) + len(digits) > 200.Decimal("Infinity").as_tuple().exponentis the string'F'('n'for NaN,'N'for sNaN), soabs('F')raises. djust rendersInfinity/-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")rendersinfon Django perfectly happily — the same mathematical value, refused only on theDecimalbranch, because that is the only branch that callsas_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", theelsearm 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,titleandlinebreaksall renderInfinityfor 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
DecimalFieldaggregate can hold — an outage bought with parity against a crash. Decided the way #2429 decidedjson_script, and more easily:json.dumps' refusal there is at least a documented contract, whereabs('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 ondec-inf, 31 ondec-nan), reaching thewith,cycle,firstof,firstof-as,@pathand@ctagaxes as well as the bare filter one; and the bare single-filter position is 11, not 12 — five onInfinity(cf_ident,default,default_if_none,join,slice) and six onNaN(those five plusget_digit). The asymmetry is not noise:int(Decimal("NaN"))raises ValueError, whichget_digit'sexcept ValueErrorcatches, so theDecimalis handed back and the render raisesabs();int(Decimal("Infinity"))raises OverflowError, which thatexceptdoes not catch, soget_digitraises 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 sameabs()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_IfDjangoFixesItasserts 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 overfloat("inf")and over a finiteDecimal("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,TestNoLocaleCanCorruptTheSpellingpins thatlocalize_plain's digits-and-a-point guard still rejectsInfinity, 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 atcrates/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_filterkeeps its assertions and gains the decision.json_scriptstays PERMISSIVE wherejson.dumpsrefuses, 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 extractsbytesas a sequence long before anystr()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 fordateis seven types wide —tuple/Decimal/date/datetime/time/timedelta/UUIDare all refused by Django as KEYS and accepted by it as VALUES, becauseDjangoJSONEncoder.defaultnever 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 anintraises'int' object is not iterablehere 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 Valueconverts an arbitrary object to its__dict__(anObject) or itsstr()(aString) 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 (ObjectKeykeeps the type, #2339), which is exactly why refusing there alone is the disagreement #2425 declined. Recovering the type means a newValuevariant threaded through the ~460Value::Stringsites incrates/**/srcand every filter, renderer and serializer that matches onValue— 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.rsclaimed thejson_scriptarm "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 aValue::DictViewat 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): #2448 —
json_scriptspells adatetime/timedeltaVALUE withstr()instead ofDjangoJSONEncoder'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 #2449 —unordered_listandfirstemit where Django raises on a scalar, which unlike this one IS decidable, because it turns on aValue's shape rather than on an erased Python type.22 cases in
python/tests/test_json_script_refusal_decision_2429.py(TestTheDivergentSetReDerived,TestDjustDoesRaiseWhereItCanSeeTheShape,TestTheValuePositionCannotSeeTheTypeAtAll), andTestTheRefusalHalfIsNotClosedHerebecomesTestTheRefusalHalfIsADecidedLimit. Five gate-off mutations — each rebuilding the crate and asserting the.somtime 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 theDictViewarm 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, sostartswith("<script")stayed true and it measured nothing.