Skip to content

feat(model): enforce resolved-value checks on carried-forward fields in create_job - #404

Merged
mwiebe merged 1 commit into
OpenJobDescription:mainfrom
mwiebe:feat/job-creation-resolved-value-checks
Sep 17, 2026
Merged

mwiebe merged 1 commit into
OpenJobDescription:mainfrom
mwiebe:feat/job-creation-resolved-value-checks

Conversation

@mwiebe

@mwiebe mwiebe commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

A job template can contain placeholder expressions in its text fields,
like args: ["{{ 'A' * Param.N }}"] — "repeat A, N times" — where N
is only supplied when a job is submitted. The spec defines three
processing stages (Template Schemas §7.4): template validation, job
creation, and task execution on the worker, with the principle that
every problem should fail at the earliest stage where it is knowable.

PRs #383 and #399 added "resolved-value" limits — caps on how big a
field's final text may get — at the first and last stages: validation
rejects what it can prove too long before parameters exist, and the
worker enforces the limits on the final text. The middle stage did
nothing: job creation is the first moment parameter values are real,
yet it carried the affected fields forward unchecked. A job guaranteed
to violate a limit passed submission and failed later, on every worker.
The caller-configurable expression evaluation budgets (memory/operation
limits) had the same gap — job creation used built-in defaults.

What was the solution? (How)

create_job now re-runs the same resolved-value checks that template
validation runs, with the parameters filled in. For each carried-forward
field — action command/args, environment variable values, embedded
file data, across step scripts and all environments including wrap
hooks — it evaluates the field against a symbol table where Param.*
have their submitted values and worker-only values (Session.*,
Task.*) are marked "unresolved". Unknown parts count as zero, so the
computed minimum length is always a safe lower bound. Environment
variable values are checked against the spec's 2048-character limit
(always on); command/args and data only against the optional
CallerLimits caps, since the spec sets no maximum for them. Failures
carry a path to the exact field:

steps[0] -> script -> actions -> onRun -> args[0]:
    resolves to at least 100000 characters, exceeding the maximum of 1024.

Two details: the pass reports limit violations but deliberately ignores
other evaluation errors (create_job may legitimately run with a
different feature profile than validation, and the worker re-resolves
anyway) — except budget exceedances, which are reported because run
time would exceed them too. And the caller's evaluation budgets now
apply to every evaluation job creation performs (job name, let
bindings, host requirements, task ranges, and the new checks),
matching the other two stages.

What is the impact of this change?

Jobs whose parameter values guarantee a violation now fail at
submission with a precise error instead of on every worker. Callers
that never set the optional caps see almost no change — the exceptions
are parameter-dependent environment variable values over 2048
characters and environment let bindings that fail with the real
parameter values, both of which previously failed on the worker and now
fail at create_job. No public API signatures changed; job creation
does roughly the evaluation work one worker would otherwise do per task.

How was this change tested?

  • Have you run the unit tests?
    • Yes — cargo test --workspace passes. (Two pre-existing Windows
      cross-user logon failures on this machine are environment-specific
      and reproduce on unmodified main.)
  • 20 new integration tests cover each field kind and location, let
    binding flow, lowered budgets, and passing controls, asserting full
    error paths + messages per the repo standard.
  • Full OpenJD conformance suite: 1,133/1,133 pass (Windows).
  • Clippy (-D warnings), rustfmt, and cargo doc all clean.
  • An independent agent review of the diff was performed; all findings
    were addressed and re-verified.

Was this change documented?

  • Are relevant docstrings in the code base updated?
    • Yes — stale CallerLimits docs corrected, and the specs updated in
      the same commit: specs/model/job-creation.md gains a
      "Resolved-value checks on carried-forward fields" section, with
      validation.md and public-api.md aligned.

Is this a breaking change?

No.

Does this change impact security?

No.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@mwiebe
mwiebe requested a review from a team as a code owner September 17, 2026 22:10
…in create_job

Of the spec's three processing stages (Template Schemas 7.4: template
validation, job creation, task execution on the worker host), job
creation is the first at which job parameters have real values.
create_job now statically evaluates the carried-forward
session/task-scope format strings - action command/args, environment
variables values, embedded-file data - against check symbol tables
with the parameters bound, and re-runs exactly the resolved-value
checks template validation (pass 8) applies to those fields. A
violation that depends only on parameter values (e.g.
args: ["{{ 'A' * Param.N }}"] with a huge N) now fails at submission
instead of on every worker; task execution remains the enforcement
boundary.

- format_strings.rs: pub(crate) check_carried_forward_step_script /
  check_carried_forward_environment walkers; FsEval::for_job_creation
  reports only budget exceedances as evaluation errors (create_job may
  run under a deliberately different profile; other evaluation errors
  are context artifacts and the worker enforces regardless).
- instantiate.rs: build_task_check_symtab (refactored from the script
  let-binding type-check block, now always built),
  build_env_check_symtab, shared add_unresolved_session_symbols.
