intent: a scheduled reminder can record what it sent and escalate by days past due (#7276) - #7342
Merged
Merged
Conversation
…days past due (#7276) A `schedules[].notify` could mail but leave no trace, and could not tell a document three days overdue from one ninety days overdue. So a reminder history filled only from manual clicks, and a seeded Second reminder / Final notice was never applied by the system - only the first level was. codbex `sales-invoices` models dunning correctly at the data level and defers the behaviour in its own comments for exactly this reason. Two capabilities, each independently useful: 1. **notify AND generate in one tick.** They are no longer mutually exclusive. Per matched row the target record is created first and the mail goes out after it, in the same fail-soft try, and the generate's `unique:` natural key gates BOTH - a row whose record already exists is skipped entirely, mail included. `unique:` is therefore required on a combined schedule (refused without it): without a key the tick re-mails every matched row every time it fires. 2. **`escalate:` - a days-past-due ladder.** `{ ladder, after, since, into }` places the row at the HIGHEST level whose threshold it has passed, counted in whole days from a date of the row; the level is written onto the generated record through `into` and joins the natural key, which is what sends each level exactly once. A row that has passed no threshold is left for a later tick and counted. `{escalation.<field>}` reads one field of the chosen level in the subject and body - the per-level wording a flat schedule cannot express. The parser refuses every way the ladder would read as working and not be: an escalation without a generate (nothing distinguishes a level already sent from one still due), a key that omits `into` (the guard finds the first reminder forever), a non-integer threshold, a non-date `since`, an `into` that does not point at the ladder or that `map`/`defaults` also assigns, a cross-model source or target, and an `{escalation.<field>}` the ladder does not declare - which would otherwise mail its own braces to the customer. The job template is now one row body with `generates` / `notifies` flags rather than two branches; `action` stays in the glue so a `.glue` written before this renders exactly what it always did. The chosen level is frozen into an effectively final local before the generation lambda closes over it, and the two halves' one-hop relation loads are merged so a relation both reach is loaded once. Verified: engine-intent unit suite (1253 tests) green, including new ScheduleEscalateIntentTest and GlueScheduleEscalationTest; IntentEngineIT 81/81 green with a new test pinning the generated job's ordering; IntentEmissionCoverageIT green with an escalating dunning tick added to its fixture, so the combined job is COMPILED by the publish; IntentCrossModelScheduleSourceIT 5/5 green; `formatter:validate` green with the cache wiped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A
schedules[].notifycould mail but not record, and could not tell a document three daysoverdue from one ninety days overdue. Two consequences, both reported in #7276 from codbex
sales-invoices, which models dunning correctly at the data level and defers the behaviour in itsown comments:
automated sends - so it was not a record of what went out;
Second reminder/Final noticewas inert: only the first level was ever applied,and the same flat wording went out every Monday forever.
The change
1.
notifyandgeneratemay be declared together. Per matched row the target record iscreated first and the mail goes out after it, in the same fail-soft try, and the generate's
unique:natural key gates both - a row whose record already exists is skipped entirely, mailincluded.
unique:is therefore required on a combined schedule (refused without it): without akey the tick re-mails every matched row on every tick and writes another record beside each send.
2.
escalate:- a days-past-due ladder.The row is placed at the highest level whose threshold it has passed; one that has passed none is
left for a later tick (and counted, so the log says so). The level joins the natural key, which is
what advances First -> Second -> Final as the document ages, each sent once.
{escalation.<field>}reads one field of the chosen level in the subject and body.What is refused, and why
Every way the ladder would read as working and not be:
escalatewithout agenerateunique:key that omitsintoafter, a non-datesinceintothat does not point at the ladder, or thatmap/defaultsalso assigns{escalation.<field>}with no ladder, or a field the ladder does not declare{escalation.name}would be mailed to the customerImplementation notes
generates/notifiesflags instead of twobranches.
actionstays in the glue and the binder falls back to it, so a.gluewritten beforethis renders exactly what it always did (asserted).
over it.
local once, so a relation both the recipient and a
mapsource reach is loaded once.CREATE_FROM_LOCALS, so an ordinary relation namedLevelis not refused on every create-from.Verification
engine-intentunit suite 1253 tests green, including the newScheduleEscalateIntentTest(10) and
GlueScheduleEscalationTest(6).IntentEngineIT81/81 green, with a new test pinning the generated job's ordering: ladderlookup before the guard, the level in the key, the guard
continueing before the send, one tryaround the whole row, both summary lines.
IntentEmissionCoverageITgreen with an escalating dunning tick added to its fixture - so thecombined job is COMPILED by the real publish (client-Java
javac), which is the only thingthat proves the typed
escalationlocal builds against the generated entities.IntentCrossModelScheduleSourceIT5/5 green.mvn formatter:validategreen with the formatter cache wiped.Not verified: a live cron firing end to end (a tick is scheduled, not triggered, in these suites) -
the fixtures use crons outside the test window by design.
Fixes #7276
🤖 Generated with Claude Code