Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
494852a
feat: Implementing appUrl inside the notify subject/body interpolation
TIVMOF Aug 10, 2026
2a5ec98
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 10, 2026
95098fe
feat: Implementing tenant config overrides into client-Java listener …
TIVMOF Aug 10, 2026
62d419c
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 10, 2026
e9cc388
Merge branch 'master' into master
delchev Aug 11, 2026
c2782f6
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 18, 2026
1b694a6
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 19, 2026
0167087
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 19, 2026
248cd16
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 19, 2026
9488579
Merge branch 'eclipse-dirigible:master' into fix/discarding
TIVMOF Aug 19, 2026
d4d64fd
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 19, 2026
54e84ae
Merge branch 'eclipse-dirigible:master' into master
TIVMOF Aug 19, 2026
1227fe9
Split the resolve write so a rejected status cannot discard the ident…
TIVMOF Aug 19, 2026
e23af82
Merge origin/fix/discarding into fix/discarding
TIVMOF Aug 19, 2026
8af0d50
Poll for the Builder shell's bootstrap instead of sampling it once
TIVMOF Aug 19, 2026
209955c
Stop the generated-code assertions from reading comments and first ma…
TIVMOF Aug 19, 2026
e53cb73
Re-trigger CI
TIVMOF Aug 19, 2026
2aee65b
Merge commit 'ae74a66342429cf7416cf2dea16fe7c5cb26fe8f' into fix/disc…
TIVMOF Aug 19, 2026
b0117ba
Merge branch 'master' into fix/discarding
delchev Aug 20, 2026
ea44645
Merge remote-tracking branch 'origin/master' into pr-6841-resolve
delchev Aug 20, 2026
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
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1843,9 +1843,23 @@ private static void validateResolveOutcomes(ResolveIntent resolve, String subjec
issues.add(subject + " outcome [" + field + "] is not a field of [" + record.getName() + "]");
} else if (!"string".equals(declared.getType())) {
issues.add(subject + " outcome [" + field + "] must be a string field, was [" + declared.getType() + "]");
} else if (declared.getLength() != null && declared.getLength() < outcomeLength(anyStatus)) {
// The trace is the one field whose whole job is to be readable afterwards, so a length that
// truncates it is worse than useless - and it truncates at the DB, where nothing reports it.
// Routing widens the set the handler writes: a status the record cannot take leaves it
// amended (`ambiguous-notRouted`) so a routed-but-rejected record is not indistinguishable
// from a fully processed one.
issues.add(subject + " outcome [" + field + "] is length [" + declared.getLength() + "], too short for the values written - "
+ "at least [" + outcomeLength(anyStatus) + "]"
+ (anyStatus ? " once an outcome routes by setStatus (a rejected route amends the trace)" : ""));
}
}

/** The longest trace value the generated handler can write, with and without status routing. */
private static int outcomeLength(boolean routesByStatus) {
return routesByStatus ? "ambiguous-notRouted".length() : "ambiguous".length();
}

