Skip to content

intent: a checks: compare may name a LITERAL, and may carry a status gate (#7338) - #7355

Open
delchev wants to merge 1 commit into
masterfrom
issue-7338-compare-literal
Open

intent: a checks: compare may name a LITERAL, and may carry a status gate (#7338)#7355
delchev wants to merge 1 commit into
masterfrom
issue-7338-compare-literal

Conversation

@delchev

@delchev delchev commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

The gap

checks: had five kinds and four of them related two things the model already names — two fields of a row, two item sums, an item count. None related a field to a constant, so the commonest validation in a business model had no declaration at all: VacationDay.Days > 0 (a negative row silently inflates the parent entitlement, because the roll-up sums the column verbatim), a quantity >= 0, a percentage <= 100, a date not in the past.

The three workarounds in the fleet were each worse than the gap: a hand-edit of the generated controller's validate() (dropped by the next regeneration, silently), a calculatedActionOnCreate that throws (a calculation rather than a refusal, firing only on the field that declares it and reaching the caller as whatever the action's exception carries), or not enforcing it at all.

The change

compare takes value: as the alternative to than: — mutually exclusive, exactly one required, since a comparison has one right-hand side:

checks:
  - { kind: compare, field: days, op: gt, value: 0, message: "A request must cover at least one working day" }
  - { kind: compare, field: discountPercent, op: le, value: 100, message: "A discount cannot exceed 100%" }
  - { kind: compare, field: from, op: ge, value: "CURRENT_DATE", message: "Leave cannot start in the past" }

The literal is typed by the field it is compared with, by CheckSupport.compareLiteral — the one rule the parser refuses on and the generator renders with, so nothing is refused that would have generated and nothing generates that was not refused. A numeric field takes a number, compared by value through BigDecimal (exact across the widths, as the two-field form already was). A temporal field takes either a moment (CURRENT_DATE / CURRENT_TIMESTAMP / NOW with at most one signed ISO-8601 offset — the vocabulary a schedule's where: already carries, resolved against the clock of the WRITE) or a quoted ISO-8601 date / instant, rendered in the shape the generated column actually carries (LocalDate for a date, Instant for a timestamp; a comparison across those two does not compile). A temporal literal must be quoted — an unquoted 2026-01-01 is a date object to the YAML loader long before the intent sees it — and that is refused with a message saying so. An absent operand is not a violation, exactly as with than:.

The second half is the gate. compare used to refuse a status:; it now takes the optional one requiredWhen has, and that is the routing: without a gate the rule holds on every user write (the entity, personal and partner controllers — a 400 with the authored message), with one it is the repository's and holds when the record is persisted carrying that status. { kind: compare, field: days, op: gt, value: 0, status: SUBMITTED } is the rule the issue describes as the one actually needed — "days > 0 before SUBMITTED" — and which was mis-authored as an itemsMin over a child the approval delegate had not created yet, refusing every submission in the field for three weeks. A gated compare needs the entity's function: EntityStatus relation; the parser says so when it is missing.

The app-test manifest carries a literal right-hand side too (npm/test steers the sample record off it), so a generated smoke test is not refused with 400 by the very check the module just declared.

Verified

  • engine-intent + ide-template unit suites green (1238 tests). New parser cases for every refusal — a non-numeric literal, a moment of the wrong shape (CURRENT_TIMESTAMP against a date), a time-based offset on a date, an unreadable temporal literal, a gate without an EntityStatus relation, both right-hand sides and neither — plus generator cases for the three rendered shapes (new java.math.BigDecimal("0"), java.time.LocalDate.now(), java.time.Instant.now().plus(java.time.Duration.parse("PT1H"))) and the gate's status/statusProperty. A ModelParameterProcessor case pins the row/document split.
  • IntentEmissionCoverageIT green (1/1). The emission module now declares an ungated literal comparison (paid >= 0) and a gated one (amount > 0 at POSTED). The IT asserts the emitted controller and repository source AND drives both over REST against the published app: a negative Paid is refused 400 with the authored message while zero passes (ge is inclusive) and an absent Paid is not a violation; a zero-amount document is accepted as a DRAFT and refused only when the write carries POSTED, then accepted with a positive amount.
  • mvn formatter:validate with the formatter cache wiped, over the whole reactor.
  • All four templates that consume $check. were updated (EntityController, EntityMyController, EntityPartnerController, Repository); no other template reads checks.
  • Docs updated: engine-intent/README.md, intent-assistant-guide.md, engine-intent/CLAUDE.md, .claude/docs/intent-layer.md.

Not done: no UI-side (client) pre-validation for the new form — like the existing compare, the refusal is the server's 400.

Fixes #7338

🤖 Generated with Claude Code

…gate (#7338)

Four of the five `checks:` kinds related two things the model already names -
two fields of a row, two item sums, an item count - and none of them related a
field to a constant. So the commonest validation in a business model had no
declaration at all: `days > 0`, a quantity `>= 0`, a percentage `<= 100`, a date
not in the past. The three workarounds in the fleet were each worse than the
gap: a hand-edit of the generated controller's `validate()` (dropped by the next
regeneration, silently), a `calculatedActionOnCreate` that throws (a calculation,
not a refusal, firing only on the field that declares it), or no enforcement.

`compare` now takes `value:` as the alternative to `than:` - mutually exclusive,
exactly one required, since a comparison has one right-hand side:

    - { kind: compare, field: days, op: gt, value: 0, message: "..." }
    - { kind: compare, field: from, op: ge, value: "CURRENT_DATE", message: "..." }

The literal is TYPED by the field it is compared with, by `CheckSupport.compareLiteral`
- the one rule the parser refuses on and the generator renders with, so nothing is
refused that would have generated and nothing generates that was not refused. A
numeric field takes a number, compared by value through `BigDecimal` (exact across
the widths, as the two-field form already was); a temporal one takes a moment
(`CURRENT_DATE` / `CURRENT_TIMESTAMP` / `NOW` with at most one signed ISO-8601
offset - the vocabulary a schedule's `where:` already carries, resolved against the
clock of the WRITE) or a quoted ISO-8601 date/instant. It renders in the shape the
generated column actually carries - `LocalDate` for a `date`, `Instant` for a
`timestamp` - because a comparison across those two does not compile. An absent
operand is not a violation, exactly as with `than:`.

The second half is the gate. `compare` used to refuse a `status:`; it now takes the
optional one `requiredWhen` has, and that is the routing: without a gate the rule
holds on every user write (the entity, personal and partner controllers, 400 with
the authored message), with one it is the repository's and holds when the record is
persisted carrying that status. "days > 0 before SUBMITTED" is the rule that was
mis-authored as an `itemsMin` over a child the approval delegate had not created
yet, which refused every submission in the field for three weeks.

The app-test manifest carries a literal right-hand side too, so the generated
smoke test's sample record satisfies the check the module just declared instead of
being refused with 400 by it.

Verified: engine-intent + ide-template unit suites green (1238 tests), including
new parser cases for every refusal (a non-numeric literal, a moment of the wrong
shape, a time offset on a date, an unreadable temporal literal, a gate without an
EntityStatus relation, both/neither right-hand side) and generator cases for the
three rendered shapes. `IntentEmissionCoverageIT` green: the emission module now
declares both an ungated literal comparison and a gated one, and the IT asserts
the emitted controller/repository source AND drives both over REST - a negative
Paid refused 400 with the authored message while zero passes, and a zero-amount
document accepted as a DRAFT but refused when the write carries POSTED. Formatter
validated with the cache wiped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant