Skip to content

feat: accept a resolved symbol table on the v0 session - #357

Merged
leongdl merged 9 commits into
OpenJobDescription:mainlinefrom
leongdl:feat/v0-resolved-symtab
Aug 25, 2026
Merged

feat: accept a resolved symbol table on the v0 session#357
leongdl merged 9 commits into
OpenJobDescription:mainlinefrom
leongdl:feat/v0-resolved-symtab

Conversation

@leongdl

@leongdl leongdl commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Supersedes #356, which is closed. That PR added a source-level
extra_let_bindings channel for the same values; the decision is that the
service's resolved symbol table is the single authoritative source, so this PR
removes that channel again. The history here contains the add and the removal;
the net diff is the table work alone.

What changed

The v0 Session gains resolved_symtab: Optional[SerializedSymbolTable] on
enter_environment, exit_environment and run_task, matching the _v1
(Rust-backed) session's parameter of the same name. Its entries seed the
session symbol table first, and the session's own values layer on top.

Why

create_job resolves a symbol table per step and environment, and the
_v1 session already accepts it. The v0 session had no way to take one, so a
caller holding a pre-resolved table could only throw it away and re-derive what
it could. Two consequences: job-template-scope let has no channel into v0 at
all, and every symbol the producer already resolved is recomputed, which is the
divergence risk a pre-resolved table exists to remove.

Layering

The contract is openjd-rs Session::build_symbol_table: the base is a base, and
runtime-local values overwrite it.

Order openjd-rs v0 (this change)
1 to_symtab(host format) same, via _resolved_base_entries
2 re-map Param.* PATH / LIST[PATH] from the session's own values existing Param.*/RawParam.* loop overwrites
3 Session.WorkingDirectory existing seeding overwrites
4 Task.* existing seeding overwrites
Job.Name rides the base constructor job_name seeds only when the base lacks it

Half of a v0 task table cannot come from a producer at all —
Session.WorkingDirectory, Task.File.*/Env.File.*, anything path-mapped are
allocated per session on the host — which is why the base is layered under them
rather than trusted wholesale.

RFC 0008 wrap hooks resolve against the base too, on both channels openjd-rs
uses: the inner entity's base (its action's table) and the wrap environment's
own frozen enter-time base. Task.Param.* stays out of hook scope, as before;
the service never copies task parameters into a base, so seeding it cannot leak
them.

Behaviour notes

  • A base whose entries fail validation fails the action through
    _fail_action_before_start, the same shape extra_let_bindings failures use.
    It does not raise out of the public API.
  • Omitting the parameter changes nothing.
  • Values arrive as engine ExprValues and keep their types, so arithmetic on a
    base integer works. evaluate_let_bindings already stores ExprValues in
    these tables, so this is not a new kind of value.

Extension purity

SerializedSymbolTable is imported under TYPE_CHECKING only, and the one
runtime import (PathFormat) sits inside the conversion helper, reachable only
when a caller passes a table and therefore already holds a native object.
test/openjd/test_import_purity.py stays green.

Tests

test_session_resolved_symtab.py covers a base-only symbol resolving in an
action, type fidelity, Job.Name precedence, runtime locals overriding the
base, PATH re-mapping over the base, both environment entry points, an invalid
base failing cleanly on run_task and on both environment paths, script-scope
let shadowing the base, and omitting the parameter.
test_wrap_scope_isolation.py gains nine covering hook scope on all three hook
paths, the wrap environment's own base, and negative controls that
Task.Param.* and the wrap environment's base still do not reach the wrapped
action.

Removing extra_let_bindings deleted eight tests whose only subject was that
parameter. Tests that used it to set up step context were rewritten to deliver
the same context through a resolved table, so the properties they pinned are
still pinned — including the wrap-scope isolation cases, both of which were
mutation-checked after rewriting.

Verification:

  • Mutation-checked. Every load-bearing behaviour was reverted in turn and a
    named test failed each time, including two mutants that initially survived:
    seeding the base after the step-context replay instead of before, and building
    hook scope by copying the inner table instead of seeding the base. Tests were
    added for both.
  • Full sessions_v0 suite: 928 passed, against 926 before this branch. The
    same six failures occur before and after in
    test_subprocess.py::TestLoggingSubprocessSameUser::test_run_gracetime_when_process_ends_but_grandchild_uses_stdout;
    they are timing-sensitive and unrelated.
  • ruff check, black --check and mypy clean.
  • Exercised end to end on a real Deadline Cloud fleet through the consuming
    worker change: 31 of 32 EXPR/jobs conformance cases pass, including all five
    step-scope let cases. The one failure is an unrelated worker-side defect with
    a served FEATURE_BUNDLE_1 simple action, tracked separately.

