Skip to content

intent: a scheduled reminder can record what it sent and escalate by days past due (#7276) - #7342

Merged
delchev merged 1 commit into
masterfrom
issue-7276-schedule-notify-record-escalate
Sep 12, 2026
Merged

intent: a scheduled reminder can record what it sent and escalate by days past due (#7276)#7342
delchev merged 1 commit into
masterfrom
issue-7276-schedule-notify-record-escalate

Conversation

@delchev

@delchev delchev commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The problem

A schedules[].notify could mail but not record, and could not tell a document three days
overdue 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 its
own comments:

  • the reminder history (and the report over it) filled only from manual clicks, never from the
    automated sends - so it was not a record of what went out;
  • a seeded Second reminder / Final notice was inert: only the first level was ever applied,
    and the same flat wording went out every Monday forever.

The change

1. notify and generate may be declared together. 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 on every tick and writes another record beside each send.

2. escalate: - a days-past-due ladder.

escalate:
  ladder: ReminderLevel      # the levels (a kind: setting table)
  after: daysAfterDue        # the integer threshold on the ladder
  since: dueOn               # the row's date the days are counted from
  into: Level                # where the chosen level is written on the target
generate:
  to: PaymentReminder
  unique: [SalesInvoice, Level]   # (document, level) - each level goes out ONCE
  map: { SalesInvoice: id }
  defaults: { sentOn: now }
notify:
  to: contactEmail
  subject: "Invoice {number} - {escalation.name}"
  body: "{escalation.wording}"    # the level's own text - per-level wording

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:

refused because
escalate without a generate nothing distinguishes a level already sent from one still due, so the top level reached would re-send every tick
a unique: key that omits into the guard finds the FIRST reminder of a document forever - the exact symptom reported
a non-integer after, a non-date since the ladder counts whole days
an into that does not point at the ladder, or that map/defaults also assigns two answers to which level applies
a cross-model source or generate target the days are counted off the row's own date, and whether the target property points at this model's ladder is knowable only here
{escalation.<field>} with no ladder, or a field the ladder does not declare an unresolvable placeholder degrades to its own literal text - the characters {escalation.name} would be mailed to the customer

Implementation notes

  • The job template is now one row body driven by generates / notifies flags instead of two
    branches. action stays in the glue and the binder falls back to it, so a .glue written before
    this renders exactly what it always did (asserted).
  • The chosen level is frozen into an effectively final local before the generation lambda closes
    over it.
  • The two halves' one-hop relation loads are merged and deduplicated: the loop declares each
    local once, so a relation both the recipient and a map source reach is loaded once.
  • The escalation's reserved locals are held in a set of their own rather than folded into
    CREATE_FROM_LOCALS, so an ordinary relation named Level is not refused on every create-from.

Verification

  • engine-intent unit suite 1253 tests green, including the new ScheduleEscalateIntentTest
    (10) and GlueScheduleEscalationTest (6).
  • IntentEngineIT 81/81 green, with a new test pinning the generated job's ordering: ladder
    lookup before the guard, the level in the key, the guard continueing before the send, one try
    around the whole row, both summary lines.
  • IntentEmissionCoverageIT green with an escalating dunning tick added to its fixture - so the
    combined job is COMPILED by the real publish (client-Java javac), which is the only thing
    that proves the typed escalation local builds against the generated entities.
  • IntentCrossModelScheduleSourceIT 5/5 green.
  • mvn formatter:validate green 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

…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>
@delchev
delchev merged commit 8a19986 into master Sep 12, 2026
10 checks passed
@delchev
delchev deleted the issue-7276-schedule-notify-record-escalate branch September 12, 2026 05:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

engine-intent: a scheduled reminder (schedules[].notify) can neither record what it sent nor escalate by level - dunning is stuck single-level

1 participant