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.

Original file line number Diff line number Diff line change
Expand Up @@ -1482,9 +1482,18 @@ private static void putSupersededTarget(GeneratesIntent g, Map<String, Object> e
// retired target to release - and warning about an unclassified nomenclature there would be
// 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()) {
if (g.isAppendMode()) {
return;
}
// The at-most-once guard and its stage-aware step-over belong to the EVENT trigger. A button
// create-from has neither - but it may still declare the reopen (#7647): its completion hook
// flips the source off the status the button is offered from, and the implied fromStatus deny
// (#7068) then refuses the second click, so the source is as stuck as a guarded one and the
// declared inverse is the same move back. Only the reopen half is emitted for that shape.
boolean guarded = g.isEventDriven();
if (!guarded && !g.hasReopen()) {
return; // a plain button create-from: no guard to make stage-aware, no reopen to emit
}
RelationIntent status = LifecycleStages.statusRelation(target);
if (status == null || status.getTo() == null) {
return;
Expand All @@ -1493,10 +1502,14 @@ private static void putSupersededTarget(GeneratesIntent g, Map<String, Object> e
List<Integer> retired = new ArrayList<>(stages.getOrDefault(LifecycleStages.CANCELLED, List.of()));
retired.addAll(stages.getOrDefault(LifecycleStages.VOID, List.of()));
if (retired.isEmpty()) {
String warning = "generates [" + g.getName() + "] is event-driven and its target [" + g.getTo()
+ "] carries a lifecycle status [" + status.getName() + "], but no seed row of [" + status.getTo()
+ "] is classified with `stage:` - the at-most-once guard can only ask whether a [" + g.getTo()
+ "] exists, so a cancelled or voided one blocks its replacement forever. Classify the seed rows of [" + status.getTo()
String warning = "generates [" + g.getName() + "] " + (guarded ? "is event-driven" : "declares sourceStatusOnRetire")
+ " and its target [" + g.getTo() + "] carries a lifecycle status [" + status.getName() + "], but no seed row of ["
+ status.getTo() + "] is classified with `stage:` - "
+ (guarded
? "the at-most-once guard can only ask whether a [" + g.getTo()
+ "] exists, so a cancelled or voided one blocks its replacement forever."
: "nothing here can tell a retired [" + g.getTo() + "] from a live one, so the source is never returned.")
+ " Classify the seed rows of [" + status.getTo()
+ "] with `stage:` (draft/live/cancelled/void) so a retired target can be superseded.";
LOGGER.warn(LoggedValue.of(warning));
if (context != null) {
Expand All @@ -1505,7 +1518,7 @@ private static void putSupersededTarget(GeneratesIntent g, Map<String, Object> e
return;
}
String property = IntentNaming.pascalCase(status.getName());
e.put("hasRetiredStatus", true);
e.put("hasRetiredStatus", guarded);
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9311,16 +9311,14 @@ private static void validateGeneratesReopen(GeneratesIntent g, String name, Map<
+ " another " + g.getTo() + "; drop the reopen, or use mode: once");
return;
}
if (!g.isEventDriven()) {
// A create-from with no event carries no guard at all, so nothing ever blocks a second
// creation: the button IS the reissue. There is no slot to free and no trigger to re-fire,
// which is why the glue emits no reopen listener for this shape - and an authored key that
// generates nothing is the silence this whole construct exists to refuse.
issues.add(subject + " declares sourceStatusOnRetire but has no event: - a create-from triggered only by a button carries"
+ " no at-most-once guard, so nothing blocks a replacement and the button already reissues. The reopen exists to"
+ " re-fire an EVENT trigger; declare event: or drop the key");
return;
}
// A BUTTON create-from used to be refused here, on the reasoning that nothing blocks a second
// click so the button already reissues. That holds only without a completion hook: a declared
// `sourceStatus` flips the source off the status the button is offered from, and the implied
// `fromStatus` deny of exactly that status (#7068) then refuses the second click with a 409 -
// so the source is stuck, `immutableWhen` locks it, and nothing can move it back (#7647). The
// hook blocks the button exactly as the guard blocks the trigger, and the declared inverse is
// the move back in both shapes. What stays refused is a button create-from with no hook at all,
// which the sourceStatus check above has already returned on.
if (crossModel) {
issues.add(subject + " cannot reopen for a cross-model target (uses [" + g.getUses() + "]) - what RETIRES a [" + g.getTo()
+ "] is the `stage:` classification of its status nomenclature, seeded in the owner model and not resolvable here;"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2073,7 +2073,9 @@ generates:
sourceStatus: 3 # optional completion hook: the SOURCE's EntityStatus seed id
# after the target is created (e.g. proforma -> INVOICED)
sourceStatusOnRetire: 2 # optional INVERSE of that hook: where the SOURCE returns when the
# target is retired (cancelled/void) - see "void and reissue"
# target is retired (cancelled/void) - see "void and reissue".
# Takes a button create-from too, where the hook is what sticks
# the source (#7647)
```

**Which source rows become lines (`items: where:` / `refuse:`).** The mirror form clones every row
Expand Down Expand Up @@ -2367,7 +2369,8 @@ generates:
slot; but where `sourceStatus:` is declared nothing could refill it. The completion hook moved the
source OFF the status its own trigger qualifies on - deliberately - and the ordinary lifecycle graph
declares no edge back, so no qualifying event is ever published again: an event-only create-from had
no reissue path at all, and only a shared `button: true` could raise the replacement.
no reissue path at all, and only a shared `button: true` could raise the replacement - and a
BUTTON-only one is no better off, the implied `fromStatus` deny refusing the second click (#7647).
`sourceStatusOnRetire:` declares the move back, so the reissue becomes the ORDINARY path:

```yaml
Expand All @@ -2388,9 +2391,12 @@ generates:
end, which is what makes it idempotent with no marker column: a redelivered retirement arriving after
the replacement exists finds a live target and does nothing.

It requires an `event:` to re-fire (a button-only create-from carries no guard, so nothing blocks a
replacement - the button already reissues) and `sourceStatus:` to invert, and must name a DIFFERENT
status; the target must be local, with a nomenclature that classifies a retiring `stage:` (a
It requires `sourceStatus:` to invert and must name a DIFFERENT status. It does NOT require an
`event:` (#7647): a BUTTON create-from carries no at-most-once guard, but its completion hook flips
the source off the status the button is offered from and the implied `fromStatus` deny then refuses
the second click, so the source is just as stuck and the declared inverse is the only move back - the
reopen is emitted for that shape too, without the guard's stage-aware step-over, which belongs to a
guard it does not have. the target must be local, with a nomenclature that classifies a retiring `stage:` (a
cross-model target is seeded in its owner model, so nothing here can recognise its retirement - keep
`button: true` and reissue by hand); `mode: append` is refused (no guard, so no slot to free); and
when the source declares a `lifecycle:`, the graph must declare the edge from `sourceStatus` back to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ class GlueGeneratesTest {
sourceStatusOnRetire: 2
""");

/**
* The same reopen on a BUTTON create-from (#7647): no {@code event:}, so no at-most-once guard to
* make stage-aware - but the completion hook still flips the fine off the status the button is
* offered from, so the reopen is the only thing that can bring it back.
*/
private static final String BUTTON_REOPEN_YAML = REOPEN_YAML.replace("""
event: { onTransition: Fine, when: "Status == POSTED" }
""", "");

@SuppressWarnings("unchecked")
@Test
void rendersHeaderAssignmentsItemsAndKeys() {
Expand Down Expand Up @@ -1226,6 +1235,23 @@ void aDeclaredReopenEmitsTheInverseOfTheCompletionHook() {
* create-from written before the key existed regenerates byte-identical output and contributes no
* listener.
*/
/**
* A button create-from emits the reopen but not the stage-aware step-over (#7647): the guard the
* step-over belongs to is the event trigger's, and this shape has none. The source is stuck all the
* same - the hook flips it off the status the button is offered from, and the implied fromStatus
* deny refuses the second click - so the inverse is exactly what is missing.
*/
@Test
void aButtonCreateFromEmitsTheReopenWithoutTheStageAwareGuard() {
Map<String, Object> g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(BUTTON_REOPEN_YAML))
.get(0);

assertEquals(true, g.get("hasReopen"));
assertEquals("2", g.get("reopenStatusValue"));
assertEquals("target.State == 3 || target.State == 4", g.get("reopenRetiredCondition"));
assertEquals(false, g.get("hasRetiredStatus"), "there is no at-most-once guard here to step a retired target over");
}

@Test
void withoutTheKeyNoReopenIsEmitted() {
Map<String, Object> g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(RETIRING_YAML))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1230,19 +1230,36 @@ void rejectsAReopenOnAnAppendingCreateFrom() {
}

/**
* A button-only create-from carries no guard at all, so nothing blocks a replacement - the button
* IS the reissue, and there is no trigger for a reopen to re-fire. The glue emits no listener for
* that shape, so accepting the key would authorise something that generates nothing.
* A BUTTON create-from takes the reopen too (#7647). It carries no at-most-once guard, which is why
* this was once refused as a key that generates nothing - but the completion hook makes the source
* just as stuck: {@code sourceStatus} flips it off the status the button is offered from, the
* implied {@code fromStatus} deny (#7068) refuses the second click, and nothing else can move it
* back. The hook blocks the button exactly as the guard blocks the trigger.
*/
@Test
void rejectsAReopenWithoutAnEventTrigger() {
IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """
void acceptsAReopenOnAButtonCreateFromWithACompletionHook() {
IntentModel model = IntentParser.parse(GENERATES_REOPEN_HEAD + """
sourceStatus: DECLARED
sourceStatusOnRetire: IDENTIFIED
""");

GeneratesIntent generates = model.getGenerates()
.get(0);
// The seeded names are resolved to their ids before the typed model is built.
assertEquals(3, generates.getSourceStatus());
assertEquals(2, generates.getSourceStatusOnRetire());
assertFalse(generates.isEventDriven(), "the shape under test is the button one");
}

/** With no completion hook there is nothing to invert, button or not. */
@Test
void rejectsAReopenOnAButtonCreateFromWithoutACompletionHook() {
IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """
sourceStatusOnRetire: IDENTIFIED
"""));
assertTrue(ex.getIssues()
.stream()
.anyMatch(i -> i.contains("no event:") && i.contains("the button already reissues")),
.anyMatch(i -> i.contains("declares sourceStatusOnRetire but no sourceStatus")),
"got: " + ex.getIssues());
}

Expand Down
Loading