Skip to content

fix: Enforce the 512-character cap on a let binding identifier - #348

Merged
leongdl merged 4 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/let-identifier-length
Sep 2, 2026
Merged

fix: Enforce the 512-character cap on a let binding identifier#348
leongdl merged 4 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/let-identifier-length

Conversation

@leongdl

@leongdl leongdl commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What

Template Schemas §3.6.1 caps a let binding's <UserIdentifier> at 512 characters. Nothing checked it, so a 513-character name was accepted.

steps:
- name: Step1
  let:
  - aaa…aaa = 42        # 513 characters: now rejected
  - aaa…aaa = 42        # 512 characters: still accepted
A 'let' binding name must be at most 512 characters long

Why

The conformance fixture pinning this is EXPR/job_templates/proposed/3.6.1--let-identifier-513.invalid.yaml, added by openjd-specifications#164. It fails on this implementation and on openjd-rs alike. I found it while running every open conformance-test PR against both implementations, each built from source; it is root cause F-6 of that sweep.

Spec text, pinned at the commit the sweep measured against — wiki/2023-09-Template-Schemas.md, L1424-L1454:

<UserIdentifier> ::= [a-z_][A-Za-z0-9_]*
  1. Maximum length of <UserIdentifier>: 512 characters.

That is L1430 and L1441. The section states one maximum and does not gate it on any extension.

Why this does not reuse the §7.1 identifier cap

This is the decision worth reviewing, because reaching for the existing cap is the obvious move and it is wrong here.

This package already carries the §7.1 <Identifier> cap in NameIdentifierLengthMixin and in seven inline 512 if "FEATURE_BUNDLE_1" in context.extensions else 64 checks. That cap is 64 characters, rising to 512 with FEATURE_BUNDLE_1. §3.6.1's <UserIdentifier> is a different type with a flat maximum.

The conformance pair proves the flat reading rather than merely suggesting it. The 512-character accept twin, EXPR/job_templates/3.6--let-boundary-edges.yaml from openjd-specifications#160, declares extensions: [EXPR] with no FEATURE_BUNDLE_1. Under the §7.1 cap its limit would be 64, so a fix built on that cap would reject a template the spec permits and turn a passing fixture red.

The two caps share the number 512 and nothing else, so this adds LET_MAX_IDENTIFIER_LEN beside the existing LET_MAX_BINDINGS, carrying its own citation and a comment recording why it is not the other one. EXPR gates whether let exists at all and moves neither cap.

Where the check goes, and why one place is enough

Four models carry a let field, and all four _validate_let field validators route through validate_let_field and then parse_let_bindings. The check therefore goes in parse_let_bindings, immediately after the _LET_NAME_RE match, and every scope is covered by one insertion.

The message omits the offending name, because at 513 characters it would dwarf the diagnostic.

The runtime helper evaluate_let_bindings is left alone. It runs on templates validation has already accepted, so rejecting there would surface too late.

Verification

Full suite: 5531 passed, 24 skipped, 3 xfailed. ruff check and mypy clean. ruff format --check reports both files I touched as already formatted.

Two pre-existing failures are unrelated to this change: TestYAMLLoaderPerformance::test_extra_large_template_performance in both model_v0 and model_v1 asserts a hard 5x speedup and lands at 4.0x-4.9x on my machine. I confirmed they are pre-existing by stashing this change and re-running on unmodified mainline, where both still fail, at 4.0x and 4.2x.

Six new tests in test/openjd/model_v0/v2023_09/test_let_bindings.py: 513 rejected and 512 accepted, each at step scope and script scope, plus both cases with FEATURE_BUNDLE_1 declared. The file's _job helper declares EXPR alone by default, so the default variants are the ones that pin the section above; without them a regression to the FEATURE_BUNDLE_1-gated cap would pass the suite.

Mutation-checked. Removing the production check fails all three reject tests and leaves all three accept tests passing, so the accept cases act as negative controls against a cap set too low.