/** Whether the entity declares a {@code function: EntityStatus} relation. */
private static boolean hasEntityStatus(EntityIntent entity) {
if (entity.getRelations() == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2565,14 +2565,21 @@ are a filterable worklist a human can finish, and so a process `decision` can br
- `between.start` / `between.end` are register date fields, `between.value` the record's date. Either
bound may be omitted (open-ended = still valid); the end is **inclusive**, and a date-only bound
covers its whole day.
- Only the resolved relation, the outcome and the status are written - nothing else of the record.
- Only the resolved relation, the outcome and the status are written - nothing else of the record,
and the RESULT (relation + outcome) is written FIRST, separately from the routing status. A
status the record cannot take where it stands - an unmodeled `lifecycle:` move, a `checks:`
gate - is rejected by the repository, and batching the three meant that rejection discarded the
identification and the trace along with it. Split, the routing can fail without taking the work
with it: the outcome is amended to `<outcome>-notRouted` (e.g. `found-notRouted`) and logged,
so the record itself shows a routed-but-rejected attempt.

**Rules:** `event` binds `onCreate` or `onUpdate` of a declared entity (never `onDelete`); `set` is a
to-one of that entity; `from` is an entity declared in **this** model; `match` needs at least one pair
(left = register property, right = record property); `between.value` is required and every period
field must be a `date` or `timestamp`; `outcome` must be a `string` field of the record; a `setStatus`
needs the record to declare a `function: EntityStatus` relation, and may be a seed id or a seeded
name.
field must be a `date` or `timestamp`; `outcome` must be a `string` field of the record, long enough for
the values written (9, or 19 once any outcome routes by `setStatus` - the amended trace); a
`setStatus` needs the record to declare a `function: EntityStatus` relation, and may be a seed id
or a seeded name.

## Allowed values

Expand Down Expand Up @@ -2610,7 +2617,7 @@ name.
| transition `when` op | `==`, `!=` |
| resolve `event` | `onCreate`, `onUpdate` (never `onDelete`); `when` is `<Field> ==\|!= <value>` |
| resolve `between` field type | `date`, `timestamp` |
| resolve `outcome` values | `found`, `notFound`, `ambiguous` (stamped into a `string` field) |
| resolve `outcome` values | `found`, `notFound`, `ambiguous`, plus `<outcome>-notRouted` when a `setStatus` route is rejected (stamped into a `string` field) |

## Mapping requests to capabilities (quick reference)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,28 @@ void rejectsAStatusOutcomeWithoutAnEntityStatusRelation() {
"got: " + ex.getIssues());
}

/**
* The trace exists to be read afterwards, and it is truncated at the DB where nothing reports it.
* Routing widens the set the handler writes, because a status the record cannot take amends the
* trace rather than losing the whole attempt.
*/
@Test
void rejectsAnOutcomeFieldTooShortForTheValuesWritten() {
IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(
VALID.replace("{ name: resolution, type: string, readOnly: true }", "{ name: resolution, type: string, length: 12 }")));
assertTrue(ex.getIssues()
.stream()
.anyMatch(issue -> issue.contains("outcome [resolution] is length [12], too short") && issue.contains("at least [19]")
&& issue.contains("routes by setStatus")),
"got: " + ex.getIssues());
// Without routing, the handler writes only the three plain outcomes, so 12 is ample.
IntentParser.parse(
VALID.replace("{ name: resolution, type: string, readOnly: true }", "{ name: resolution, type: string, length: 12 }")
.replace("found: { setStatus: IDENTIFIED }", "found: {}")
.replace("notFound: { setStatus: UNRESOLVED }", "notFound: {}")
.replace("ambiguous: { setStatus: UNRESOLVED }", "ambiguous: {}"));
}

