intent: a checks: compare may name a LITERAL, and may carry a status gate (#7338) - #7355
Open
delchev wants to merge 1 commit into
Open
intent: a checks: compare may name a LITERAL, and may carry a status gate (#7338)#7355delchev wants to merge 1 commit into
delchev wants to merge 1 commit into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), acalculatedActionOnCreatethat 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
comparetakesvalue:as the alternative tothan:— mutually exclusive, exactly one required, since a comparison has one right-hand side: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 throughBigDecimal(exact across the widths, as the two-field form already was). A temporal field takes either a moment (CURRENT_DATE/CURRENT_TIMESTAMP/NOWwith at most one signed ISO-8601 offset — the vocabulary a schedule'swhere: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 (LocalDatefor adate,Instantfor atimestamp; a comparison across those two does not compile). A temporal literal must be quoted — an unquoted2026-01-01is 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 withthan:.The second half is the gate.
compareused to refuse astatus:; it now takes the optional onerequiredWhenhas, 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 anitemsMinover a child the approval delegate had not created yet, refusing every submission in the field for three weeks. A gated compare needs the entity'sfunction: EntityStatusrelation; the parser says so when it is missing.The app-test manifest carries a literal right-hand side too (
npm/teststeers the sample record off it), so a generated smoke test is not refused with 400 by the very check the module just declared.Verified
CURRENT_TIMESTAMPagainst adate), a time-based offset on a date, an unreadable temporal literal, a gate without anEntityStatusrelation, 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'sstatus/statusProperty. AModelParameterProcessorcase pins the row/document split.IntentEmissionCoverageITgreen (1/1). The emission module now declares an ungated literal comparison (paid >= 0) and a gated one (amount > 0at POSTED). The IT asserts the emitted controller and repository source AND drives both over REST against the published app: a negativePaidis refused 400 with the authored message while zero passes (geis inclusive) and an absentPaidis 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:validatewith the formatter cache wiped, over the whole reactor.$check.were updated (EntityController,EntityMyController,EntityPartnerController,Repository); no other template reads checks.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