Mutant Result
length check removed 3 reject tests fail, 20 pass

I cleared __pycache__ between runs and confirmed the mutated tree still imports, so the failures are the assertion firing rather than a broken build.

Out of scope

The §3.6.1 minimum length of 1 character. _LET_NAME_RE already rejects an empty name, and the conformance suite covers it.

Related

Comment thread src/openjd/model/v2023_09/_model.py Outdated
Comment thread test/openjd/model_v0/v2023_09/test_let_bindings.py Outdated
# requires a 512-character name to be accepted, so borrowing the §7.1 cap would
# limit it to 64 and reject a template the spec permits. EXPR gates whether
# `let` exists at all and moves neither cap.
LET_MAX_IDENTIFIER_LEN = 512

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Parity note: this cap lands only in the v0 pure-Python path, and there is no corresponding coverage on the Rust-backed side. test/openjd/model_v1/test_let_bindings.py has no length-boundary test, and test_known_gaps.py records no gap for it — so if openjd-model does not enforce a 512-character <UserIdentifier> cap, the two implementations now silently disagree on a template the spec section this PR cites is specifically about.

AGENTS.md calls out reference parity as a tracked artifact and test_known_gaps.py as the place divergences get recorded (xfail, driven to zero). Either a matching v1 test or a known_gaps entry would keep this from drifting unnoticed; a v0-only cap with nothing on the v1 side is the shape that parity tracking exists to catch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ack and there is a rust PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Parity is covered by OpenJobDescription/openjd-rs#358, which adds the same cap on the Rust side with its own seven tests, so the two implementations agree rather than diverging.

On the tracking ask specifically: not adding a known_gaps entry, because there is no gap left to record once #358 merges, and an xfail that never fails is worse than nothing. Not adding a v1 test either, since the v1 surface is evaluate_let_bindings, which runs on already-validated templates and does not perform this check in either implementation.

The honest residue is a release-ordering window: this repo's cap lands with this PR, the Rust one lands with #358, and they release independently, so between the two releases the pinned behaviour differs by version. The conformance fixture that covers this is parked in proposed/ in openjd-specifications#164 for exactly that reason and moves out once both have shipped. Happy to add a tracking entry instead if you would rather that window were recorded here.

leongdl added a commit to leongdl/openjd-model-for-python that referenced this pull request Sep 2, 2026
Review finding on OpenJobDescription#348. The validator is a `field_validator` on the whole
`let` list, so pydantic anchors the error at `steps[0] -> let` with no list
index. Measured on a four-binding template where the third name is 513
characters: the error was

    steps[0] -> let:
        A 'let' binding name must be at most 512 characters long

which identifies neither the binding nor its position. With 50 bindings
allowed that is not diagnosable.

Truncate rather than omit, as the reviewer suggested. The message now
carries a 32-character prefix and the true length:

    A 'let' binding name must be at most 512 characters long:
    'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'... (513 characters)

Total error text goes from 110 to 166 characters, so it stays bounded while
becoming locatable. The original concern about a 513-character name dwarfing
the diagnostic still holds against interpolating the name in full, which is
why this truncates.

Two assertions pin it, both mutation-checked: reverting to the bare message
fails `test_name_513_chars` and the new
`test_name_513_chars_names_the_offending_binding`, which uses a
multi-binding `let` to cover the case the finding was about.

No matching change in openjd-rs: its path is `steps[0] -> let[0]`, so the
index already identifies the binding there.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Template Schemas §3.6.1 caps a `<UserIdentifier>` at 512 characters. Nothing
checked it, so a 513-character `let` binding name was accepted. The
conformance fixture pinning this is
`EXPR/job_templates/proposed/3.6.1--let-identifier-513.invalid.yaml`, added
by openjd-specifications#164, which failed here and in openjd-rs alike.

