fix: Keep LIST[PATH] in one ExprValue variant and set its session symbol - #338
fix: Keep LIST[PATH] in one ExprValue variant and set its session symbol#338leongdl wants to merge 6 commits into
Conversation
…stively Signed-off-by: David Leong <leongdl@amazon.com>
| fn representation_expr_type(param_type: JobParameterType) -> openjd_expr::ExprType { | ||
| match param_type { | ||
| JobParameterType::Path => openjd_expr::ExprType::STRING, | ||
| other => other.expr_type(), |
There was a problem hiding this comment.
representation_expr_type uses a catch-all other => arm, which reintroduces exactly the failure mode the new comment on list_element_type (right above, lines 1165-1169) argues against.
The two functions are a matched pair — list_element_type is now exhaustive so a newly added list type is a compile error rather than a silent fallback, but the representation mapping next to it will silently return the declared type for any future parameter type whose representation differs from its declared expr_type(). That is the same "empty and non-empty land in different variants" bug this function exists to prevent, just deferred to the next type added.
Since this is inside openjd-model (the #[non_exhaustive] attribute on JobParameterType only forces a wildcard on downstream crates), the same treatment is available here:
fn representation_expr_type(param_type: JobParameterType) -> openjd_expr::ExprType {
match param_type {
JobParameterType::Path => openjd_expr::ExprType::STRING,
JobParameterType::String
| JobParameterType::Int
| JobParameterType::Float
| JobParameterType::Bool
| JobParameterType::RangeExpr
| JobParameterType::ListString
| JobParameterType::ListInt
| JobParameterType::ListFloat
| JobParameterType::ListPath
| JobParameterType::ListBool
| JobParameterType::ListListInt => param_type.expr_type(),
}
}There was a problem hiding this comment.
Fair catch — I wrote a comment arguing against catch-all arms and then put one in the function immediately below. Fixed in aaf14ca, exactly as you wrote it.
The pairing is the point: list_element_type says what the element type is and representation_expr_type says how that type is represented, and a new variant needs an answer from both or the empty-list hint silently disagrees with the populated case again.
| /// would put empty and non-empty `LIST[PATH]` in different variants, and `openjd-sessions` | ||
| /// discriminates on the variant when it applies path mapping -- one of its two `LIST[PATH]` | ||
| /// arms matches `ListString` with no fallback, so a `ListPath` would silently not get its | ||
| /// `Param.<name>` symbol set at all. |
There was a problem hiding this comment.
High-level note on where the fix lands: this makes the producer always emit the variant the consumer happens to match, but leaves the consumer itself unguarded.
openjd-sessions session.rs:2366-2391 (the resolved_symtab branch) is an if let ExprValue::ListString(..) with no else. Because build_symbol_table deliberately omits Param.<name> for LIST[PATH] (parameters.rs:1317-1323), a value that is not ListString means Param.<name> is never set at all — an expression referencing it fails as an unknown symbol rather than misbehaving visibly. The from-scratch branch at session.rs:2428-2444 does have an other => self.apply_path_mapping_to_value(other) fallback, so the two arms disagree.
ExprValue::ListPath is a reachable input for a LIST[PATH] parameter, not just a hypothetical: JobParameterValue is public, and both build_symbol_table (parameters.rs:1336) and instantiate.rs:95 explicitly accept the ListPath variant. So a library caller (e.g. a service constructing JobParameterValue directly rather than going through JSON coercion) can still hit the silent-symbol-loss path that this change is written to avoid.
Worth adding the same other => fallback to the resolved_symtab arm, so the invariant does not depend on every producer agreeing on the representation.
There was a problem hiding this comment.
Agreed, and this is the better fix — mine made the producer well-behaved but left the invariant depending on every producer agreeing. Added in aaf14ca.
Checked the reachability claim before acting on it, since it's what makes this more than hypothetical. build_symbol_table does accept ListPath for a LIST[PATH] parameter:
JobParameterType::ListPath => {
... if let ExprValue::ListString(..) = pv.value { ... }
} else if let ExprValue::ListPath(ref elements, _, _) = pv.value { ... }So the model layer treats both variants as valid input while the sessions resolved_symtab arm accepted only one. And because Param.* for PATH/LIST[PATH] is deliberately excluded from the template-scope symtab, that arm is the only place the symbol gets set — so the failure is a missing symbol, not a wrong value.
The arm is now a match with other => self.apply_path_mapping_to_value(other), matching the from-scratch branch at 2428 rather than diverging from it.
Pinned by resolved_list_path_sets_param_for_every_variant, which drives the resolved branch with ListString, ListPath, and an empty ListString, and asserts Param.Paths exists in each case. Reverting just the fallback fails it:
ListPath: Param.Paths was not set at all
Two notes on scope. This does widen the PR into openjd-sessions, which I'd otherwise have kept out of a model-layer change — happy to split it if you'd rather review it separately. And I deliberately did not make the model reject ListPath for LIST[PATH]: it's accepted today in two places, so tightening that is a separate decision.
| /// | ||
| /// Usually the declared type's own `expr_type()`, but `PATH` is represented as a string: | ||
| /// `coerce_from_str` returns `ExprValue::String` for it, so a list of `PATH` elements is a | ||
| /// `ListString`. |
There was a problem hiding this comment.
The invariant this change establishes — a LIST[PATH] parameter value is always ExprValue::ListString, empty or not — is now load-bearing across a crate boundary (openjd-sessions matches on the variant), but it is only recorded in a private function’s doc comment.
specs/model/parameters.md §"PATH Parameter Handling" item 4 currently states only:
LIST[PATH]:
RawParam.XforLIST[PATH]islist(STRING), notlist(PATH).
That covers RawParam but not the parameter value itself, which is the thing this change is about and the thing sessions depends on. Per AGENTS.md ("before committing, always confirm the spec and code line up"), extending that item to say the coerced LIST[PATH] value is ListString regardless of length — and that Param.X only becomes list(PATH) at session scope after path mapping — would put the cross-crate contract somewhere a sessions maintainer would actually look.
There was a problem hiding this comment.
Right — a private doc comment is the wrong home for something another crate matches on, and AGENTS.md is explicit about the spec and code lining up before committing. Updated in aaf14ca.
Item 4 now covers the value, not just RawParam:
LIST[PATH]: the coerced parameter value is
ExprValue::ListStringwhatever its length, including empty, becausePATHelements are represented as strings.RawParam.XforLIST[PATH]is correspondinglylist(STRING), notlist(PATH).Param.Xonly becomeslist(PATH)at session scope, after the session applies path mapping.The variant is part of the contract, not an implementation detail:
openjd-sessionsmatches on it when applying path mapping. An empty list has no elements to infer a variant from, so the coercion supplies the element representation type as a hint rather than the declared element type — otherwise an emptyLIST[PATH]would be aListPathwhile a populated one is aListString. Consumers should still handle other variants, since a caller can constructJobParameterValuedirectly.
That last sentence is deliberate, and it's why I also added the consumer-side fallback you asked for on the other thread: the spec now states the producer's guarantee and tells consumers not to rely on it exclusively, which is the honest version given JobParameterValue is public.
…presentation Signed-off-by: David Leong <leongdl@amazon.com>
| } | ||
| .unwrap() | ||
| } | ||
| other => self.apply_path_mapping_to_value(other), |
There was a problem hiding this comment.
The new other => fallback fixes the missing-symbol case, but apply_path_mapping_to_value does not preserve the two invariants the ListString arm right above it establishes:
-
ListPathkeeps the source path format.apply_path_mapping_to_value(session.rs:2569-2579) maps each element withpath_mapping::apply_rules, which emits host separators (apply_rules→apply_rules_with_format(..., PathFormat::host())), then rewraps withExprValue::new_path(mapped_s, *fmt)— the incoming format, not host. So aListPath(_, Posix, _)on a Windows worker yields backslash-separated strings taggedPosix. TheListStringarm two lines up correctly usesPathFormat::host(), and the comment at session.rs:2336-2337 states that this branch is exactly the boundary where "Path values stored as Posix in template scope get normalized to host format". The fallback is now the one place in this branch that skips that normalization. -
Anything that is neither
PathnorListPathis returned unchanged (the_ => value.clone()arm at session.rs:2580). For aLIST[PATH]-typed parameter that means a value arriving as, say, a bareExprValue::Stringis written intoParam.<name>with no path mapping applied at all and with a scalar type wherelist(PATH)is expected. Previously that input produced a loud unknown-symbol error; now it silently produces an unmapped path. For a path-mapping mechanism, silently not mapping is the worse of the two failure modes — worth either mapping the string forms explicitly or returningSessionError::Runtimefor a value that cannot be interpreted as a list of paths.
resolved_list_path_sets_param_for_every_variant would not catch either of these: it only asserts symtab.get_value("Param.Paths").is_some(). Asserting the resulting variant/format (and, with a rule configured, that mapping was actually applied) would pin down the contract the fallback is meant to uphold.
There was a problem hiding this comment.
This is the most useful comment in the batch — my fallback fixed the missing symbol and introduced a worse failure in its place. Both invariants confirmed:
apply_path_mapping_to_value'sListPatharm callsapply_rules, which isapply_rules_with_format(..., PathFormat::host()), then rewraps with*fmt— the incoming format. So host separators tagged with the source format._ => value.clone()returns anything else untouched, so aLIST[PATH]value arriving as a bareStringwas written intoParam.<name>unmapped.
Agreed that silently not mapping is worse than the missing symbol I was fixing. Rewritten in 7cce3fe to handle the variants directly rather than delegating:
let elements: Option<&Vec<String>> = match ¶m.value {
ExprValue::ListString(elements, _) => Some(elements),
ExprValue::ListPath(elements, _, _) => Some(elements),
// An empty list carries no element type to match on.
ExprValue::ListList(items, _, _) if items.is_empty() => None,
other => {
return Err(SessionError::Runtime(format!(
"Parameter '{name}' is declared LIST[PATH] but its value is {}, \
which is not a list of paths",
other.type_name()
)));
}
};Both list variants hold their elements as strings, so they now take the same path and get PathFormat::host(), which is what this branch is for. Anything that cannot be read as a list of paths is a SessionError::Runtime — loud, and at the point where the value is wrong, rather than an unmapped path or a missing symbol later.
The empty ListList arm is deliberate: an empty list genuinely has no element type, and a caller (or older serialized data) can present one, so erroring on it would be wrong.
Tests strengthened as you suggested — asserting existence only was exactly why neither problem would have been caught. resolved_list_path_sets_param_for_every_variant now installs a real rule (/src -> /dst) and asserts, for both ListString and ListPath inputs, that the result is a ListPath, that every element actually contains dst, and that the format is PathFormat::host(). Plus resolved_empty_list_path_is_accepted and resolved_list_path_rejects_a_value_that_is_not_a_list_of_paths.
I did not change apply_path_mapping_to_value itself. The incoming-format rewrap looks wrong to me for its other callers too, but it is pre-existing and used elsewhere, so that wants its own change and its own tests rather than riding along here.
| 4. **LIST[PATH]**: `RawParam.X` for `LIST[PATH]` is `list(STRING)`, not `list(PATH)`. | ||
| 4. **LIST[PATH]**: the coerced parameter value is `ExprValue::ListString` whatever its | ||
| length, including empty, because `PATH` elements are represented as strings. `RawParam.X` | ||
| for `LIST[PATH]` is correspondingly `list(STRING)`, not `list(PATH)`. `Param.X` only |
There was a problem hiding this comment.
This newly-asserted half of the contract — RawParam.X for LIST[PATH] is list(STRING) — is not upheld by openjd-sessions on the same input the PR says reaches it.
openjd-model::build_symbol_table does comply: parameters.rs:1346-1354 normalizes both ListString and ListPath down to ListString for RawParam. But openjd-sessions session.rs:2410 in the from-scratch branch is
JobParameterType::Path | JobParameterType::ListPath => param.value.clone(),so a ListPath value passes straight through and RawParam.<name> becomes list(PATH). Validation type-checks {{ RawParam.X }} as list(STRING) (validate_v2023_09/format_strings.rs:46 and :84 both map ListPath → list(STRING)), so the static type and the runtime type disagree for exactly the direct-caller input this PR added the Param.* fallback for.
Since the PR is establishing this as a cross-crate contract rather than an implementation detail, applying the same ListPath → ListString normalization at session.rs:2410 would close the other half.
There was a problem hiding this comment.
Right, and fair to hold me to it: the moment I wrote the contract into the spec, the other half became mine to close. Fixed in 7cce3fe.
Confirmed the asymmetry first. openjd-model normalizes both variants:
JobParameterType::ListPath => {
if let ExprValue::ListString(ref elements, _) = pv.value { ListString(elements.clone(), 0) }
else if let ExprValue::ListPath(ref elements, _, _) = pv.value { ListString(elements.clone(), 0) }while session.rs cloned. The from-scratch arm now normalizes to match, for PATH as well as LIST[PATH] — the same clone meant a Path{} value produced RawParam = PATH where the model produces String, so both halves of that arm had the same defect:
JobParameterType::Path => match ¶m.value {
ExprValue::Path { value, .. } => ExprValue::String(value.clone()),
other => other.clone(),
},
JobParameterType::ListPath => match ¶m.value {
ExprValue::ListPath(elements, _, _) => ExprValue::ListString(elements.clone(), 0),
other => other.clone(),
},Covered by resolved_list_path_raw_param_is_a_list_of_strings and scalar_path_raw_param_is_a_plain_string. Both go through the from-scratch branch, since that is where RawParam is computed — the resolved branch inherits it from the base symtab, which is worth knowing if you look for these next to the other new tests.
| /// discriminates on the variant when it applies path mapping -- one of its two `LIST[PATH]` | ||
| /// arms matches `ListString` with no fallback, so a `ListPath` would silently not get its | ||
| /// `Param.<name>` symbol set at all. | ||
| fn representation_expr_type(param_type: JobParameterType) -> openjd_expr::ExprType { |
There was a problem hiding this comment.
This rationale is stale as of this same PR: it says one of the two openjd-sessions LIST[PATH] arms "matches ListString with no fallback", but the sessions half of this diff (session.rs:2374-2393) adds the other => fallback, so neither arm lacks one now. A reader who checks the cross-reference will find it no longer describes the code, and the stated justification for representation_expr_type existing appears to have evaporated.
The invariant is still worth keeping — the two arms produce different values for ListPath vs ListString (see the separate note on apply_path_mapping_to_value losing host-format normalization), so a variant that flips with list length still changes behavior. But that is a different argument than "the symbol is never set", and the comment should make the surviving one.
The same stale claim is repeated in the test comment at parameters.rs:1591-1593.
There was a problem hiding this comment.
Caught me arguing from a premise my own diff had removed, in the same PR. Fixed in 7cce3fe, in both places.
The surviving argument is the one you named — the two variants take different paths through the consumer and are not obliged to produce identical values — so that is what it says now:
/// would put empty and non-empty `LIST[PATH]` in different variants, which matters because
/// `openjd-sessions` discriminates on the variant when it applies path mapping: the variants
/// take different code paths and are not required to produce identical values, so a parameter
/// whose variant flips with its length changes behaviour purely by being empty.
The test comment at what was parameters.rs:1591 is updated to match, rather than leaving the two out of step.
| /// The guarantee is scoped to JSON because that is the only way in: a library caller handing | ||
| /// over an already-typed list variant (`ExprValue::ListInt` for a `LIST[FLOAT]`, say) never | ||
| /// reaches here, since [`coerce_to_job_parameter_type`] only routes strings through | ||
| /// [`coerce_from_str`]. That path stays strict and rejects the mismatch. |
There was a problem hiding this comment.
"That path stays strict and rejects the mismatch" is not true for LIST[LIST[INT]], and this new paragraph invites a reader to rely on it.
coerce_to_job_parameter_type returns early on value_matches_type (parameters.rs:668), and that predicate is variant-only for the nested case — parameters.rs:793 is (ExprValue::ListList(_, _, _), JobParameterType::ListListInt) with a wildcard on the element type. So a direct caller passing a ListList whose inner lists are ListString gets Ok(value.clone()) at line 669, never reaching the strict other => Err(...) arm at line 711.
check_constraints does not catch it either: the ListList arm at parameters.rs:606-607 is if let ExprValue::ListInt(ints) = inner, so a non-ListInt inner list is silently skipped rather than rejected. Same shape for (ExprValue::ListString(_, _), JobParameterType::ListString | ListPath) at :787-789, though there the two representations are genuinely interchangeable so it is benign.
Either narrow the claim to the scalar/LIST[scalar] cases it actually covers, or tighten value_matches_type for ListListInt to check the element type. As written the doc asserts a guarantee the code does not provide for one of the six list types.
There was a problem hiding this comment.
Correct — and it is my paragraph making the overclaim, so it should go rather than be defended.
Verified the mechanism you describe: coerce_to_job_parameter_type returns Ok(value.clone()) on value_matches_type, and that predicate's ListListInt arm is (ExprValue::ListList(_, _, _), JobParameterType::ListListInt) — element type wildcarded — so a ListList of ListString never reaches the strict arm. And check_constraints' ListList arm is if let ExprValue::ListInt(ints) = inner, so a non-ListInt inner is skipped rather than rejected.
Narrowed the claim in 7cce3fe rather than tightening value_matches_type:
/// That path is stricter, but not airtight, and the gap is worth knowing about: it returns the
/// value untouched when `value_matches_type` accepts it, and that check is variant-only for
/// `LIST[LIST[INT]]` -- a `ListList` whose inner lists are `ListString` passes. `LIST[STRING]`
/// and `LIST[PATH]` also accept each other, which is harmless because they share a
/// representation. For the scalar types and the flat `LIST[scalar]` types a mismatch is
/// rejected.
Left the code alone deliberately. Tightening value_matches_type for ListListInt is a new rejection for input accepted today, which is the same call as the typed-list widening from the #335 review — the sort of thing that wants to be its own change with its own tests rather than a rider on a doc fix. Happy to do it if you want it; otherwise the doc now describes what the code does, including the hole.
…ize RawParam Signed-off-by: David Leong <leongdl@amazon.com>
| } | ||
| other => other.clone(), | ||
| }, | ||
| JobParameterType::ListPath => match ¶m.value { |
There was a problem hiding this comment.
The RawParam half of the from-scratch branch was normalized here, but the Param.* half of the same branch (session.rs:2473-2489) was left as it was — so the design rule this PR states in the resolved branch is not applied in the branch being edited.
The resolved branch's new comment (session.rs:2378-2384) says explicitly why it does not delegate to apply_path_mapping_to_value:
it rewraps with the value's incoming format rather than host, and returns anything that is not a Path or ListPath unmapped. For a path-mapping mechanism, writing a path that was never mapped is a worse outcome than refusing the value
Both of those hazards are still live a few lines below, in the from-scratch Param.* arm:
JobParameterType::ListPath => match ¶m.value {
openjd_expr::ExprValue::ListString(elements, _) => { /* ... new_path(m, host()) ... */ }
other => self.apply_path_mapping_to_value(other),
},-
ListPathgets the wrong format tag, and the value becomes unusable.apply_path_mapping_to_value(session.rs:2608-2618) maps each element withapply_rules, which emits host separators (apply_rules→apply_rules_with_format(.., PathFormat::host())), then rewraps withnew_path(mapped_s, *fmt)— the incoming format. AListPath(_, Posix, _)on a Windows worker therefore yields backslash-separated strings taggedPosix(normalize_path_separatorsis a no-op forPosix), andmake_listpropagates that format, soParam.XisListPath(_, Posix, _). The evaluator then rejects it outright:check_path_format(evaluator.rs:562-567) errors on any non-emptyListPathwhose format differs from the evaluator's. The resolved branch produceshostfor this same input; this branch produces a symbol that fails on first reference. -
A non-list value is written unmapped.
apply_path_mapping_to_value's_ => value.clone()arm (session.rs:2619) means aLIST[PATH]-declared parameter arriving as, say,ExprValue::Stringis written intoParam.<name>with no path mapping applied — the outcome the resolved branch now deliberately turns into aSessionError::Runtime.
Since ListPath reaching build_symbol_table from direct JobParameterValue callers is the premise of this PR, and that premise doesn't distinguish between the two branches, the same elements match + new_path(.., host()) + error-on-non-list logic looks like it belongs at 2473-2489 too. resolved_list_path_raw_param_is_a_list_of_strings exercises the from-scratch branch with exactly a ListPath value but only asserts on RawParam.Paths, so it passes while Param.Paths carries the mis-tagged format.
There was a problem hiding this comment.
Confirmed and fixed in 1305812 — and this round's finding pattern (same fix missing from the sibling branch, twice) says the real problem was duplication, so the fix is structural rather than another copy.
Both branches now call one map_list_path_param, which carries the host-format tagging, the empty-list handling, and the error on non-list values. The from-scratch arm is now:
JobParameterType::ListPath => self.map_list_path_param(name, ¶m.value)?,so the two branches cannot drift apart again.
A consequence worth flagging: this removed apply_path_mapping_to_value's last caller, so the function is deleted — the incoming-format rewrap and the _ => value.clone() passthrough are gone from the codebase rather than lurking for the next caller. (I'd said in an earlier reply it had other callers; checking again after this change, it didn't. Clippy's dead-code warning caught it.)
Pinned by from_scratch_list_path_matches_the_resolved_branch, which drives the from-scratch branch with a ListPath(_, Posix, _) and a /src → /dst rule and asserts the mapped elements and PathFormat::host() — the exact mis-tagging you traced through check_path_format. Plus from_scratch_list_path_rejects_a_value_that_is_not_a_list_of_paths for the passthrough half.
| }, | ||
| JobParameterType::ListPath => match ¶m.value { | ||
| openjd_expr::ExprValue::ListPath(elements, _, _) => { | ||
| openjd_expr::ExprValue::ListString(elements.clone(), 0) |
There was a problem hiding this comment.
The hardcoded 0 is the cached_memory_size field, and setting it to 0 makes this list free as far as the evaluator's memory limit is concerned.
ListString's second field is the cached heap size (value.rs:260), and it is the only source heap_size() consults for that variant:
Self::ListString(_, cached) | Self::ListPath(_, _, cached) => *cached,(value.rs:1541)
heap_size() feeds memory_size(), which the evaluator charges on every symbol read — eval_name/eval_attribute call self.track(val.clone()), and track does self.current_memory += value.memory_size() and compares against memory_limit (evaluator.rs:425-431). So RawParam.<name> for a LIST[PATH] built here is charged only size_of::<ExprValue>() no matter how many megabytes of path strings it holds. budgeted_vec.rs:44 and the dispatch_with_node input accounting at evaluator.rs:453/475 undercount the same way.
This is a regression from the line being replaced: param.value.clone() carried the incoming ListPath's correctly-computed cached size through. The private make_list_string helper computes it as v.len() * size_of::<String>() + v.iter().map(|s| s.len()).sum() (value.rs:373-377); from outside the crate, routing through the public make_list with a STRING hint reaches that helper and gets the right number:
JobParameterType::ListPath => match ¶m.value {
openjd_expr::ExprValue::ListPath(elements, _, _) => openjd_expr::ExprValue::make_list(
elements.iter().cloned().map(openjd_expr::ExprValue::String).collect(),
openjd_expr::ExprType::STRING,
)
.map_err(|e| SessionError::Runtime(format!("Failed to build {raw_key}: {e}")))?,
other => other.clone(),
},(openjd-model's build_symbol_table at parameters.rs:1354-1357 has the same , 0) in pre-existing code, so it is worth fixing there too rather than treating it as the precedent to follow — but the new occurrence is the one that turns a previously-accurate charge into a zero.)
There was a problem hiding this comment.
Confirmed — heap_size() consults only the cached field for ListString (value.rs:1541), so the hardcoded 0 made the list free against the evaluator's memory limit, and it was a regression from the param.value.clone() it replaced. Fixed in 1305812 with the routing you suggested:
openjd_expr::ExprValue::ListPath(elements, _, _) => openjd_expr::ExprValue::make_list(
elements.iter().cloned().map(openjd_expr::ExprValue::String).collect(),
openjd_expr::ExprType::STRING,
)Pinned by list_path_raw_param_carries_its_memory_size, which asserts the cached size is non-zero for a list with real content.
On the pre-existing , 0) in openjd-model's build_symbol_table (parameters.rs:1354-1357): agreed it's the same defect and shouldn't be treated as precedent, but I've left it out of this PR deliberately — it's in merged code on a different layer, and this PR has already grown twice. I'll take it as a follow-up unless someone objects; the fix is the same shape.
| // scope, so this is the only place it gets set. Anything other than a | ||
| // ListString still has to produce a symbol: without a fallback the | ||
| // parameter is simply absent and an expression referencing it fails as | ||
| // an unknown symbol. `ListPath` reaches here from callers that build |
There was a problem hiding this comment.
This comment's reasoning applies verbatim to the sibling JobParameterType::Path arm 20 lines above, which the PR leaves with the failure mode it argues against.
build_symbol_table in openjd-model omits Param.<name> for both path types — parameters.rs:1335-1342 builds is_path from Path | ListPath and skips the Param.* set for either. So the premise "this is the only place it gets set" holds equally for PATH. But the resolved branch's Path arm is:
let raw = match ¶m.value {
openjd_expr::ExprValue::String(s) => s.as_str(),
openjd_expr::ExprValue::Path { value, .. } => value.as_str(),
_ => continue, // <-- session.rs:2352
};continue means Param.<name> is never set at all, which is exactly the outcome this comment identifies as the reason a fallback is needed ("the parameter is simply absent and an expression referencing it fails as an unknown symbol"). And the trigger is the same one the PR cites for ListPath: a caller constructing JobParameterValue directly rather than going through JSON coercion — e.g. a PATH parameter handed an ExprValue::Int.
The from-scratch branch's Path arm has the mirror-image problem, using _ => "" (session.rs:2465) so Param.<name> is set to the empty path — a silent wrong value rather than a missing symbol.
Both should probably get the same treatment the ListPath arm just got: an explicit SessionError::Runtime naming the parameter and its declared type. Handling one of the two path types and not the other leaves the same class of bug reachable via the neighbouring arm, and a reader of this comment will reasonably assume it was considered.
There was a problem hiding this comment.
Right — is_path is built from Path | ListPath, so the "only place the symbol gets set" premise covers both types, and I'd fixed one arm while the comment I wrote argued for both. Fixed in 1305812.
Both scalar PATH arms now go through one path_param_raw helper that errors on any non-path variant:
- the resolved branch's
_ => continue(missing symbol later) is gone, - the from-scratch branch's
_ => ""(a silently wrong empty path — arguably the worst of the three failure modes) is gone.
other => Err(SessionError::Runtime(format!(
"Parameter '{name}' is declared PATH but its value is {}, which is not a path",
other.type_name()
))),Pinned by scalar_path_with_a_non_path_value_is_refused_in_both_branches, which drives an ExprValue::Int through both branches and asserts both refuse with the type named.
…ge list memory Signed-off-by: David Leong <leongdl@amazon.com>
| other => other.clone(), | ||
| }, | ||
| JobParameterType::ListPath => match ¶m.value { | ||
| // Through make_list rather than ListString(.., 0): the second field |
There was a problem hiding this comment.
This comment asserts parity with openjd-model's build_symbol_table, but the zero-heap-size bug it describes is only fixed on the sessions side — the model still constructs RawParam for LIST[PATH] with a hardcoded 0.
crates/openjd-model/src/job/create_job/parameters.rs:1353-1359:
JobParameterType::ListPath => {
if let openjd_expr::ExprValue::ListString(ref elements, _) = pv.value {
openjd_expr::ExprValue::ListString(elements.clone(), 0)
} else if let openjd_expr::ExprValue::ListPath(ref elements, _, _) = pv.value {
openjd_expr::ExprValue::ListString(elements.clone(), 0)
} else {
pv.value.clone()
}
}Both arms pass 0, and the first one is worse than the sessions case this PR fixes: it discards a correct cached size that make_list_string had already computed. heap_size() (value.rs:1541) reads that field and nothing else for the variant, and memory_size() is what evaluator.rs:425 charges on every value it produces — so {{ RawParam.SomeBigPathList }} evaluated against this symtab is charged the 32-odd bytes of the enum and nothing for the strings, no matter how large the list.
This symtab is not test-only: create_job/mod.rs:62 builds it and resolves the template's format strings against it, and openjd_model::build_symbol_table is public API called from openjd-cli/src/run/execution.rs:25. The make_list(.., ExprType::STRING) construction added here is exactly the fix the model side needs too, and doing both in one change would make the "normalizes the same way" claim in this comment true.
There was a problem hiding this comment.
Agreed — once my comment claimed parity, deferring the model half stopped being defensible, especially with the symtab feeding create_job's format-string resolution and the public API the CLI calls. Fixed in 0d8264f with the same make_list(.., STRING) construction, both arms:
JobParameterType::ListPath => match &pv.value {
ExprValue::ListString(elements, _) | ExprValue::ListPath(elements, _, _) => {
ExprValue::make_list(
elements.iter().cloned().map(ExprValue::String).collect(),
ExprType::STRING,
)
.map_err(|e| ModelError::DecodeValidation(...))?
}
_ => pv.value.clone(),
},You're right the ListString arm was the worse of the two — it discarded a size make_list_string had already computed correctly.
Pinned on the model side by test_raw_param_list_path_carries_its_memory_size, which goes through preprocess_supplied → build_symbol_table and asserts the cache is non-zero, alongside the sessions-side test from the previous commit.
| _ => "", | ||
| }; | ||
| // Erroring, not defaulting to "": an empty path is a silently wrong | ||
| // value, which is worse than refusing the input. |
There was a problem hiding this comment.
This rule is applied to the job-parameter PATH arm but not to the task-parameter PATH arm 55 lines below, which is the identical pattern and is left as _ => "".
session.rs:2486-2498, in the same function:
TaskParameterType::Path => {
let s = match &tv.value {
openjd_expr::ExprValue::String(s) => s.as_str(),
openjd_expr::ExprValue::Path { value, .. } => value.as_str(),
_ => "", // <-- session.rs:2491
};
let mapped = self.apply_path_mapping_to_string(s);
openjd_expr::ExprValue::new_path(mapped, PathFormat::host())
}The failure mode is exactly the one this comment names: Task.Param.<name> is set to an empty path, apply_path_mapping_to_string("") returns "", and the action runs with an empty string interpolated where a path was expected. And the trigger is the same one the PR cites for the job-parameter case — a caller constructing the value directly rather than through coercion, e.g. a PATH task parameter handed an ExprValue::Int.
Task.RawParam.<name> at session.rs:2471 has the mirror asymmetry: TaskParameterType::Path => tv.value.clone() passes any variant through, where the job-parameter Path arm this PR just rewrote (session.rs:2395-2400) unwraps Path to String.
path_param_raw is a static method taking only (name, value), so it is directly reusable here if the type name in the message were parameterized. Leaving the task-parameter path with the behaviour the job-parameter path just abandoned means a reader of this comment will reasonably assume both were covered.
There was a problem hiding this comment.
Confirmed and fixed in 0d8264f. The task arms were the identical pattern and I stopped one function short of them.
Task.Param.<name>forPATHnow goes through the samepath_param_raw, so anExprValue::Intis aSessionError::Runtimenaming the parameter, instead ofapply_path_mapping_to_string("")interpolating an empty path into the action.Task.RawParam.<name>unwrapsPathtoString, mirroring the job-parameter arm.
path_param_raw was reusable as-is, as you noted. I left the message saying "declared PATH" without distinguishing job from task parameters — the key already carries Task. and the name disambiguates — rather than threading a type-label parameter through for wording alone.
Pinned by task_path_with_a_non_path_value_is_refused and task_path_raw_param_is_a_plain_string. The existing scenario_task_path_* tests still pass, so well-formed task paths behave as before.
TaskParameterType::ChunkInt => tv.value.clone() I've left alone: it's not a path type and nothing in this PR's rule applies to it.
| // from it, which is the empty/non-empty variant split this function exists to avoid. | ||
| match param_type { | ||
| JobParameterType::Path => openjd_expr::ExprType::STRING, | ||
| JobParameterType::String |
There was a problem hiding this comment.
The JobParameterType::ListPath arm falls into the param_type.expr_type() group, which returns list(PATH) — contradicting this function's own documented contract and the invariant the rest of this PR establishes.
The doc says this returns "the ExprType that coerce_to_job_parameter_type actually produces for param_type", and the spec change in this same diff says the coerced LIST[PATH] value is ExprValue::ListString whatever its length. So the correct answer for ListPath is list(STRING), not list(PATH).
It is not a live bug today — the single call site (parameters.rs:1262) only ever passes an element type, and list_element_type never yields ListPath because there is no LIST[LIST[PATH]]. But the exhaustive match is written specifically so that "the next parameter type whose representation differs from it" cannot slip through silently, and ListPath is already such a type sitting in the pass-through group. The failure it would produce is the exact one this function exists to prevent: any future caller asking for the top-level representation of LIST[PATH] — a make_list hint for an outer empty list, a transport-type annotation — gets list(PATH) back and re-splits the variant.
Moving JobParameterType::ListPath up beside Path with ExprType::list(ExprType::STRING) would make the function match its docstring, and a_list_lands_in_the_same_variant_whether_or_not_it_is_empty would still pass since it exercises the element-type path.
There was a problem hiding this comment.
Fixed in 0d8264f, moved up as suggested:
JobParameterType::ListPath => openjd_expr::ExprType::list(openjd_expr::ExprType::STRING),Agreed on the framing: not live today, but "a type whose representation differs sitting silently in the pass-through group" is precisely the shape the exhaustive match was written to make impossible, so leaving it there made the function a counterexample to its own docstring. The comment on the arm records why no caller currently reaches it, so nobody wonders why the tests don't.
a_list_lands_in_the_same_variant_whether_or_not_it_is_empty still passes, as you predicted — it exercises the element-type path.
| /// value untouched when `value_matches_type` accepts it, and that check is variant-only for | ||
| /// `LIST[LIST[INT]]` -- a `ListList` whose inner lists are `ListString` passes. `LIST[STRING]` | ||
| /// and `LIST[PATH]` also accept each other, which is harmless because they share a | ||
| /// representation. For the scalar types and the flat `LIST[scalar]` types a mismatch is |
There was a problem hiding this comment.
This paragraph describes the value_matches_type gap as "LIST[STRING] and LIST[PATH] also accept each other", but that understates it in a way that matters for this PR: value_matches_type has no ExprValue::ListPath arm at all, and no ExprValue::Path arm either.
value_matches_type (parameters.rs:775-795) enumerates String, Int, Float, Bool, RangeExpr, ListString, ListInt, ListFloat, ListBool, ListList. The path variants are absent. So for LIST[PATH]:
ExprValue::ListString→ matches (via theListString | ListPatharm) ✅ExprValue::ListPath→ no match, falls throughcoerce_to_job_parameter_type's numeric arms, then hitsother => Err(...)at parameters.rs:711-717 →"Cannot coerce list[path] to LIST[PATH]"
and identically for scalar PATH, where ExprValue::Path is rejected as "Cannot coerce path to PATH". A value is refused by the type it is.
This is reachable from the same direct-caller entry point the sessions half of this PR exists to serve: coerce_to_job_parameter_type is called on user input at parameters.rs:919 from preprocess_job_parameters. So a library caller handing over ExprValue::ListPath for a declared LIST[PATH] — precisely the input map_list_path_param and path_param_raw were just written to accept in openjd-sessions — cannot get through openjd-model's front door at all. The two halves of the PR disagree about whether that input is legal.
Adding (ExprValue::Path(..), JobParameterType::Path) and (ExprValue::ListPath(..), JobParameterType::ListString | JobParameterType::ListPath) to value_matches_type would close it. Note the ListPath case cannot be returned untouched if the ListString invariant is to hold, so it needs a converting arm rather than a matching one.
Either way, the sentence here should say the path variants are unrepresented rather than mutually accepted — a reader relying on this doc would conclude ListPath input is accepted-and-passed-through when it is rejected.
…am memory Signed-off-by: David Leong <leongdl@amazon.com>
|
|
||
| /// Apply path mapping rules to a value if it's a Path or ListPath type. | ||
| fn apply_path_mapping_to_value( | ||
| /// The raw path string of a scalar PATH parameter value. |
There was a problem hiding this comment.
The deleted apply_path_mapping_to_value doc comment was left behind and is now the first line of path_param_raw docs:
/// Apply path mapping rules to a value if it is a Path or ListPath type.
/// The raw path string of a scalar PATH parameter value.
The first line describes a function that no longer exists anywhere in the crate — the only remaining mentions of that name are the prose references at session.rs:2576 and session.rs:3640 — and it contradicts the sentence directly below it: path_param_raw applies no mapping at all, it just unwraps a string. cargo doc renders both lines as one summary paragraph, so the rendered doc reads as though this function path-maps its argument.
Dropping the stray line is all that is needed.
There was a problem hiding this comment.
Valid finding, won't fix in this PR.
The stray line is real — my function-removal script deleted the body but left its doc line above the insertion point — and the two stale prose mentions with it. But this PR started as a one-commit variant fix and is now five commits across three layers, each round of review-driven fixes generating the next round's findings. A doc-line deletion is exactly the kind of marginal change that keeps that loop alive without changing behaviour.
Leaving it for either the maintainer's review pass or a follow-up sweep, whichever lands first. If a human reviewer wants it fixed in this PR, it's a one-line deletion and I'll do it immediately.
| SessionError::Runtime(format!("Failed to build {raw_key}: {e}")) | ||
| })? | ||
| } | ||
| other => other.clone(), |
There was a problem hiding this comment.
The ListString arm falls through to other => other.clone(), so this half of the fix keeps the very hole the comment above says it closes — and the PR contains a value that demonstrates it.
The model side normalizes both list variants through make_list (parameters.rs:1362-1363 matches ListString(elements, _) | ListPath(elements, _, _) and rebuilds), precisely because the cached size in the second field cannot be trusted. Here only ListPath is rebuilt; a ListString is cloned with whatever cached size it arrived with. ExprValue::ListString is a public tuple variant, so any caller building JobParameterValue directly — the same direct-caller entry point this PR exists to serve — supplies that field itself, and heap_size() (value.rs:1541) reads it and nothing else for the variant.
The new test in this diff supplies a wrong one:
ExprValue::ListString(vec!["/src/a".into(), "/src/b".into()], 2),Two String headers plus 12 bytes of text is ~60 bytes on a 64-bit target; 2 is what RawParam.Paths would be charged. list_path_raw_param_carries_its_memory_size only asserts *cached > 0, so a value like this passes while under-reporting by an order of magnitude — the same class of miss as the hardcoded 0 being fixed, just less extreme.
Matching the model and folding ListString into the ListPath arm (ListPath(elements, _, _) | ListString(elements, _)) makes the claim in the comment above true and makes the sessions and model sides genuinely identical. Note the make_list route also recomputes the size from the actual strings, so it is the normalization, not just the variant change, that does the work.
There was a problem hiding this comment.
The analysis is correct — the ListString arm clones the caller-supplied cached size while the ListPath arm recomputes, my own test value (ListString(vec![...], 2), true size ~60 bytes) demonstrates the miss, and folding ListString into the make_list arm would match the model side exactly.
Won't fix in this PR, deliberately. Drawing the line here rather than at the next finding:
- The severity is a bounded undercount of an advisory memory charge, from a caller constructing a public tuple variant with a wrong cached size — a caller-supplied-garbage case, not the hardcoded-zero regression this PR fixed.
- This PR has grown from one commit to five, and the last two review rounds have been findings in the fixes, converging on smaller and smaller deltas. Stopping that loop needs a stop rule, and "behaviour-changing defects only" is the one I'm applying: this changes an accounting number, not behaviour.
Noting it here so it isn't lost: the right shape is ListPath(elements, _, _) | ListString(elements, _) through make_list, same as parameters.rs:1362, plus tightening the test to assert the recomputed size rather than > 0. If the maintainer wants it in this PR, say so and it's a five-minute change; otherwise it belongs in the same follow-up as any other cached-size hygiene.
| not a list of paths", | ||
| other.type_name() | ||
| ))); | ||
| } |
There was a problem hiding this comment.
This PR turns three previously-tolerant paths in build_symbol_table into hard SessionError::Runtime failures — LIST[PATH] here, scalar PATH at session.rs:2561, and task PATH via the same helper — but specs/sessions/session.md still describes the old contract, so the spec and code no longer line up (AGENTS.md: "before committing, always confirm the spec and code line up").
Two places in that document are now wrong or incomplete:
- session.md:274 — "PATH-type parameters have path mapping rules applied. LIST_PATH parameters have rules applied to each element." That is now conditional: only
String/Pathvalues andListString/ListPath/empty-list values get that treatment, and every other variant aborts symbol-table construction. - session.md:281-283 — the failure-mode sentence enumerates exactly one way construction can fail: "A value that cannot be coerced to its declared type (e.g. INT
\"abc\") fails symbol table construction with aSessionError::Runtimenaming the parameter." PATH types do not go throughcoerce_param_valueat all, so a reader concludes they cannot fail this way. After this change they can, and the new tests (scalar_path_with_a_non_path_value_is_refused_in_both_branches,task_path_with_a_non_path_value_is_refused, both*_rejects_a_value_that_is_not_a_list_of_paths) assert precisely that.
This matters more than a doc nit because SessionConfig.job_parameter_values is caller-supplied public API and the failure is now fatal to the session rather than silent. A caller reading the spec to learn which values are acceptable would not know that, say, a LIST[PATH] handed an ExprValue::ListInt, or a PATH handed an ExprValue::Int, is now a run-time refusal instead of a best-effort conversion.
Worth also recording in that section which variants are accepted for LIST[PATH] — ListString, ListPath, and an empty ListList — since the empty-list case is a deliberate carve-out (session.rs:2589) that is not obvious from the declared type alone.
| .collect() | ||
| }) | ||
| .unwrap_or_default(); | ||
| Ok(openjd_expr::ExprValue::make_list(mapped, openjd_expr::ExprType::PATH).unwrap()) |
There was a problem hiding this comment.
.unwrap() on make_list in a function that already returns SessionError — and the other two make_list call sites this same PR adds both propagate:
- session.rs:2414 —
.map_err(|e| SessionError::Runtime(format!("Failed to build {raw_key}: {e}")))? - parameters.rs:1372 —
.map_err(|e| ModelError::DecodeValidation(...))?
Only this one panics. It is not reachable today (every element is an ExprValue::Path, and make_list only errors on the 3-level-nesting check), but that is an invariant of the loop directly above rather than anything the type system holds — and it is the kind of invariant that breaks when someone widens the accepted input variants, which is exactly what this PR just did to this function. A panic here aborts the worker process instead of failing the session with a diagnostic; AGENTS.md asks for Result over panicking.
The same treatment as line 2414 — .map_err(|e| SessionError::Runtime(format!("Failed to build Param.{name}: {e}")))? — costs one line and makes all three new call sites consistent.
There was a problem hiding this comment.
Fixed in 5b88bb0. Taking this one despite the PR being past the point where I've been declining, because it's in the behaviour column: an .unwrap() here is a worker-process abort where the function's own signature promises a SessionError, and the "unreachable today" guard is a loop invariant that this very PR demonstrates gets widened.
openjd_expr::ExprValue::make_list(mapped, openjd_expr::ExprType::PATH)
.map_err(|e| SessionError::Runtime(format!("Failed to build Param.{name}: {e}")))All three make_list call sites this PR added now propagate identically. One line, and it makes the code more uniform rather than adding a new variant of anything.
| return Err(SessionError::Runtime(format!( | ||
| "Parameter '{name}' is declared LIST[PATH] but its value is {}, which is \ | ||
| not a list of paths", | ||
| other.type_name() |
There was a problem hiding this comment.
type_name() collapses every list variant to the literal string "list" (value.rs:1464: _ if self.is_list() => "list"), so the most likely wrong input produces a self-contradictory message:
Parameter 'Paths' is declared LIST[PATH] but its value is list, which is not a list of paths
A caller who passed ExprValue::ListInt or ExprValue::ListBool is told their list is not a list, with nothing identifying what it actually was. The new tests do not catch this because they only exercise ExprValue::String ("string" reads fine) and assert on substrings rather than the full message — which AGENTS.md asks for ("assert on the full error message content", so error messages stay human-readable).
value.expr_type() renders the parameterized type (list[int], list[bool] — value.rs:1432-1437), which is both accurate and the vocabulary the rest of the diff uses: the comment two lines up talks about list(STRING) vs list(PATH), and the parallel path_param_raw message at session.rs:2561 does not have the problem because scalar type_name() values are distinct.
Adding an ExprValue::ListInt or ExprValue::ListBool case to resolved_list_path_rejects_a_value_that_is_not_a_list_of_paths would pin whichever wording you pick.
There was a problem hiding this comment.
Correct on all points — type_name() does collapse every list variant to "list", the message is self-contradictory for exactly the likeliest wrong input, and the tests only exercise the String case with substring assertions.
Won't fix in this PR. The refusal itself is right; this changes the wording of a diagnostic, which is hygiene, and this PR is well past its fix-round budget — the last several review rounds have been findings inside the fixes.
Deferral spec so it isn't lost: replace other.type_name() with other.expr_type() in map_list_path_param's error arm (renders list[int] / list[bool], the same vocabulary as the surrounding comments), and add an ExprValue::ListInt case to resolved_list_path_rejects_a_value_that_is_not_a_list_of_paths asserting the full message. path_param_raw needs no change, since scalar type_name() values are distinct.
If a human reviewer wants it in this PR, say so and it's a five-minute change.
Signed-off-by: David Leong <leongdl@amazon.com>
Follow-up to #335, addressing two review comments that arrived after it merged.
LIST[PATH]landed in two different variants depending on lengthmake_listinfers a list'sExprValuevariant from its elements, and consults the hint only when the list is empty. #335 passed the declared element type as that hint, butPATHis represented as a string —coerce_from_str'sPatharm returnsExprValue::String— so the two routes disagreed:That matters because
openjd-sessionsdiscriminates on the variant when it applies path mapping, and one of its twoLIST[PATH]arms has no fallback:No
else, so an emptyLIST[PATH]never getsParam.<name>set at all and an expression referencing it fails as an unknown symbol. The other call site (session.rs:2428) has another =>arm and was unaffected.The fix is to hint with the element's representation type rather than its declared type:
Normalizing the empty case to
ListStringrather than coercing elements toExprValue::Path, becauseListStringis already what bothopenjd-sessionscall sites expect, so nothing else has to change.This is not a regression from #335. Before it the hint was
NULLTYPE, so an emptyLIST[PATH]was aListList([], NULLTYPE)— equally not aListString, sosession.rs:2366skipped it just the same. #335 changed which non-matching variant it was; this makes the empty case actually work there, which it never has.A side effect worth noting:
make_list'sTypeCode::Pathbranch is no longer reached from here, so its hardcodedPathFormat::host()can no longer quietly overridepath_options.path_format.The consumer no longer depends on the producer agreeing
The above makes the producer always emit the variant the consumer matches, but
openjd-sessions'resolved_symtabarm was anif let ExprValue::ListString(..)with noelse, andListPathis reachable there —build_symbol_tableaccepts both variants for aLIST[PATH]parameter, andJobParameterValueis public, so a caller constructing one directly bypasses JSON coercion entirely.Because
Param.*forPATH/LIST[PATH]is deliberately excluded from the template-scope symtab, that arm is the only place the symbol is set, so the failure mode is a missing symbol rather than a wrong value: any expression referencing the parameter fails as an unknown symbol. It is now amatchwithother => self.apply_path_mapping_to_value(other), matching the from-scratch branch rather than diverging from it.resolved_list_path_sets_param_for_every_variantdrives the resolved branch withListString,ListPathand an emptyListStringand asserts the symbol exists in each case. Reverting just the fallback fails it withListPath: Param.Paths was not set at all.The representation is now in the spec
specs/model/parameters.mditem 4 covered onlyRawParam, while the thing sessions depends on is the parameter value. It now states that aLIST[PATH]value is aListStringwhatever its length, thatParam.Xbecomeslist(PATH)only at session scope, and that consumers should still handle other variants sinceJobParameterValueis public. AGENTS.md requires the spec and code to line up before committing, and a private doc comment was the wrong home for a cross-crate contract.list_element_typefailed open on a new list typeThe
_ => return Nonewildcard meant a seventh list type (sayLIST[LIST[STRING]]) would compile, fall through to the scalar arm, and have its elements built from their JSON types — the exact bug #335 fixed, reintroduced with no compile error and no test failure.every_list_type_has_an_element_typeenumerates the six types by hand, so it cannot catch a variant nobody added.Now matched exhaustively, as is
representation_expr_typenext to it — a catch-all there would silently return the declared type for the next parameter type whose representation differs from it, which is the same variant split.JobParameterTypeis#[non_exhaustive], but that only forces a wildcard on downstream crates, so withinopenjd-modela new variant is a hard error here.Tests
a_list_lands_in_the_same_variant_whether_or_not_it_is_emptycomparesstd::mem::discriminantof the populated and empty results across all six list types. Onad94e73it fails with the message above;empty_list_is_accepted_for_every_list_typealso fails there, since it previously pinnedLIST[PATH]toListPath— pinning the inconsistency rather than catching it. Both pass here.cargo test --workspace(7231 tests),cargo fmt --all -- --checkandcargo clippy --workspace --all-targetsare clean.Still open from that review
A third comment noted that already-typed list inputs bypass the element coercion entirely:
ExprValue::ListInt([1, 2])for aLIST[FLOAT]is rejected withCannot coerce list[int] to LIST[FLOAT], whileExprValue::String("[1, 2]")succeeds, even though scalarINT→FLOATwidening is supported. #335 scoped its doc comment to say the typed path is deliberately strict, so this is documented rather than silent, and the behaviour is unchanged from before #335. Not addressed here because widening it is a new capability across every list type rather than a fix — happy to do it if wanted.By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.