Skip to content

feat!: enforce resolved-value constraints at template validation - #383

Open
mwiebe wants to merge 15 commits into
OpenJobDescription:mainfrom
mwiebe:feat/group-a-resolved-value-validation
Open

feat!: enforce resolved-value constraints at template validation#383
mwiebe wants to merge 15 commits into
OpenJobDescription:mainfrom
mwiebe:feat/group-a-resolved-value-validation

Conversation

@mwiebe

@mwiebe mwiebe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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

The 2023-09 Template Schemas place constraints on what several format-string fields resolve to — the spec's own phrasing is "after the format string has been resolved." A job name must resolve to ≤ 128 characters (512 with FEATURE_BUNDLE_1); an attribute capability value to ≤ 100 identifier-like characters; a task-parameter range element to ≤ 1024; an environment variable value to ≤ 2048; notifyPeriodInSeconds to a positive integer ≤ 600. Call these the spec-mandated resolved-value constraints — the fields whose limits the spec applies to the value an interpolation produces, in contrast to fields the spec deliberately leaves uncapped (command, args, embedded-file data).

Before this change, none of them were enforced at template validation time for interpolated values. openjd check evaluates every format-string expression during validation (that is how static type checking works: unknown symbols are unresolved placeholders and evaluation propagates them) — but it threw the results away. Concretely:

  • name: "{{ 'A' * 600 }}" passed check and failed at job submission.
  • name: "{{ Param.X }}{{ 'A' * 600 }}" — guaranteed over-limit whatever Param.X is — passed check and job submission logic that only sees the resolved whole.
  • notifyPeriodInSeconds: "{{ 300 + 400 }}" (statically 700 > 600) passed check and failed on the worker, mid-job.
  • Environment variable values had their 2048-character limit enforced nowhere — not at validation, submission, or run time.
  • The TASK_CHUNKING chunks format strings were never validated in the format-string pass at all: a typo'd expression surfaced only at job creation.

Investigating the fix surfaced a second, related problem: resolution itself deviated from the spec's target-type rules. The Expression Language spec (§1.3.2 "Evaluation Within Template Schemas") derives a target type for every field's whole-field expression from its schema context — T for required fields, T? for optional ones (null = field omitted) — and the Template Schemas doc explicitly mandates int? for timeout/notifyPeriodInSeconds and string? for a deferred cancelation mode. Our gates resolved all of these untargeted (render to display text, trim, parse). Observable symptoms: timeout: "{{ 120.0 }}" failed at run time with "must be a positive integer, got Float" where the spec's coercion accepts 120; a whole-field null in a required string field silently became the empty string (which the same fields' minimum-length-1 constraints forbid); an amount bound resolving to null was a parse error instead of "field omitted".

The requirement: enforce these resolved-value constraints at the earliest stage where a violation is knowable, and make validation and resolution agree exactly on what a field accepts — full staging rationale in the design document.

What was the solution? (How)