- Evaluation budgets (CallerLimits::max_eval_memory_bytes /
  max_eval_operations) now apply to every evaluation job creation
  performs - job name, let bindings, host requirements, task ranges,
  and the new carried-forward checks - closing the "job creation
  evaluates under the spec defaults" gap.
- 20 new integration tests asserting full field paths + messages;
  stale CallerLimits doc comments updated.
- specs: job-creation.md gains the Resolved-Value Checks on
  Carried-Forward Fields section (and current
  create_job/evaluate_let_bindings signatures); validation.md and
  public-api.md updated to match.

Verified: clippy -D warnings clean; cargo test --workspace green
except two pre-existing environment-specific Windows cross-user logon
failures that reproduce on unmodified main; conformance suite
1133/1133 on Windows.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the feat/job-creation-resolved-value-checks branch from e360eb5 to 55529fb Compare September 17, 2026 22:11
Comment thread crates/openjd-model/src/job/create_job/instantiate.rs
Comment thread crates/openjd-model/src/job/create_job/instantiate.rs
Comment thread crates/openjd-model/src/job/create_job/instantiate.rs
@leongdl

leongdl commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Where the new enforcement sits in the create_job flow

Reviewed at 55529fb, read against the earlier rounds on #383 and #399. Everything below was run on the branch or read at a cited line; probes were removed and the tree left clean.

This PR fills the one empty cell in the enforcement ledger. Of the spec's three processing stages (Template Schemas §7.4), the S3 row previously read "does not check: anything task or host scoped — onRun.command, args, data, env variables". Those four are now checked, and the caller's evaluation budgets reach every evaluation job creation performs. Both were open items from #399.

The mechanism is a fourth and fifth symbol table. build_task_check_symtab takes the step's S3 table — concrete Param.*, RawParam.*, Job.Name, Step.Name, step let — and adds Unresolved placeholders for everything only a worker can bind: Session.*, Task.Param.*, Task.RawParam.*, Task.File.*, and PATH-typed Param.*. build_env_check_symtab does the same at session scope with Env.File.* and without Task.*. The lower bound stays sound because unknowns still contribute zero; it is simply tighter than pass 8's, because Param.* is no longer zero.

The PATH exclusion is worth calling out as correct rather than incidental: the job-creation symbol table holds real values for non-PATH parameters only, because path mapping is a host operation, so backfilling them as Unresolved(PATH) typed from the corresponding RawParam.* variant is the right shape.

Flow

flowchart TD
    subgraph S1["S1 validate (pass 8) — values unknown"]
        P8["validate_format_strings<br/>FsEval::new, report_eval_errors=true"]
        P8G["gate: step.script ONLY<br/>format_strings.rs:1627"]
        P8 --> P8G
    end

    subgraph S3["S3 create_job — job parameters bound  (NEW)"]
        CJ["create_job — mod.rs:86"]
        BUD["EvalBudgets::from_ctx — mod.rs:105"]
        IS["instantiate_step — instantiate.rs:20"]
        SUG["script_template = resolve_syntax_sugar<br/>instantiate.rs:78"]
        TCS["build_task_check_symtab — :263"]
        UNRES["add_unresolved_session_symbols<br/>Session.*, PATH Param.* — :215"]
        CHK1["check_carried_forward_step_script<br/>format_strings.rs:1054"]
        IR1["into_result — ABORTS — :129"]
        CHK2["stepEnvironments →<br/>check_carried_forward_environment :1098"]
        CHK3["jobEnvironments → same — mod.rs:219"]
        CJ --> BUD --> IS --> SUG --> TCS --> UNRES --> CHK1 --> IR1 --> CHK2 --> CHK3
    end

    subgraph CORE["shared check core — reused from pass 8"]
        FSE["FsEval::for_job_creation<br/>report_eval_errors=FALSE — :825"]
        VFW["validate_fs_with — :886"]
        VE["validate_expressions<br/>opts carry the budgets"]
        CRC["check_resolved_constraint — :448"]
        ERR["Err arm — :924<br/>compound if/else kind is Other<br/>budget silently dropped"]
        FSE --> VFW --> VE
        VE -->|Ok| CRC
        VE -->|Err| ERR
    end

    subgraph CAPS["what gets enforced"]
        C1["command, args[i] — max_resolved_arg_len (opt-in)"]
        C2["embeddedFiles[j].data — max_resolved_data_len (opt-in)"]
        C3["variables values — 2048 chars (ALWAYS ON, new)"]
    end

    CHK1 --> FSE
    CHK2 --> FSE
    CHK3 --> FSE
    CRC --> C1
    CRC --> C2
    CRC --> C3
    P8G -.->|"desugar NOT validated here"| SUG
    CHK3 --> RT["S4 run — resolve_action_args,<br/>embedded_files, SessionLimits (authoritative)"]