Not verified: Windows. The host path-format branch (os.name == "nt") and the
Windows arm of the path-mapping test were not exercised; this was developed on
macOS.

Related

Consumed by aws-deadline/deadline-cloud-worker-agent#1077. Supersedes #356.

`Session.run_task` had no channel for step-template-scope `let` bindings
(RFC 0005 §3.6), so a step script referencing one failed at resolve time with
`Undefined variable`. `enter_environment` has accepted `extra_let_bindings`
since OpenJobDescription#333; this adds the same parameter to `run_task`.

Why the gap was invisible: step-scope bindings resolve at job instantiation,
and `StepTemplate.resolve_syntax_sugar` folds them into the script's own `let`
so they survive into the `Job`. Any caller holding a `Job` from `create_job` —
openjd-cli, and every test in this repo — therefore never sees the problem. A
caller handed an *un-instantiated* `StepTemplate`, where `let` and `script.let`
are still separate fields, has no way to deliver them at all. That is the
Deadline Cloud worker agent, which receives one from the service; the symptom
there was 32 conformance execution cases failing with `Undefined variable` on
names their templates plainly define.

Ordering matches `enter_environment` exactly: seeded after `Step.Name`, so a
step binding may reference it, and before path mapping and env-var evaluation,
so both see a complete table. Script-scope bindings shadow step-scope ones
rather than colliding, for free — `StepScriptRunner` evaluates `script.let`
into a child table sourced from the session-scope one. Wrap-hook isolation is
unaffected: `_build_wrap_hook_scope` builds a fresh table, so step bindings
reach a wrapped `onRun` but never the hook, which is what RFC 0008 requires.

A failing binding fails the action through `_fail_action_before_start` rather
than raising out of the public API, the same contract `enter_environment`
holds.

The parameter is additive and optional, so existing callers are unaffected.

6 tests, mutation-checked 3 of 3 caught: dropping the apply, seeding before
`Step.Name`, and dropping the try/except. Coverage includes the negative
control that omitting the parameter changes nothing, and that a script-scope
binding can build on a step-scope one — the shape the failing fixtures use.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The service resolves a per-step symbol table at CreateJob (Param.*,
RawParam.*, Job.Name, Step.Name, step-level `let` values) and serves
it as resolvedSymbolTable, but only the Rust-backed _v1 session could
accept it -- the default v0 Python runtime had no channel for it, so
job-template-scope `let` never reached v0 and every service-resolved
symbol was re-derived or missing on the default runtime.

Mirror the _v1 surface: enter_environment, exit_environment, and
run_task gain resolved_symtab (a SerializedSymbolTable). Its entries
seed the session symbol table first and the session's own values
layer on top, matching the openjd-rs layering: Session.WorkingDirectory
and the path-mapped Param.* values overwrite the base, Job.Name from
the base wins over the constructor value, and script-scope `let`
still shadows base symbols. A base that fails entry validation fails
the action through the normal callback path instead of raising out of
the public API.

Extension purity holds: the only runtime import of openjd.expr is
inside the conversion helper, reachable only when the caller already
holds a native SerializedSymbolTable.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
exit_environment removed the environment from tracking but popped its
stored Step.Name and extra `let` bindings only after the resolved
symbol table deserialization and path mapping failure branches. Either
failure stranded both entries permanently: identifiers are allocated
per-enter, so no later exit can reach them.

Move both pops up beside the wrap embedded-file record pop, so every
failure branch below drains them. The replay into the symbol table
stays where the table exists. The deserialization branch's comment
claimed it matched the extra-lets guard, which pops before failing;
it now cites the _materialize_path_mapping failure instead.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The exit-side deserialization branch had no coverage: the existing
invalid-base test exits without a base. The new test enters with step
context, exits with a bad-typed base, and asserts the action fails
cleanly without raising.

It also asserts the stored step context was drained, which is what
pins the preceding drain fix — the FAILED assertions pass either way.
The drain is asserted on private dicts because the strand has no public
surface; the docstring records why it still matters, namely that
identifiers are caller-supplied and reusable, so a stale entry can
replay on a later exit.

Verified by mutation: restoring the pops below the failure branches
fails this test and only this test.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
_build_wrap_hook_scope built its table with no resolved base, so an
RFC 0008 hook could not resolve a name only the service-resolved base
defines. openjd-rs resolves a hook against the current action's full
symbol table, base included, so the same hook passes there and failed
here.