The cap is a flat 512 and deliberately does not reuse the §7.1
`<Identifier>` cap that `NameIdentifierLengthMixin` and the inline
`512 if "FEATURE_BUNDLE_1" in context.extensions else 64` checks apply.
§3.6.1 states one maximum for a `<UserIdentifier>` and gates it on no
extension, and the fixture pair proves the flat reading: the 512-character
accept twin `EXPR/job_templates/3.6--let-boundary-edges.yaml` declares EXPR
alone, so borrowing the §7.1 cap would limit it to 64 and reject a template
the spec permits. The two caps share the number 512 and nothing else, so
this adds `LET_MAX_IDENTIFIER_LEN` beside the existing `LET_MAX_BINDINGS`
with its own citation. EXPR gates whether `let` exists at all and moves
neither cap.

All four `_validate_let` field validators route through
`validate_let_field` and then `parse_let_bindings`, so the check goes in
`parse_let_bindings` after the `_LET_NAME_RE` match and every scope is
covered by one insertion. The message omits the offending name: at 513
characters it would dwarf the diagnostic.

Six tests, mutation-checked: removing the check fails all three reject tests
while the three accept tests keep passing, so the accept cases act as
negative controls against a cap set too low. The default `_job` helper
declares EXPR alone, which is what would catch a regression to the
FEATURE_BUNDLE_1-gated cap; the `_with_fb1` variants pin that declaring
FEATURE_BUNDLE_1 does not move it.

The matching openjd-rs change is OpenJobDescription/openjd-rs#358.

Design note: SuperDaveDocs docs/conformance-0901/4.6-let-513/fix.md

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The rationale lives in the commit message, the PR description and the
design note. The code only needs the citation and the one fact a reader
editing this line must not miss: the cap is flat, so it is not the
FEATURE_BUNDLE_1-gated section 7.1 cap.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Review finding on OpenJobDescription#348. The validator is a `field_validator` on the whole
`let` list, so pydantic anchors the error at `steps[0] -> let` with no list
index. Measured on a four-binding template where the third name is 513
characters: the error was

    steps[0] -> let:
        A 'let' binding name must be at most 512 characters long

which identifies neither the binding nor its position. With 50 bindings
allowed that is not diagnosable.

Truncate rather than omit, as the reviewer suggested. The message now
carries a 32-character prefix and the true length:

    A 'let' binding name must be at most 512 characters long:
    'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'... (513 characters)

Total error text goes from 110 to 166 characters, so it stays bounded while
becoming locatable. The original concern about a 513-character name dwarfing
the diagnostic still holds against interpolating the name in full, which is
why this truncates.

Two assertions pin it, both mutation-checked: reverting to the bare message
fails `test_name_513_chars` and the new
`test_name_513_chars_names_the_offending_binding`, which uses a
multi-binding `let` to cover the case the finding was about.

No matching change in openjd-rs: its path is `steps[0] -> let[0]`, so the
index already identifies the binding there.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl
leongdl force-pushed the fix/let-identifier-length branch from 28de2b2 to c476cf8 Compare September 2, 2026 20:36
Review finding on OpenJobDescription#348. The three accept-side tests bound a 512-character
name and never referenced it, so they proved `parse_let_bindings` does not
raise but not that the binding is usable, which is the property the
conformance fixture is about.

Each now interpolates the bound name in the script args, so the name flows
through the variable-reference validation and into the expression parser. All
24 tests still pass, which answers the finding's concern directly: no cap
further down that path, in `Identifier`, `FormatString`, or the Rust
expression layer, rejects a 512-character binding.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
# Truncated rather than omitted: the caller is a field_validator on the
# whole list, so the error path is `let` with no index to identify which
# binding is over.
if len(name) > LET_MAX_IDENTIFIER_LEN:

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 length check is placed after the _LET_NAME_RE check, which defeats the truncation this block exists to provide. The comment above says the name is truncated to 32 chars so the error stays readable, but line 948 (A 'let' binding name must be a valid identifier: {name!r}) interpolates the full, untruncated name — and that is the branch a long name hits whenever it also contains a disallowed character or starts with a digit/uppercase.

