Skip to content

fix(model): implement PR #383 review follow-ups for resolved-value checks - #397

Merged
mwiebe merged 3 commits into
OpenJobDescription:mainfrom
mwiebe:fix/resolved-value-followups
Sep 15, 2026
Merged

mwiebe merged 3 commits into
OpenJobDescription:mainfrom
mwiebe:fix/resolved-value-followups

Conversation

@mwiebe

@mwiebe mwiebe commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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

An OpenJD job template goes through two checkpoints before it runs on a
worker: template validation (what openjd check does — the template
alone, with no parameter values) and job creation (when a user
submits the template with actual parameter values). A guiding principle
from the review of #383: when we can already tell at an earlier
checkpoint that a template can never be valid, we should fail there,
instead of letting the problem surface later.

That review found two places where we report a problem later than we
could:

  1. Single-valued host requirements. A template can declare host
    requirements like "this step must run on Linux". Two of these
    attributes — the OS family and the CPU architecture — are
    single-valued: a host has exactly one OS, so an allOf list
    (conditions that must all hold) with more than one value can never
    be satisfied. Template validation enforced this, but only when every
    list entry was plain text. As soon as one entry was an expression
    like "{{ Param.X }}", the whole check was skipped until job
    creation — even for a list like ["linux", "windows", "{{ Param.X }}"],
    where the two plain-text entries already make the list impossible on
    their own, no matter what the expression turns out to be.

  2. Control characters in job names. The spec forbids control
    characters (newlines, tabs, etc.) in the final job name. When the
    name contains an expression — e.g. "render-{{ Param.Suffix }}" —
    the final name is only known at job creation, once Param.Suffix
    has a value. But job creation only re-checked the name's length and
    non-emptiness, not control characters. So submitting with
    Suffix = "a\nb" produced a job whose name contains a newline, and
    nothing ever rejected it.

What was the solution? (How)

  1. In template validation (structure.rs), the single-valued rule now
    counts the plain-text entries instead of requiring the whole list to
    be plain text. A plain-text entry always contributes exactly one
    value to the final list (only expressions can drop out or expand), so
    two or more plain-text entries violate the rule under every
    possible outcome — it is safe to reject immediately. Lists with at
    most one plain-text entry still defer to the existing job-creation
    re-check. For all-plain-text lists the new condition is identical to
    the old one, so no previously-valid template is affected.

  2. In job creation (create_job/mod.rs), the resolved job name is now
    rejected if it contains control characters, right next to the
    existing emptiness re-check, using the same error type
    (ModelError::DecodeValidation).

What is the impact of this change?

  • Templates whose single-valued allOf is impossible regardless of
    expression values now fail at openjd check instead of at job
    submission. The error message and path are unchanged — it just
    arrives earlier.
  • Job submissions that would produce a job name with control characters
    now fail with Validation error: Job name must not contain control characters. Previously they silently succeeded; per the spec such a
    name was never valid.
  • No conforming template or submission is newly rejected.

How was this change tested?

  • Four new integration tests, following the repo's error-message test
    standard (failure tests assert the full path + message):
    • test_attr_os_family_all_of_two_literals_plus_expr — mixed list
      with two literals rejected at decode.
    • test_attr_os_family_all_of_one_literal_plus_expr_deferred — one
      literal + one expression still passes decode (deferred to job
      creation, which may resolve it to a single value).
    • test_create_job_resolved_name_with_control_chars_rejected —
      interpolated name with a newline in the parameter value rejected at
      job creation (exact error asserted).
    • test_create_job_resolved_name_without_control_chars_ok — passing
      control.
  • cargo test --workspace: passes. (Two pre-existing
    openjd-sessions Windows cross-user test failures on the dev
    machine are environmental — domain-joined host changes the
    LogonUserW error code — and reproduce identically on origin/main.)
  • Full OpenJD conformance suite on Windows: 1116 passed, 0 failed.
  • cargo clippy --all-features --all-targets --workspace -- -D warnings
    and cargo fmt clean.

Was this change documented?

  • specs/model/validation.md: documented the literal-count rule for
    single-valued attributes in the pass-6 deferral discussion, and the
    job-name control-character re-check in the resolved-value
    consequences section.
  • specs/model/job-creation.md: documented the full set of resolved
    job-name checks (length, non-empty, no control characters).
  • No public API surface changed, so public-api.md is untouched; code
    comments added at both check sites.

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 15, 2026 05:50
@mwiebe
mwiebe force-pushed the fix/resolved-value-followups branch from 7887218 to 9f66402 Compare September 15, 2026 05:51
… resolved-value checks

Two early-detection gaps flagged in the PR OpenJobDescription#383 review:

- Single-valued allOf literal count (gate 1): the single-valued
  standard-attribute check fired only for all-literal lists. A literal
  element always contributes exactly one resolved element, so more than
  one literal violates the rule under every possible resolution — now
  gated on the literal count instead of all-literal, rejecting e.g.
  allOf: [linux, windows, '{{ Param.X }}'] at decode instead of at job
  creation.

- Job-name control characters (gate 2): §1.1.1 constrains the resolved
  name, but create_job only re-applied the length and emptiness checks.
  An interpolated name resolving to a value with Cc characters passed
  both gates; create_job now rejects it alongside the emptiness check.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the fix/resolved-value-followups branch from 9f66402 to e364d4e Compare September 15, 2026 16:27
Comment thread crates/openjd-model/src/template/validate_v2023_09/structure.rs Outdated
Comment thread crates/openjd-model/src/job/create_job/mod.rs
…sors