Loading

Call stack

create_job                                              mod.rs:86
├─ EvalBudgets::from_ctx                                mod.rs:105   NEW: budgets sourced once
├─ job_name.resolve_with(budgets.fs_options())          mod.rs:110  NEW: budgeted
├─ steps.map(instantiate_step)                          mod.rs:177
│  └─ instantiate_step                          instantiate.rs:20
│     ├─ step `let`, budgeted(..)                              :59   NEW: budgeted
│     ├─ script_template = resolve_syntax_sugar()              :78
│     ├─ build_task_check_symtab                              :263
│     │  ├─ add_unresolved_session_symbols                    :215   Session.*, PATH Param.*
│     │  ├─ Task.Param.* / Task.RawParam.* = Unresolved
│     │  ├─ Task.File.* = Unresolved
│     │  └─ script `let`, budgeted
│     ├─ resolve_host_requirements(.., budgets)                      NEW: budgeted
│     ├─ ranges::resolve_parameter_space(.., budgets)                NEW: budgeted
│     ├─ check_carried_forward_step_script      format_strings.rs:1054  NEW CHECK
│     │  ├─ FsEval::for_job_creation                          :825   report_eval_errors=FALSE
│     │  ├─ validate_action_fs                                :991   command, args[i]
│     │  └─ check_embedded_files_data                        :1184   data
│     ├─ into_result("JobTemplate")?             instantiate.rs:129   aborts the whole job here
│     └─ stepEnvironments: build_env_check_symtab              :366
│        └─ check_carried_forward_environment   format_strings.rs:1098
│           └─ into_result                       instantiate.rs:154
└─ jobEnvironments: build_env_check_symtab + check_..._environment
   └─ into_result                                       mod.rs:228

inside validate_fs_with                         format_strings.rs:886
   is_literal()  → raw chars vs cap                     (added by #399)
   else validate_expressions(opts with budgets)
        Ok(sr)  → check_resolved_constraint             :448
        Err(e)  → if !report_eval_errors:               :924
                     budget kind? report : return       (compound Other → return)

Two things the call stack makes visible that prose hides. The dotted edge in the diagram is the desugar divergence: pass 8's gate at format_strings.rs:1627 reads step.script, while S3's input at instantiate.rs:78 is the desugared script, so the two stages inspect different objects while the docs claim equivalence. And the abort at instantiate.rs:129 runs inside the per-step closure, before that step's environments are checked and before jobEnvironments is reached at all.

Findings

Four items, in the inline threads. None is a regression against main — before this commit S3 applied no resolved-value checks and no budgets, so each is "the new protection is narrower than the docs claim", which is the same shape #399's review closed with. Recorded here rather than raised as change requests.

Where Item Verified by
format_strings.rs:924 A budget exceedance inside an if/else with an unresolved test is silently dropped — the one error class this stage may report Measured, both budget kinds
instantiate.rs:78/:122 The desugared script is checked at S3 but never at pass 8; the reported path names a node the author did not write Measured
instantiate.rs:390 Environment let becomes a hard failure, against the report_eval_errors = false policy set one file over; and the two check-symtab builders disagree on parse profile Read at cited lines
instantiate.rs:129 Errors do not accumulate the way pass 8's do; the first violation masks the rest of the template Read at cited lines

Three prior conclusions land correctly and are worth naming. The reuse of validate_action_fs, ResolvedConstraint and path_field directly from pass 8 means paths and messages agree by construction rather than by convention, which is what #349 had to fix by hand. The env-variable constraint at S3 matches pass 8's exactly, forbid_control_chars: false, forbid_empty: false — I checked, the equivalence claim holds there. And EvalBudgets::fs_options() single-sources the POSIX path format plus budgets, so the "policy encoded twice" shape from #399's finding 5 is not repeated.

One correction to my own earlier review. In #399 I wrote that the two caps "have no job-creation stage available, because command/args/data are @fmtstring[host] and resolve only at the session; for those, say two stages." That conflated fully resolving a field with bounding one. Binding the parameters and leaving host symbols unresolved gives a sound bound at S3, which is what this PR does, so the prescription was wrong and the three-stage wording now in CallerLimits is right.

Verified

Probes on 55529fb, macOS/aarch64; worktree restored, git status --porcelain empty. Not re-run here: the full workspace suite, clippy, and the conformance claim of 1,133/1,133 on Windows.

mwiebe added a commit to mwiebe/openjd-rs that referenced this pull request Sep 17, 2026
@mwiebe
mwiebe merged commit 158eb0e into OpenJobDescription:main Sep 17, 2026
22 checks passed
@mwiebe
mwiebe deleted the feat/job-creation-resolved-value-checks branch September 17, 2026 23:57
mwiebe added a commit to mwiebe/openjd-rs that referenced this pull request Sep 17, 2026
@github-actions github-actions Bot mentioned this pull request Sep 17, 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.

2 participants