@Test
void rejectsAnUnknownRegister() {
IntentValidationException ex = assertThrows(IntentValidationException.class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import gen.${javaGenFolderName}.data.${javaRegisterPerspective}.${registerEntity
* logged with the keys and the date that were checked.
*
* A record that already carries ${setProperty} is skipped, so a manual correction is never overwritten
* and a re-delivered event is a no-op. The write is TARGETED (updateProperties) - only the resolved
* column, the outcome and the status are in the UPDATE statement, so a concurrent user write to any
* other column cannot be reverted, and no "-updated" event re-fires.
* and a re-delivered event is a no-op. The writes are TARGETED (updateProperties / updateProperty) -
* only the resolved column, the outcome and the status are in the UPDATE statements, so a concurrent
* user write to any other column cannot be reverted, and no "-updated" event re-fires. The result and
* the routing status go out SEPARATELY, so a rejected status move cannot discard the identification -
* see stamp().
*
* When an outcome routes the record by status, the "-transitioned" topic IS published afterwards, so
* that an automatic resolution reaches the same constructs a manual transition does - `generates:` and
Expand Down Expand Up @@ -111,41 +113,64 @@ public class ${className}Resolve implements MessageHandler {
}

/**
* One targeted write carrying everything this attempt decided: the resolved relation when there is
* one, the outcome trace, and the routing status. Nothing else of the record is touched.
* Persists what this attempt decided, in two writes rather than one, and in that order deliberately.
*
* A targeted write publishes NO event, so when this lookup routes the record by status it must
* announce that transition itself - see the "-transitioned" publish below.
* First the RESULT - the resolved relation and the outcome trace - because that is the work: the
* register was read, exactly one row covered the date, and this is the answer. Then, separately, the
* ROUTING status.
*
* They were one updateProperties call, and the DAO runs the lifecycle and checks gates against the
* post-write row BEFORE persisting anything, so a rejected status move discarded the relation and the
* trace with it: the lookup did the work, got the right answer, and threw all of it away - including
* the field whose entire purpose is recording what happened. Split, a rejected transition can no
* longer destroy the identification, and the audit trace is the LAST thing a failure can take.
*
* Nothing else of the record is touched (both writes are targeted), so no "-updated" re-fires and a
* concurrent write to any other column cannot be reverted. A targeted write also publishes NO event,
* so a status that IS written announces itself - see the "-transitioned" publish below.
*/
private static void stamp(Object id, String outcome, Integer resolved#if($writesStatus == "true"), Integer status#end) {
${entity}Repository repository = new ${entity}Repository();
java.util.Map<String, Object> values = new java.util.LinkedHashMap<>();
if (resolved != null) {
values.put("${setProperty}", resolved);
}
#if($outcomeProperty != "")
values.put("${outcomeProperty}", outcome);
#end
#if($writesStatus == "true")
if (status != null) {
values.put("${statusProperty}", status);
}
#end
if (!values.isEmpty()) {
new ${entity}Repository().updateProperties(id, values);
repository.updateProperties(id, values);
}
#if($writesStatus == "true")
if (status != null) {
// The status this lookup just routed the record to IS a transition, and the constructs that
// react to one - `generates:` and `postings:` bound to `event: { onTransition: ... }` - listen
// on "-transitioned". updateProperties above publishes nothing at all, so without this the
// AUTOMATIC path silently did nothing while the manual one (a `transitions:` button, which
// does publish) worked: exactly the wrong way round, and with no log line to show it.
// Reload so the payload carries the committed row rather than the pre-write snapshot, the
// same shape the transition controller and the create-from completion hook publish.
${entity}Entity transitioned = new ${entity}Repository().findById(id);
if (transitioned != null) {
org.eclipse.dirigible.sdk.messaging.Producer.sendToTopic("${projectName}-${perspective}-${entity}-transitioned",
Json.stringify(transitioned));
try {
repository.updateProperty(id, "${statusProperty}", status);
// The status this lookup just routed the record to IS a transition, and the constructs
// that react to one - `generates:` and `postings:` bound to `event: { onTransition: ... }`
// - listen on "-transitioned". The targeted write above publishes nothing at all, so
// without this the AUTOMATIC path silently did nothing while the manual one (a
// `transitions:` button, which does publish) worked: exactly the wrong way round, and with
// no log line to show it. Inside the try on purpose - a status the record could not take
// is not a transition, so a rejected move must announce nothing. Reload so the payload
// carries the committed row rather than the pre-write snapshot, the same shape the
// transition controller and the create-from completion hook publish.
${entity}Entity transitioned = repository.findById(id);
if (transitioned != null) {
org.eclipse.dirigible.sdk.messaging.Producer.sendToTopic("${projectName}-${perspective}-${entity}-transitioned",
Json.stringify(transitioned));
}
} catch (org.eclipse.dirigible.sdk.db.ValidationException rejected) {
// The record cannot take this status where it currently stands - an unmodeled lifecycle
// move, or a gate the status would trip. Retrying cannot help (nothing about the record
// changes by re-reading the register), so the failure is recorded rather than thrown: the
// relation and the outcome above are already committed and stay that way.
LOG.warn("${name}: ${entity} [{}] resolved [{}] but could not be routed to status [{}] - [{}]", id, outcome, status,
rejected.getMessage());
#if($outcomeProperty != "")
// Amend the trace so the record itself carries the evidence - otherwise a
// routed-but-rejected record is indistinguishable from a fully processed one.
repository.updateProperty(id, "${outcomeProperty}", outcome + "-notRouted");
#end
}
}
#end
Expand Down
Loading
Loading