So let: ["A" * 100000 + " = 1"] (uppercase first char → fails the regex) produces a ~100 KB ValueError that pydantic wraps into the DecodeValidationError message, while the same name lowercased produces the nice 32-char-truncated one. The over-long case that is most likely to be adversarial is exactly the one that skips the guard.

Moving the length check above the regex check fixes both: nothing over 512 characters ever reaches an interpolation site.

        if len(name) > LET_MAX_IDENTIFIER_LEN:
            raise ValueError(
                f"A 'let' binding name must be at most {LET_MAX_IDENTIFIER_LEN} "
                f"characters long: {name[:32]!r}... ({len(name)} characters)"
            )
        if not _LET_NAME_RE.match(name):
            raise ValueError(f"A 'let' binding name must be a valid identifier: {name!r}")

Worth noting {binding!r} at lines 941 and 957 is unbounded for the same reason (pre-existing), so the reordering only closes the name path — but the name path is the one this PR is adding a bound for.

# §3.6.1: maximum length of a `let` binding's `<UserIdentifier>`. Flat, so not
# the §7.1 cap NameIdentifierLengthMixin applies: that one is 64 without
# FEATURE_BUNDLE_1, and a 512-character name must be accepted with EXPR alone.
LET_MAX_IDENTIFIER_LEN = 512

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Scope question on the cap: parse_let_bindings is only reached from the four let field validators (lines 1077, 1127, 1174, 3558). But <UserIdentifier> in §3.6.1 also covers the names bound inside an expression — comprehension variables and inline let ... in ... bindings — e.g. {{ [aaaa… for aaaa… in Param.Items] }}. Those go through ExprNode/the Rust engine (self._parsed.local_bindings in _format_strings/_nodes.py), which this check never sees.

If the intent is "enforce the §3.6.1 identifier cap," the two forms should agree; if the intent is narrower — only the let field, because the engine already bounds its own binders — the constant name and comment are misleading, since LET_MAX_IDENTIFIER_LEN reads as the identifier limit generally. Either extending the check to local_bindings or narrowing the comment to say the engine owns the in-expression form would remove the ambiguity.

test_comprehension_shadows_let shows the comprehension path is already exercised in this file, so a boundary test there would be cheap if the cap is meant to apply.

@leongdl
leongdl merged commit 262d3d2 into OpenJobDescription:mainline Sep 2, 2026
31 checks passed
leongdl added a commit to leongdl/openjd-model-for-python that referenced this pull request Sep 3, 2026
openjd-model 0.6.0 carries openjd-rs#358, which enforces Template Schemas
§3.6.1's 512-character cap on a `let` binding's `<UserIdentifier>`; before it a
513-character name was accepted. The v0 side has covered this since OpenJobDescription#348, in
`test/openjd/model_v0/v2023_09/test_let_bindings.py`. The v1 path had nothing, so
the bump brought the enforcement with no test on this side to hold it.

Measured through `decode_job_template` on both implementations before writing the
assertions. 512 characters is accepted and 513 rejected, identically, with `EXPR`
alone and with `EXPR` plus `FEATURE_BUNDLE_1`.

The accept at 512 with `EXPR` alone is the case worth having. The cap is flat,
not §7.1's `<Identifier>` cap of 64 rising to 512 under `FEATURE_BUNDLE_1`, so a
fix built on the wrong constant would reject a template the spec permits. A
65-character control covers the same ground from the other side: over the §7.1
cap, under §3.6.1's, and accepted.

The two implementations' messages differ in wording, so the assertion is on the
error path and the phrase `exceeds 512 characters`, not on the whole string.

Verified: 5572 passed, 24 skipped, 3 xfailed with the 94% coverage gate; ruff,
black and mypy clean.

Signed-off-by: David Leong <116610336+leongdl@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.

3 participants