Three commits, ordered so each layer builds on the previous. The key invariant throughout: a field's validation-time target type must equal its resolution-time target type, or validation would reject values resolution accepts (or vice versa).

  1. fix(sessions)! — spec-mandated targets in the session runtime. resolve_action_timeout, resolve_notify_period_seconds, and resolve_effective_cancelation now pass int?/int?/string? via with_target_type, so whole-field expressions coerce per the spec ({{ 120.0 }} → 120, {{ '600' }} → 600, null → field unset). Multi-segment strings concatenate as before and now trim before parsing, matching the reference implementation's int() leniency.

  2. feat(model)! — schema-derived targets at job creation. Job name, STRING/PATH range elements, and attribute capability values resolve with target string; amount min/max with float? (a whole-field null now means the bound is unset); chunks defaultTaskCount with int and the optional targetRuntimeSeconds with int? (so {{ 4.0 }} coerces to 4); environment variable values with string in the session runtime. Since there is no list → string or null → string coercion, a whole-field list or null in a required string field is now an error instead of silently rendering its display form.

  3. feat(model)! — gate 1: the validation layer. validate_fs gains a per-field ResolvedConstraint applied to the StaticResolution that pass 8 already computes (the feat(expr)!: return StaticResolution from FormatString::validate_expressions #373 mechanism), in two stages:

    • Lower bound: min_resolved_string_len is a guaranteed lower bound on every possible run-time resolution (unresolved segments contribute 0), so a bound past the field's limit is a certain violation and fails check without knowing the unresolved parts. This is what catches "{{ Param.X }}{{ 'A' * 600 }}".
    • Full value check: when the field is fully static, the exact check job creation or the worker would run executes at check time with the same error messages (notifyPeriodInSeconds must not exceed 600., value 'linuxx' is not valid for attr.worker.os.family., …).

    Numeric fields get a soft 100-character bound rather than an exact one — leading zeros are legal and whitespace is trimmed, so no exact maximum exists, but an i64 needs at most 20 characters and nothing reasonable exceeds 100. Standard attribute capabilities get a sharper bound: no resolution longer than the longest allowed value (e.g. 7 for attr.worker.os.family) can ever conform. Literal (non-interpolated) fields are excluded — the existing raw-text passes already check those, and for literals raw text and resolved value coincide.

What is the impact of this change?

  • No public API changes in any crate: ResolvedConstraint is private to the validation pass, and the sessions functions are pub(crate). openjd-expr is untouched (this consumes the API shipped in feat(expr)!: return StaticResolution from FormatString::validate_expressions #373).
  • Templates whose format strings statically violate a spec-mandated resolved-value constraint now fail openjd check instead of failing later — or, for environment variable values and chunks fields, instead of never failing.
  • Behavior changes at resolution time are listed under the breaking-change section below.
  • Validation cost is unchanged in essence: the constraint checks consume evaluation results pass 8 already produced.

How was this change tested?

  • Have you run the unit tests?
    • Yes — cargo test --workspace: all suites pass, including 2,032 tests in openjd-model and the sessions suites. (Two pre-existing Windows cross-user logon test failures on the development machine are environment-specific and unrelated.)
  • 47 new integration tests in tests/integration/test_resolved_value_constraints.rs cover, per field: a fully static over-limit value fails check with the exact path and message; a partially-unresolved value whose lower bound alone exceeds the limit fails; and under-limit / fully-unresolved controls pass. Includes the target-type behaviors: {{ 120.0 }} timeout and {{ 4.0 }} defaultTaskCount valid; whole-field null/list in string fields rejected with the resolution diagnostic; null inside surrounding text still interpolating as empty; {{ null }} amounts and targetRuntimeSeconds treated as unset.
  • Existing tests asserting the old untargeted behaviors were updated in the same commits that change them (amounts coercion diagnostics; a LIST[PATH]-parameter-as-range-element test now asserts rejection, with a comment explaining the alternative).
  • Full OpenJD conformance suite passes: 1,133/1,133 — run after each behavioral layer, confirming no conformance fixture relies on untargeted rendering.
  • cargo clippy --all-features --all-targets --workspace -- -D warnings is clean; cargo fmt clean; cargo doc builds without warnings.

Was this change documented?

  • Yes — specs/model/validation.md gains a "Spec-Mandated Resolved-Value Constraints" section: the two-stage check, the per-field table (target type, bound, fully-static check), the §1.3.2 general rule, the named consequences of string targets, and the literals exclusion. specs/model/job-creation.md documents the chunks/amount targeting; specs/sessions/runners.md documents the action-field targeting.
  • Doc comments on ResolvedConstraint and the resolution helpers explain the invariant and cite the spec sections.
  • Follow-up (separate branch): specs/resolved-value-limits.md on docs/resolved-value-limits-design should mark gate 1 implemented and record the remaining open question.

Is this a breaking change?

Yes — all three commits carry BREAKING CHANGE footers. Summary of observable changes:

  • Templates statically violating spec-mandated resolved-value constraints now fail openjd check (previously accepted, failing later or never).
  • Whole-field null or list-valued expressions in required string fields (job name, range elements, attribute values, environment variable values) are now resolution errors; there is no null → string or list → string coercion. A bare LIST[*] parameter reference as a single range element previously rendered the list's display form as one element and is now rejected.
  • timeout/notifyPeriodInSeconds/mode/chunks fields coerce whole-field expressions per their targets: whole-number floats and numeric strings are accepted where they previously errored; uncoercible values fail with coercion diagnostics instead of hand-written parse messages.
  • Amount min/max resolving to null now means the bound is unset (previously a parse error); non-numeric or non-finite amount strings fail with coercion diagnostics.

These changes have not reached production consumers; landing them now, before the behavior is depended upon, is the point of doing this in one coordinated change.

Does this change impact security?

No

Follow-ups

  1. List-item targets for range/attribute elements. Under §1.3.2's list-item rule these would target string? | list[string] with skip/flatten semantics (as args does). They currently target plain string (no skip/flatten). Needs reference-implementation cross-checking and an upstream clarification issue; the chunks int reading adopted here is worth confirming upstream at the same time.
  2. §4.4.2 reading. The 2048-character environment variable value limit is enforced here on the resolved value; the raw-text check also remains. Cross-check openjd-model-for-python behavior and file an upstream clarification (pre-existing action item from the design document).

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 11, 2026 01:13
Comment thread crates/openjd-model/src/template/validate_v2023_09/format_strings.rs Outdated
Comment thread crates/openjd-model/src/job/create_job/instantiate.rs
Comment thread crates/openjd-model/src/template/validate_v2023_09/format_strings.rs Outdated
Comment thread crates/openjd-model/src/template/validate_v2023_09/format_strings.rs Outdated
Comment thread crates/openjd-model/tests/integration/test_create_job.rs Outdated
Comment thread specs/model/validation.md Outdated
Comment thread crates/openjd-model/tests/integration/test_path_param_scope.rs Outdated
Comment thread specs/model/validation.md Outdated
@mwiebe
mwiebe force-pushed the feat/group-a-resolved-value-validation branch from b1be0fd to 863bf9b Compare September 11, 2026 19:17
Comment thread crates/openjd-model/src/job/create_job/ranges.rs Outdated
Comment thread crates/openjd-model/src/job/create_job/mod.rs
Comment thread crates/openjd-model/src/template/validate_v2023_09/structure.rs
Comment thread crates/openjd-sessions/src/runner/mod.rs Outdated
Comment thread crates/openjd-sessions/src/session.rs
Comment thread crates/openjd-model/src/job/create_job/instantiate.rs
Template Schemas mandates target types for single whole-field
expressions in three action fields: `int?` for timeout (§5) and
notifyPeriodInSeconds (§5.3.2), `string?` for a deferred cancelation
mode. The runtime resolved all three untargeted and hand-matched the
value, deviating from the spec: `timeout: "{{ 120.0 }}"` failed with
"must be a positive integer, got Float" where the mandated coercion
accepts it as 120.

resolve_action_timeout, resolve_notify_period_seconds, and
resolve_effective_cancelation now pass the mandated targets via
with_target_type. The String match arms remain for multi-segment format
strings (which concatenate regardless of target) and now trim before
parsing, matching the reference implementation's int() leniency.

BREAKING CHANGE: whole-field expressions in `timeout` and
`notifyPeriodInSeconds` now coerce under `int?` — whole-number floats
and numeric strings are accepted, and uncoercible values fail at
resolution with coercion diagnostics instead of hand-written messages.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Expression Language §1.3.2 ("Evaluation Within Template Schemas") gives
every format-string field a target type for its whole-field
expressions: `T` for a required field, `T?` for an optional one (null =
field omitted). Job creation and the session runtime resolved the
fields with spec-mandated resolved-value constraints untargeted
(resolve_string_with, then trim-and-parse), which silently rendered a
whole-field null as the empty string and a list value as its display
form.

Adopt the schema-derived targets at resolution time:

- job `name`, task-parameter STRING/PATH range elements, and
  hostRequirements attribute values resolve with target `string`
  (create_job / resolve_string_range / resolve_string_list)
- hostRequirements amount `min`/`max` resolve with `float?` — a
  whole-field null now means "field omitted" instead of a parse error
- chunks `defaultTaskCount` resolves with `int` and the optional
  `targetRuntimeSeconds` with `int?`, so `{{ 4.0 }}` coerces to 4
- environment variable values resolve with `string` in the session
  runtime

Multi-segment strings concatenate as before; numeric text parses with
surrounding whitespace tolerated, matching the reference
implementation.

BREAKING CHANGE: a whole-field `null` or list-valued expression in a
required string field (job name, range elements, attribute values,
environment variable values) is now a resolution error; there is no
list→string or null→string coercion. A bare LIST[*] parameter reference
as a single range element previously rendered the list's display form
as one element and is now rejected. Amount `min`/`max` resolving to
`null` now means the bound is unset instead of erroring, and
non-numeric/non-finite amount strings fail with coercion diagnostics.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Gate 1 of the resolved-value limits design: template validation
(pass 8) no longer discards what static evaluation computes. Each field
whose spec constraints apply to the resolved value — "after the format
string has been resolved" in the spec's wording — carries a
ResolvedConstraint applied to the StaticResolution that
validate_expressions returns, in two stages:

1. Lower bound: min_resolved_string_len holds for every possible
   run-time resolution (unresolved segments contribute 0), so a bound
   past the field's limit fails `openjd check` without knowing the
   unresolved parts: `"{{ Param.X }}{{ 'A' * 600 }}"` can never be a
   valid job name.
2. Full value check: when the field is fully static, the same check job
   creation or the worker would run on the resolved value runs at
   `check` time with matching messages: a static notifyPeriodInSeconds
   of 700 fails with "must not exceed 600."

Fields and limits: job name (128/512 chars + Cc check), attribute
capability values (100 chars / standard allowed sets, including a
longest-allowed-value bound for standard capabilities), STRING/PATH
range elements (1024), environment variable values (2048 — previously
enforced nowhere), timeout and notifyPeriodInSeconds (positive, ≤ 600),
deferred cancelation mode (enum + 21-char bound), chunks
defaultTaskCount/targetRuntimeSeconds (≥ 1 / ≥ 0; these format strings
were previously never validated in pass 8 at all), and amount min/max
(≥ 0 / > 0, finite). Numeric fields use a soft 100-character bound
(MAX_RESOLVED_NUMERIC_LEN): leading zeros preclude an exact maximum,
but an i64 needs at most 20 characters.

Each field validates with the same schema-derived target type gates 2/3
resolve it with (Expression Language §1.3.2), so validation accepts
exactly what resolution accepts. Literal fields are excluded — the
raw-text passes already check those.

47 tests cover, per field: a fully static over-limit value fails, a
partially-unresolved value whose bound alone exceeds the limit fails,
and under-limit / fully-unresolved controls pass. Full conformance
suite passes (1,133/1,133).

BREAKING CHANGE: templates whose format strings statically violate
resolved-value constraints — including whole-field null or list values
in required string fields — now fail template validation instead of
failing at job creation or on the worker (or, for environment variable
values and chunks fields, not failing at all).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback on the gate-1 range-element constraint found two
issues:

1. Wrong limit field: the constraint used max_task_param_range_len (the
   maximum number of elements in a list-form range) where the
   per-element character limit is max_task_param_string_len. Both are
   1024 today, so no behavior differed — but the moment either limit
   moves, gate 1 would validate elements against the count cap while
   job creation enforces the length cap.

2. Bytes vs characters: the spec states its string limits in characters
   (and the reference implementation's len() counts characters), but
   several checks compared byte lengths — falsely rejecting non-ASCII
   values (a 600-character/1,200-byte range element failed job
   creation). Converted to character counts: range elements and
   float-range text in job creation (and the error message that
   reported bytes), the resolved job-name check, attribute capability
   values (whose length diagnostic also fired before the charset error
   for non-ASCII), and the raw-literal checks for job/step/environment
   names, identifiers, filenames, and literal attribute values.

Tests pin both gates: 'é' * 1024 (2,048 bytes) validates and 'é' * 1025
fails at check; a 600-character non-ASCII range element now survives
job creation with its character length intact. Conformance suite
passes 1,133/1,133.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback: float? targeting made a new state reachable — an
amount requirement whose only provided bound is a whole-field
expression resolving to null ends up with both min and max unset after
resolution. Decode's "must have at least one of min or max." check
runs on field presence, so it passes; check_resolved_amount_bounds
only re-applied the sign and min <= max rules; and the job carried a
boundless amount requirement that matches every worker.

Re-apply the at-least-one rule on the resolved values, alongside the
sign checks that are re-applied there for the same reason. The message
gains an "after resolution" suffix so an author who did provide the
field understands why it fired.

Tests: min-only and min+max whole-field nulls are rejected at job
creation; a null min alongside a concrete max still resolves to a
one-sided bound.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback: the gate-1 bound for environment variable values
hardcoded 2048, silently duplicating the value the raw-text pass reads
from EffectiveLimits — the same divergence-on-change failure mode as
the range-element limit fixed earlier: if the limit ever moves, the
literal and interpolated forms of the same field would disagree.

Introduce a dedicated EffectiveLimits::max_env_var_value_len (2048)
rather than reusing max_description_len: the raw-text pass had been
borrowing the §7.2 Description limit for §4.4.2 environment variable
values, another values-coincidence that would break the moment either
spec section moves independently. Both the raw-text check and the
gate-1 resolved-value constraint now read the same field, threaded into
validate_env_format_strings by its three callers (job environments,
step environments, and environment templates).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
…:Int

Review feedback: after every integer field adopted its schema-derived
target type, all four Int constructions passed targeted: true, so the
targeted: false arm of target_type() was unreachable and the stage-2
checker ignored the flag. Worse than dead weight: a gate-1/gate-2
target mismatch is exactly the failure mode this design guards
against, so an unused untargeted knob was a footgun — a future field
constructed with targeted: false would get an untargeted gate 1
against a targeted gate 2 and silently diverge.

Remove the field and let target_type() derive int vs int? from
nullable alone; it now returns ExprType rather than Option<ExprType>,
since every constraint has a target. Also scrub the three doc comments
that still described untargeted numeric handling the code no longer
has (the enum-level scoping paragraph, the Int variant doc, and the
stage-2 trim comment).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback: the amount non-finite test had been loosened from an
exact assert_eq! to a substring check — against the repo's
error-message test standard — and in the process lost the only
coverage of resolve_to_f64's is_finite branch: under the float? target
all five "nan"/"inf" loop values fail during coercion and none reach
the parse path.

Restore exact full-message assertions on both amount failure tests
(the loop is now explicitly a coercion test), tighten the
LIST[*]-in-range rejection test to assert the full path and message,
and add the multi-segment case that actually reaches the finite check:
"1e{{Param.Exp}}" with Exp=999 concatenates to "1e999", which parses
to infinity.

Also restore the finite-check diagnostic to report the resolved text
("'1e999' is not a finite number") instead of the parsed f64 ("'inf'"),
which this PR had regressed — the text is what the author can act on.
The check moves into the parse arm, the only path that can produce a
non-finite value (a coerced Float64 excludes NaN and the infinities by
construction).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback: code comments, the test-suite header, and
specs/model/validation.md referenced specs/resolved-value-limits.md —
a design document that lives on an unmerged branch, not in the repo —
and leaned on its "gate 1/2/3" vocabulary, which nothing in the repo
defines.

Use the terminology the spec already establishes instead: the three
processing stages of Template Schemas §7.4 (template validation, job
creation, task execution on the worker host). The invariant reads as
"a field's validation-time target must always equal its
resolution-time target", and the constraint machinery is described as
making template validation the earliest stage to catch a violation
that is already knowable. References to the missing document are
repointed at the Spec-Mandated Resolved-Value Constraints section of
specs/model/validation.md, which is self-contained; the open question
about list-item string? | list[string] semantics now lives there too.

Also describe the current state rather than the change that produced
it: "Pass 8 uses the values that static evaluation can resolve" rather
than "no longer discards", and the string-target consequences state
what is an error and why rather than comparing against prior behavior.
The change narrative lives in the commit history and PR description.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback established that Expression Language §1.3.2's list-item
rule — "For list items (e.g., in `args`), the target type is
`T? | list[T]`" — applies to list items generally, with `args` as its
worked example, not its scope. Task-parameter STRING/PATH range
elements and hostRequirements attribute `anyOf`/`allOf` values are the
same list-of-format-strings shape, so their whole-field expressions
target `string? | list[string]`: a `null` result skips the element and
a list result flattens inline, one element per list member, mixable
with literal elements:

  range: ["first", "{{ RawParam.Paths }}", "last"]

This replaces the plain-`string` targeting these fields briefly carried
on this branch (which rejected list values), and is complementary to —
not redundant with — the whole-field `<ListExpressionString>` range
form (Expression Language §1.3.12), which replaces an entire range with
one expression and cannot mix literals.

At template validation, the per-element constraints (§3.4.2's 1024
characters, §3.3.2.2's charset/allowed-set rules) apply to each element
of a fully static list, and the string-length bound only applies when
`StaticResolution::resolved_type` says the resolution is certainly a
string — a list-valued resolution distributes its characters across
elements, so the display-form length says nothing about any single
element.

Because null-skips can empty a list whose non-emptiness was checked on
field presence at decode, job creation re-checks after resolution
("has no elements after resolution") — the same shape as the amounts
both-bounds-null fix.

Spec references verified against the upstream wiki: Expression Language
§1.3.2 "Evaluation Within Template Schemas" and §1.3.12 "Task Parameter
Range Field Extensions"; Template Schemas §3.4.2 TaskParameterStringValue,
§3.3.2.2 AttributeCapabilityValue, §3.4.1.5 ChunkIntTaskParameterDefinition,
§3.3.1 AmountRequirement. validation.md's per-field table now cites the
owning section for every field.

Tests: flatten and skip at both validation and job creation, mixed
literal + expansion ranges, per-element limit and charset violations,
all-null lists rejected after resolution, and the LIST[PATH]-in-range
test flips to asserting the flatten. Full conformance suite passes
(1,133/1,133).

BREAKING CHANGE: in task-parameter range elements and attribute
values, a whole-field expression resolving to `null` now skips the
element and a list result flattens inline (previously an error on this
branch, and the list's display form as a single element before that).
A range or attribute list emptied entirely by null-skips fails job
creation.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback: validation.md justified rejecting whole-field nulls by
asserting a minimum length of 1 on the string fields, but nothing
enforced that minimum on resolved values — a whole-field expression
resolving to the empty string passed every stage. The claim was also
wrong for one field: §4.4.2 sets the environment variable value minimum
at 0 characters, so empty env values are legal and stay unchecked.

Enforce the minima the spec actually sets, at both stages:

- job name (§1.1.1 minimum 1): rejected statically when fully static
  ("must not resolve to an empty string.") and re-checked on the
  resolved value at job creation. The raw-text pass only sees literal
  names.
- STRING/PATH range elements (§3.4.2 minimum 1): the per-element check
  covers both a static empty string and an empty element of a flattened
  list at validation; job creation's existing empty check, which
  applied only to PATH elements, now covers STRING elements too (the
  spec's constraint is on <TaskParameterStringValue>, which both share).
- attribute values already reject empties via
  validate_attribute_capability_value.

Also fixes the job-name maximum-length message to report characters
(it compared characters but still printed the byte count), and
tightens the empty-PATH-element test to an exact-message assertion.

validation.md now states which fields have a resolved minimum and that
env values deliberately have none.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the feat/group-a-resolved-value-validation branch from e4a0bce to 46cb1f6 Compare September 11, 2026 22:38
Review feedback caught two stragglers from the bytes-to-characters
conversion:

1. The range-element length check compares characters but the count
   interpolated into its message was still s.len() — bytes — so a
   1,200-character non-ASCII element reported "(2400 chars)". The
   job-name message had the same split and was already fixed; this one
   was missed. Hoist the count into a local used by both the comparison
   and the message.

2. The command length check — the one remaining limit in structure.rs
   not confined to ASCII by a charset pattern — still compared bytes,
   so a 600-character non-ASCII command (1,200+ bytes) was falsely
   rejected against the 1,024-character limit.

Convert both, and for a single unit everywhere also convert the checks
that are ASCII-confined in practice (amount and attribute capability
names, the combination expression, environment variable names): bytes
equal characters for values their patterns accept, but the length
diagnostics fire before the pattern checks, so counting characters
keeps them truthful for invalid non-ASCII input too. Every remaining
.len() comparison in the validation pass is an element count.

Tests pin the range-element message count (1,200 two-byte characters
routed through a parameter so job creation's diagnostic is exercised)
and the command limit at both sides of the boundary in two-byte
characters.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback: the int? target made the timeout resolver's catch-all
arm reachable for non-positive integers — {{ 0 }}, {{ '0' }}, and
{{ 0 - 5 }} all coerce to Int before the positivity guard, fell through
Int(n) if n > 0, and produced 'timeout must be a positive integer, got
Int(0)' — a Rust Debug rendering of an internal enum in a user-facing
error.

Mirror resolve_notify_period_seconds: accept Int(n) unconditionally and
reject non-positive values with a dedicated check that interpolates the
value ('got '-5''), leaving the catch-all for genuinely unexpected
types, which the int? target makes unreachable in practice.

Unit tests pin the exact message for all three coercion routes and the
positive/null paths.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback: environment variable values were the one constrained
field with no enforcement stage after template validation — validation
only catches statically knowable violations (an interpolation of an
unresolved symbol contributes 0 to its lower bound), so a value that
only becomes long at session time entered the process environment
unchecked, while every sibling field re-checks at job creation or in
the runtime.

Enforce Template Schemas §4.4.2's 2048-character maximum where the
value is resolved, alongside the existing NUL-byte guard, via a
session-side constant kept in sync with the model's
EffectiveLimits::max_env_var_value_len (which is not exported).
Integration tests pin the exact message one character over the limit
and acceptance exactly at it; specs/sessions/session.md documents the
check in the enter_environment steps.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review feedback plus follow-up: list flattening and null-skipping make
the resolved element count of anyOf/allOf unrelated to the count decode
validated, in both directions.

Flattening grows lists past decode-time rules: a single-valued
attribute (attr.worker.os.family / attr.worker.cpu.arch) could carry an
allOf with more than one element via a flattening expression, and a
flattening expression over a longer list could exceed the 50-element
cap. Re-apply both rules at job creation alongside the existing
emptiness re-check, with the same "after resolution" wording — the
sibling range path already re-checks its element cap after resolution
for exactly this reason.

Null-skips shrink lists below decode-time counts: a single-valued allOf
built from two complementary conditionals always resolves to exactly
one element, but decode counted template elements unconditionally and
falsely rejected the pattern. Gate the decode-time "> 1" check on all
elements being literal — the same gating the attribute value checks in
that function already use — deferring expression-bearing lists to the
job-creation re-check. The generic 50-element cap is deliberately not
relaxed: it applies to the template element count regardless of
expressions, so a deferred single-valued list is still bounded at
decode.

Tests: the flatten violations (a two-element LIST parameter flattened
into a single-valued allOf; 51 flattened elements) and their passing
controls (exactly 50; single-valued anyOf flattening to several
candidates); conditional range elements skipping down (three template
elements resolving to two, either branch); the complementary-
conditional allOf resolving to one element for both parameter values
and rejected when both conditionals survive; and the 50-element decode
backstop on a deferred single-valued list.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
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.

1 participant