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
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ generates:
defaults: { InvoiceDate: now }
items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } }
sourceStatus: 3 # optional completion hook: the SOURCE's EntityStatus after creation
sourceStatusOnRetire: 2 # optional INVERSE: where the SOURCE returns when the target is retired
```

`items:` has two mutually-exclusive shapes. As an OBJECT (above) it MIRRORS each source child row
Expand All @@ -426,6 +427,18 @@ status init and calculated fields fire. `sourceStatus:` flips the SOURCE to the
seed id once the target exists (proforma -> INVOICED) - a system write: no `-updated` re-fire, but
the source's `-transitioned` topic is published.

`event: { onTransition: <Source>, when: "Status == <status>" }` (or `onCreate`, or a process step)
mints the target with nobody clicking; the `map:` entry copying the source's key is then the
**at-most-once guard**, and a target retired into a `cancelled`/`void` `stage:` stops blocking, so the
source may be generated from again. `sourceStatusOnRetire:` is the INVERSE of the completion hook and
what makes that reissue automatic: retiring the target returns the source to the named status - one
targeted write carrying the source's `-transitioned` - so the ordinary trigger re-fires and mints the
replacement. It fires only while the source still stands at `sourceStatus` and no target of it still
counts, so a redelivered retirement is a no-op. Without it, a source flipped by `sourceStatus:` can never re-qualify and only a shared
`button: true` can reissue. It needs an `event:` to re-fire, `sourceStatus:` (a different
status), a local target whose nomenclature classifies a retiring stage, `mode: once`, and - when the
source declares a `lifecycle:` - the edge back.

`prompt:` (#6685) declares a small input form shown before the target is created - the values the
source cannot derive (which payment, how much). Entries name fields / to-one relations of the
TARGET, so the dialog's controls are typed from the target's own definitions and its `dependsOn:`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ public void generate(IntentGenerationContext context) {
glue.put("generateEvents", generates.stream()
.filter(entry -> Boolean.TRUE.equals(entry.get("hasEvent")))
.toList());
// The declared-reopen subset (issue #6868), filtered from the same descriptors for the same
// reason: the listener that returns the source when its target is retired must agree with the
// create-from's own guard about what "retired" means, and a create-from that declares no reopen
// must contribute no listener at all.
glue.put("generateReopens", generates.stream()
.filter(entry -> Boolean.TRUE.equals(entry.get("hasReopen")))
.toList());
glue.put("transitions", transitions);
glue.put("sends", sends);
glue.put("postings", postings);
Expand Down Expand Up @@ -714,7 +721,9 @@ private static List<Map<String, Object>> buildSettlementListeners(List<Map<Strin
* <p>
* An entry declaring an {@code event} (issue #6711) additionally lands in the
* {@code generateEvents} collection, whose template renders the listener that calls the same
* create-from - see {@link #putGeneratesEvent}.
* create-from - see {@link #putGeneratesEvent}. One declaring a {@code sourceStatusOnRetire} (issue
* #6868) lands in {@code generateReopens} as well, whose template renders the listener that returns
* the source when the target it made is retired - see {@link #putSupersededTarget}.
*/
private static List<Map<String, Object>> buildGenerates(IntentModel model, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, IntentSettings settings, IntentGenerationContext context) {
Expand Down Expand Up @@ -835,6 +844,15 @@ private static List<Map<String, Object>> buildGenerates(IntentModel model, Map<S
e.put("sourceStatusProperty", sourceStatusProperty);
e.put("sourceStatusValue",
g.getSourceStatus() == null || sourceStatusProperty.isEmpty() ? "" : String.valueOf(g.getSourceStatus()));
if (sourceStatusProperty.isEmpty()) {
// No status FK resolved on the source, so there is no completion hook and nothing for a
// retired target to return the source to. Emitting the reopen listener anyway would give
// it a column to write that does not exist - a failure visible only at run time. Cleared
// whole, so the .glue never records a reopen that cannot be generated.
e.put("hasReopen", false);
e.put("reopenStatusValue", "");
e.put("reopenRetiredCondition", "");
}

GeneratesItemsIntent items = g.getItems();
boolean hasItems = items != null && items.getFrom() != null && !items.getFrom()
Expand Down Expand Up @@ -1036,6 +1054,13 @@ private static void putGeneratesEvent(GeneratesIntent g, Map<String, Object> e,
* carry a lifecycle whose nomenclature nobody classified gets the warning - that combination is the
* silent one, where the guard looks state-aware and is not.
*
* <p>
* The SAME resolution also drives the declared reopen (issue #6868), which reads the classification
* from the other end: the guard asks whether the target that already exists is retired, the reopen
* listener asks whether the transition it just saw is what retired it. Emitting both from one
* resolution is what stops them disagreeing about what "retired" means - and the reason the reopen
* introduces no vocabulary of its own to say it.
*
* @param g the create-from
* @param e the glue entry being built
* @param model the model being generated
Expand All @@ -1047,9 +1072,13 @@ private static void putSupersededTarget(GeneratesIntent g, Map<String, Object> e
e.put("hasRetiredStatus", false);
e.put("retiredStatusProperty", "");
e.put("retiredStatusCondition", "");
e.put("hasReopen", false);
e.put("reopenStatusValue", "");
e.put("reopenRetiredCondition", "");
// An appending create-from (issue #6800) keeps no guard at all, so there is nothing for a
// retired target to release - and warning about an unclassified nomenclature there would be
// noise about a guard that does not exist.
// noise about a guard that does not exist. A reopen is refused on that shape by the parser, so
// there is nothing to emit for it here either.
if (!g.isEventDriven() || g.isAppendMode()) {
return;
}
Expand Down Expand Up @@ -1077,17 +1106,41 @@ private static void putSupersededTarget(GeneratesIntent g, Map<String, Object> e
e.put("retiredStatusProperty", property);
// Rendered against the template's loop variable: a retired candidate is stepped over, the first
// one that is not is this source's document.
e.put("retiredStatusCondition", retiredCondition("candidate", property, retired));
// The declared reopen (issue #6868) reads the SAME classification from the other end: the guard
// asks "is the document that exists retired?", the reopen listener asks "did this transition
// retire it?". One resolution, so the two can never disagree about what retired means - which
// is the whole reason the reopen adds no vocabulary of its own for it.
if (!g.hasReopen()) {
return;
}
e.put("hasReopen", true);
e.put("reopenStatusValue", String.valueOf(g.getSourceStatusOnRetire()));
e.put("reopenRetiredCondition", retiredCondition("target", property, retired));
}

/**
* The retiring-status test as a Java disjunction over a named local - {@code cancelled} and
* {@code void} ids in seed order.
*
* @param local the Java local the status is read off
* @param property the status FK property
* @param retired the retiring seed ids
* @return the rendered condition
*/
private static String retiredCondition(String local, String property, List<Integer> retired) {
StringBuilder condition = new StringBuilder();
for (Integer id : retired) {
if (condition.length() > 0) {
condition.append(" || ");
}
condition.append("candidate.")
condition.append(local)
.append('.')
.append(property)
.append(" == ")
.append(id);
}
e.put("retiredStatusCondition", condition.toString());
return condition.toString();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,36 @@ public class GeneratesIntent {
*/
private Integer sourceStatus;

/**
* The INVERSE of {@link #sourceStatus} (issue #6868): the seed id the SOURCE returns to when the
* target generated from it is RETIRED - reaches a status its nomenclature classifies {@code
* cancelled} or {@code void}. Void and reissue, declared.
*
* <p>
* Why it is needed at all: {@link #sourceStatus} moves the source off the status its own
* {@code event} guard qualifies on, deliberately, so the guard-claimed source stops matching. The
* at-most-once guard learned to step over a retired target (issue #6814), which frees the source's
* one-shot slot - but nothing could refill it: the source stands at its post-generation status and
* the ordinary lifecycle graph declares no edge back, so no qualifying {@code -transitioned} is
* ever published again and an event-only create-from had no reissue path at all.
*
* <p>
* This declares the move back. The retirement of the target flips the source to this status through
* the same targeted primitive the completion hook uses, publishing the source's
* {@code -transitioned} with the write - so the ordinary trigger re-fires, the guard steps over the
* retired document, and the replacement is minted. Nothing about the reissue is a special path: it
* is the source's own lifecycle move plus the machinery that was already there. The retired
* document is kept, never edited or re-pointed.
*
* <p>
* It is opt-in and refused where it could never fire (see
* {@code IntentParser.validateGeneratesReopen}): it requires an {@link #event} to re-fire and
* {@link #sourceStatus} to invert, must name a status other than that one, needs a LOCAL target
* whose nomenclature classifies a retiring {@code stage:}, and - when the source declares a
* {@code lifecycle:} - needs that graph to declare the edge back.
*/
private Integer sourceStatusOnRetire;

/** Target property -> source property (a field or to-one relation name of {@link #from}). */
private Map<String, String> map = new LinkedHashMap<>();

Expand Down Expand Up @@ -337,6 +367,22 @@ public void setSourceStatus(Integer sourceStatus) {
this.sourceStatus = sourceStatus;
}

public Integer getSourceStatusOnRetire() {
return sourceStatusOnRetire;
}

public void setSourceStatusOnRetire(Integer sourceStatusOnRetire) {
this.sourceStatusOnRetire = sourceStatusOnRetire;
}

/**
* Whether a retired target returns the source to a status of its own (see
* {@link #sourceStatusOnRetire}).
*/
public boolean hasReopen() {
return sourceStatusOnRetire != null;
}

public Map<String, String> getMap() {
return map;
}
Expand Down
Loading
Loading