Release Notes
Upcoming preview: instanceof pattern variables may not reuse a field name (JULC0059, #207)
Section titled “Upcoming preview: instanceof pattern variables may not reuse a field name (JULC0059, #207)”JuLC binds an instanceof pattern variable only when the pattern is the whole
condition of an if statement outside a loop (#204). In every other position (a loop
body, an && condition, a negated guard such as if (!(x instanceof T v)) return ...;,
a ternary, a lambda, a boolean return) the variable is not bound. When a field of the
class, including a @Param field, has the same name, those positions silently read or
updated the field. For example, with a field q holding 40,
if (s instanceof Sq q && q.s() > 1) { return q.s(); } returned 40 where Java returns 3.
This behaviour dates back to the first preview.
The compiler now rejects a pattern variable that reuses the name of a field of an
enclosing class (JULC0059) in every position, including the one where the variable is
bound correctly. Rename the variable. switch case patterns (case Sq q ->) bind their
variables in every position and are not affected. This is validation only: every program
that still compiles produces the same script bytes and hashes.
Known limitation until #204 (planned for the next preview): a pattern variable with any
name that is used outside that one position fails to compile, usually with
Undefined variable. Use a
switch on the sealed type, or the plain if (x instanceof T v) { ... } form outside a loop.
Upcoming preview: value-oriented contract code guide (#163)
Section titled “Upcoming preview: value-oriented contract code guide (#163)”A new Best Practices page, Value-Oriented Contract Code,
shows how to compute values in initializers, switch expressions, helper methods and list
operations instead of updating variables across branches, and how to test the compiled
validator. Every example is compiled and evaluated on the VM by tests in julc-examples,
and a test fails if the page and the tested code drift apart.
The page records current list-operation behaviour that differs from Java:
- compiled
anyandallevaluate the predicate for every element, so a predicate that fails on a later element fails the script where Java would already have returned; list.find(...)returns an optional value in compiled code although its Java signature returns the element;- the static
ListsLib.any,all,filter,map,find,foldlandzipforms compile only from source text, becauseListsLibdoes not declare them in Java.
No compiler behaviour changes in this entry.
Upcoming preview: a source name can never resolve to a compiler-generated binder (ADR-060)
Section titled “Upcoming preview: a source name can never resolve to a compiler-generated binder (ADR-060)”The compiler generates variables around your code for switch dispatch, loops, the
validator wrapper, @Param decoding and the list and map builders. Several of
those names were legal Java identifiers, so a source variable with the same name
silently resolved to the compiler’s variable instead:
BigInteger __match_tag = ...;read inside a switch arm gave the constructor tag;- a
@Paramnameddatum__compared the spent datum with itself, so any parameter value was accepted; - a variable named
xs__,__acc_tupleorloop__forEach__0read inside a loop saw the loop’s remaining list, accumulator tuple or recursive function; - a
@Paramnamedredeemer__,scriptContextData,ctxFields__ortag__, or a pair of@Paramscfg__rawandcfg, read the wrapper’s value.
Every generated name now begins with #, which no Java identifier can contain, and
switch-dispatch names are also chosen away from every name a branch uses
(ADR-060).
The same change fixes adjacent silent miscompiles:
- Compound assignment in loops dropped the operator:
count += 1over three items returned 1 andwhile (k < 3) { k += 1; }never terminated.+=,-=,*=,/=and%=on integers, and+=on strings, now meanx = x op y.&=,|=,^=and the shift operators are rejected (JULC0052), because lowering&=as&&would skip a check Java always evaluates. - Overloaded methods: the last declaration ran for every call, so
check(BigInteger)next tocheck(long)used the wrong body. Overloads are rejected (JULC0054) unless they compile to identical code, like the stdlib’s twointegerToByteStringforms. - Declarations with several variables,
BigInteger a = x, b = y;, bound only the first, so a laterbcould read a fieldb. They are rejected (JULC0053); fields may still declare several variables. - Methods now have their own namespace, as in Java: a local, parameter, field or
@Parammay share a method’s name (BigInteger fee = fee(a); return fee(fee);failed at run time, and a@Param limitnext to a methodlimit(...)gave the wrong decision).x.name()orx.nameon a value whose type is not known used to call whatever was namedname; it is now rejected (JULC0055). A static field initializer that calls a method of its class now gets an explicit error (JULC0057). - Fields updated in loops: a loop assignment to a static field or
@Paramrebinds the name only inside that method, so another method reading the field saw the original value (a helper returned 0 aftercount += 1three times), and every call of the updating method started again from the initial value (a helper called twice returned 6 where Java returns 9). Such an update is now rejected (JULC0056) unless it is in an entry method (a validator entrypoint, a multi-validator handler or thecompileMethodtarget) that no method calls, outside any lambda, and no other method reads the field. Updating a field in a helper or a library method no longer compiles; copy the field into a local accumulator. A loop-body local that shadows a field and is reassigned was threaded out of the loop as if it were the field; it is now a local. So is a for-each variable that shadows a field: reassigning it updated the field, so the field read 1 after the loop where Java reads 10. - Reassignment in a loop without an accumulator: a for-each loop that updates no
variable declared before it cannot reassign its loop variable or a body local. This
was already rejected, with “Unsupported expression: AssignExpr”; it now reports
JULC0058and suggests declaring a new local.
Migration. Programs that use none of these shapes keep byte-identical scripts
and hashes at every optimization level; this was checked on 1,432 in-repo corpus
rows (all levels, with and without source maps, including @Param and
multi-validator programs) and the 344 external example artifacts. A recompiled script that used an affected shape changes its bytes, hash
and budget, and its behaviour now matches Java: review it and redeploy if the old
behaviour mattered. Deployed scripts never change. Code that is now rejected must
be rewritten as the diagnostic describes. PIR and UPLC text output, such as the
.uplc file from julc build and the playground pipeline view, now shows #
names; this is cosmetic. Code that drives PirGenerator directly must register a
method with SymbolTable.declareMethod: a method registered as a variable with
SymbolTable.define is no longer callable. Diagnostic codes JULC0044–JULC0051
are reserved for the generic backend work.
Upcoming preview: builder binders no longer capture user variables (#186)
Section titled “Upcoming preview: builder binders no longer capture user variables (#186)”A variable referenced inside a lambda or argument could silently resolve to an
internal variable of the list, map or loop code the compiler generated around it.
For example, outer.any(x -> inner.any(y -> y.equals(x))) compared each inner
element with itself and returned true whenever inner was non-empty, so a
validator could accept a transaction it should reject. Affected names were the
builders’ internal binders: x, acc, lst, go and h for any, all,
find and foldl; x_map, acc_map, x_flt and acc_flt for map and
filter; the *_get, *_c, go_*, ps_*, xs_*, *__ao and *__f binders of
get, contains, reverse, concat, take, drop, size, keys, values,
map lookups, ListsLib.contains, MapLib.member/lookup/delete and
Value.assetOf; and the statement binders _if, _forEach, _while,
_nested, __t and _u. Instance and static ListsLib forms were both affected.
Each generated binder is now renamed only when a caller term actually refers to that name, so evaluation order is unchanged. Scripts without such a reference keep byte-identical output at every optimization level. Recompiling an affected script changes its bytes, hash and budget; review it and redeploy if its behaviour mattered. Deployed scripts never change. The generated names #186 did not reach, such as the loop and switch binders, are covered by ADR-060 below.
Upcoming preview: release regression gates and profile freeze (#121)
Section titled “Upcoming preview: release regression gates and profile freeze (#121)”The proposed pre17 hash-stability policy freezes existing optimization profiles, with explicit, reviewed exceptions for correctness fixes. It distinguishes script-hash changes from network execution-cost changes.
Developer-node gates now support an explicitly configured native Haskell CLI/socket as well as an existing DevKit container. New Value/Array and G1/G2 MSM regressions pin script hashes, sizes and execution budgets and check confirmed node spends. Normal builds run only the offline regressions; node tests remain opt-in. This test/documentation change does not alter compiler output or deployed scripts. The documentation build dependencies are also updated to resolve known advisories.
Upcoming preview: decode boolean and String switch fields (#166)
Section titled “Upcoming preview: decode boolean and String switch fields (#166)”Switch case-pattern access such as case Sum s -> s.enabled() ? ONE : ZERO
now decodes a boolean record field to native Bool. Previously the cached field
remained raw Data and evaluation failed. String fields had the same missing decode.
DataMatch field extraction now reuses the shared decoder used by other field-access
paths rather than maintaining a separate incomplete type table (ADR-051).
This is separate from the loop/switch validation fixes. Constructor encodings, branch selection, field order and #162’s rejection of case-pattern reassignment are unchanged. Only the selected arm’s bound fields are decoded, strictly before its body; malformed Bool/String fields can now fail there even when unused. The existing decoder’s permissive Bool tag handling is unchanged; this is not a new strict Data-validation boundary.
Recompiling affected switches changes script bytes, hashes and budgets at all optimization levels, including NONE and BASELINE. Recompile and reassess affected scripts before deployment. Existing deployed scripts and ledger encodings do not change; switches without these field types retain their existing decoding.
Upcoming preview: preserve loop state across enclosing branches (#161)
Section titled “Upcoming preview: preserve loop state across enclosing branches (#161)”A loop inside an if at method level or inside a switch-expression arm now
preserves its accumulator updates for statements following the branch. Previously
total = 0; if (condition) { for (...) { total = total.add(x); } } return total;
could silently return 0. The fix reuses existing continuation lowering with a
shared join lambda for loop-containing branches, including
for while loops, nested branches, multiple accumulators and break. Conditions and
branches keep their evaluation order; early return/yield still short-circuit.
Continuation-bearing branches also rename local and instanceof pattern bindings
internally to protect join arguments when a local shadows a class field updated
by the other branch, and to prevent capture in the existing inline return/yield
lowering. Diagnostics display the original source names.
The validation follow-up also accepts this pattern when the switch expression is inside an outer loop body. Previously the outer-loop guard conservatively rejected arm-local guarded-loop updates. It now validates the selector and leaves arm bodies to their ownership guard and nested loops’ own checks. Switch arms still cannot update enclosing variables, and conditional updates to actual loop-body locals remain unsupported. This validation-only change adds no lowering or encoding change for previously accepted programs.
Recompiled sources with loop-containing branches or affected name captures may change script bytes, hashes, size and execution budgets at all optimization levels, including NONE and BASELINE. The following code is bound once outside the branches: sequential guarded loops grow linearly instead of exponentially duplicating their tails. Join calls add execution overhead; reassess budgets as well as size. Recompile and reassess affected scripts before deployment; existing deployed scripts do not change. Public APIs, ledger encoding and the restrictions below remain unchanged.
This was a missed case in the older discarded-result path: #79 added continuations for returns and #137 extended them to yields. #157 explicitly left this case open.
Upcoming preview: reject lost conditional loop-local updates (#155)
Section titled “Upcoming preview: reject lost conditional loop-local updates (#155)”The compiler now rejects assignments inside an if/else branch to a variable
declared in the loop body outside that branch. Previously the update could be
silently lost. This applies to for-each and while loops at every optimization
level, including nested and break-aware forms. The diagnostic identifies the
variable and source location, and suggests a conditional initializer, moving
the declaration before the loop (resetting it each iteration if necessary), or
declaring it inside the branch when its value is only needed there.
Switch-expression arms also reject updates to enclosing variables, including
updates made by nested loops to either body locals or outer accumulators, with
or without an enclosing if. Declare the accumulator inside the arm and yield
its result instead. This conservative rule also rejects previously correct code
that updated an enclosing variable but consumed the update only via yield inside
the arm; move the accumulator declaration into the arm to preserve that behavior.
Reassignment of a switch case-pattern binding is also rejected (#162), even if
the arm only yields the record itself. Previously field projections could read
the original record after reassignment. Copy the binding to a fresh arm-local
accumulator instead. Supported instanceof binding reassignment is unchanged.
The separate enclosing-if/loop miscompile is fixed by #161, described above.
This is a conservative restriction, including constant conditions and dead updates. This validation alone preserves accepted programs’ generated bytes; the separate #161 lowering fix may change them. Existing deployed scripts are unchanged. See conditional loop state for supported patterns.
Upcoming preview: independent compiler and evaluation costs (#153)
Section titled “Upcoming preview: independent compiler and evaluation costs (#153)”pv11-costed now needs no compiler cost profile. Its list-to-array rule uses
structural eligibility; its transformations and the default pv11-safe level
are unchanged. Omitting an optional profile preserves generated script bytes,
hashes and source maps.
The immutable evaluation snapshot is now named plutus-v3-pv11-costs-v1, with
neutral Java constants OptimizationCostProfiles.PLUTUS_V3_PV11_COSTS_V1 (and
_ID/_PARAMETER_HASH). The old ID remains accepted and the old constants are
deprecated aliases to the same snapshot. The parameters and hash are unchanged.
See the profile catalog for the exact upstream mapping.
Optional compiler profile settings remain accepted and validated for compatibility,
but no longer appear as dependencies in compilation reports. Reports now also
carry the compiler build version; target, optimization level and applied rules
remain available. Benchmark reports independently retain the model actually
used for evaluation. Runtime callers continue supplying cost parameters through
the VM API or ProtocolParamsSupplier; no named profile is required there.
Upcoming preview: typed BLS12-381 values and multi-scalar multiplication (ADR-047)
Section titled “Upcoming preview: typed BLS12-381 values and multi-scalar multiplication (ADR-047)”BLS12-381 values now have their own types: JulcG1 and JulcG2 for points,
JulcMlResult for a Miller-loop result (all in org.julclang.core.types).
Every Builtins.bls12_381_* method and every BlsLib method now takes and
returns these types wherever it took or returned a point or a Miller-loop
result as byte[]; only g1Compress/g2Compress produce bytes and only
g1Uncompress/g2Uncompress consume them (messages, domain separation tags
and scalars keep their byte[]/BigInteger types). A compressed byte string where a
point is required, a G2 point where a G1 point is required, a point compared
with == or placed in a datum, redeemer, record or list is rejected at compile
time (JULC0041); a point at a validator or compileMethod boundary is
JULC0042. Compress at the boundary and uncompress on the other side.
Multi-scalar multiplication is now callable from source on the PV11 target:
BlsLib.g1MultiScalarMul(JulcScalars, JulcG1Points) and the G2 form take the
native lists built by Builtins.scalars(...), Builtins.g1Points(...),
Builtins.g2Points(...) (literal or runtime elements) or decoded from Data
lists by Builtins.scalarsFromList(JulcList<BigInteger>),
Builtins.g1PointsFromCompressed(JulcList<byte[]>) and the G2 form. Every
scalar is validated first (512 bytes of two’s complement), the lists are
zipped to the shorter one, and the empty sum is the identity. On the pinned
PV11 costs one MSM call is smaller than the manual g1ScalarMul/g1Add chain
from three points and cheaper in CPU from seven (about 322 million to enter
plus 25 million per point against 77 million per point); nothing rewrites one
into the other. An element of the wrong type in scalars(...), or a Data list
of the wrong element type in a converter, is JULC0041.
Migration. A local, helper parameter or return type that named a BLS value
as byte[] no longer compiles (JULC0041, at the initializer, the helper
call or the return): declare it as JulcG1/JulcG2/JulcMlResult or use
var. The same check covers the two branches of a conditional (b ? p : q
with a G2 q where a G1 is required), declarations inside a loop body and
assignments to a loop accumulator or a loop-body local; a native accumulator
must be its loop’s only accumulator (a multi-accumulator loop packs its
accumulators as Data). Two loop-body shapes that compiled with their update
silently dropped are handled: an assignment inside a bare nested block now
updates the accumulator, and an assignment in expression position or to an
undeclared variable is rejected. Code that used var for BLS values compiles unchanged and keeps its
bytes; the compressed encodings are still byte[]. The two
bls12_381_*_multiScalarMul signatures changed from PlutusData to the typed
lists (the old ones compiled but could not evaluate successfully).
No program in the example corpus or the golden suites uses the BLS surface, so
no existing script changes bytes or hash at any level.
Upcoming preview: array literals and literal folding (ADR-046)
Section titled “Upcoming preview: array literals and literal folding (ADR-046)”JulcArray.of(a, b, ...) writes a PV11 array down. On-chain it is
JulcList.of(a, b, ...).toArray(): the elements are Data-encoded as the
declared element type requires (declare it: JulcArray<BigInteger> t = JulcArray.of(...)) and the list is converted with ListToArray, so get and
length behave exactly as on an array converted from a list.
At pv11-safe (the default) and pv11-costed, an array whose elements are all
literals (integers, byte strings, strings, booleans, nested list literals)
becomes one UPLC array constant; length() on it folds to a constant and
get(i) with a literal index folds to the element, decode included
(JulcArray.of(1, 2, 3).get(1) is 2: 42 → 6 bytes, 1,238,594 → 16,100 CPU).
A runtime index keeps the access over the embedded constant (a three-entry fee
table indexed by a runtime tier: 57 → 46 bytes, 1,435,338 → 625,598 CPU on a
valid tier and 809,740 CPU less on an invalid one, which fails exactly as
before). The same folds apply to list.toArray()
and JulcArray.fromList(list) over a JulcList.of literal. An index outside
the array, literal or runtime, fails at IndexArray with the same text as
before. Nothing changes at none/baseline; the optimization report records
pv11.o10.array-literal-fold, and the rule can be switched off with
CompilerOptions.disableOptimizationRule.
This change is additive for the example corpus and every golden suite: no
program there contains an array constant or converts a list literal to an
array, so those scripts keep their bytes and hashes at every level. A program
of your own that already spells JulcList.of(...).toArray() or
JulcArray.fromList(JulcList.of(...)) does change at pv11-safe (it gains the
array constant). A native Value cannot be an array element (JULC0041, as for
every Data-backed container). Spell a negative literal element as
new BigInteger("-5"). var t = JulcArray.of(...) infers the element type from
the elements, as javac does; elements of different types under var are rejected
with JULC0012 (declare JulcArray<T>).
Upcoming preview: native Value literals and literal folding (ADR-045)
Section titled “Upcoming preview: native Value literals and literal folding (ADR-045)”Builtins gains three native Value literal producers on the PV11 target:
emptyValue() (a UPLC Value constant), singletonValue(policyId, tokenName, quantity) and lovelaceValue(quantity) (inserts into the empty Value, with
insertCoin’s rules: a zero quantity yields the empty Value, a non-zero
quantity needs keys of at most 32 bytes and a quantity in the signed 128-bit
range). They replace the unValueData(mapData(mkNilPairData())) idiom for new
code; the idiom keeps working and keeps its bytes. They are intrinsics rather
than NativeValueLib methods so that programs which do not call them keep
their bytes at every level.
At pv11-safe (the default) and pv11-costed, a call of insertCoin,
lookupCoin, union, contains, scale, toData or fromData whose arguments
are all literals (constants, or locals bound once to a literal) is folded at
compile time into the Value, integer, boolean or Data it evaluates to, by the
same code the VM runs. A requirement such as one NFT plus two ADA becomes one
constant in the script (40 → 7 bytes and 883,863 → 16,100 CPU for a literal
lookup); the runtime part of a check (fromData(minted), contains) stays. A
literal call the builtin would reject (a 33-byte key, an overflow, a negative
quantity under contains) is left as written and fails at runtime with the
same text. No algebraic identity is applied and nothing changes at
none/baseline. The fold only fires when the literal is not larger than the
call it replaces as it stands in the script, so toData(Builtins.emptyValue())
stays a call and a literal local shared by several calls is never copied into
them (the calls stay, the local keeps its one constant). The optimization report
records pv11.o14.value-literal-fold, and the rule can be switched off with
CompilerOptions.disableOptimizationRule.
This change is additive: no program compiled before it contains a Value
literal, so existing scripts keep their bytes and hashes at every level.
Spell a negative literal quantity as new BigInteger("-5")
(BigInteger.valueOf(-5) compiles to a runtime subtraction).
Upcoming preview: automatic sharing of repeated record field projections (ADR-044)
Section titled “Upcoming preview: automatic sharing of repeated record field projections (ADR-044)”PV11_SAFE (the default) and PV11_COSTED now share a repeated projection of
the same record field of the same variable (txInfo.outputs() on a TxInfo
local or parameter, b.amount() on a cast local, out.value() on a loop item)
once per scope when that projection is already the first non-trivial
evaluation of the scope, and share the record’s decoded fields list when two
or more different fields of one variable are projected. The compiler binds the
projection (or the fields list) as one strict let and reuses it at every
later occurrence, including occurrences inside a loop body and inside
compareTo, which evaluates its receiver twice on its own. A projection of a
projection (b.inner().x() and b.inner().y()) shares the inner record first.
The output for the motivating shape is byte-identical to writing
BigInteger amount = b.amount(); yourself. The optimization report records
pv11.o15.projection-sharing.
Nothing observable on-chain changes: the shared projection already ran first on
every path, so results, traces, the failure point and the failure text are
identical on Java, Truffle and Scalus for well-formed and malformed input, at
every level. Budgets move, on failing paths too (a failing path that reaches
only the leading site pays the binding, one that fails inside the shared unit
pays a few machine steps less), so off-chain tests that pin a budget need
refreshing. A path that reaches a later occurrence saves the whole projection (about
462,000 CPU for an integer field at the first position, 1,058,000 CPU for a raw
field at the sixth, 279,000 CPU for the fields list); a path that reaches none,
such as an untaken branch or an empty loop, pays at most one lambda, one
application and one variable lookup (48,000 CPU) per shared binding. A
projection is never hoisted above a trace, an error, a saturated call or a
projection of another variable, and projections in exclusive branches of one
conditional are left exactly as written. A validator that projects
ctx.txInfo().outputs() twice drops from 429 to 380 bytes and from 11.6 million
to 8.8 million CPU on the path that uses both.
Earlier safe-profile rules (Case on booleans, pairs and integers, list Case
loops) already moved most pv11-safe hashes; this is the first whose trigger
shape is in most validators since the safe profile became the default: any
program that projects one field of one variable twice in leading position, or
two fields of one variable, compiles to a different (smaller) script. Deployed scripts are unaffected; recompiling changes the
script hash and therefore the script address. Tests or deployments that pin a
hash need to be refreshed. NONE/BASELINE retain historical bytes.
One costed-only consequence: because the shared list projection is a variable,
pv11-costed now also promotes b.items().get(0) beside b.items().get(1)
to a PV11 array (ADR-043), with ADR-043’s IndexArray failure text for an
out-of-range index on such sites.
Each PIR-to-PIR rule can be switched off on its own for review and measurement:
new CompilerOptions().disableOptimizationRule("pv11.o15.projection-sharing")
(also pv11.o8.value-sharing and pv11.o9.list-to-array); any other id fails
with JULC0043. The optimization level remains the supported rollout control,
and the Gradle plugin and CLI do not expose the switch.
Upcoming preview: list-to-array promotion at pv11-costed (ADR-043)
Section titled “Upcoming preview: list-to-array promotion at pv11-costed (ADR-043)”PV11_COSTED (opt-in; julc { optimization = 'pv11-costed' }, no cost profile
required) now converts a JulcList variable that its scope indexes at two
or more get sites, or at a get site inside a loop body, to a PV11 array once
(ListToArray) and rewrites those sites to IndexArray. Every other use of the
list (for-each, size, head, passing it to a helper) is untouched, the array
binding is placed at the innermost sub-term that contains every index site (so a
path on which the binding is not placed, such as a branch whose sibling holds all
the sites, pays nothing), and for two sites in one expression the output is
byte-identical to writing list.toArray() by hand at that point. The
optimization report records pv11.o9.list-to-array. PV11_SAFE (the default),
BASELINE and NONE keep their bytes and hashes.
The recursive get costs about 620,000 CPU plus 683,000 CPU per index step and
60 FLAT bytes per site; the array form costs 49,000 + 24,838 CPU per element
once plus 312,000 CPU per site. Two sites at indexes 0 and 1 save CPU for lists
of up to 44 elements, sites at indexes 3 and 7 for up to 291, and loops
multiply the saving: a WingRiders-shaped request loop with sixteen requests
drops from 349.6 million to 81.9 million CPU. A path that converts but then
indexes less than twice pays at most the conversion plus one binding
(97,000 + 24,838·n CPU per list): an empty loop, or an untaken branch when both
branches index. The compiler assumes a promoted list is indexed at least twice
per evaluation; this is why the rule is confined to the costed profile.
Failure contract (costed profile only): a promoted get with an index outside
0..length-1 now fails at the IndexArray builtin instead of inside the
recursive traversal. Both fail after the index is evaluated and before any other
effect, so results, traces and the failure point are unchanged; only the
off-chain text and the failing path’s budget (smaller) differ. Because the typed
boundary does not range-check integers, a redeemer- or datum-supplied index
reaches get unchecked and a validator compiled at pv11-costed itself
reports IndexArray: index I out of bounds for array of size N (Scalus:
… indexArray: index I out of bounds for array of length N) instead of
HeadList: empty list (index equal to the length) or TailList: empty list
(negative or larger). You see it wherever the costed artifact is evaluated: the
Gradle plugin or annotation processor with julc.optimization=pv11-costed,
julc build --optimization pv11-costed followed by julc eval, direct
JulcCompiler use, and the MCP tools. The testkit’s JulcEval,
MethodEvaluator and ValidatorTest compile at the default level and never
show it. On-chain a failure is a failure. Tests that assert on that text under
the costed profile need updating; nothing changes at the default level.
MultiIndexArray remains illegal at PV11.
A JulcList variable is promoted only when the compiler can prove it holds a
list on every path: a value decoded at the typed boundary, a list built by a list
operation, a helper parameter that every call in the program passes such a list,
or a helper result whose body returns one. A local bound to an unchecked cast
such as (JulcList<T>) (Object) somePlutusData, every alias of it, a callback
lambda’s list-typed parameter (which holds the raw element), and any helper
parameter or result such a value can reach are never promoted, so a program that
never indexes on some path keeps accepting on that path at the costed level. An
earlier preview build trusted helper parameters by their declared type; review
showed that a callback’s element passed into a helper could then turn acceptance
into a ListToArray failure. That is fixed, and no released artifact was built
at the costed level.
Upcoming preview: automatic sharing of repeated native Value conversions (ADR-042)
Section titled “Upcoming preview: automatic sharing of repeated native Value conversions (ADR-042)”PV11_SAFE (the default) and PV11_COSTED now share a repeated native Value
conversion of the same variable, NativeValueLib.fromData(x) or
Builtins.unValueData(x), once per scope when that conversion is already the
first non-trivial evaluation of the scope. The compiler binds it as one strict
let and reuses the bound value at every later occurrence, including
occurrences inside a loop body, which hoists a per-iteration conversion out of
the loop. The output for the motivating shape is byte-identical to writing the
JulcValue binding by hand. The optimization report records
pv11.o8.value-sharing.
Nothing observable changes: the conversion that is shared already ran first on
every path, so results, traces, the failure point and the failure text are
identical on Java, Truffle and Scalus for valid, malformed and non-canonical
input. A path that reaches a second occurrence saves one full conversion
(about 513,000 CPU for even a one-token Value, more for larger Values); a path
that reaches none, such as an untaken branch or an empty loop, pays at most one
lambda, one application and one variable lookup (48,000 CPU). A conversion is
never hoisted above a trace, an error, a saturated call or another partial
conversion, and conversions in exclusive branches of one conditional are left
exactly as written; binding JulcValue explicitly remains the recommended style
and applies at every level.
Only safe-profile output of programs that convert one Data variable at least
twice changes bytes and hash; no shipped example uses native Values, and
ValuesLib is untouched. NONE/BASELINE retain historical bytes. ValueData
sinking, adjacent conversion cancellation and Data-side (ValuesLib) sharing
remain deferred (ADR-032 O8/O15).
Upcoming preview: integer Case dispatch for sealed-interface switches (ADR-041)
Section titled “Upcoming preview: integer Case dispatch for sealed-interface switches (ADR-041)”PV11_SAFE (the default) and PV11_COSTED now dispatch every switch on a
sealed interface with two or more constructors through one PV11 integer Case
on the decoded constructor tag instead of an equalsInteger chain. Branch i
runs for tag i; field decoding, branch bodies, traces and evaluation order are
unchanged, and only the selected branch is evaluated. The dispatch cost is now
flat instead of growing with the constructor’s position: in the ADR-041
fixtures a five-way switch selecting its last constructor saves about 710,000
CPU and 33 FLAT bytes, and a two-way switch saves 116,000 to 265,000 CPU and 12
bytes (8 bytes on the ADR-038 on-chain validator). Single-constructor
switches and instanceof chains are untouched. The optimization report records
pv11.o5.case-integer.
Failure contract: a constructor tag outside 0..n-1 now fails at Case selection
instead of reaching the chain’s terminal error. Both fail at the same point
with no branch effects and no traces; only the off-chain failure text and the
failing path’s budget differ. The strict typed boundary already rejects invalid
tags on datum, redeemer and nested typed fields before user logic runs, so
validators observe no change. The change is observable where no boundary runs:
JulcCompiler.compileMethod, and therefore the testkit’s JulcEval and
MethodEvaluator, now report Case: tag T out of range for N branches (Scalus:
Case index T out of bounds for N branches) for a sealed argument with an
out-of-range constructor tag instead of Error term encountered; the same
applies to direct-PIR consumers of compilePirToProgram and to sealed values a
program casts from raw PlutusData without a typed boundary. Tests that assert
on that failure text need updating. Both the Java and Truffle VMs now
range-check integer Case scrutinees over the full integer domain before
narrowing, so tags beyond the int range fail as the same machine error.
Recompiling an eligible validator with a safe profile changes its script bytes
and hash. NONE/BASELINE retain historical bytes; deployed scripts and ledger
Data encodings are unchanged. The decompiler recovers the new dispatch as the
same DataMatch HIR node. ADR-041 also closes the O6 unit Case question as
rejected: JuLC emits no ChooseUnit, and a census of shipped artifacts shows no
typed-unit statement population worth a new PIR surface.
Upcoming preview: conditional yield in switch case blocks
Section titled “Upcoming preview: conditional yield in switch case blocks”A yield inside an if/else branch of a switch-expression case block was
silently discarded when statements followed the conditional: the block lowered
as “evaluate the if, then run the rest”, so a guard such as
if (cond) { yield false; } yield true; always produced true
(#137). The frontend now applies
the same fall-through continuation lowering that #79 introduced for early
return, so a yield exits its owning switch expression and the trailing
statements only run for branches that fall through. A yield inside a nested
switch expression still belongs to that inner switch.
Two diagnostics accompany the fix: a case block whose paths do not all end in a
yield is rejected instead of falling off the end, and a yield inside a
for-each or while loop body is rejected like return already was.
This is frontend statement lowering, so recompiling a source that uses the
affected shape changes its script bytes and hash under every profile, including
NONE and BASELINE. Sources that do not use the shape keep their bytes.
Deployed scripts and ledger Data encodings are unchanged. The shipped stdlib and
examples were scanned and do not use the shape. The two Blaster controlled-mint
verification fixtures do; their locked artifacts and counterexample binding are
refreshed for the corrected lowering with the same property outcomes.
Upcoming preview: serialiseData compiled without a force wrapper
Section titled “Upcoming preview: serialiseData compiled without a force wrapper”Builtins.serialiseData and ByteStringLib.serialiseData previously compiled
to a forced SerialiseData builtin. SerialiseData is monomorphic and takes
its Data argument directly, so the extra force made every generated program
that reached the call fail at evaluation on the Java, Truffle and Scalus
backends (#132). The compiler
now emits the builtin without the type-instantiation force.
Recompiling a program that calls serialiseData changes its script bytes and
hash under every profile; programs that do not call it keep their bytes. The
CBOR serialization format, VM behavior and ledger Data encodings are unchanged,
and already deployed scripts are not modified. Any execution path that reached
the old forced builtin failed, so the new hash only replaces scripts that could
not have succeeded on that path.
Two follow-ups guard against regression: an exhaustive comparison of all 102
DefaultFun force counts against the shared builtin metadata (PR #134), and a
Java-source regression matrix for serialiseData under BASELINE and
PV11_SAFE on Java and Scalus with exact CBOR and BLAKE2b-256 vectors
(PR #135).
Upcoming preview: lossless Data map decoding
Section titled “Upcoming preview: lossless Data map decoding”CBOR decoding now preserves Plutus Data map entry order and duplicate keys,
including nested maps and Data constants read from serialized FLAT programs.
Previously, the decoder could collapse {1:10,1:20} to {1:20} before evaluation.
Encoding and compiler lowering are unchanged. Re-encoding affected decoded data
now retains entries that older versions lost; stored scripts are not modified.
Incomplete map pairs, misplaced map breaks and impossible declared map lengths
are rejected. This is a focused decoder correction, not a general strict-CBOR or
resource-limits change. Converting an already deduplicated external cbor-java
Map cannot recover its missing entries; use PlutusDataCborDecoder.decode when
starting from bytes. See ADR-037 for design and validation scope.
Upcoming preview: typed native Pair Case lowering
Section titled “Upcoming preview: typed native Pair Case lowering”PV11_SAFE (the default) and PV11_COSTED now destructure proven native pairs
from strict constructor-boundary checks with a single PV11 Case. The compiler
requires both tag/fields projections of the same once-bound UnConstrData
result; it leaves field decoding and failure/trace order unchanged. Arbitrary
pair values, aliases, map traversal and Data-encoded tuples are outside this rule.
The optimization report records pv11.o4.case-pair.
Recompiling an eligible validator with a safe profile changes its script bytes
and hash. NONE/BASELINE retain historical bytes; deployed scripts and ledger
Data encodings are unchanged. The bounded rule has passed independent correctness
review; JuLC remains experimental. compilePirToProgram also applies this rule
in safe profiles, so eligible caller-supplied PIR can produce new bytes/hashes.
ADR-038 extends the same rule to sealed-interface switch decomposition during
UPLC lowering. This preserves the integer tag-dispatch chain and selected-field
decoding, including errors in unused fields. The measured switch fixtures save
10 bytes per site compared with the previous safe output; NONE/BASELINE remain
byte-identical. Direct PIR DataMatch callers also receive this safe-profile
change. The switch extension passed its own independent correctness review in
PR #129. The decompiler follow-up #130
recovers native pair and verified legacy constructor decomposition as explicit
HIR DataMatch, preserving raw fields, ordered integer tag tests and the final
fallback. Readable output uses decomposition and if/else dispatch; it does not
invent Java record schemas. FLAT-erased bindings are recovered from de Bruijn
indices. External exhaustive HIR visitors must handle the new DataMatch node;
the compiler’s output bytes and costs are unaffected by this decompiler change.
Upcoming preview: stable typed formal-verification API v1
Section titled “Upcoming preview: stable typed formal-verification API v1”JuLC now provides a stable construction API for exact-artifact formal verification. API version 1 uses canonical property schema 1 and supports typed composition over compiler-projected contract data, four standard Plutus purposes (spending, minting, rewarding, and certifying), transaction context, authorization, certificates, multi-asset values, governance data, and reviewed raw-data adapters.
julc verify dsl-init emits only schema 1. The unreleased E.2–E.4 milestone
formats previously numbered 1 through 10 have been removed from public
generation and replay rather than carried as compatibility aliases. Regenerate
those experimental workspaces with the current CLI. Historical certificates
remain hash-bound records of their original runs, but they are not accepted as
current replay inputs. New semantic vocabulary requires a new schema rather
than changing schema-1 meanings.
@RequiresSigner, @ControlledMint, and the complete
@RequiresSigner + @PreservesValue + @Monotonic stateful profile now lower to
the same canonical DSL IR used by explicit Java specifications. The former
annotation-specific duplicate Lean security formulas have been removed. These
annotations remain UPLC-neutral: they state properties and do not add checks to
validator execution.
The stable API covers documented Java construction and canonical property semantics. It does not promise solver termination, complete CardanoLedgerApi coverage, general compiler correctness, or that a verified validator is safe under every unspecified property. Voting/proposing validator selection, parameter-derived authorities, arbitrary Lean in the DSL, and temporal verification are not included. The E.5 bounded temporal experiment did not meet its solver-calibration gate and was not promoted.
See the formal verification guide for annotation and DSL workflows, local/Docker backends, exact certificate scope, outcome classification, and CI guidance.
Upcoming preview: explicit PV11 compiler target
Section titled “Upcoming preview: explicit PV11 compiler target”JuLC compilation now resolves one explicit, immutable compiler profile:
plutus-v3-pv11-uplc-1.1.0. The profile covers the Plutus language, ledger
protocol version, and UPLC version together. Existing compile entry points
select this named profile by default; unknown profiles, future protocol
versions, and a latest alias are rejected rather than interpreted as PV11.
CompileResult now has a public target() record component containing this
provenance. Compatibility constructors preserve existing constructor call
sites by assigning the documented PV11 target, but code that inspects the
record shape, uses record patterns, or depends on generated record equality or
string representation must account for the additional component. Compiler
target plumbing and validation alone do not change generated UPLC, FLAT bytes,
or script hashes.
Testkit operations that retain a CompileResult now pass its exact ledger
target to the selected VM. With the Java VM, this means source-based validator
and method evaluation uses explicit PV11 semantics and costs instead of the
legacy language-only path, which defaults to PV10 when no protocol-aware cost
model is configured. A caller may also provide an explicit evaluation target;
it must match CompileResult.target() or evaluation fails before VM execution.
The Scalus adapter now implements JuLC’s explicit-target VM SPI. Scalus 1.1.0
has no certified target in JuLC, so ValidatorTest.evaluate(result, ...) with
Scalus as the only provider throws UnsupportedOperationException at the
backend-capability gate. This prevents a testkit rejection assertion from
mistaking an unevaluated profile for a rejected script. For a Scalus
compatibility cross-check, evaluate result.program() through the language-only overload.
The Java VM supports both forms, although the raw Program form cannot carry
compiler-target provenance. See
ADR-033’s certification evidence.
The CLI, Gradle plugin, annotation processor, JRL compiler, and MCP tools accept and report the same stable profile ID. Supporting a later protocol version will add and verify a separate pinned profile; it will not silently change the PV11 default.
Upcoming preview: Scalus protocol-aware evaluation (ADR-033)
Section titled “Upcoming preview: Scalus protocol-aware evaluation (ADR-033)”The Scalus adapter now has atomic per-language configurations, target-bound V1/V2/V3 VM construction, and one validated explicit-target pipeline. Public explicit-target calls remain fail-closed because six reason-coded upstream Scalus 1.1.0 divergences prevent V3/PV10 and V3/PV11 certification.
Cardano Client Lib continues to supply current protocol-parameter cost arrays
for V1, V2, and V3. Configured Scalus language-only evaluation now passes all
three arrays to MachineParams.fromCostModels with the matching language and
protocol. Compared with main, this changes configured V1/V2 transaction
evaluation from Scalus’s bundled mainnet defaults to the current live model.
Scalus consumes the mapped live V1/V2 prices; its 1.1.0 V1/V2
adapters still reference-fill PV11-only builtin costs and ignore audited
Constr/Case positions, so that compatibility behavior is not a complete
ledger-parity claim. Pinned conformance vectors are test evidence only and are
never substituted for runtime protocol parameters.
A non-null ExBudget is now enforced; exceeding it returns
EvalResult.BudgetExhausted. Array and Value results now cross the Scalus
bridge. Unconfigured language-only V3 retains Scalus 1.1.0’s PV11/E bundled
default, which differs from Java/Truffle’s PV10 compatibility default. These VM
changes do not modify compiled UPLC, FLAT bytes, or script hashes.
Upcoming preview: strict typed datum/redeemer boundaries
Section titled “Upcoming preview: strict typed datum/redeemer boundaries”Typed validator boundaries now reject non-canonical Data before user code
runs. Records and variants require exact constructor tags and arities;
primitive, optional, list, map, nested, and productive recursive fields are
checked eagerly. Explicit PlutusData roots remain raw.
Typed, non-record entrypoint arguments now reach validator code in their
declared representation. In older compiler versions, a root declared as
BigInteger, byte[], String, boolean, List, or Map could remain raw
Data at runtime even though the PIR type said otherwise. Strict-boundary
lowering fixes that compiler bug by decoding the root before invocation. Remove
workarounds such as an explicit Builtins.unIData(redeemer), unBData,
asList, or asMap when the entrypoint argument is already declared with the
corresponding typed Java form. This change concerns transaction-time entrypoint
arguments; deployer-applied @Param fields are a separate pipeline.
A concrete record that implements a sealed interface cannot currently be used as an independent typed datum/redeemer root or nested boundary field. Declare the sealed interface instead. JuLC now rejects the concrete form at its Java parameter because constructor lowering uses the variant’s sum tag while the record schema does not yet carry that nominal tag.
This is an intentional preview-language breaking change. Recompiling an affected validator changes its UPLC and script hash. There is no permissive mode: retain the old compiler to reproduce an old artifact. Before moving state to a new script address, decode and canonically re-encode existing datums so wrong-tag or trailing-field values accepted by a permissive-era script are not carried to the strict address.
Strict traversal has a real execution cost. In the activation measurements, a
250-element List<BigInteger> guard used approximately 502 million CPU units
(about 2 million per element and about 5% of the 10-billion transaction
ceiling), compared with approximately 0.9 million for the legacy no-op path.
Measure production maximum-size inputs; use an explicitly raw PlutusData
boundary with a reviewed manual check when full traversal is unsuitable.
Blueprint compiler identities include +boundary.strict-data-v1, and formal
verification certificates record boundarySemantics: strict-data-v1. See the
strict data boundary guide for the exact
coverage, cost guidance, raw-data escape, and migration checklist.
Upcoming preview: purpose-indexed multi-validator blueprints
Section titled “Upcoming preview: purpose-indexed multi-validator blueprints”An explicit @MultiValidator with supported SPEND, MINT, WITHDRAW, or
CERTIFY entrypoints now publish one standard CIP-57 validator entry per
purpose. The titles are purpose-qualified—for example Protocol.spend and
Protocol.mint—while every entry retains byte-identical compiled code and the
same Cardano script hash because they describe one deployed script.
Artifact consumers must migrate from the unsuffixed multi-validator title to
the exact purpose-qualified entry. julc verify and julc verify init instead
take the base Java title plus --purpose. Normal single-purpose titles and
script bytes are unchanged. Every newly generated blueprint now records an
explicit purpose on its datum, redeemer, and parameter arguments, so its JSON
changes even for an ordinary single-purpose validator.
CERTIFY is emitted as CIP-57 publish, following Aiken’s mapping of the
ledger certificate purpose; the JuLC source-level name remains CERTIFY.
Manual dispatch remains fail-closed. Single-purpose and multi-purpose VOTE
or PROPOSE validators now also fail strict blueprint generation: older
previews emitted incomplete purpose-free metadata for these single-purpose
validators, but the pinned CIP-57 vocabulary cannot name them truthfully. Use
the blueprint opt-out to compile without metadata when needed. See the
purpose-indexed blueprint guide for
examples and limitations.
Upcoming preview: typed List-case lowering (#110)
Section titled “Upcoming preview: typed List-case lowering (#110)”pv11-safe (the default) and pv11-costed now use PV11 Case List for
eligible compiler-generated for-each loops. The compiler binds the raw head
and tail together and keeps element decoding at its original point. Nested
loops, records, accumulators, conditional break, and recursive methods retain
their source behavior.
The existing NullList guard remains: it preserves failures for unchecked casts
that carry a non-list value. Loops with an unconditional break and no tail use
retain their prior projection form to avoid increasing script size. Map/native
pair iteration, arbitrary while traversals and HOF builders are outside this
rule’s scope.
none and baseline preserve their prior bytes, checked against fixtures from
commit 8c9f1f63, including source-map compilation. This does not add a PV10
compiler target. Recompilation at pv11-safe can change script bytes and hashes;
existing deployed scripts are unaffected. Use baseline when reproducing
historical lowering, or retain the prior compiler version for exact reproduction
of its pv11-safe artifacts.
The new PirTerm.ListMatch variant is an internal compiler IR extension;
consumers with exhaustive switches over PIR must handle it. Java validator APIs
and ledger Data encodings are unchanged. See
ADR-034
and its measured evidence
for scope, failure analysis, pinned budgets and hash migration.
The on-chain validation
adds confirmed PV11 spending transactions, direct Haskell/Java budget agreement,
and malformed-input checks. This completes the bounded rule’s node evidence gate
without enabling additional traversal families.
Upcoming preview: target-aware PV11 optimizations
Section titled “Upcoming preview: target-aware PV11 optimizations”ADR-032 adds an explicit optimizer rollout boundary without adding another
compiler target. The legal output target remains
plutus-v3-pv11-uplc-1.1.0. After the initial opt-in review window and full
semantic, benchmark, repository, and hosted-verification gates, pv11-safe
is now the default and enables these reviewed rules:
JulcList.drop(...)lowers directly to PV11DropList;- typed boolean conditionals lower to UPLC 1.1.0
Case Bool; - successful all-literal
ExpModIntegercalls fold at compile time while invalid literal calls retain their exact runtime failure.
The experimental native Value surface now uses a distinct JulcValue type.
Conversion from and to ledger Data is explicit, and the compiler rejects
native/Data mixing at assignments, equality, Data-backed containers/records,
and external method or validator boundaries. These checks apply at every
optimization level, including baseline. This type correction adds no UPLC
cost by itself and does not automatically rewrite existing Data-encoded ledger
Value operations.
The Scalus backend is pinned to the stable 1.1.0 release. Focused PV11
cross-checks now cover Case Bool false/true branch order and confirm that an
unselected failing branch is not evaluated. Scalus remains a language-only
compatibility backend in JuLC; protocol/cost provenance still belongs to the
canonical target-aware evaluation path. Cross-backend budget tests therefore
configure one explicit cost profile on both VMs rather than treating Scalus’s
version-dependent built-in default as ledger evidence.
Measurements use the immutable
plutus-v3-pv11-costs-v1 cost profile (parameter SHA-256
40ea9e0b7df77a7bd2cb7d4e4d9da040f8bee7ff0324a7cdb7e51702330e43a8).
Java and Truffle produced identical results, failures, traces, and ledger
budgets for these fixtures:
| Fixture | FLAT bytes | CPU | Memory |
|---|---|---|---|
one-element drop path |
162 → 109 | 3,886,301 → 2,311,844 | 17,931 → 10,864 |
| three-condition Bool fixture | 134 → 117 | 2,251,555 → 1,687,408 | 9,694 → 7,591 |
| three literal ExpMod calls | 42 → 6 | 3,406,498 → 16,100 | 3,407 → 200 |
| aggregate validator-like accepted path | 169 → 88 | 5,346,583 → 1,935,936 | 19,605 → 8,533 |
The aggregate fixture’s script hash changes from
3d1e9ac3561e68d3d0864705686adca6105ff5fc15f5a43e904de1f2 to
2f2ea7f79a5dd083ed51e573dc247267ae7cb978ecc615692479c594.
These are exact fixture results rather than general percentage guarantees.
Default recompilation now produces a new deployment artifact; it does not
change an already deployed script. Select baseline explicitly when
reproducing the earlier ADR-031 lowering and hash.
Raw JulcVm language-only evaluation overloads retain the deliberate PV10
compatibility default from ADR-030. Because default compiler output may now
contain PV11-only Case Bool or DropList, evaluate a CompileResult through
its result.target().ledgerTarget(). JuLC testkit and CLI evaluation propagate
that target automatically; MCP test/evaluate and the playground do the same.
The decompiler also recovers the PV11 Case Bool form as a Java conditional
only when its untyped UPLC scrutinee is provably Boolean, leaving ambiguous SOP
cases as switches.
Array promotion/folding, native Value algebra, BLS fusion, broader list traversal rewrites beyond #110, and general conversion sharing remain explicitly deferred (pair Case rewrites were later delivered by ADR-036/038, integer Case dispatch by ADR-041, unit Case sequencing was rejected by ADR-041, and strict-prefix native Value conversion sharing was delivered by ADR-042): the current compiler lacks the typed literal, representation, or use-analysis proof needed to preserve failures and strict evaluation. A future protocol target starts with ADR-032 rules disabled and enables each rule only after its legality and semantics are revalidated; cost-directed rules additionally need a new pinned cost profile.
Upcoming release: PV11 builtin contract correction
Section titled “Upcoming release: PV11 builtin contract correction”JuLC’s current compiler targets the Plutus V3/PV11 feature set. This contract
was verified against Plutus 1.63.0.0
(f92b7d7d82622a26caf456a6be33859f697e2cfc), as shipped by cardano-node 11.0.1.
The released PV11 Batch 6 is exactly:
| Tags | Builtins | Specification |
|---|---|---|
| 87 | ExpModInteger |
CIP-109 |
| 88 | DropList |
CIP-132 |
| 89-91 | LengthOfArray, ListToArray, IndexArray |
CIP-138 |
| 92-93 | BLS12-381 G1/G2 multi-scalar multiplication | CIP-133 |
| 94-100 | Native MaryEraValue operations | CIP-153 |
multiIndexArray migration warning
Section titled “multiIndexArray migration warning”Earlier JuLC previews exposed Builtins.multiIndexArray as if it were a PV11
builtin and could generate FLAT tag 101. Tag 101 is the future CIP-156 operation;
it is not part of PV11, and scripts containing it are not valid for that target.
The current compiler now rejects multiIndexArray with a compile-time diagnostic.
Recompile affected scripts and replace the call with repeated IndexArray
operations (for typed arrays, repeated array.get(index) calls). There is no
automatic bytecode migration for an already-generated tag-101 script.
The tag remains in JuLC’s AST/FLAT support and Java VM for forward-development
experiments only. That experimental implementation does not make it ledger-valid
for PV11. It also retains JuLC’s legacy (array, indices) argument order rather
than CIP-156’s proposed indices-first signature, so it is not a conformant preview
of the future builtin.
