Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,12 @@ field may declare:
`field` is the entity's own field or a one-hop `Relation.field`; `when` is a guard (see *the event
axis*) over the record's own columns. This is how "a fine cannot be marked identified without a
driver" is declared - the requiredness that `required: true` cannot express because the value is
legitimately empty earlier in the life of the record.
legitimately empty earlier in the life of the record. The `status:` gate is OPTIONAL, and its
PRESENCE is the routing: without one the rule holds on every user write (each generated
controller's `validate()`, a 400 with the authored message), with one the repository enforces it
when the record is persisted carrying that status - so a draft may still be filled in, and the
refusal reaches the person completing the task that sets the status rather than dead-lettering
as a process incident. A gated one needs the `function: EntityStatus` relation.
- `{ kind: forbidWhen, when: "SalesInvoice.Status == PAID", message: "..." }` (#7275): the
reject-twin - it refuses the write while its condition holds and reads no value, so it carries no
`field`. Its one reach beyond `requiredWhen` is that a term may name a one-hop `Relation.field`, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ class IntentEmissionCoverageIT extends IntegrationTest {
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
# The hop the Entry's gated requiredWhen reads - deliberately OPTIONAL, so that a
# runtime refusal is reachable at all: a rule over a REQUIRED column of the related
# row can never fire, which is how that check stayed source-text-only (#7238).
- { name: taxCode, type: string, length: 20 }
relations:
- { name: Parent, kind: manyToOne, to: Account }

Expand Down Expand Up @@ -225,8 +229,8 @@ class IntentEmissionCoverageIT extends IntegrationTest {
# requiredWhen (#7094), gated + over a relation hop: the value lives on the related
# account, so the generated repository loads it by FK before it can read it, and the
# rule only applies at the status the value is finally needed at.
- { kind: requiredWhen, field: Account.name, when: "note == 'audited'", status: 2,
message: "An audited entry must be booked against a named account" }
- { kind: requiredWhen, field: Account.taxCode, when: "note == 'audited'", status: 2,
message: "An audited entry must be booked against an account carrying a tax code" }
# Two values of the SAME row, related (#7095) - one temporal pair and one numeric,
# the two comparison families the generated code emits differently.
- { kind: compare, field: due, op: ge, than: date, message: 'A "due" date is never before the entry date' }
Expand Down Expand Up @@ -2208,7 +2212,7 @@ private void assertEmission() {
// invoice needs the customer's address"), and the condition is rendered against the guarded
// property's declared type, because a boxed comparison across types is silently always-false.
assertTrue(
entryRepository.contains("An audited entry must be booked against a named account")
entryRepository.contains("An audited entry must be booked against an account carrying a tax code")
&& entryRepository.contains("AccountRepository().findById(hop0Fk)")
&& entryRepository.contains("java.util.Objects.equals(entity.Note, \"audited\")"),
"checks: requiredWhen must load the hop, test the condition and refuse the empty value, got: " + entryRepository);
Expand Down Expand Up @@ -4595,6 +4599,102 @@ private void assertRuntimeEnforcement() {
.then()
.statusCode(200));

// checks: requiredWhen, GATED and reading a value one HOP away, at runtime (#7238). Doc's
// ungated twin above is the controller's; this is the other routing - no controller carries
// the rule, the repository enforces it at the gate status - and the hop is the reason the
// kind exists at all ("an e-mailed invoice needs the customer's address"). A source-text
// assertion cannot tell a gate that fires from one whose condition is never true, which is
// why the refusal, its authored message and the acceptance afterwards are asserted here.
AtomicInteger uncodedAccount = new AtomicInteger();
restAssuredExecutor.execute(() -> uncodedAccount.set(given().contentType("application/json")
.body("{\"Name\":\"Uncoded\"}")
.when()
.post(API + "/account/AccountController")
.then()
.statusCode(200)
.extract()
.path("Id")));
AtomicInteger auditedEntry = new AtomicInteger();
restAssuredExecutor.execute(() -> auditedEntry.set(given().contentType("application/json")
.body("{\"Date\":\"2026-01-21\",\"Account\":" + uncodedAccount.get()
+ ",\"Note\":\"audited\"}")
.when()
.post(API + "/entry/EntryController")
.then()
.statusCode(200)
.extract()
.path("Id")));
// Balanced lines, so the two document checks that gate on the same status are satisfied and
// the refusal below can only be the requiredWhen one.
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Entry\":" + auditedEntry.get() + ",\"Debit\":30}")
.when()
.post(API + "/entry/EntryLineController")
.then()
.statusCode(200));
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Entry\":" + auditedEntry.get() + ",\"Credit\":30}")
.when()
.post(API + "/entry/EntryLineController")
.then()
.statusCode(200));
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Id\":" + auditedEntry.get() + ",\"Date\":\"2026-01-21\",\"Account\":"
+ uncodedAccount.get() + ",\"Note\":\"audited\",\"Status\":2}")
.when()
.put(API + "/entry/EntryController/" + auditedEntry.get())
.then()
.statusCode(400)
.body("message", containsString(
"An audited entry must be booked against an account carrying a tax code")));
// ...and the condition is a CONDITION: an entry with no note reaches the same status against
// the SAME un-coded account, so what was just refused is the authored rule and not a
// `required` nobody declared.
AtomicInteger plainEntry = new AtomicInteger();
restAssuredExecutor.execute(() -> plainEntry.set(given().contentType("application/json")
.body("{\"Date\":\"2026-01-22\",\"Account\":" + uncodedAccount.get() + "}")
.when()
.post(API + "/entry/EntryController")
.then()
.statusCode(200)
.extract()
.path("Id")));
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Entry\":" + plainEntry.get() + ",\"Debit\":10}")
.when()
.post(API + "/entry/EntryLineController")
.then()
.statusCode(200));
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Entry\":" + plainEntry.get() + ",\"Credit\":10}")
.when()
.post(API + "/entry/EntryLineController")
.then()
.statusCode(200));
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Id\":" + plainEntry.get() + ",\"Date\":\"2026-01-22\",\"Account\":"
+ uncodedAccount.get() + ",\"Status\":2}")
.when()
.put(API + "/entry/EntryController/" + plainEntry.get())
.then()
.statusCode(200));
// ...and the audited entry passes once the RELATED row carries the value. The ACCOUNT is
// amended, not the entry, which is what proves the gate reads the hop when the write is
// checked rather than a copy the entry took when it was created.
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Id\":" + uncodedAccount.get() + ",\"Name\":\"Uncoded\",\"TaxCode\":\"BG-42\"}")
.when()
.put(API + "/account/AccountController/" + uncodedAccount.get())
.then()
.statusCode(200));
restAssuredExecutor.execute(() -> given().contentType("application/json")
.body("{\"Id\":" + auditedEntry.get() + ",\"Date\":\"2026-01-21\",\"Account\":"
+ uncodedAccount.get() + ",\"Note\":\"audited\",\"Status\":2}")
.when()
.put(API + "/entry/EntryController/" + auditedEntry.get())
.then()
.statusCode(200));

// entity-level unique (#6763): the composite key exists in the database (the second insert
// cannot land) AND the generated controller recognises which key was hit, so the caller is
// told what collided instead of getting a 500. The third write flips one column, which must
Expand Down
Loading