Two small accessors for reasoning statically about resolution shape:

- segment_count(): a format string with more than one segment always
  concatenates to a single string (Expression Language §1.3.2) — only a
  whole-field single-expression format string can resolve to null or to
  a list. is_literal() distinguishes the two single-segment cases.
- literal_segments(): the literal text runs, which appear verbatim in
  every possible resolution; expression source text is excluded since
  it never appears in a resolved value.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-ups:

- The single-valued allOf count now includes multi-segment format-string
  elements alongside literals: a multi-segment element always
  concatenates to exactly one string, so it contributes exactly one
  resolved element — only a whole-field single-expression element can
  null-skip or flatten. allOf: [linux, 'windows{{ Param.X }}'] on
  attr.worker.os.family is now rejected at decode.

- The decode-time job-name control-character check now runs on the
  name's literal segments instead of the whole raw text. Literal text
  appears verbatim in every resolution, so a control character there is
  still a certain violation even in an interpolated name — but
  expression source text never appears in a resolved value, so a
  control character inside an expression (e.g. a tab in a string
  literal that resolution removes) no longer over-rejects. What an
  expression resolves to is checked on the resolved value, statically
  when fully static and at job creation otherwise. Comments and specs
  now describe all three checks accurately.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe enabled auto-merge (squash) September 15, 2026 20:15
@mwiebe mwiebe changed the title fix(model): implement PR #383 review follow-ups for resolved-value checks fix(model): implement PR #383 review follow-ups for resolved-value checks Sep 15, 2026
@mwiebe
mwiebe merged commit a43b521 into OpenJobDescription:main Sep 15, 2026
22 checks passed
@mwiebe
mwiebe deleted the fix/resolved-value-followups branch September 15, 2026 20:36
@github-actions github-actions Bot mentioned this pull request Sep 15, 2026
@leongdl

leongdl commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What it does and what it fixes

Three check sites, all following one principle from the #383 review: enforce a rule at the earliest checkpoint where the violation is certain, defer the rest to where the value is actually known.

Site 1, structure.rs job name — a false-positive fix. The decode-time control-character check moved from jt.name.raw() to jt.name.literal_segments(). A control character inside expression source text (a multi-line conditional, say) never appears in a resolved value, so rejecting it was wrong. This is a relaxation: templates that previously failed openjd check now pass.

Site 2, crates/openjd-model/src/job/create_job/mod.rs — the real defect fix. name: "render-{{ Param.Suffix }}" submitted with Suffix = "a\nb" produced a job named render-a\nb, which §1.1.1 forbids, and nothing rejected it. Job creation now rejects control characters in the resolved name, right next to the existing length and emptiness re-checks.

Site 3, structure.rs single-valued host requirements — a diagnostics-timing fix. attr.worker.os.family / attr.worker.cpu.arch allOf was previously only counted when the whole list was literal. It now counts elements that certainly contribute one resolved element each: v.is_literal() || v.segment_count() > 1. So ["linux", "windows", "{{ Param.X }}"] fails at openjd check instead of at submission. It was always rejected, just later.

The two new FormatString accessors (segment_count, literal_segments) exist to carry those two facts across the crate boundary.

Data flow and call stack

decode_job_template (template/parse.rs:296)
└── validation::validate_job_template → validate_v2023_09::validate_job_template (mod.rs:180)
    ├── pass 5  limits::enforce_limits          — raw name length, on .raw()
    ├── pass 6  structure::validate_structure
    │     ├── name.literal_segments() → is_control          ◄ SITE 1 (relaxed)
    │     └── validate_host_requirements
    │           certain = is_literal() || segment_count() > 1
    │           count > 1 → "single-valued attribute cannot have more than 1 element."  ◄ SITE 3
    └── pass 8  format_strings::validate_format_strings
          validate_fs_with(&jt.name, ResolvedConstraint::Text {
              forbid_control_chars: true, forbid_empty: true, max_len })
          → fires only when the name is fully static

create_job (job/create_job/mod.rs:49)
├── name.resolve_with(target_type = STRING)     — null/list are errors, not renderings
├── length → empty → chars().any(char::is_control)          ◄ SITE 2 (new)
├── symtab.set("Job.Name", …)                  — after the check, so nothing leaks downstream
└── instantiate_step → resolve_host_requirements (instantiate.rs)
      ranges::resolve_string_list  — Null => continue (skip), is_list() => flatten
      is_single_valued && field == "allOf" && values.len() > 1
        → "… cannot have more than 1 element after resolution"

Claims verified rather than taken on trust

The certain predicate rests on "only a whole-field single expression can null-skip or list-flatten". That is exact. FormatString::resolve_inner returns a typed ExprValue passthrough only when segments.len() == 1 and that segment is an Expression; every other shape goes through resolve_string_with and comes back ExprValue::String. The skip and flatten arms in resolve_string_list are reachable only from the passthrough.

The deferral is not a hole. The job-creation re-check exists at instantiate.rs:429 and firing it is pinned — guarding it with false && fails two named tests.

The relaxation opens no hole. Four routes carrying an expression-derived control character to a resolved name were probed (parameter default with \n, whole-field expression with U+007F and U+009F, and control-char-in-source-with-clean-resolution). The first three are rejected at create_job; the fourth is correctly accepted. Fully-static routes are still caught at decode because pass 8 sets forbid_control_chars: true unconditionally, not gated on EXPR.

char::is_control is exactly Unicode Cc (U+0000–U+001F, U+007F–U+009F), which matches §1.1.1. Notably it does not use the crate's own has_control_chars helper, which exempts \n\r\t — correct here.

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