Six rules for a consistent core
Status: Accepted; partially implemented. The backlog tracks remaining scope. The evidence below records research against local
mainatb4580c2don 2026-10-31, with probes on the debug compiler built that day. The evidence is the 2026-10-31 language consistency survey, recorded as issues #612–#637. Accepted and scheduled as backlog rows B88–B93 (staged plan); the review decisions settle five questions. A seventh rule from the same survey, a written convention page for the standard library, is separate documentation work and is only referenced here.
Summary
The survey found a coherent core with local exceptions. Each exception has its own explanation, and a learner has to memorise them one at a time. This proposal replaces about twenty such exceptions with six rules:
- One copy rule. Tuples, built-in types and function values copy when
their parts copy. A declared class or enum copies only when it says
@copy. The rule is stated in the same words in the reference, the cheat sheet and the agent skill. - One formatting path.
print(x)writes whatf"{x}"writes, andstr.repr(x)writes whatf"{x!r}"writes. Both use a type’sdisplayanddebugmethods, at the top level and inside aggregates. - An owner converts to its view. Where a view type (
&[T],&substr) is expected, a reference to the owner (&xs,&s,b"...") is accepted without[:]. Every byte owner and byte view is a byte source. - Adapters belong to iterators.
iter(source)accepts everyforsource and gives the same adapters and terminals. Only iterators (pipelines and generators) have adapter methods; collections, ranges, views and the free sources do not. - An error type names only what can fail. Allocation-only failure is
AllocError. A domain error type carries allocation asAllocation(AllocError). Invalid arguments follow one four-part policy. - Every callable form is a value wherever a callback is accepted. Named functions, closures with or without captures, stored or literal, variant constructors and class constructors all pass to the same parameter, and a callback behaves the same however it is written.
Each rule is a change to behaviour or teaching, so each has its own backlog
row and can ship independently. The rules do not depend on one another,
except that rule 5 decides the error type that rule 2’s print reports.
How to read this page
Each rule has the same sections: what happens today, the proposed rule,
alternatives, cost, what a learner no longer has to learn, and phasing.
“Today” claims were checked against the reference pages linked in the text
and, where marked probe, with plenty --check or a run on the 2026-10-31
debug build.
Earlier changes removed features rather than deprecating them (std.iter was removed in favour of iter(); see
fluent iterator pipelines). This proposal follows that
precedent: a breaking change ships with a diagnostic that states the new
spelling, and the same change updates std, tests, the tutorial and the
reference. Package language editions are not
implemented and are not required.
Rule 1: one copy rule
Today
The bindings, moves and copies
page says scalars, strings, ranges, named function values and “aggregates of
copyable components” copy, and that classes are affine unless declared
@copy. “Aggregates”
includes tuples and enums, but the enums
page never mentions copying. The agent-facing pages each give a different
list (issue #636):
| Source | Says copies |
|---|---|
| Reference | Scalars, str, ranges, function values, copyable aggregates, @copy classes |
| Cheat sheet | Scalars, str, copyable tuples, @copy classes; also says a @copy class “cannot take type parameters”, which classes contradicts (IntType/FloatType bounds are allowed) |
plenty agent skill | Scalars, str, @copy classes |
None of the agent-facing pages mentions enums, and the rule itself has an exception: a class must opt in, an enum copies implicitly.
That exception has a cost. Issue #535, which introduced @copy, made the
annotation “part of the type’s public contract: removing it is a breaking
change”. An enum’s copyability is the same contract, but it is implied by its
payloads. Probe: enum E: A(i64) | B copies, so take(e); take(e) checks.
Changing the payload to A(list[i64]) makes E affine, and the error appears
at the second take(e) in every caller (use of moved binding e), not at the
declaration that changed. @copy enum is rejected today
(`@copy` applies only to class declarations).
Proposed rule
A value copies when its type is a scalar,
str,bytes, a range, a function value, a tuple or built-in generic (Option,Result) whose parts copy, or a class or enum declared@copy. Every other value moves.@copyis checked at the declaration: every field or payload must copy, and the type has no__del__.
The rationale, stated with the rule: anonymous and built-in types copy by structure, because they have no author who could intend otherwise. A declared type copies only when its author says so, because copying is part of its public contract and must not change when a field changes.
The same sentence goes in the reference, the cheat sheet and the skill. The cheat sheet’s type-parameter claim is corrected. A test compares the three copies so they cannot drift again.
Alternatives
- State today’s rule without changing it (classes opt in, enums by structure). This fixes the documentation but keeps the exception and the distant-caller breakage. Rejected as the end state; it is phase 1.
- Make classes structural too, and remove
@copy. One rule with no annotation, but a class that holds a token, an index or an invariant would silently copy, and adding a field would flip the contract of every class. This reverses issue #535. Rejected. - Allow
@copyon enums but keep structural copying as the default. The annotation would only add a declaration-site check. Two ways to get the same behaviour, and the default still breaks distant callers. Rejected.
Cost and compatibility
- Phase 1 is documentation and one test.
- Phase 2 (
@copyaccepted on enums) is additive. - Phase 3 makes undeclared enums affine. std has 158 enum declarations;
tests, examples and book pages have about 460. A migration script adds
@copyto every enum whose payloads all copy, which preserves today’s behaviour exactly; reviewers then remove it where an enum should move. Generic enums whose copying depends on a type argument need a copyable bound, which is B84’s open work for generic@copyclasses. Phase 3 waits for that bound (decided 2026-10-31), so B84 moves earlier: it now gates this rule as well as generic copy classes. - Copy-dependent behaviour elsewhere (dictionary-key rules in user type hashing, by-value receivers) keys off “copies”, so it follows the new rule without its own change.
- The use-after-move diagnostic for an affine enum names the declaration
and suggests
@copywhen its payloads copy.
What a learner no longer has to learn
Which kinds of declaration copy implicitly, and why an enum change broke a caller. The question “does this copy?” has a two-line answer.
Phasing
- One statement in all three places; fix the cheat sheet (issue #636).
- Accept and check
@copyon enums. - Undeclared enums move; migrate std, tests and the book.
Rule 2: one formatting path
Today
The formatting page says
print and str.repr never call user formatting methods. The
format strings page
says a field without a conversion uses a public display method, !r uses
debug, and also that “a field is written as print writes it”, which is
false for such types (issue #615). Inside aggregates, neither path calls user
methods. Probe, for a public class P whose display writes <3> and
debug writes P#3:
| Expression | Output |
|---|---|
print(p) | P(x=3) |
str.repr(p)? | P(x=3) |
f"{p}"? | <3> |
f"{p!r}"? | P#3 |
print(ps), f"{ps}"? for ps = [p] | [P(x=3)] |
A non-pub class with display cannot appear in an f-string in its own
module; the diagnostic names __plenty_fstring_… (issue #615). main’s
error report originally used a third hook, std.report.Formattable.format,
over a different Formatter type, and otherwise the str.repr spelling.
Issue #634 subsequently unified the formatter, error, and reporting protocol in
std.fmt; choosing display for entrypoint reports remains separate work. See
entrypoint reporting.
Proposed rule
A value has two spellings.
displayis the default:print(x),f"{x}"andmain’s error report write it.debugis the precise one:str.repr(x),f"{x!r}"and the elements of a formatted aggregate write it. A type withoutdisplayuses itsdebugspelling; a type withoutdebuguses its structure.
So print(p) writes <3>, print(ps) writes [P#3], as Python’s
print([p]) uses each element’s repr. Strings stay raw at the top level
of print and quoted inside aggregates, which is today’s behaviour and is
the same rule (display of str is its text, debug is quoted).
A type’s own methods are found from its defining module whatever its visibility, so a private class formats in its module. Internal names never appear in diagnostics.
Alternatives
- Make f-strings ignore
display, asprintdoes. Consistent, but removes the only user formatting hook from core syntax and breaks std types (std.path,std.complex,std.errorand others) that definedisplay. Rejected. - Keep the paths separate and fix the sentence in format-strings.md.
Documents the difference instead of removing it;
print(p)andf"{p}"still disagree. Rejected. - Use
displayinside aggregates too. Ambiguous for strings and for types whose display text contains separators; Python’sstr/reprsplit exists for this reason. Rejected.
Cost and compatibility
printandstr.reprstart running user code. That code can fail withFormatError, so their result types follow rule 5: unchanged for values with built-in spellings, widened withFormatErrorwhen a user method can run. Programs that print such types and returnResult[(), IoError]need the wider error; the diagnostic names the type whose method can fail.- Output changes for every printed value of a type with
displayordebug. Expected-output fences in the tutorial and reference that print std types with these methods change; the test suite finds each one. main’s error report usesdisplay.std.fmt.Formattablebecomes a transitional fallback while the entrypoint hook migrates. Issue #634 delivered the shared formatter; the meaning of a method namedformatbelongs to the std conventions page (rule 7). This rule fixes which hook core formatting calls.- Automatic formatting still rejects recursive types. Each user method an
aggregate element calls gets its own
std.fmtformatter with a fresh depth of 32 and no byte budget of its own; the methods do not share one depth count across the aggregate. Theprintorstr.reprbuffer grows with checked allocation as before.
What a learner no longer has to learn
Which of three output paths calls which hook, and why print and an f-string
disagree. One rule: display for people, debug for precision.
Phasing
- Fix the false sentence and the private-class lookup; hide internal names (issue #615).
- Route
print,str.reprand aggregate elements throughdisplay/debug. main’s report usesdisplay; its shared formatter dependency is delivered by issue #634.
Rule 3: an owner converts to its view
Today
Update 2026-11-01: phases 1 and 3 shipped. Owners convert to views at view types, byte-reading std APIs take
&[u8], andByteSourceis removed; naming it is a compile error pointing to&[u8]. The remaining&Bytes, start, stopinputs are listed under std conventions. The probes below record the state before phase 1.
Slice views give
&[T] for a list, a Bytes buffer and a bytes literal, so “one &[u8]
parameter reads any of them”. But “a list is not converted to a view
implicitly; pass the whole list as &xs[:]”. Probes:
| Call | Result |
|---|---|
total(&xs) for total(values: &[i64]) | Rejected: pass &xs[:] |
n(b"lit") for n(t: &[u8]) | Rejected: pass &xs[:] |
n(&s) or n("lit") for n(t: &substr) | Rejected: pass &s[:] |
s.startswith(w) for w: &substr | Rejected: expected str, got &substr |
w.upper() for w: &substr | Rejected; the diagnostic lists the dozen methods a &substr has |
n(&xs) for S: ByteSource and xs: list[u8] | Rejected: protocols require classes, enums or bytes |
n(&xs[:]) for the same | Rejected, and the diagnostic names Result[list[u8], AllocError], the copying slice’s type |
ByteSource has two
implementations, Bytes and bytes; a &[u8] view and a list[u8] are not
among them, although &[u8] already unifies all three owners. Byte APIs
therefore take source: &S, start, stop triples that a view expresses
directly.
Proposed rule
Where a view type is expected, a reference to an owner of that view is accepted:
&xsfor&[T],&mut xsfor&mut [T],&buforb"..."for&[u8],&sor a literal for&substr. The conversion is free: it creates the same view as[:], with no copy and no allocation. Every operation that reads text accepts&substr; every operation that reads bytes accepts&[u8].
&xs[:] keeps working. Expected-type positions are parameters, annotated
locals and returns; a view still cannot be stored (B11).
Alternatives
- Keep
[:]explicit. The current rationale is that conversions should be visible. That matters for costs; this conversion has none, and the design goal is “no hidden allocation”, not “no hidden pointer arithmetic”. Rust performs the same coercion from&Vec<T>to&[T]. Rejected. - Accept views where owners are expected (a
&substrargument for astrparameter). This would need an allocation or a second representation forstr. Rejected; text-reading parameters change to&substrinstead, which is B12’s planned scope. - Make
&[u8]implementByteSourceand keep the protocol. Smaller change, but leaves two spellings for “bytes I read”. Accepted as the first phase only; std then moves to&[u8]parameters.
Cost and compatibility
- Phase 1 is additive: code with
[:]keeps compiling. - Phase 2 changes std signatures. Text APIs taking
strto read change to&substr; callers passingstrkeep working through the conversion. Byte APIs taking&S, start, stopchange to&[u8], sow.write(&buf, a, b)becomesw.write(&buf[a:b]). Thestd.ioWriterprotocol’swritesignature changes, which breaks user writers; the std.io page and its examples change with it. Range-error results on those methods (BytesError.OutOfRange) disappear, because view bounds clamp. ByteSourceis removed once nothing in std takes it.- Per-type specialisation of byte generics goes away: one function body serves every byte owner. Codegen cost is a pointer and a length either way.
- The misleading
Result[list[u8], AllocError]diagnostic is fixed in phase 1.
What a learner no longer has to learn
When to write [:], which of &substr and str a text method wants, and
the ByteSource protocol with its start/stop convention. One rule: pass the
owner or a view of part of it.
Phasing
- Owner-to-view conversion at expected view types;
&[u8]satisfiesByteSource; fix the diagnostic. - Text-reading std APIs accept
&substr(part of B12). - Byte-reading std APIs take
&[u8]; removeByteSource.
Box already converts to &T, while Shared does not (survey section 2).
&Shared[T] converts to &T as &Box[T] does (decided 2026-10-31). The
content is immutable while shared, so the view is sound; the borrow keeps
the handle, and so its count, alive for the view’s lifetime. This joins
phase 1.
Rule 4: adapters belong to iterators
Today
Pipelines start with
iter(source). Probe: iter(...) accepts every for source already:
lists, sets, strings, bytes, ranges, reversed, enumerate, zip,
keys(), values(), items(), generators and pipelines. Some sources also
take adapters directly and others do not (issue #622):
| Source | Direct .count() today |
|---|---|
Generator, enumerate(&xs), zip(&xs, &ys), d.items() | Accepted |
range(3) | Rejected: expected str, got range |
xs (list) | xs.map(...): unsupported method map; xs.count(): list count needs an element |
reversed(&xs) | Rejected: reversed is supported in loops only |
d.keys() | Rejected; suggests the allocating keys_snapshot() |
The three dictionary views differ in kind
(dictionaries, issue #624):
keys() is a bindable KeyView with len and set algebra, values() cannot
be bound or measured, and items() starts chains but cannot be bound.
Terminals differ by item type: sum, min and max read copyable reference
items through, while find and reduce reject them (issue #621). len(x)
and in reject std containers such as Deque and Bytes that define
.len() (issue #623).
Proposed rule
A collection, range or view is a source. An iterator is a single-use stream: a pipeline (
Iter[T]) or a generator.iter(source)makes an iterator from anyforsource, anditer(it)on an iterator returns it. Adapters and terminals are methods of iterators only, and every adapter and terminal treats items the same way whatever the source.
This matches Python’s iterable/iterator split and Rust’s Vec/Iterator
split. It is the only choice with no name clashes: a list already has
count(e) and find, a string has count and find, a set has its own
algebra, and none of them is consumed by iterating.
Under the rule:
enumerate(...),zip(...)andd.items()stayforsources anditer(...)arguments, but lose direct adapter methods; the iterator methods.enumerate()and.zip()already exist, soenumerate(&xs).count()becomesiter(&xs).enumerate().count().keys(),values()anditems()are the same kind of thing: bindable borrowed views withlen, usable infor,in(keys) anditer().KeyViewis accepted wherever a borrowed set is read.find,reduceand every other terminal copy copyable reference items asminandmaxdo.- Diagnostics for an adapter on a source suggest
iter(source).
Alternatives
- Give every source the adapter methods directly. Clashes with existing
collection methods (
list.count,list.find,str.find), and makes it unclear whether a call consumes. Rejected. - Remove direct methods from generators too, so only
Iterhas them. A generator is single-use and supportsnext, exactly like a pipeline; denying it methods would be a distinction without a reason. Rejected. - Fix only the diagnostics. Issue #622’s minimum. Keeps two rules. Rejected as the end state; it is phase 1.
Cost and compatibility
- Direct adapters on
enumerate,zipanditems()are rare: two uses in Plenty code and pages, about thirty intests/*.rs(mostlytest_pipelines.rs). Each becomesiter(...)or the iterator method. - Dictionary-view changes are additive.
find/reduceon copyable references is additive.lenmethod dispatch is implemented for classes and enums (issue #623). Membership on std containers still depends on B49’s protocol work; unsupported operations name their actual type.
What a learner no longer has to learn
Which sources take methods directly, three kinds of dictionary view, and
which terminals read through references. One rule: iter() first, and
everything that works on one iterator works on all.
Phasing
- Diagnostics suggest
iter(source)(issue #622);find/reducecopy copyable items (issue #621). - Uniform dictionary views (issue #624).
- Remove direct adapters from
enumerate,zipanditems(). lenmethod dispatch delivered (issue #623);inon std containers remains with B49.
Rule 5: an error type names only what can fail
Today
Allocation failure has several shapes (issue #632). The table and the probe
describe the state before phasing items 2 and 3. Item 2 made f-strings without
user formatting fail with AllocError; item 3 changed the FormatError,
std.bytes and union rows and std.bytes.with_capacity(-1):
| Operation | Allocation failure reported as |
|---|---|
[1, 2], a + b, copy(x) | AllocError |
| An f-string | std.fmt.FormatError.Bytes(BytesError.Allocation(...)) |
print | IoError.Data(DataError.Allocation(...)) (I/O errors) |
std.bytes.empty(), from_text | BytesError.Allocation |
clist.append(row) | AppendError[T], which returns the row |
| sketch, linalg, mime, graph | A union E | AllocError |
Switching a + b to f"{a}{b}" changes the function’s error type, and
FormatError needs an import although every f-string produces it. Probe:
-> Result[str, FormatError] gives unknown type FormatError, and
-> Result[str, AllocError] returning f"n={n}" is rejected. Tuple slices
return Result although tuples never allocate (issue #625).
Invalid arguments follow four policies (issue #629):
| Argument | Today |
|---|---|
range step | Compile error unless NonZero (ranges) |
split(""), even a literal | Run-time trap (text splitting) |
repeat(-1) | Empty result (text building) |
list.with_capacity(-1) | Err(CapacityOverflow) (construction) |
std.bytes.with_capacity(-1) | Err(OutOfRange) |
.par(&pool, chunks=0) | Trap with the unrelated integer cast out of range |
Proposed rule
An operation’s error type names only failures it can produce. If allocation is its only failure, the type is
AllocError. A domain error type that can also fail to allocate carries it as one variant,Allocation(AllocError). An operation that cannot fail returns noResult.
For f-strings: Result[str, AllocError] when every field has a built-in
spelling and no spec is computed at run time; Result[str, AllocError | FormatError] when a field runs a user display/debug or a computed spec
can be invalid. FormatError becomes a prelude name. print and
str.repr follow the same rule (see rule 2). Tuple slices return the tuple.
Invalid arguments.
- A size passed to allocation that cannot be allocated, including a negative one, is
AllocError.CapacityOverflow.- Slice bounds and repeat counts clamp, as in Python. This is the operation’s defined result, not an invalid argument.
- Any other argument outside its documented domain traps, with a message naming the operation and the argument. A compile-time constant outside the domain is a compile error. Where a type can state the domain (
NonZerofor a range step), the parameter uses it.Erris for failures the caller cannot rule out by checking its arguments: allocation, I/O, and external data.
So split("") with a literal is a compile error and with a variable traps
(as now); std.bytes.with_capacity(-1) is CapacityOverflow; chunks=0
traps naming chunks. repeat(-1) and slice clamping are unchanged.
Alternatives
- Always a union, never a variant (
IoError | AllocErroreverywhere). Uniform for matching, butprintwould needAllocErrorin about 390mainsignatures that now returnResult[(), IoError], and the survey’s agreed wording keeps domain errors for APIs that have them. Rejected. About 42 std error types already use anAllocationvariant and four use a union; the four change. - Keep
FormatErroron every f-string and add it to the prelude. Fixes the import but not the signature change from+to an f-string, which is the more common edit. Rejected; the prelude addition is kept for the user-method case. - Make every invalid argument an
Err. Puts a?on calls whose failure is a bug the caller could rule out, which teaches ignoring it. Rejected. - Trap on negative
repeatcounts. Breaks the padding idiom" ".repeat(width - len(s))that Python habits rely on. Rejected.
Cost and compatibility
- f-string signatures: functions returning
FormatErrorfor f-strings that only use built-in spellings must change toAllocError(or keep a union that contains it). This is the largest edit; it is mechanical, and the diagnostic states the new type. Formatting throughout.format(f"...")indisplaymethods keepsFormatError. FormatError.Bytes(BytesError.Allocation)becomesFormatError.Allocation(AllocError); code matching the nested shape changes.std.bytesconstructors returnAllocError; their callers’ signatures narrow.- The four union-style std modules move to an
Allocationvariant. This is shared with the std conventions page (rule 7). AppendError[T]remains: it returns the moved row, a separate convention (also issue #630 forspawn) that the std conventions page owns.- Tuple slices:
t[0:2]?becomes an error with the slice-view wording (issue #625).
What a learner no longer has to learn
Five shapes of allocation failure, an import for f-strings, and four invalid-argument policies. One rule for each: the type names what can fail, and an argument bug traps.
Phasing
FormatErrorin the prelude (issue #632); infallible tuple slices (issue #625); literalsplit("")rejected and thechunksmessage (issue #629).- f-strings,
printandstr.reprreportAllocErrorwhen no user method runs. Done:printandstr.reprwith B89 phase 2 (issue #615), f-strings with B92 phase 2. A:cspec counts as computed, since an out-of-range scalar isInvalidSpec. - std error types:
Allocation(AllocError)variants,std.bytesconstructors, negative capacities. Done with B92 phase 3:FormatError.Allocation,std.bytesconstructors returnAllocError, andstd.graph,std.sketch,std.linalg,std.shlexandstd.mime(newMimeError) carry the variant;std.actor.stagereturnsStageError.Bytesmethods that fail only to allocate, and sketchto_bytes, returnAllocError. Out-of-domain arguments that depend on run-time state trap through the std-only__trapstatement, with a message naming the operation and argument:Bytesranges and indices (includingread_fileandread_stdin), andRng.index,sample_indicesandfillcounts and ranges. Their results narrow:Bytes.getreturnsu8,sliceResult[Bytes, AllocError], the scans plain values, andRandomErrorlosesOutOfRangeandAllocation. An empty random population staysEmptyRange, since data can produce it. Encoding offsets, UUIDwrite_bytes, cryptofinish_into, and CSV field positions now trap on caller bounds errors (issue #667). Encoding pack/unpack, UUID writes, and CSV positions return plain values. Streamreadranges and TLS session transfers follow the same trap rule (issue #666). Unreachable range variants and byte-error adapters are removed (issue #668). Statically checkable domains (positive capacities and limits) wait for a type that expresses them;BytesError.OutOfRangeremains forstd.actor.netconnection limits.
Rule 6: every callable form is a value wherever a callback is accepted
Today
Function values and closures give four callable types, two constraints and three passing modes. Which forms pass depends on the callee (issues #617–#620). Probes:
| Form | Pipeline filter/map | sort_by_key | f: Callable[...] | f: &mut F |
|---|---|---|---|---|
| Named function | Yes | Yes | Yes | No: named function declarations are immutable |
| Closure literal | Yes | No: sort_by_key requires a named function or reusable closure | Yes, without captures | Needs a binding, as any borrow does |
| Stored closure, no captures | No: pipeline callbacks require a named function or reusable closure | Yes | Yes | Yes, in a mut binding |
| Stored closure with captures | Yes | Yes | No (code address only) | Yes, in a mut binding |
Variant constructor E.A | No | No | Yes | Not probed |
Some, Ok, Err | Not probed | Not probed | No: Some is not a function value | Not probed |
Class constructor Point | Not probed | Not probed | No: unknown binding Point | Not probed |
A closure with a mut capture passed as &F fails at instantiation with
cannot borrow shared reference as mutable, without naming the capture
(issue #619). Behaviour can differ too: inside checked:, callback overflow
is Err for a closure literal and a trap for a named function (issue #614).
Proposed rule
Named functions, closures with or without captures (stored or literal), variant constructors and class constructors are callable values. Each passes wherever a callback with its signature is accepted, and it behaves the same whichever form it has.
Concretely:
- Pipelines,
sort_by_keyand every other builtin callback parameter accept every form. Each currently accepts a different subset: pipelines reject a stored capture-free closure,sort_by_keyrejects a literal. &mut fis accepted for a named function, which has no state to mutate, so a&mut Fparameter accepts every reusable callable.&Fremains the default for read-only callbacks; its diagnostic for amutcapture names the capture and suggests&mut F.- A class constructor
Pointis a function value with the constructor’s positional signature. A fallible initializer gives aResult[Point, AllocError]result, as a call does. - Generic variants, including
Some,OkandErr, follow the rule generic functions already follow: an explicit instance (Option[i64].Some) or a concrete expectedCallablespecialises them. Probe: a genericident[T]passed tof: Callable[[i64], i64]checks today, whileOption[i64].Someis rejected. - Inside
checked:, whether overflow isErrdepends on where the code is written, not on the callback’s form (decided 2026-10-31, issue #614). Arithmetic whose text is in the block, including the bodies of closures defined in the block (literal or stored), reportsErr. Any other function, a named function or a closure defined elsewhere, behaves as its signature says: an infallible signature keeps trapping, and a function that can fail returnsResult, which the block handles withmatchor?like any other call. Builtin reductions such assumare calls, so theiri64result traps on overflow, as the pipelines page already says; to sum with checking, writefoldwith a closure literal in the block. Today a lowered closure literal reportsErrwhile the numbers page says closures defined in a block keep trapping; both change to this rule.
Cost and compatibility
All of rule 6 is additive except two items. The checked: fix (issue #614)
changes which programs trap: a closure defined in a block, stored or
lowered, now reports Err, while sum inside a block, which reports Err
today because of pipeline lowering, traps as documented. A closure defined
in a block may not outlive it, since a later overflow would have no block to
exit (decision 5). Constructor values make a class name usable as a value
expression; how that interacts with local bindings of the same name needs a
check against the name-resolution rules in
names. Worker-thread effect
certification originally rejected thin function values, including constructor
values (local PR #681). Issue #661 adds conservative certification of every
matching address in the closed program; see
thin callable targets.
This is an effect rule independent of callable forms.
What a learner no longer has to learn
Which callback positions accept which forms, why adding an unused capture
fixes an error, and why &mut rejects a named function. One rule: anything
you can call can be passed.
Phasing
- Pipelines and
sort_by_keyaccept every closure form (issue #617);&mutnamed functions and the&Fdiagnostic (issue #619); lead the reference with the constraint form (issue #620). - Variant constructors in pipelines; class constructors as values; generic variants with an expected type (issue #618).
- Inside
checked:, code written in the block and closures defined in it reportErr; other functions behave as their signatures say (issue #614). Delivered as lexical checked callbacks; the reference documents scope and callable-erasure limits.
What the six rules remove
| Rule | Exceptions removed | Issues |
|---|---|---|
| 1 | Three copy lists; implicit enum copying | #636 |
| 2 | print/f-string disagreement; private-class f-string failure; third report hook | #615, part of #634 |
| 3 | [:] at view parameters; &substr rejected by text APIs; &[u8] not a byte source | B12 scope |
| 4 | Five sources without methods, three kinds of dictionary view, uneven terminals, len/in | #621–#624 |
| 5 | Five allocation shapes; f-string import; four invalid-argument policies; fallible tuple slices | #625, #629, #632 |
| 6 | Seven callable forms with different acceptance; form-dependent checked: | #614, #617–#620 |
Not covered: the defects in survey section 1 that are bugs, not rules (issues #612, #613, #616), diagnostics for Python habits (issue #627), reference diagnostics (issue #628), and std conventions (rule 7; issues #630, #631, #633–#635). Survey items with existing backlog homes (B16 contextual literals, B36 builtin argument names, B11 borrowed returns) stay there.
Decisions
Recorded 2026-10-31 after review:
- Rule 1: phase 3 waits for B84’s copyable bound, and B84 is brought forward.
- Rule 3:
&Shared[T]converts to&T, asBoxdoes. - Rule 6:
Some,OkandErrfollow the generic-function rule; issue #556’s exclusion is lifted unless implementation shows the fallback is needed. - Rule 6:
checked:covers arithmetic written in the block and in closures defined in it, whatever their form. Every other function, including a named function passed as a callback and builtins such assum, behaves as its signature says: it traps, or it returns aResultthat the block handles or propagates with?, which also leaves the block. A function’s possible failures are part of its signature, not of the context that calls it. - Rule 6: a closure defined in a
checked:block may not leave it. It leaves by assignment to a binding declared outside the block, as the block’s value or a return value, by being stored in a collection, field,Sharedor channel, or by being passed by value to a function that keeps it (spawn,pool.submit). Passing it as&for&mut fto a function called in the block is allowed. The checker can treat the closure as borrowing a block-scoped value, so existing borrow checking rejects every escape; the diagnostic nameschecked:and suggests defining the closure outside the block. Rejection can be relaxed later without breaking programs; trapping when called outside the block would reintroduce behaviour that depends on where a closure is called.
Staged plan and suggested backlog wording
Each rule’s phase 1 is small and independent; the issues listed are the
work items. Later phases change behaviour and need the user’s agreement per
row. The “Proposal” links below point into this page; in the backlog they
become proposals/consistency-rules.md#....
| ID | Area | Suggested wording |
|---|---|---|
| B88 | Copy rule | One copy rule in the reference, cheat sheet and skill, checked by a test; @copy on enums; undeclared enums affine after a migration that annotates every currently copyable enum. Generic enums wait for B84’s copyable bound, so B84 moves earlier. Proposal; issue #636. |
| B89 | Formatting path | print/f"{x}"/main’s report use display; str.repr/!r/aggregate elements use debug; private types format in their module. Report hook migration uses the shared formatter delivered by issue #634. Proposal; issue #615. |
| B90 | Owner-to-view conversion | &xs, &s, b"..." accepted at view types; &[u8] satisfies ByteSource; &Shared[T] converts to &T like Box; then byte-reading std APIs take &[u8] and ByteSource is removed. Text APIs accepting &substr stay in B12. Proposal. |
| B91 | Iteration sources | Adapters only on iterators; iter() for every source; uniform dictionary views; uniform terminals over copyable references. Membership on std containers stays B49; len method dispatch is delivered (issue #623). Proposal; issues #621, #622, #624. |
| B92 | Error types and invalid arguments | Allocation-only failure is AllocError, including f-strings without user formatting; domain errors carry Allocation(AllocError); FormatError in the prelude; infallible tuple slices; the four-part invalid-argument policy. Proposal; issues #625, #629, #632. |
| B93 | Callable forms | Every callable form accepted at every callback parameter; &mut named functions; class constructors and generic variants, including Some/Ok/Err, as function values; inside checked:, closures defined in the block report Err and other functions follow their signatures. Callable names stay (issue #24). Proposal; issues #614, #617–#620. |