feat: EXPR and WRAP_ACTIONS v0 model parity via Rust bindings#318
Conversation
…e v0 Python model to the openjd-rs crates (openjd-expr, openjd-model) and bring the 2023-09 model to RFC 0007 (EXPR) and RFC 0008 (WRAP_ACTIONS) parity: - Rust-backed openjd.expr module (PyO3): expression parsing/evaluation, typed symbol tables (build_symbol_table), FormatString resolve_value, RangeExpr/IntRange, path mapping, and evaluation profiles with the spec's operation/memory limits. - WRAP_ACTIONS: onWrapEnvEnter/onWrapTaskRun/onWrapEnvExit environment actions with all-or-nothing validation, per-hook WrappedAction.* / WrappedEnv.Name / WrappedStep.Name template-variable injection, and create_job instantiation coverage. - EXPR: script-level let bindings on step and environment scripts, typed parameter definitions, creation-scope validation of variable references (including cancelation-mode format strings), spec string coercion and value constraints, timeout format-string scope checks. - Operation-limit error expectations pinned to the evaluator's pre-charged allocation counts (openjd-rs OpenJobDescription#272/OpenJobDescription#276). Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| # string is parsed as a range expression. | ||
| from .._internal._create_job import resolve_whole_field_typed_list | ||
|
|
||
| if resolve_whole_field_typed_list(self.range, symtab) is not None: |
There was a problem hiding this comment.
The new §3.4 1024-element range cap is bypassed on the typed whole-field resolve path.
When a range is a whole-field expression that resolves to a list (e.g. range: "{{Param.Values}}" with a LIST[*] job parameter), _get_range_task_param_type returns RangeListTaskParameterDefinition and the field is instantiated to the native list via resolve_whole_field_typed_list. But RangeListTaskParameterDefinition.range is typed TaskRangeList (list[...], no max_length, line 1176/1213), and no length check runs on this path:
validate_task_param_range_list_lenonly runs at parse time, and the template value here is aRangeString(format string), so it takes the deferred format-string branch and skips the list-length check.- The
IntRangeExprlength check only applies when the string parses as a range expression, not when it resolves to a literal list.
So an EXPR LIST parameter carrying >1024 values yields a task-parameter range of >1024, while an equivalent literal list-form range is correctly rejected. This is inconsistent with the limit this PR is introducing, and openjd-rs enforces max_task_param_range_len during range instantiation. Consider re-applying validate_task_param_range_list_len to the resolved list in resolve_whole_field_typed_list’s callers (or at instantiation of RangeListTaskParameterDefinition).
Review fixes on top of the EXPR/WRAP_ACTIONS parity commit: - Re-comment the local-dev [patch.crates-io] block and regenerate Cargo.lock against crates.io so CI and published wheels resolve the openjd-* crates from the registry (fixes the all-red CI). - Enforce the §3.4 1024-value task-parameter range cap at job creation for expression-driven ranges (typed whole-field LIST resolution and RANGE_EXPR strings), matching openjd-rs's resolve-time checks. Conformance fixtures added to openjd-specifications. - Cache the EXPR engine symbol table and host-context profile on SymbolTable, keyed on a mutation version (versioned expr_types dict, property setters, __ior__; pickle via __reduce__). Removes the per-expression Rust-boundary rebuild: 8.6 -> 1.9 ms per 20-arg action. - Compute RFC 0006 typed whole-field resolution exactly once per field; the create_as callable now receives the typed-values mapping so the target-model decision and field value cannot disagree. - Single-source the whole-field check (FormatString.whole_field_expression), the let-binding evaluation loop (openjd.model.evaluate_let_bindings, parse-memoized, shared with openjd-sessions), and the Int/ChunkInt range helpers; extract _resolve_path_default_2023_09 and _compute_typed_resolutions to reduce branch counts; narrow the typed resolution's error swallow to ValueError. Note: this also makes three spec-conformant validation tightenings from the base commit explicit — onEnter required without WRAP_ACTIONS, the §3.4 range caps, and §7.1 name lengths — all matching openjd-rs; they newly reject previously-accepted templates, so the change set is not purely additive for template acceptance. Tests: 5342 passed (12 new: cache invalidation contract incl. |= / reassignment / pickle, single-evaluation invariant); mypy/black/ruff clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
CI resolves openjd-expr/model/sessions from crates.io (the local [patch.crates-io] override stays commented out), but the previous pins (expr 0.2.0, model 0.3.1, sessions 0.3.2) predate the CancelationMode::DeferredMode surface and the FormatString-typed embedded-file filename the bindings use, failing every build with E0599/E0308. Bump to the published releases that carry that surface: - openjd-expr 0.2.1 - openjd-model 0.4.0 - openjd-sessions 0.4.0 Cargo.lock is regenerated against the registry (source + checksum entries verified), and THIRD-PARTY-LICENSES.txt is regenerated via scripts/check_third_party_licenses.sh --update so the lock-driven license check passes; it was also stale for earlier transitive lock updates (tinyvec, futures, wasm-bindgen, granit-parser, ...). No binding code changes were needed: the crate compiles cleanly against the published API (fmt, clippy -D warnings, test, doc all green), the full Python suite passes (5342 passed, 24 skipped, 3 xfailed), and the sessions-repo let-bindings/wrap smoke tests pass (36 passed). Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
…Description#313 Port the test-only additions of PR OpenJobDescription#313: None-value rendering in format strings and nodes (RFC 0005 null rendering) and the TestCancelationRoundTripForwarding cases for wrap-hook cancelation forwarding (RFC 0008). These pin behavior whose production code OpenJobDescription#318 already carries, so OpenJobDescription#313 can close as superseded. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| # `range: "{{Param.Values}}"` with a LIST[*] parameter) are subject to | ||
| # the same limit as literal template ranges — matching openjd-rs's | ||
| # resolve-time checks in create_job (ranges.rs). | ||
| return validate_task_param_range_list_len(value) |
There was a problem hiding this comment.
The range-length cap was correctly propagated to the instantiation target RangeListTaskParameterDefinition so that ranges produced by RFC 0006 typed whole-field resolution (range: "{{Param.X}}") are checked at resolve time. But the PATH-specific empty-string check (_validate_no_empty_path_values, §3.4.2) was not propagated here.
A PATH task parameter whose range is a whole-field expression against a LIST[PATH] job parameter (e.g. range: "{{Param.Paths}}") instantiates directly into RangeListTaskParameterDefinition. PathTaskParameterDefinition._validate_no_empty_path_values runs only at decode time, where the value is still a FormatString with expressions and is therefore skipped ("resolve later"). LIST[PATH] element validation (_check_string_item) does not reject empty strings either (no implicit minLength). So an empty path can reach the resolved range, which §3.4.2 says is never a valid path on any OS — the same resolve-time gap the length cap was added here to close, but for empties.
Consider adding an equivalent empty-string check to RangeListTaskParameterDefinition (guarded on type == PATH) to match openjd-rs’ resolve-time checks.
Raise coverage over the 94% gate (93.42% -> 94.39%): - test_let_bindings.py (new): the shared v0 evaluate_let_bindings loop — chained bindings, typed value preservation (float rendering fidelity, let-bound path property access), malformed-binding skipping, ValueError naming the failing binding, RHS parse memoization (cache_info), and parse errors propagating uncached. - test_symbol_table_cache.py: every remaining _VersionedDict mutator (__delitem__, setdefault, pop, popitem, clear) bumps the version and invalidates the engine-table cache. - test_symbol_table.py: expr_host_rules copied by the copy constructor and union; __repr__. - test_expr_node.py: symbol-free static validation errors at parse time, ExprNode.__repr__, InterpolationExpression.evaluate_value error wrapping and typed result, FormatString.resolve_value whole-field typed resolution, fallback, and error paths. - test_validator_functions.py (new): missing-context internal errors, float ge constraint, and validate_list_field error aggregation. - test_step_dependency_graph.py: max_indegree / max_outdegree. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Dependency resolution now pulls annotated-types 0.8.0; regenerated via scripts/check_third_party_licenses.sh --update and verified with the script's check mode. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Brings in 29940e9 (chore(dependabot): group dependabot PRs). Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
- Use pytest's tmp_path instead of Path("/tmp") in the single-evaluation
regression test: WindowsPath("/tmp") has no drive letter, so
is_absolute() is False and preprocess_job_parameters rejects it on the
Windows CI matrix.
- Bind mutating expressions to variables before asserting in the
_VersionedDict version-bump tests (CodeQL py/side-effect-in-assert).
- Define _VersionedDict.__eq__ (plain dict equality; the owner backref is
bookkeeping, not value state) and explicit __hash__ = None
(CodeQL py/missing-equals).
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
…fixes
Three fixes:
1. Defer run-time-resolvable action fields to run time, matching
openjd-rs (job/create_job/instantiate.rs clones timeout and cancelation
unresolved into the Job; the session resolves them right before the
action runs). Previously Action.timeout and the cancelation classes'
notifyPeriodInSeconds were in resolve_fields, so create_job resolved
them at job creation. A wrap hook forwarding
"{{WrappedAction.Timeout}}" or
"{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}" (RFC 0008
round-trip forwarding) decoded cleanly but crashed create_job with
"Undefined variable" because those symbols only exist at run time.
The fields now carry their raw FormatStrings through job instantiation;
validation still happens at TEMPLATE scope at decode time (unchanged,
matching openjd-rs format_strings.rs).
Deliberate behavior change: a plain FEATURE_BUNDLE_1 template with
timeout "{{Param.X}}" now carries the FormatString into the Job instead
of resolving it at creation — resolution moves to the session, as in
openjd-rs. The sessions runtime already resolves FormatString
timeouts/notify periods at run time (_resolve_action_timeout,
resolve_effective_cancelation).
Fixes the wrap-cancelation-roundtrip-job-environment conformance
fixture, which failed at create_job for exactly this bug.
2. Fix a typed/untyped cache-key collision in symtab_to_expr_values:
priming the engine-table cache with types=None on a symbol table whose
expr_types is non-empty cached an untyped table that a later typed call
at the same mutation version was served, losing the type coercions.
The cache condition is now strict: cacheable iff types is the symtab's
own expr_types, or types is None and the symtab has no expr_types.
3. Module-level Path import in symbol-table cache tests (ruff
F821/F401), retained from the previous revision of this commit.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
…n and wrap hooks
Fix three review findings on the run-time-deferred timeout/cancelation
work:
1. Jobs carrying deferred FormatStrings were unpicklable and
un-deepcopyable. FormatString.__new__ requires a keyword-only parsing
context, but str.__getnewargs__ supplies only the value, so
pickle.loads, copy.copy/deepcopy, and model_copy(deep=True) raised
TypeError - and subclasses that default the context (ArgString,
CommandString) either failed pickling on the engine-backed parse tree
or re-parsed EXPR-grammar strings with the legacy parser. FormatString
now snapshots the parse-relevant context (spec revision + extensions)
at construction and implements __getnewargs_ex__ (reconstructing
through __new__ with a context that reproduces the original grammar)
plus __getstate__ returning None (the parse tree is rebuilt, never
serialized). EXPR strings round-trip with identical typed evaluation.
2. SimpleAction validated timeout/cancelation at the ambient TASK scope
while the identical desugared Action form validates them at TEMPLATE
scope. Add the same _template_field_scopes to SimpleAction, so
e.g. a bash-sugar timeout referencing Task.Param.* is now rejected
exactly like the script-form equivalent.
3. Wrap-hook timeout/cancelation could reference WrappedEnv.Name /
WrappedStep.Name because all per-hook symbols were injected at
TEMPLATE scope. openjd-rs validates hook timeout/cancelation against
template symbols + WrappedAction.* only. Split the injection:
_template_field_inject keeps WrappedAction.* (all scopes) and the new
_template_field_inject_session carries WrappedEnv.Name /
WrappedStep.Name at SESSION scope, so hook command/args still accept
them but timeout/cancelation reject them.
4. §3.4.2 empty-PATH propagation (flagged by the automated PR review):
PathTaskParameterDefinition rejects literal empty-string range items
at parse time, but RangeListTaskParameterDefinition - the model that
RFC 0006 typed whole-field resolution instantiates ranges into - had
no such check, so `range: "{{Param.Paths}}"` with a LIST[PATH] job
parameter containing "" flowed into the instantiated Job. Add the
matching validator on the create-time target model (PATH only;
STRING ranges legitimately allow ""), mirroring openjd-rs's
resolve-time check in create_job (ranges.rs).
Also reword three stale comments claiming create-time resolution (the
fields validate at template scope but resolve at run time), and hoist
redundant function-local imports in the FB1 and wrap-actions tests.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The typed-range positive-polarity test asserted the literal POSIX strings '/a' and '/b', but PATH values resolved through the EXPR surface are separator-normalized to the host's native form (the documented Path invariant in openjd-rs value.rs), yielding '\\a' and '\\b' on Windows. Assert the platform-appropriate expected values, following the existing convention in test_lists.py. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
…dation
Three parity fixes against the openjd-rs reference and the spec, from an
independent external review (all three confirmed with two-sided repros):
- Typed whole-field range resolution now preserves engine element type
variants and enforces element/target agreement via a per-model coercion
hook (JobCreationMetadata.typed_resolve_coerce), mirroring openjd-rs's
per-variant checks in create_job (ranges.rs): INT ranges accept only int
elements ("Expected int in range, got bool"), FLOAT accepts int|float,
and STRING/PATH ranges take each element's spec display form (bool ->
true/false, nested list -> "[1, 2]"). Previously the engine list was
unwrapped with .item(), so a LIST[BOOL] parameter silently produced 1/0
task values that the Rust implementation rejects.
- PATH and LIST[PATH] symbol contracts now follow RFC 0005 "Job Parameter
Types" and Template Schemas 2.12/7.3.1: LIST[PATH] gets the scalar-PATH
scoping (processed Param.* at SESSION scope only; RawParam.* at TEMPLATE
scope), create_job no longer seeds a template-scope Param.* for
LIST[PATH], and RawParam.* for path types is typed string/list[string]
(Rust: build_template_scope_symtab, parameters.rs build_symbol_table).
Fixes both the false accept ("{{ Param.Paths[0].name }}" in a job name)
and the false reject ("{{ RawParam.File.upper() }}").
- Type-aware expression validation is now threaded per defining model
through the traversal (ScopedSymtabs.types, populated from each
definition's sibling `type` field with raw-variable string semantics via
TemplateVariableDef.raw), replacing the single root-level job-parameter
map. Task.Param.*/Task.RawParam.* references are now type-checked, with
per-step separation for same-named task parameters of different types,
matching openjd-rs's build_task_scope_symtab; conflicting merged types
drop to name-only validation rather than last-writer-wins. CHUNK[INT]
stays untyped (deliberate: Python's session runtime skips it; Rust types
it RANGE_EXPR — parity question tracked in a code comment).
Tests: 37 new/updated across test_typed_symbol_contracts.py and
test_create_job_expr_params.py (element matrix, scope contracts, per-step
localization, raw-string method access, name-only fallback preservation).
Suite: 5437 passed; coverage 94%; mypy/black/ruff clean; sessions suite and
conformance spot-runs (2.12/2.13/2.16/3.4 fixtures) unchanged.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
|
Still got a Windows test failing: |
Two parity fixes from external review feedback, both confirmed with
two-sided repros against openjd-rs:
- Runtime-injected built-in symbols now carry static EXPR types
(_BUILTIN_SYMBOL_EXPR_TYPES, applied at every injection site and
threaded into `let`-binding validation), mirroring openjd-rs's typed
validation symtabs (format_strings.rs build_task_scope_symtab and
add_wrapped_action_scope, including the nullable int?/string? unions
for the WrappedAction forwarding symbols). Previously expressions such
as "{{ Job.Name + 1 }}", "{{ Session.WorkingDirectory + 1 }}", and
"{{ WrappedAction.Command + 1 }}" passed decode, survived create_job,
and only failed on a worker; Rust rejects them at check. Valid usage
(string concat, path properties, the RFC 0008 round-trip forwarding
fields) is unaffected; expressions touching untyped names keep the
name-only fallback.
- Submitted values for EXPR-typed job parameters are now coerced from
their string forms, mirroring openjd-rs's coerce_from_str
(job/create_job/parameters.rs): BOOL accepts true/false, 1/0, yes/no,
on/off (case-insensitive) and LIST[*] accepts JSON strings — the
public input type is dict[str, str], so string forms must work.
Previously BOOL "no" was stored verbatim and "[1,2]" was rejected
with "value must be a list" while the Rust CLI accepted both. Native
values still pass through; error messages match the Rust shapes.
One existing test updated: test_expr_param_constraints.py pinned the
pre-coercion rejection message for a string list value; split into
string-input (JSON-coercion error) and native-input (list-type error)
cases.
Tests: 19 new (test_builtin_symbol_types.py, test_expr_param_coercion.py).
Suite: 5457 passed; coverage 94%; mypy/black/ruff clean; sessions suite
against this tree unchanged (2 known timing flakes only, pass in
isolation).
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The typed-range positive test expected Windows separator normalization,
a leftover from when its fixture used the processed {{Param.Items}}
reference. The LIST[PATH] scope fix switched it to {{RawParam.Items}},
and raw values are the original unmapped strings (RFC 0005) — no
path-separator normalization applies on any platform. Expect the
verbatim values everywhere.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| @property | ||
| def expr_host_rules(self) -> Optional[list[Any]]: | ||
| return self._expr_host_rules |
There was a problem hiding this comment.
This returns a reference to the _expr_host_rules list so callers can append to it directly, bypassing the bump_version stuff. Should we return a read-only view or copy from this getter instead?
Similar thing for expr_types
TL;DR
This PR makes the pure-Python (v0) 2023-09 model speak the same EXPR (RFC 0007) and WRAP_ACTIONS (RFC 0008) language as the Rust reference implementation. Templates can now use typed expressions end-to-end (
range: "{{Param.Values}}"becomes a real list, not a string), wrap hooks can forward the wrapped action's timeout and cancelation (mode: "{{WrappedAction.Cancelation.Mode}}"decided at run time), and the expression engine is the Rust one, reached through PyO3 bindings — so Python and Rust can no longer silently disagree. It is the base of the RFC 0008 stack: openjd-sessions-for-python #333 and openjd-cli #230 build on this surface.Everything new is extension-gated. Three validation tightenings (listed below) are the only behavior changes for existing templates, and each one matches both the spec and openjd-rs.
What's in it
Deferred cancelation mode (FEATURE_BUNDLE_1). A cancelation
modethat is a format string becomes a third union shape,CancelationMethodDeferred, mirroringCancelationMode::DeferredModein openjd-rs.modeis normally the schema selector — the parser needs it to pick an object shape — but a forwarded value like{{WrappedAction.Cancelation.Mode}}only exists at run time, so the shape decision is deferred: the parser accepts the format string (a callable pydanticDiscriminatorroutes on"{{"), static validation still checks the expression, and the runtime resolves it right before the action runs.Typed whole-field resolution (RFC 0006).
FormatString.resolve_valueand thetyped_resolve_fieldsjob-creation metadata keep the engine's typed value when a field is a single whole-field{{ ... }}expression — lists stay lists, null stays null. Task-parameter ranges use this so aLIST[*]job parameter instantiates to the literal value list; the resolution is computed exactly once per field and shared with the target-model decision so the two can never disagree.Per-hook WRAP_ACTIONS variables.
WrappedAction.*(includingCancelation.Mode/Cancelation.NotifyPeriodInSeconds),WrappedEnv.Name, andWrappedStep.Nameare injected per wrap hook via new_template_field_injectmetadata — visible only inside their hook's action, replacing the first-cut script-wide injection (referencing them inonEnteris now rejected).timeout/cancelationvalidate at template scope via_template_field_scopes, since they resolve at job creation, while still allowing round-trip forwarding of the wrapped action's values.EXPR runtime plumbing. Step-level
letbindings evaluate at job creation (extends_symtab) and are preserved onStep.letfor the runtime;Job.Nameis seeded per RFC 0007 §7.3.1; URI-form PATH values (s3://...) are preserved verbatim under EXPR;openjd.model.evaluate_let_bindingsis exported (parse-memoized) and consumed by the sessions runtime so the binding-evaluation policy is single-sourced.Performance. The EXPR engine symbol table and host-context profile are cached on
SymbolTable, keyed on a mutation version (a versionedexpr_typesdict, property setters,__ior__, pickle via__reduce__). A session action resolving ~20 expressions previously rebuilt the full engine table across the Rust boundary per expression; now it builds twice per action (once beforeletbindings, once after): 8.6 → 1.9 ms per 20-arg action on a 125-symbol table.Validation behavior changes (not purely additive)
These newly reject previously-accepted templates; all three match openjd-rs and the spec:
onEnteris required for an environment script unless WRAP_ACTIONS is declared (Template Schemas §3.5).namefields are capped at 64 characters without FEATURE_BUNDLE_1 (§7.1; 512 with it), anddecimalsrequires a SPIN_BOX control (§2.4).Commit guide
openjd-expr 0.2.1/openjd-model 0.4.0/openjd-sessions 0.4.0(the published releases containingDeferredMode) + regenerated lock and THIRD-PARTY-LICENSESHow was this change tested?
|=/reassignment/pickle, single-evaluation invariant, deferred-cancelation round-trip forwarding, None-rendering).mypyclean (106 files);black/ruffclean.-D warnings/fmt/doc).Supersedes #313 (all of its production code is contained here, verified hunk-by-hunk; its tests are ported in the final commit).
Review guide
Review sign-off guide — openjd-model-for-python PR #318
PR: #318
Branch:
python-rs, tipca44376· Merge: squash (auto-merge armed)Verification at tip: 5437 tests passed / coverage 94% / mypy, black, ruff,
clippy
-D warningsall clean · conformance 2023-09: 1159/1162 (the 3remaining failures need sessions-runtime work outside this PR)
1. Problem statement
Open Job Description has two implementations of one specification: the Rust
reference (openjd-rs) and
this pure-Python "v0" model. Before this PR, the Python 2023-09 model had no
support for the EXPR expression language
(RFC 0007)
or environment wrap actions
(RFC 0008),
and its format-string evaluator was a hand-written Python implementation that
could silently drift from the Rust engine.
Concretely, that meant: no typed values (
{{ true }}rendered as Python's"True"instead of the spec's"true"), noLIST[*]/BOOL/RANGE_EXPRjob-parameter types, no
letbindings, no wrap-hook template variables, andno way for a wrap hook to forward the wrapped action's timeout or cancelation.
The downstream RFC 0008 stack (openjd-sessions-for-python
#333,
openjd-cli
#230) needs all
of this surface.
This PR closes that gap by binding the Python model to the same Rust crates
the reference uses (
openjd-expr0.2.1,openjd-model0.4.0 via PyO3 inrust-bindings/), so both implementations share one expression engine — andthen aligning every model-level behavior the review process found diverging.
2. What we fixed, and what it mirrors in openjd-rs
Every behavioral decision traces to a spec section and a Rust function. The
review process (multi-aspect agent passes + an independent external model
review + the repo's automated PR review) confirmed each divergence with
two-sided repros before it was fixed.
LIST[*]andRANGE_EXPR), not just parse timejob/create_job/ranges.rs—max_task_param_range_lenchecked on every resolve pathranges.rs~L379 (is_path && s.is_empty())modeas a third union shape, decided at run timetemplate/actions.rs—CancelationMode::DeferredModeserde routing on"{{"timeout/notifyPeriodInSecondscarried unresolved into instantiated Jobs (run-time resolution) — makes wrap-hook forwarding ({{WrappedAction.Timeout}}) workjob/create_job/instantiate.rs:253-274clones them unresolved; symtab filter preserves their symbolsWrappedAction.*/WrappedEnv.Name/WrappedStep.Nameinjection; hooktimeout/cancelationsee onlyWrappedAction.*template/validate_v2023_09/format_strings.rs:1173-1195onEnterrequired without WRAP_ACTIONS; §7.1 name lengths; §2.4 decimals rule (validation tightenings)validate_v2023_09/structure.rs:383-406;validate_uitrue/false,"[1, 2]")ranges.rsresolve_int_range/resolve_float_range/resolve_string_range(to_display_string)Param.*is session-scope only;RawParam.*is template-scopestring/list[string]format_strings.rsbuild_param_symtab/build_template_scope_symtab;parameters.rsbuild_symbol_tableTask.Param.*/Task.RawParam.*type-checked per step (raw PATH → string), same-named params across steps independentformat_strings.rsbuild_task_scope_symtab(~160-230)letat job creation +Step.Name/Job.Nameseeding; URI-form PATH values preservedinstantiate_stepper-step symtab;uri_path::is_uriConformance backstop: 4 new fixtures were added to the shared suite in
openjd-specifications#155
(§3.4 create-time caps, both polarities; §7.3.1 Step.Name in step
environments), each validated against both implementations, so fixes 1
and the Step.Name contract can never silently regress.
Engineering fixes that rode along (not spec-behavioral): a
mutation-versioned cache for the engine symbol table (8.6 → 1.9 ms per
20-arg action, 4.4x), pickle/deepcopy support for Jobs carrying deferred
FormatStrings (parse-context snapshot in
__getnewargs_ex__), singleevaluation of typed range expressions, and the shared parse-memoized
openjd.model.evaluate_let_bindingsconsumed by the sessions runtime.3. The code changes, with call stacks
Read the diff in this order — each layer builds on the previous one.
3.1 Foundation: the Rust boundary and the cache
Files:
rust-bindings/**,Cargo.toml/Cargo.lock,src/openjd/model/_symbol_table.py,_format_strings/_expr_support.py,_format_strings/_nodes.pyThe bindings pin the published crates (
openjd-expr 0.2.1,openjd-model 0.4.0) — no local path patches.SymbolTablecarries amonotonic
_versionbumped by every mutation (__setitem__, a versionedexpr_typesdict including|=/reassignment, theexpr_host_rulessetter),and the expensive engine-table build is cached per version:
Key insight: cache entries are written only after a successful Rust build
(exception-safe), and a session action resolving ~20 expressions builds the
engine table twice (before/after
letbindings) instead of 20+ times.3.2 Job creation: typed resolution and deferral
Files:
_create_job.py,_internal/_create_job.py,_types.py,v2023_09/_model.py(task-parameter classes,StepTemplate)Key insight: the typed resolution result feeds both the target-model decision
and the field value, so they cannot disagree; and the deferral in step 9 is
what makes
timeout: "{{WrappedAction.Timeout}}"create successfully — thesymbol only exists when the session runs the hook.
3.3 Template validation: scopes and types
Files:
_internal/_variable_reference_validation.py,v2023_09/_model.py(
EnvironmentActions,Action,SimpleAction, parameter definitions)Key insight: types ride the same per-field threading as the symbol names, so
two steps defining a task parameter
Fileas PATH and STRING validateindependently (error localized to the offending step only) — exactly Rust's
per-step symtabs. Conflicting merged types drop to name-only validation,
never last-writer-wins.
3.4 Deferred cancelation round-trip (the RFC 0008 headline)
4. Sign-off checklist
unwrap/expect/panic!on user data inrust-bindings/**; every Rust error surfaces asDecodeValidationError/ExpressionError/FormatStringError(audited three times; spot-check
template_types.rsDeferredMode arms).behaviors were verified by running both implementations, and the
conformance suite passes 1159/1162 (3 known sessions-side gaps).
(§2 row 6) and the FB1 deferral (row 4 — instantiated Jobs may carry
FormatString timeouts; pickle/deepcopy verified) are intentional and
Rust-matching. They newly reject/change previously-accepted inputs.
__setitem__,expr_typesitem-set/update/|=/reassignment,expr_host_rulesassignment; entries written only after successfulbuilds; pinned by
test_symbol_table_cache.py.symbol contracts, per-step typing, wrap forwarding; coverage holds the
94% gate.
Known-and-accepted (not blockers): 3 wrap-cancelation conformance
fixtures need sessions-runtime eager validation (tracked for #333's repo);
serialized-Job re-parse requires callers to re-supply extensions (FB1 fields
were ints before);
evaluate_let_bindingsraisesValueError(notExpressionError); CHUNK[INT] task params stay untyped at validation(Python runtime skips them; Rust types them RANGE_EXPR — noted in code).
Companion documents: deep findings log
2026-07-23-pr318-cr-pass.md;stack-wide review history
2026-07-22-review.md; call-stack artifacts underopenjd-model-for-python/review/call-stacks/.