Skip to content

intent: a value required only under a condition - checks: requiredWhen (#7094) - #7129

Merged
delchev merged 1 commit into
masterfrom
issue-7094-check-required-when
Sep 8, 2026
Merged

intent: a value required only under a condition - checks: requiredWhen (#7094)#7129
delchev merged 1 commit into
masterfrom
issue-7094-check-required-when

Conversation

@delchev

@delchev delchev commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

required: true is unconditional, and most rules about a missing value are not: the value is needed for ONE way of handling the record and meaningless for the others. An invoice sent by e-mail needs the customer's e-mail address; one sent by post does not - so required on the address is not the rule, checks: knew no kind that was (exactlyOne / itemsSumEqual / itemsMin), and the real rule was therefore declared nowhere. An e-mailed invoice whose customer carried no address went through Send: status SENT, the mail step logging a no-op for a recipient it did not have, nothing stamped on the record, and the clerk who pressed the button told it had succeeded.

- name: SalesInvoice
  checks:
    - { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT,
        message: "Sent Method is E-mail but the customer has no e-mail address" }
    - { kind: requiredWhen, field: reference, when: "kind == 'export'",
        message: "An export needs a reference" }

The value may be one hop away, which is why the kind exists at all - the rule is about the record being sent and the address belongs to its customer. field: is walked by ResolvePathSupport, the resolver every other path in the DSL uses, so a cross-model target reads too and a path walking on past one is refused there. The hops travel into the .model as the check's pathLoads and ModelParameterProcessor.resolveCheckPathLoads turns them into the generated Entity/Repository FQNs - the pass that knows the generation folder, exactly as for a master's inherited lock, and the .model twin could not re-derive another model's folder at all. The generated reader loads each hop by id and reads the field null-guarded, so a missing link is an EMPTY value the check fires on rather than a throw inside a repository.

The status: gate is optional here, and its presence is the routing. No gate = a row check: ModelParameterProcessor files it with rowChecks and all three controller templates enforce it in validate() as a 400 carrying the authored message, like exactlyOne. A gate = the repository's enforceChecks, like itemsMin - which puts it on the synchronous path #7014/#7063 opened, so the refusal is the 400 the Inbox shows the person who pressed the button instead of a dead-lettered process incident. A gated check requires a function: EntityStatus relation to read the gate from (parser-enforced).

The condition is closed and typed. CheckSupport owns both halves - the pattern the parser refuses on and the Java the EDM generator emits - in one class so they cannot drift. Two refusals, both about a guard that would otherwise be silently wrong:

  • a condition the generator cannot compile is a parse error. Degrading it to true, which is what the shared NotificationSupport.guard does for a glue listener, would make the value unconditionally required - a required nobody authored;
  • a literal that is not a value of the property's declared type is refused, because Objects.equals(Long, int) never holds and a guard on a long column would switch the rule off while looking authored.

Only string/text/integer/int/long/boolean and a to-one's integer FK are guardable at all; a decimal, a double or a date is compared for equality by nobody who means it. A status name in the condition resolves to its seed id like every other guard (StatusSymbolResolver.rewriteWhen on the check node), and the list form is an implicit AND, as in #6957.

A model declaring no requiredWhen generates byte-identically; the only pre-existing emission change is that the Criteria import in the DAO now follows the item-counting kinds specifically, so a model whose only document check is a gated requiredWhen does not import it unused.

Tests

  • Unit: IntentParserTest.conditionallyRequiredValuesParseAndValidate (the four refusals), EdmIntentGeneratorTest.conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThrough, ModelParameterProcessorTest (the routing split + the hop FQNs). 1129 unit tests green across engine-intent + ide-template.
  • IT: IntentEmissionCoverageIT (green locally, 137 s) - a gated check over a relation hop in EntryRepository, an ungated one in DocController with its status name resolved, and the assertion that the ungated one is NOT in the repository. The generated client Java compiles in that run.

Docs

Fixes #7094

🤖 Generated with Claude Code

…en` (#7094)

`required: true` is unconditional, and most rules about a missing value are
not: the value is needed for ONE way of handling the record and meaningless
for the others. An invoice sent by e-mail needs the customer's e-mail
address; one sent by post does not - so `required` on the address is not the
rule, `checks:` had no kind that was, and the real rule was therefore not
declared anywhere. An e-mailed invoice whose customer carried no address went
through Send: status SENT, the mail step logging a no-op for a recipient it
did not have, nothing stamped on the record, and the clerk who pressed the
button told it had succeeded.

`- { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1",
status: SENT, message: ... }`.

**The value may be one hop away**, which is why the kind exists at all - the
rule is about the record being sent and the address belongs to its customer.
`field:` is walked by `ResolvePathSupport`, the resolver every other path in
the DSL uses, so a cross-model target reads too and a path walking on past
one is refused there. The hops travel into the `.model` as the check's
`pathLoads` and `ModelParameterProcessor` turns them into the generated
Entity/Repository FQNs - the pass that knows the generation folder, exactly
as for a master's inherited lock. The reader loads each hop by id and reads
the field null-guarded, so a missing link is an EMPTY value the check fires
on rather than a throw inside a repository.

**The `status:` gate is optional, and its presence is the routing.** No gate
= a row check: every generated controller's `validate()`, a 400 with the
authored message, like `exactlyOne`. A gate = the repository's
`enforceChecks`, like `itemsMin`, which puts it on the synchronous path
#7014/#7063 opened - the refusal reaches the person completing the task
instead of dead-lettering as a process incident.

**The condition is closed and typed.** `CheckSupport` owns the pattern the
parser refuses on and the Java the generator emits, in one class so they
cannot drift. Two refusals, both about a guard that would otherwise be
silently wrong: a condition the generator cannot compile is a parse error
(degrading it to `true`, as the shared glue guard does, would make the value
unconditionally required - a `required` nobody authored), and a literal that
is not a value of the property's declared type is refused, because
`Objects.equals(Long, int)` never holds and a guard on a `long` column would
switch the rule off while looking authored. Only strings, integers, booleans
and a to-one's key are guardable; a decimal, a double or a date is compared
for equality by nobody who means it. A status name resolves to its seed id
like every other guard, and the list form is an implicit AND.

Unit: `IntentParserTest`, `EdmIntentGeneratorTest`, `ModelParameterProcessorTest`.
IT: `IntentEmissionCoverageIT` - a gated check over a hop in `EntryRepository`,
an ungated one in `DocController`, and that the ungated one is not in the
repository.

Fixes #7094

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