Thread the inner entity's base through from the three call sites
(enter, exit, run_task). This does not weaken the inner-to-hook
isolation the two-scope split exists for: the base a step's action
carries never holds Task.Param.*, because the service copies only
Param.*, RawParam.*, Job.Name, Step.Name and step-level `let` values
into it. Base Step.Name does become hook-visible, which is the parity
openjd-rs has.

New tests cover all three hook paths, base Step.Name visibility, and
an isolation control that fails if the hook scope is built by copying
the inner entity's table instead.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
A wrap environment's hooks resolve in its enter-time scope, and in
openjd-rs that scope is the environment's frozen resolved symbol table
merged onto the action's table. Python replayed only the step-name and
extra-`let` fallback, so a hook referencing its own step's context
delivered through the base resolved on openjd-rs and failed here.

Store each environment's converted base at enter time and seed it in
_seed_wrap_env_scope. The store happens after the deserialization
succeeds, not beside the step-name and extra-lets stores, so a failed
deserialization cannot leave a base behind. The base seeds before the
existing replay, which keeps the fallback authoritative when the two
disagree; exit drains the entry with the other two.

Tests: the wrap env's own base resolves in a later task's hook, does
not reach the wrapped action's scope, and the fallback wins over a
disagreeing base. The exit-drain test now asserts this dict too.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Trim restated wording and correct one claim: a stranded per-enter entry
was described as unreachable, but identifiers may be supplied by the
caller and reused after an exit, so a re-entered identifier would replay
stale context. That is why the drain matters.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
identifier = self._wrap_env_identifier(wrap_env)
# Values only, exactly as _symbol_table seeds a base: the EXPR types
# ride the values the engine already built.
for base_name, base_value in self._environment_resolved_bases.get(identifier, {}).items():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_seed_wrap_env_scope seeds the wrap environment's remembered base last, which inverts the layering that _symbol_table establishes.

_build_wrap_hook_scope builds hook_symtab via _symbol_table(version, resolved_base=...), where the base is seeded first and then session-derived values deliberately overwrite it — Session.WorkingDirectory, and notably the path-mapped Param.* values from processed_parameter_value(). This loop then writes the wrap env's own base entries over that result.

Since the base is documented to carry Param.*/RawParam.* and to_symtab(path_format=...) only chooses a rendering format (it does not apply this session's path-mapping rules), a wrap hook that references {{Param.SomePath}} will see the unmapped service value here, not the session's remapped one — the opposite of what test_path_param_remapped_over_base pins for the non-wrap path.

There's a secondary inconsistency: this loop writes values without touching symtab.expr_types, so for a name where _symbol_table recorded a type (e.g. a Param.* PATH) the value now comes from the base while the recorded EXPR type is the session's — value and type no longer come from the same source.

Suggest restricting this to names the session-scope table did not already define, or re-applying the session-derived overrides after seeding, so the hook scope keeps the same "runtime locals win over the base" ordering as every other scope.

# wrap_env.script.
hook_symtab = self._build_wrap_hook_scope(step_script.revision, symtab)
hook_symtab = self._build_wrap_hook_scope(
step_script.revision, symtab, resolved_base=resolved_base

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Threading the inner entity's resolved_base into _build_wrap_hook_scope punches a hole in the isolation that method exists to enforce.

_build_wrap_hook_scope's own docstring states that a wrap environment must not read "the extra_let_bindings the inner environment was entered with", and test_inner_extra_let_bindings_are_not_in_the_hook_scope pins that. But the docstring here also acknowledges the base carries "step-level let values" — so the exact same inner step-scope values now reach the hook whenever the caller supplies them via resolved_symtab instead of extra_let_bindings.

_seed_wrap_env_scope only masks the overlap: it overwrites names the wrap env's own base/lets also define, so an inner-step binding name that the wrap env does not itself define stays visible to the hook. Same for inner Step.Name when the wrap env was entered without one (the new test_base_step_name_is_hook_visible documents that as intended, but it is the mirror of test_running_step_name_is_not_in_the_hook_scope, which asserts the opposite for the non-base channel).

Net effect: the two channels for identical data have opposite isolation properties, and which one a caller uses is not something the template author controls. If parity with openjd-rs really requires the inner base to be hook-visible, it would be worth reconciling the extra_let_bindings/Step.Name isolation rules with it rather than leaving them contradictory — otherwise consider filtering the inner base down to the session-scope names (Param.*/RawParam.*/Job.Name) before seeding the hook scope.

# the interceptor is always a different, still-entered outer
# environment, and an environment that has exited can never intercept
# again.
self._environment_resolved_bases.pop(identifier, None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exit side has an asymmetry with the other two remembered pieces of enter-time context.

Step.Name and extra_let_bindings are stored at enter time and automatically replayed on exit (lines 1196-1200) precisely so "its onExit resolves in the same scope as its onEnter". The base is stored the same way in _environment_resolved_bases, but on exit it is only dropped here — never used. The exit action's base comes solely from the caller re-passing resolved_symtab=.

So an environment entered with resolved_symtab=base and exited without it silently resolves its onExit in a different scope than its onEnter: any base-only name in onExit (or in an onExit arg / embedded file) fails to resolve, while the identical value delivered through extra_let_bindings would have worked. The docstring at line 1104 turns this into a caller obligation, but nothing enforces it and the failure mode is a resolution error at exit — the worst time, since exit is the cleanup path.

Since the value is already tracked, using the stored base as the fallback when resolved_symtab is None would make the three channels behave consistently and remove the obligation:

resolved_base = stored_base if resolved_symtab is None else self._resolved_base_entries(resolved_symtab)

# matching the openjd-rs runtime's layering over the same table.
# A base entry for a name the session does not know survives.
if resolved_base:
for base_name, base_value in resolved_base.items():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path mapping is only re-applied to base Param.* entries that also appear in self._job_parameter_values.

The base seeded here is produced by to_symtab(path_format=host_format), which only chooses a rendering format — it does not apply this session's _path_mapping_rules. Mapping is re-applied further down solely by iterating self._job_parameter_values and calling processed_parameter_value(). So for any Param.X / RawParam.X the base carries but the session's job_parameter_values does not, the base's value survives untouched and reaches the action unmapped.

test_path_param_remapped_over_base covers only the overlapping case (the parameter is in both), so the divergent case is untested. Since path mapping is the mechanism that keeps a job from being handed host paths it should not see, a base-supplied PATH that bypasses it is more than a cosmetic mismatch.

Two options depending on the intended contract: either run base entries under the Param./RawParam. prefixes through apply_mapping when the session has rules, or document that the caller must pass a job_parameter_values that is a superset of the base's Param.* names (and ideally assert it) so the gap cannot open silently.

The resolved symbol table is now the single authoritative channel for
step-scope EXPR `let` values. `extra_let_bindings` was the source-string
fallback that predated it; carrying both meant two channels for the same
values, with a documented divergence where the locally re-evaluated
binding won over the value the service resolved.

Removed from `run_task`, `enter_environment` and `exit_environment`,
along with the `_environment_extra_let_bindings` tracking dict and the
replay in `_seed_wrap_env_scope`. That replay was the method's only
failure path, so its return type is now `None` and the three call sites
no longer branch on it. `apply_let_bindings` stays imported: it is still
used by `_build_wrapped_inner_scope` for inner script lets.

The environment case is safe because the service stores a step-scoped
environment with the owning step's resolved symbol table, so an
ENV_ENTER already receives `Step.Name` and the step's template-scope
`let` values in its table. Nothing needed the source-string channel.

`step_name` is kept on both methods. On `run_task` it feeds RFC 0008's
`WrappedStep.Name`, an injected overlay symbol the table does not carry.
On `enter_environment` it is redundant with the base but retained for a
caller that has a step name and no resolved table.

Tests whose only subject was the parameter are deleted; those that used
it to set up step context now deliver the same context through
`resolved_symtab`, so the wrap-scope isolation and hook-seeding
properties stay pinned.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
CI runs `mypy src test` (hatch.toml) and rejected the monkeypatched
_build_wrap_hook_scope stand-in: a **kwargs signature is not assignable
to the bound method's type once fix 1a added resolved_base, and the
existing `type: ignore[method-assign]` does not cover the `assignment`
error code. All 18 Python matrix legs failed at Run Linting.

Give the stand-in the real signature and forward resolved_base rather
than widening the suppression, so the harness observes the builder
transparently instead of altering what it is meant to watch.

My miss: I ran `mypy src` locally, not the repo's `mypy src test`.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
identifier: Optional[EnvironmentIdentifier] = None,
os_env_vars: Optional[dict[str, str]] = None,
extra_let_bindings: Optional[list[str]] = None,
step_name: Optional[str] = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extra_let_bindings is removed from enter_environment, but it is a released public keyword argument, and nothing in this PR marks the removal as breaking.

It shipped in 0.10.11 (df96902, called out by name in that CHANGELOG entry: "enter_environment(extra_let_bindings=...) so a steps environments see the step-level lets"), so any caller on 0.11.x that passes it gets a TypeError on upgrade. The run_task counterpart is safe to drop — 82c3be7 added it inside this same PR — but the enter_environment one is not.

The version bump makes it worse: pyproject.toml puts refactor in [tool.semantic_release] patch_tags with minor_tags = [], and the commit is refactor: Drop extra_let_bindings parameter with no BREAKING CHANGE: footer. So this lands as a patch release with no ⚠ BREAKING CHANGES note — the one signal a downstream caller has.

Two ways out, depending on how much the "single authoritative channel" goal is worth:

  • Keep the kwarg accepted-and-ignored (or accepted-and-applied) for one release with a DeprecationWarning, then drop it. Preserves the patch bump honestly.
  • Keep the removal, but add a BREAKING CHANGE: footer to the commit so semantic-release bumps and documents it.

Worth confirming against the worker agent too: the commit message argues the removal is safe because "the service stores a step-scoped environment with the owning steps resolved symbol table", which is a claim about one caller, not about every caller of a public API.

# injection to keep all three hook paths reading the same way.
if not self._seed_wrap_env_scope(hook_symtab, wrap_env):
return
self._seed_wrap_env_scope(hook_symtab, wrap_env)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_seed_wrap_env_scope now runs after _try_inject_wrapped_symbols, and it writes arbitrary service-supplied names — so a base entry can silently overwrite the WrappedAction.* overlay.

The comment above says ordering "is no longer load-bearing here", which is true for the direction it argues (the base cannot leak into the wrapped action's scope, since hook_symtab is a separate table). But it is load-bearing in the other direction: injection writes WrappedAction.Command, WrappedAction.Args, WrappedAction.Environment, WrappedAction.Timeout, WrappedAction.Cancelation.* and WrappedStep.Name / WrappedEnv.Name into hook_symtab, and this loop then does an unconditional symtab[base_name] = base_value for every name in the stored base.

A base carrying an entry literally named WrappedAction.Command replaces the command the hook is supposed to run on behalf of the wrapped action. Unlike the old let-binding replay, these names are not template-authored — they come off the wire in SerializedSymbolTable, and _resolved_base_entries copies every engine_tab.symbols name through unfiltered. So the one symbol group RFC 0008 says the hook must be able to trust is the one a base can redefine. The same applies to the three hook paths, since all three call this after injection.

Note this is specific to _seed_wrap_env_scope: the inner entity's base goes in via _build_wrap_hook_scope, which runs before injection, so injection correctly wins there. It's only the wrap env's own stored base that lands late.

Cheapest fix is to move the call before _try_inject_wrapped_symbols in all three paths, so the overlay stays authoritative (the reason it currently sits after — "to keep all three hook paths reading the same way" — is satisfied either way). Alternatively skip WrappedAction.* / Wrapped*.Name names when seeding.

@seant-aws

Copy link
Copy Markdown
Contributor

This removes extra_let_bindings from enter_environment, which shipped in released 0.11.0 — so this is a backwards-incompatible public API change, not a patch.

The repo's [tool.semantic_release] config has minor_tags = [] and feat in patch_tags, so a bare feat: commit will cut 0.11.1 (patch). Per the README's versioning policy ("MINOR is incremented when backwards incompatible changes are introduced to the public API"), this should cut 0.12.0.

Can you add a BREAKING CHANGE: footer to the squash/merge commit so semantic-release bumps MINOR? Otherwise the removal ships silently as a patch and anyone pinned ~=0.11.0 picks it up.

Note the PR description says the parameter "was added in the superseded #356" and never shipped — but it's present in mainline _session.py at 0.11.0.

@leongdl
leongdl enabled auto-merge (squash) August 25, 2026 21:49
@leongdl
leongdl merged commit 457a364 into OpenJobDescription:mainline Aug 25, 2026
30 checks passed
leongdl added a commit that referenced this pull request Aug 26, 2026
0.11.6 is the first openjd-model release that accepts an environment
defining only `onExit` (openjd-model 5b9c661, released in f7ca58f). This
package enters and exits environments handed to it by a caller, so a
template the model previously rejected at validation is one it can now be
given. 0.11.5 carried only an openjd-rs crate dependency bump.

The upper bound is unchanged: 0.11.6 is the newest release and `< 0.12`
still holds.

BREAKING CHANGE: `extra_let_bindings` was removed from
`Session.enter_environment`, `Session.exit_environment` and
`Session.run_task` in #357 (457a364). It was public in 0.11.0, so the
removal is backwards incompatible for the public API. That squash commit
landed without this footer, so semantic-release would otherwise cut the
removal as a patch and anyone pinned `~=0.11.0` would pick it up. Callers
should deliver step-scope EXPR `let` values through `resolved_symtab`
instead. Per the README's versioning policy this bumps MINOR (0.12.0);
`major_on_zero = false` keeps it inside 0.x.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
This was referenced Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants