From 1644811dfcce2d2f3f8e39ee20860931c7a4c7aa Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 08:10:05 +0000 Subject: [PATCH 1/2] Add owner-facing reminder sweeps (lead-time, due-soon, overdue day one) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/flows/index.ts | 9 +- src/flows/reminders.flow.ts | 565 ++++++++++++++++++++++++++++++++++++ 2 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 src/flows/reminders.flow.ts diff --git a/src/flows/index.ts b/src/flows/index.ts index a706c10..3b7040d 100644 --- a/src/flows/index.ts +++ b/src/flows/index.ts @@ -14,7 +14,14 @@ // while empty and infers correctly the moment something is pushed into it. import { AssignmentFanout } from './assignment.flow.js'; +import { + DueSoonReminder, + LeadTimeReminder, + OverdueOwnerEscalation, + dulyReminderFlows, +} from './reminders.flow.js'; export { AssignmentFanout }; +export { DueSoonReminder, LeadTimeReminder, OverdueOwnerEscalation, dulyReminderFlows }; -export const dulyFlows = [AssignmentFanout]; +export const dulyFlows = [AssignmentFanout, ...dulyReminderFlows]; diff --git a/src/flows/reminders.flow.ts b/src/flows/reminders.flow.ts new file mode 100644 index 0000000..9c9568e --- /dev/null +++ b/src/flows/reminders.flow.ts @@ -0,0 +1,565 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { expression } from '@objectstack/spec'; +import { defineFlow } from '@objectstack/spec'; + +/** + * The notifications that replace the status meeting — the OWNER-FACING half. + * + * Three sweeps live here: the two lead-time reminders and the day-one overdue + * escalation. The two MANAGER-FACING digests the card also asks for (day-seven + * overdue, weekly stagnation) are deliberately NOT in this file. What is + * missing for them is not effort; it is authoring surface, and the gap is + * recorded at the bottom of this comment rather than worked around here. + * + * ── Why every flow here is a `time_relative` sweep ─────────────────────── + * The card says `type: 'schedule'`, and each flow declares that — but the + * binding the engine actually resolves is `time_relative`, because + * `resolveTriggerBinding` reads the start node's `config` and answers + * `time_relative` the moment `config.timeRelative` is present, BEFORE it looks + * at `config.schedule` or `flow.type`. That is the intended shape, not a + * coincidence of ordering: `TimeRelativeTriggerSchema` documents itself as the + * declarative replacement for exactly the hand-written "cron job + range + * query" a `schedule` flow would otherwise have to be. + * + * Two properties come with that choice, and both are load-bearing here: + * + * 1. **The sweep launches the flow once per matching record**, with the record + * in the automation context — so `{record.x}` templates and the start + * condition work exactly as they do for a record-change flow. That is right + * for an owner-facing reminder (one message, one task, one owner) and it is + * precisely WRONG for a digest, which is the gap noted below. + * + * 2. **Idempotency is the platform's, not ours.** Before launching the flow + * for a record the trigger takes a dispatch claim through the automation + * service (`claim(key)`, backed by the persisted `sys_flow_dispatch` + * ledger, objectstack#10220). The key is + * `time-relative:::offset:`. So the card's + * "record what was sent and check it before sending" is already answered by + * the platform for this trigger, and this app adds no marker field of its + * own — which is the right outcome: a per-task marker column would be a + * second writer for state the platform already keeps, and it would drift. + * + * Read the degradation contract before trusting it: if the claim CALL + * throws the trigger dispatches anyway (availability over strict-once), and + * if no claim surface resolves at all the dedup is in-process only and the + * trigger says so once, loudly. Neither is silent. + * + * ── Why "fires once, ever" holds for the two reminders ─────────────────── + * In OFFSET mode the claim scope is the TARGET day plus the offset + * (`:offset`), not the sweep day. A task's `visible_from` + * is a fixed date, so `offsetDays: [0]` on it matches on exactly one calendar + * day and claims exactly one key — for good. Re-running the sweep the same day + * finds the claim and skips; there is no later day on which the record matches + * again. That is the card's "two notifications per task, maximum, ever", + * obtained from the trigger rather than from a flag this app has to maintain. + * + * The overdue sweep is the other mode and is described on its own flow. + * + * ── Why each flow reads the duty ───────────────────────────────────────── + * Two of the card's suppression rules cannot be expressed in the sweep filter, + * because they live on `duly_duty` and the filter is a flat where-map on + * `duly_task`: + * + * - the duty's `effective_from` / `effective_to` window, and + * - `grace_days`, which decides WHICH DAY is day one of the overdue ladder. + * + * So each flow reads the duty with a `get_record` and gates on the result. + * A task with no duty (an assignment fan-out row — `assignment.flow.ts` + * creates tasks with `duty` unset) skips the read and is treated as an open + * window with zero grace, which is what "no duty governs this row" means. + * + * The third suppression — `form: 'standing'` — needs nothing here. A standing + * duty never produces a task at all (`dispatch.plan.ts` returns + * `{ reason: 'standing' }` before a draft exists), so there is no row for a + * sweep to match. `test/reminders.test.ts` asserts that rather than assuming + * it, because the card asked for the assertion and because a silent change in + * the dispatcher is exactly what would turn this from "free" into a bug. + * + * ── The `int()` calls are not decoration — measured ────────────────────── + * `daysBetween()` returns a CEL **int**. `duly_duty.grace_days` arrives from + * the record as a host **number**, and CEL tags arithmetic over it as a + * double. Measured on `@objectstack/formula` 17.2.0 with the same scope shape + * `evaluateCondition` builds: + * + * daysBetween(due, today()) == 1 + grace → false (grace 6, 7 over) + * daysBetween(due, today()) == int(1 + grace)→ false (same) + * daysBetween(due, today()) == int(grace) + 1→ TRUE + * + * All three read as the same arithmetic and two of them are silently wrong — + * `7 == 7.0` throws `no such overload: int == double` when both sides are + * literals, but the same mismatch reached through a record field answers + * `false` instead. A false gate on a notification flow is indistinguishable + * from "nothing was due", forever. `int()` goes around the FIELD, which is the + * only placement measured to work. + * + * ── `has()` before `isBlank()`, always ─────────────────────────────────── + * A column that is NULL in the store can be ABSENT from the row the sweep + * hands the flow — the time-relative trigger does no `materializeDeclaredFields` + * (only the record-change trigger does). Measured: `isBlank(record.duty)` on a + * row with no `duty` key THROWS `No such key: duty`, and a throwing predicate + * faults the run. `has(record.duty)` is total — false when absent, true when + * present-and-null — so the presence test is always `has(...) && !isBlank(...)`. + * + * ── Why the predicates are built with `expression()`, not P`…` ────────── + * The house tag `P` is `cel` from `@objectstack/spec`, and its template + * interpolation is VALUE interpolation, not text splicing — measured: + * + * const X = 'has(record.duty)'; + * P`${X} && true` → { dialect: 'cel', source: '"has(record.duty)" && true' } + * + * The fragment arrives as a quoted string LITERAL, so a composed predicate + * built that way is not the predicate that was written and never was — it + * parses, it ships, and it means something else. `expression(source)` takes a + * plain string and wraps it in the identical envelope, so the shared constants + * above compose safely. Predicates written out in full may keep using P`…`; + * the rule is only that a `${…}` hole in P is a value, never CEL. + * + * ── Two platform gaps this file does NOT work around ───────────────────── + * **1. Digest by recipient is not authorable.** The manager-facing flows are + * absent because the node vocabulary cannot express "one message per manager + * listing their N tasks": + * - there is no aggregate / group-by node, so records can only be bucketed + * by enumerating the recipient population and re-querying per recipient; + * - `notify.message` is a flat string and an array token JSON-stringifies + * (`stringifyForTemplate`), and `sys_email_template` holes are scalar-only + * (`String(raw)`, no iteration), so a list of 30 tasks has nowhere to be + * rendered; + * - the CEL stdlib HAS `joinNonEmpty(list, sep)`, but no authoring slot + * evaluates CEL to a VALUE — `FLOW_NODE_EXPRESSION_PATHS` declares only + * `predicate` and `flow-template` roles, and the `assignment` node + * interpolates rather than evaluating — so it is unreachable. + * Filed upstream; see the card. Shipping a count-only digest here would have + * satisfied "one message, not thirty" while quietly dropping "listing 30", and + * that is the kind of workaround that makes a platform gap permanent. + * + * **2. Notification text cannot be localized in this app.** The localizable + * `notify` path is `template`, naming a `sys_email_template` bundle; + * `defineStack` accepts an `emailTemplates` collection but this app wires no + * barrel for one, and `objectstack.config.ts` is not editable from a feature + * branch. So the inline `title`/`message` below are the only content path + * available, and they are the explicitly NON-localizable one — a declared + * deviation from the house rule "do not hard-code display text in a flow" + * (AGENTS.md §8), not an oversight. English is the source language, so the + * strings are at least the right source text; they are simply untranslatable + * until the app has an email-template barrel. + */ + +// ─── Shared authoring constants ────────────────────────────────────────── + +/** + * The statuses a reminder may fire for. `done` / `skipped` / `cancelled` are + * excluded IN THE SWEEP FILTER rather than in a flow condition, so a completed + * task never launches a run and never consumes a dispatch claim — which is + * what makes "completing a task before its due date produces no further + * notifications of any kind" true by construction rather than by a gate that + * has to be repeated on every path. + */ +const LIVE_TASK = { status: { $in: ['open', 'in_progress'] } } as const; + +/** + * Sweep cadence. The trigger defaults to this exact expression when `schedule` + * is omitted (`TIME_RELATIVE_DEFAULT_CRON`); it is written out because the day + * a deployment wants a different hour, the knob should be visible rather than + * discovered. It is a SIBLING of `timeRelative` on the start node's config, + * never a key inside it, and never a flow-level key. + */ +const DAILY_AT_0800_UTC = { type: 'cron', expression: '0 8 * * *' } as const; + +/** + * Fields read off the duty. A projection rather than the whole row: these + * three are the only ones any gate here reads, and a narrow projection is what + * keeps a later field rename from looking like it works. + */ +const DUTY_GATE_FIELDS = ['id', 'grace_days', 'effective_from', 'effective_to'] as const; + +/** + * How far back the overdue sweep looks, and therefore the largest + * `grace_days` the day-one escalation can honour. + * + * `duly_duty.grace_days` declares `min: 0` and NO maximum, so these two + * numbers are coupled with nothing to hold them together but this comment and + * the test that pins it: a duty with grace ≥ 15 has its escalation day fall + * outside the swept window and is never escalated. Raising the field's ceiling + * means raising this. The alternative — an unbounded lookback — re-launches + * the flow every day for every task ever missed, which is the "ancient record + * re-alerting forever" the trigger's own `withinDays` doc warns about. + */ +const OVERDUE_LOOKBACK_DAYS = 15; + +/** `true` when the task names a duty whose row is worth reading. */ +const HAS_DUTY = 'has(record.duty) && !isBlank(record.duty)'; + +/** `true` when the task names no duty at all — total over absent AND null. */ +const NO_DUTY = '!has(record.duty) || isBlank(record.duty)'; + +/** + * `true` when TODAY falls inside the duty's effective window. + * + * Evaluated against `today()`, not against the task's `due_date`: the card's + * rule is that nothing FIRES outside the window, and firing happens now. A + * duty retired last week stops nagging about the tasks it already produced, + * which is the behaviour "effective_to" is bought for. + * + * Both ends are optional on the object and may be absent from the row, hence + * the `has()` guards; an unset end is an open end. + */ +const DUTY_WINDOW_OPEN = + '(!has(vars.duty_record.effective_from) || isBlank(vars.duty_record.effective_from)' + + ' || date(vars.duty_record.effective_from) <= today())' + + ' && (!has(vars.duty_record.effective_to) || isBlank(vars.duty_record.effective_to)' + + ' || date(vars.duty_record.effective_to) >= today())'; + +/** The duty's grace in days, defaulting to 0 when unset or absent. */ +const DUTY_GRACE = + '(has(vars.duty_record.grace_days) && !isBlank(vars.duty_record.grace_days)' + + ' ? vars.duty_record.grace_days : 0)'; + +/** Days elapsed since the due date. Positive once the task is late. */ +const DAYS_PAST_DUE = 'daysBetween(record.due_date, today())'; + +// ─── 1 · Lead-time reminder — the task appears on the owner's list ─────── + +/** + * `duly_task_lead_time_reminder` — fires the day a task crosses + * `visible_from`, to its owner. One message per task, ever. + * + * `visible_from` is `due_date - duty.lead_days`, stamped at dispatch. Sweeping + * the STORED column rather than recomputing the lead time is what lets this be + * one `offsetDays: [0]` descriptor instead of a per-duty calculation — the + * same reason the column exists at all. + */ +export const LeadTimeReminder = defineFlow({ + name: 'duly_task_lead_time_reminder', + label: 'Lead-time reminder', + description: + "Tells a task's owner, once, on the day the task becomes visible on their list.", + + type: 'schedule', + status: 'active', + // A sweep has no trigger user. Under the default `runAs: 'user'` its data + // operations are REFUSED outright (#3760), so this is not a preference. + runAs: 'system', + + // Declared so it is BOUND: a `get_record` that never runs leaves its + // `outputVariable` unset, and an unbound name aborts the CEL predicate that + // reads it instead of yielding false. `duty_record` collides with no + // `duly_task` field — a declared variable SHADOWS a record field of the same + // name, which would silently replace the field for the rest of the flow. + variables: [{ name: 'duty_record', type: 'record', defaultValue: null }], + + nodes: [ + { + id: 'start', + type: 'start', + label: 'Task becomes visible today', + config: { + // The trigger reads the swept object from `timeRelative.object` and + // falls back to `objectName`; both are stated because `objectName` is + // also where `objectstack validate` and the repo's flow-predicate + // stopgap look for the flow's bound object. Omitting it would leave + // both gates with no field list to anchor on — passing vacuously. + objectName: 'duly_task', + timeRelative: { + object: 'duly_task', + dateField: 'visible_from', + // Exactly the day it crosses. Offset mode, so the claim key is tied + // to that day and the reminder cannot repeat on any later sweep. + offsetDays: [0], + filter: LIVE_TASK, + }, + schedule: DAILY_AT_0800_UTC, + }, + }, + { + id: 'read_duty', + type: 'get_record', + label: 'Read the governing duty', + config: { + objectName: 'duly_duty', + filter: { id: '{record.duty}' }, + fields: [...DUTY_GATE_FIELDS], + outputVariable: 'duty_record', + }, + }, + { + id: 'notify_owner', + type: 'notify', + label: 'Tell the owner', + config: { + recipients: '{record.owner}', + title: '{record.subject}', + message: 'This is now on your list. Due {record.due_date}.', + severity: 'info', + topic: 'duly.task_lead_time', + // The pair only takes effect together; a half-specified target is + // dropped so the inbox never renders a dead link. + sourceObject: 'duly_task', + sourceId: '{record.id}', + }, + }, + { id: 'end', type: 'end', label: 'Done' }, + ], + + edges: [ + { + id: 'e_read_duty', + source: 'start', + target: 'read_duty', + type: 'conditional', + label: 'Task belongs to a duty', + condition: expression(HAS_DUTY), + }, + // An assignment fan-out task has no duty and therefore no effective + // window to be outside of. + { id: 'e_no_duty', source: 'start', target: 'notify_owner', isDefault: true }, + + { + id: 'e_window_open', + source: 'read_duty', + target: 'notify_owner', + type: 'conditional', + label: 'Duty is in its effective window', + condition: expression(DUTY_WINDOW_OPEN), + }, + { id: 'e_window_closed', source: 'read_duty', target: 'end', isDefault: true }, + + { id: 'e_done', source: 'notify_owner', target: 'end' }, + ], +}); + +// ─── 2 · Lead-time reminder — two days out ─────────────────────────────── + +/** + * `duly_task_due_soon_reminder` — the second and last owner reminder, two days + * before `due_date`. + * + * A separate flow rather than a second branch of the one above, because a + * `timeRelative` descriptor carries exactly ONE `dateField` and this one + * sweeps `due_date` while that one sweeps `visible_from`. Splitting is what + * the descriptor's shape requires; it does not change the volume budget, which + * is counted per task (two, maximum, ever) and not per flow. + * + * When a duty's lead time is under two days the two reminders can land in the + * other order, or on the same day — a task whose `visible_from` equals its + * `due_date` (every assignment fan-out row) gets the due-soon note first. That + * is still two messages, and telling somebody about work two days out is the + * point; it is recorded here so it reads as a decision. + */ +export const DueSoonReminder = defineFlow({ + name: 'duly_task_due_soon_reminder', + label: 'Due-soon reminder', + description: "Tells a task's owner, once, two days before the task is due.", + + type: 'schedule', + status: 'active', + runAs: 'system', + + variables: [{ name: 'duty_record', type: 'record', defaultValue: null }], + + nodes: [ + { + id: 'start', + type: 'start', + label: 'Task falls due in two days', + config: { + objectName: 'duly_task', + timeRelative: { + object: 'duly_task', + dateField: 'due_date', + offsetDays: [2], + filter: LIVE_TASK, + }, + schedule: DAILY_AT_0800_UTC, + }, + }, + { + id: 'read_duty', + type: 'get_record', + label: 'Read the governing duty', + config: { + objectName: 'duly_duty', + filter: { id: '{record.duty}' }, + fields: [...DUTY_GATE_FIELDS], + outputVariable: 'duty_record', + }, + }, + { + id: 'notify_owner', + type: 'notify', + label: 'Tell the owner', + config: { + recipients: '{record.owner}', + title: '{record.subject}', + message: 'Due in 2 days, on {record.due_date}.', + severity: 'info', + topic: 'duly.task_due_soon', + sourceObject: 'duly_task', + sourceId: '{record.id}', + }, + }, + { id: 'end', type: 'end', label: 'Done' }, + ], + + edges: [ + { + id: 'e_read_duty', + source: 'start', + target: 'read_duty', + type: 'conditional', + label: 'Task belongs to a duty', + condition: expression(HAS_DUTY), + }, + { id: 'e_no_duty', source: 'start', target: 'notify_owner', isDefault: true }, + + { + id: 'e_window_open', + source: 'read_duty', + target: 'notify_owner', + type: 'conditional', + label: 'Duty is in its effective window', + condition: expression(DUTY_WINDOW_OPEN), + }, + { id: 'e_window_closed', source: 'read_duty', target: 'end', isDefault: true }, + + { id: 'e_done', source: 'notify_owner', target: 'end' }, + ], +}); + +// ─── 3 · Overdue escalation, stage one — the owner ─────────────────────── + +/** + * `duly_task_overdue_owner_escalation` — day one past `due_date + grace_days`, + * to the owner. + * + * ── Why this one is a RANGE sweep with a gate, and the others are not ──── + * The escalation day is `due_date + grace_days + 1`, and `grace_days` lives on + * the duty. `offsetDays` is a static array authored at build time, so it + * cannot express an offset that varies per record — an `offsetDays: [-1]` + * sweep would notify on day one past DUE and silently skip every graced duty's + * real day one. So the sweep casts a bounded range (`withinDays: + * -OVERDUE_LOOKBACK_DAYS`) and the exact day is decided in the flow, where the + * duty is readable. + * + * The cost is stated rather than hidden: in range mode the claim scope is the + * SWEEP day, so this flow is launched once a day for every task in the window + * and takes a ledger row each time, most of them ending at `end` without + * notifying. What the claim still buys is the thing that matters — a re-run or + * a retry within the same day cannot double-notify — and the exact-day gate + * gives the once-per-stage property the card asks for. + * + * ── Why the gate is `== grace + 1` and not `>= grace + 1` ──────────────── + * `>=` would re-notify on every remaining day of the window. Equality is the + * "day one" the card names, and the day-seven manager stage is the escalation + * that follows — not a second nudge at the owner. + */ +export const OverdueOwnerEscalation = defineFlow({ + name: 'duly_task_overdue_owner_escalation', + label: 'Overdue escalation — owner', + description: + "Tells a task's owner, once, on the first day the task is past its due date plus the duty's grace.", + + type: 'schedule', + status: 'active', + runAs: 'system', + + variables: [{ name: 'duty_record', type: 'record', defaultValue: null }], + + nodes: [ + { + id: 'start', + type: 'start', + label: 'Task is inside the overdue lookback', + config: { + objectName: 'duly_task', + timeRelative: { + object: 'duly_task', + dateField: 'due_date', + // Negative = a bounded past-due lookback, deliberately bounded so an + // ancient record does not re-alert forever. + withinDays: -OVERDUE_LOOKBACK_DAYS, + filter: LIVE_TASK, + }, + schedule: DAILY_AT_0800_UTC, + }, + }, + { + id: 'read_duty', + type: 'get_record', + label: 'Read the governing duty', + config: { + objectName: 'duly_duty', + filter: { id: '{record.duty}' }, + fields: [...DUTY_GATE_FIELDS], + outputVariable: 'duty_record', + }, + }, + { + id: 'notify_owner', + type: 'notify', + label: 'Tell the owner it is late', + config: { + recipients: '{record.owner}', + title: '{record.subject}', + message: 'Past due since {record.due_date}.', + severity: 'warning', + topic: 'duly.task_overdue', + sourceObject: 'duly_task', + sourceId: '{record.id}', + }, + }, + { id: 'end', type: 'end', label: 'Done' }, + ], + + edges: [ + { + id: 'e_read_duty', + source: 'start', + target: 'read_duty', + type: 'conditional', + label: 'Task belongs to a duty', + condition: expression(HAS_DUTY), + }, + // No duty means no grace and no effective window: day one is the day after + // the due date. Written as its own conditional rather than a default, + // because the default edge cannot carry the day-one test as well. + { + id: 'e_no_duty_day_one', + source: 'start', + target: 'notify_owner', + type: 'conditional', + label: 'Duty-less task, first day late', + condition: expression(`(${NO_DUTY}) && ${DAYS_PAST_DUE} == 1`), + }, + // Every other day in the lookback window. + { id: 'e_not_today', source: 'start', target: 'end', isDefault: true }, + + { + id: 'e_day_one', + source: 'read_duty', + target: 'notify_owner', + type: 'conditional', + label: 'First day past due plus grace', + // `int()` around the FIELD — see the header. `int(1 + grace)` is + // measurably NOT equivalent and answers false. + condition: expression( + `${DUTY_WINDOW_OPEN} && ${DAYS_PAST_DUE} == int(${DUTY_GRACE}) + 1`, + ), + }, + { id: 'e_not_day_one', source: 'read_duty', target: 'end', isDefault: true }, + + { id: 'e_done', source: 'notify_owner', target: 'end' }, + ], +}); + +/** + * Everything in this file, in card order, so the barrel spreads one name. + * + * The two manager digests the card asks for are absent by decision, not by + * omission — see the file header and the report on the card. + */ +export const dulyReminderFlows = [ + LeadTimeReminder, + DueSoonReminder, + OverdueOwnerEscalation, +]; From c4257a474d26ef84b7d00b8d196af0e56225bdce Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 08:17:42 +0000 Subject: [PATCH 2/2] Pin the reminder sweeps: bindings, volume discipline, two measured CEL traps Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- test/reminders.test.ts | 391 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 test/reminders.test.ts diff --git a/test/reminders.test.ts b/test/reminders.test.ts new file mode 100644 index 0000000..03a75a6 --- /dev/null +++ b/test/reminders.test.ts @@ -0,0 +1,391 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { + DueSoonReminder, + LeadTimeReminder, + OverdueOwnerEscalation, + dulyFlows, + dulyReminderFlows, +} from '../src/flows/index.js'; +import { Duty, Task } from '../src/objects/index.js'; +import { planDispatch } from '../src/jobs/dispatch.plan.js'; + +/** + * These are not schema tests. `pnpm validate` parses each flow and — measured + * below the fold — resolves every `record.` in every predicate against + * the bound object, so a misspelt qualified read is already caught. + * + * What is pinned here is the part nothing in the toolchain checks, and every + * one of these fails SILENTLY when it breaks: + * + * - **The bound object.** `objectstack validate` anchors its record-read + * check on the start node's `objectName`, and so does the repo's + * bare-identifier stopgap (`test/flow-predicates.test.ts`, whose own + * "binds a declared object" assertion covers `record_change` flows only). + * On a `time_relative` flow the object ALSO lives at + * `config.timeRelative.object`, so it is easy to write the flow with only + * that one — and then both gates resolve nothing and pass. Measured on + * `@objectstack/cli` 17.2.0 by ablation, both legs in one shell: + * + * misspell `record.due_date` → `record.due_dat` + * · with `objectName` on the start nodes → validate EXIT 1, two located + * findings: "unknown field `due_dat` on `duly_task` — did you mean + * `due_date`?", naming edge `e_no_duty_day_one` and edge `e_day_one` + * · with `objectName` deleted → validate EXIT 0, + * "✓ Validation passed", no finding at all + * + * So `objectName` is not redundant with `timeRelative.object`; it is what + * keeps the whole predicate surface of these flows checked. Hence the test. + * + * - **Volume discipline.** Nothing type-checks "one message per recipient", + * "nothing for a completed task", or "the escalation gate is day equality + * and not a threshold". A `>=` where a `==` belongs re-notifies daily and + * reads identically. + * + * - **The two measured CEL traps** the flows are written around — the `P` + * tag's value interpolation, and `int()` placement in the grace + * arithmetic. Both produce a predicate that parses, ships, and means + * something else. + */ + +type AnyRec = Record; +interface NodeLike { id: string; type: string; label: string; config?: AnyRec } +interface EdgeLike { + id: string; source: string; target: string; isDefault?: boolean; + condition?: string | { dialect?: string; source?: string }; +} +interface FlowLike { + name: string; label: string; type: string; status?: string; runAs?: string; + variables?: { name: string }[]; nodes: NodeLike[]; edges: EdgeLike[]; +} + +const flows = dulyReminderFlows as unknown as FlowLike[]; + +const startOf = (f: FlowLike): NodeLike => { + const n = f.nodes.find((x) => x.type === 'start'); + if (!n) throw new Error(`flow '${f.name}' has no start node`); + return n; +}; +const nodesOf = (f: FlowLike, type: string): NodeLike[] => f.nodes.filter((n) => n.type === type); +const timeRelativeOf = (f: FlowLike): AnyRec => (startOf(f).config?.timeRelative ?? {}) as AnyRec; +const sourceOf = (c: EdgeLike['condition']): string => + c == null ? '' : typeof c === 'string' ? c : (c.source ?? ''); +/** Every predicate authored anywhere in the flow, with its site. */ +const predicatesOf = (f: FlowLike): { where: string; source: string }[] => { + const out: { where: string; source: string }[] = []; + for (const n of f.nodes) { + const s = sourceOf(n.config?.condition as EdgeLike['condition']); + if (s) out.push({ where: `${f.name} node '${n.id}'`, source: s }); + } + for (const e of f.edges) { + const s = sourceOf(e.condition); + if (s) out.push({ where: `${f.name} edge '${e.id}'`, source: s }); + } + return out; +}; +/** Conditional edges into `target`, i.e. every gate a run must pass to reach it. */ +const gatesInto = (f: FlowLike, target: string): EdgeLike[] => + f.edges.filter((e) => e.target === target && e.condition != null); + +// ─── Wiring ────────────────────────────────────────────────────────────── + +describe('reminder sweeps — wiring', () => { + it('all three are in dulyFlows (a flow not in its barrel never runs)', () => { + for (const f of [LeadTimeReminder, DueSoonReminder, OverdueOwnerEscalation]) { + expect(dulyFlows).toContain(f); + } + expect(dulyReminderFlows).toHaveLength(3); + }); + + it('each runs as system and is active, not draft', () => { + // A sweep has no trigger user, so under the default `runAs: 'user'` every + // data operation in the run is REFUSED (#3760) — the flow binds, fires and + // does nothing. A `draft` flow registers and never dispatches, which looks + // identical to a working flow from the outside. + for (const f of flows) { + expect(f.runAs, `flow '${f.name}'`).toBe('system'); + expect(f.status, `flow '${f.name}'`).toBe('active'); + expect(f.type, `flow '${f.name}'`).toBe('schedule'); + } + }); + + it('binds on the START node config, never at the flow top level', () => { + // `FlowSchema` is strict and declares no `object` / `schedule` / `trigger` + // key; the engine's `resolveTriggerBinding` reads the start node's config + // and nowhere else. + for (const f of flows) { + for (const key of ['object', 'objectName', 'schedule', 'trigger', 'timeRelative']) { + expect(Object.keys(f), `flow '${f.name}' top-level '${key}'`).not.toContain(key); + } + expect(startOf(f).config?.timeRelative, `flow '${f.name}'`).toBeDefined(); + } + }); + + it('carries the cron as a SIBLING of timeRelative, not a key inside it', () => { + // The descriptor's own guidance channel exists for this exact wrong-layer + // mistake; a `schedule` nested inside `timeRelative` is refused at bind. + for (const f of flows) { + expect(startOf(f).config?.schedule, `flow '${f.name}'`).toEqual({ + type: 'cron', + expression: '0 8 * * *', + }); + expect(Object.keys(timeRelativeOf(f)), `flow '${f.name}'`).not.toContain('schedule'); + } + }); + + it('every start node names the bound object, so both record-read gates anchor', () => { + // See the file header: with `objectName` deleted, an ablated misspelling + // in these predicates passes `pnpm validate` with exit 0 and no finding. + const declared = new Set([Task.name, Duty.name]); + for (const f of flows) { + const objectName = startOf(f).config?.objectName; + expect(objectName, `flow '${f.name}' start node has no objectName`).toBe(Task.name); + expect(declared.has(String(objectName))).toBe(true); + // …and it must agree with the object the sweep actually queries. + expect(timeRelativeOf(f).object, `flow '${f.name}'`).toBe(objectName); + } + }); + + it('declares `duty_record` bound, and it shadows no duly_task field', () => { + // A `get_record` leaves its outputVariable unset when it does not run, and + // an unbound name aborts the CEL predicate that reads it. A declared + // variable named after a record field would silently REPLACE that field. + const taskFields = Object.keys(Task.fields); + for (const f of flows) { + const declared = (f.variables ?? []).map((v) => v.name); + expect(declared, `flow '${f.name}'`).toContain('duty_record'); + for (const name of declared) expect(taskFields, `flow '${f.name}'`).not.toContain(name); + } + }); +}); + +// ─── The sweep windows are the schedule ────────────────────────────────── + +describe('reminder sweeps — the windows say what the card says', () => { + it('the two owner reminders use OFFSET mode, which is what makes them once-ever', () => { + // In offset mode the trigger's dispatch-claim scope is the TARGET day plus + // the offset, so a record matches on exactly one calendar day and claims + // one key for good. In range mode the scope is the SWEEP day and the same + // record re-fires daily — correct for the overdue lookback, fatal here. + expect(timeRelativeOf(LeadTimeReminder as unknown as FlowLike)).toMatchObject({ + dateField: 'visible_from', + offsetDays: [0], + }); + expect(timeRelativeOf(DueSoonReminder as unknown as FlowLike)).toMatchObject({ + dateField: 'due_date', + offsetDays: [2], + }); + for (const f of [LeadTimeReminder, DueSoonReminder] as unknown as FlowLike[]) { + expect(Object.keys(timeRelativeOf(f)), `flow '${f.name}'`).not.toContain('withinDays'); + } + }); + + it('the two reminders sweep DIFFERENT date fields — which is why they are two flows', () => { + // A `timeRelative` descriptor carries exactly one `dateField`. The card + // asks for one reminder on `visible_from` and one on `due_date`, so the + // split is the descriptor's shape, not a choice. The budget the card sets + // is per TASK (two, maximum, ever) and both flows respect it. + const fields = [LeadTimeReminder, DueSoonReminder] + .map((f) => timeRelativeOf(f as unknown as FlowLike).dateField); + expect(new Set(fields).size).toBe(2); + }); + + it('the overdue sweep is a BOUNDED past-due lookback', () => { + const tr = timeRelativeOf(OverdueOwnerEscalation as unknown as FlowLike); + expect(tr.dateField).toBe('due_date'); + expect(tr.withinDays).toBe(-15); + expect(Object.keys(tr)).not.toContain('offsetDays'); + }); + + it('the lookback still covers the largest grace a duty can declare', () => { + // The escalation day is `due_date + grace_days + 1`, so a duty whose grace + // pushes that day outside the swept window is never escalated — silently. + // `grace_days` declares `min: 0` and no maximum, so nothing but this + // assertion holds the two numbers together. + const lookback = -Number(timeRelativeOf(OverdueOwnerEscalation as unknown as FlowLike).withinDays); + const graceMax = (Duty.fields.grace_days as { max?: number }).max; + expect( + graceMax === undefined || graceMax + 1 <= lookback, + `duly_duty.grace_days declares max ${String(graceMax)}, which needs a lookback of at least ` + + `${Number(graceMax) + 1} days; the sweep looks back ${lookback}`, + ).toBe(true); + // And the unbounded case, stated so it is a known limit and not a surprise: + // grace ≥ lookback is out of range whatever the field says. + expect(graceMax, 'grace_days grew a max — re-read the coupling above').toBeUndefined(); + }); +}); + +// ─── Volume discipline — the acceptance criteria ───────────────────────── + +describe('reminder sweeps — volume discipline', () => { + it('nothing fires for done / skipped / cancelled — enforced in the sweep FILTER', () => { + // In the filter rather than in a gate: a completed task then never launches + // a run at all, so it also never consumes a dispatch claim. That is what + // makes "completing a task produces no further notifications of any kind" + // true by construction instead of by a condition repeated on every path. + for (const f of flows) { + expect(timeRelativeOf(f).filter, `flow '${f.name}'`).toEqual({ + status: { $in: ['open', 'in_progress'] }, + }); + } + const live = ['open', 'in_progress']; + const statuses = (Task.fields.status as { options: { value: string }[] }).options + .map((o) => o.value); + // Pins the complement rather than the list: a new terminal status added to + // duly_task would otherwise start receiving reminders unnoticed. + expect(statuses.filter((s) => !live.includes(s)).sort()) + .toEqual(['cancelled', 'done', 'skipped']); + }); + + it('every notification goes to the task OWNER and to nobody else', () => { + // This file is the owner-facing half of the card by decision. The manager + // digests are absent (see the flow file header and the report), and the + // card's "the stagnation digest never addresses the task owner" has its + // mirror here: no sweep in this file addresses a manager. + for (const f of flows) { + const notifies = nodesOf(f, 'notify'); + expect(notifies.length, `flow '${f.name}' should send exactly one notification`).toBe(1); + const cfg = notifies[0]!.config ?? {}; + expect(cfg.recipients, `flow '${f.name}'`).toBe('{record.owner}'); + expect(JSON.stringify(cfg), `flow '${f.name}' mentions a manager`).not.toMatch(/manager/i); + } + }); + + it('the click-through target is a complete pair, never half-specified', () => { + // `sourceObject` / `sourceId` only take effect together; a half-specified + // target is dropped at execute time and the inbox renders no link at all. + for (const f of flows) { + const cfg = nodesOf(f, 'notify')[0]!.config ?? {}; + expect(cfg.sourceObject, `flow '${f.name}'`).toBe(Task.name); + expect(cfg.sourceId, `flow '${f.name}'`).toBe('{record.id}'); + } + }); + + it('the overdue gate is day EQUALITY, not a threshold', () => { + // `>=` re-notifies on every remaining day of the lookback window and reads + // identically to the correct predicate. + const escalation = OverdueOwnerEscalation as unknown as FlowLike; + const gates = gatesInto(escalation, 'notify_owner').map((e) => sourceOf(e.condition)); + expect(gates.length).toBe(2); // the duty path and the duty-less path + for (const g of gates) { + expect(g).toMatch(/daysBetween\(record\.due_date, today\(\)\) ==/); + expect(g, 'a threshold here re-notifies daily').not.toMatch(/daysBetween\([^)]*\)[^=]*>=/); + } + }); + + it('no reminder can be reached without passing the duty effective-window gate', () => { + // "Nothing fires outside a duty's effective_from / effective_to window." + // The duty-less path is the deliberate exemption: an assignment fan-out + // task names no duty and so has no window to be outside of. + for (const f of flows) { + const viaDuty = f.edges.filter((e) => e.source === 'read_duty' && e.target === 'notify_owner'); + expect(viaDuty.length, `flow '${f.name}'`).toBe(1); + const gate = sourceOf(viaDuty[0]!.condition); + expect(gate, `flow '${f.name}'`).toContain('effective_from'); + expect(gate, `flow '${f.name}'`).toContain('effective_to'); + expect(gate, `flow '${f.name}' compares the window against today`).toContain('today()'); + } + }); + + it('a standing duty never produces a task, so no sweep can ever match one', () => { + // The card asks for this to be asserted rather than assumed: it is free + // only for as long as the dispatcher keeps refusing to draft one, and a + // change there would turn "falls out for free" into a silent regression. + const plan = planDispatch({ + duties: [{ + id: 'd_standing', + name: 'Keep the register current', + form: 'standing', + owner: 'u1', + status: 'active', + // Everything a dispatchable duty would need, so the only reason the + // plan can come back empty is the form. + frequency: 'monthly', + due_anchor: 'period_end', + due_offset_days: 0, + lead_days: 7, + timezone: 'UTC', + source: 'catalog', + }], + now: new Date('2026-09-01T08:00:00.000Z'), + }); + expect(plan.drafts, 'a standing duty drafted a task').toEqual([]); + expect(plan.skipped.map((s) => s.reason)).toEqual(['standing']); + + // The control leg: the SAME duty as `recurring` does draft, so the empty + // plan above is the form being refused and not the fixture being inert. + const control = planDispatch({ + duties: [{ + id: 'd_recurring', + name: 'File the emissions return', + form: 'recurring', + owner: 'u1', + status: 'active', + frequency: 'monthly', + due_anchor: 'period_end', + due_offset_days: 0, + lead_days: 7, + timezone: 'UTC', + source: 'catalog', + }], + now: new Date('2026-09-01T08:00:00.000Z'), + }); + expect(control.drafts.length, 'the control duty drafted nothing — fixture is inert') + .toBeGreaterThan(0); + }); +}); + +// ─── The two measured CEL traps ────────────────────────────────────────── + +describe('reminder sweeps — the predicates are written around measured traps', () => { + it('no predicate carries a QUOTED CEL fragment (the P`${…}` interpolation trap)', () => { + // Measured on @objectstack/spec 17.2.0: + // const X = 'has(record.duty)'; + // P`${X} && true` → source: '"has(record.duty)" && true' + // The fragment becomes a string LITERAL, so the composed predicate is not + // the one that was written. `expression(source)` splices text instead. + for (const f of flows) { + for (const { where, source } of predicatesOf(f)) { + expect(source, `${where}: a CEL call appears inside a string literal`) + .not.toMatch(/"[a-zA-Z_]+\(/); + } + } + }); + + it('every isBlank() on a record field is guarded by has() first', () => { + // The time-relative trigger does no `materializeDeclaredFields` (only the + // record-change trigger does), so a NULL column can be ABSENT from the row + // the sweep hands the flow. Measured: `isBlank(record.duty)` on a row with + // no `duty` key THROWS `No such key: duty`, and a throwing predicate faults + // the run. `has()` is total over absent AND null. + for (const f of flows) { + for (const { where, source } of predicatesOf(f)) { + for (const m of source.matchAll(/isBlank\((record\.[a-z_]+|vars\.[a-z_.]+)\)/g)) { + expect(source, `${where}: isBlank(${m[1]}) without a has() guard`) + .toContain(`has(${m[1]})`); + } + } + } + }); + + it('int() wraps the grace FIELD, never the sum', () => { + // Measured on @objectstack/formula 17.2.0 with the scope shape + // `evaluateCondition` builds, for a task 7 days past due and grace 6: + // daysBetween(due, today()) == 1 + grace → false + // daysBetween(due, today()) == int(1 + grace) → false + // daysBetween(due, today()) == int(grace) + 1 → TRUE + // daysBetween() returns a CEL int; a host number makes the arithmetic a + // double, and int == double answers `false` here rather than throwing the + // `no such overload` it throws for two literals. + const escalation = OverdueOwnerEscalation as unknown as FlowLike; + const dutyGate = sourceOf( + escalation.edges.find((e) => e.id === 'e_day_one')!.condition, + ); + expect(dutyGate).toContain('int((has(vars.duty_record.grace_days)'); + expect(dutyGate, 'int() around the SUM is measurably not equivalent') + .not.toMatch(/int\(\s*1\s*\+/); + expect(dutyGate).toMatch(/int\([^;]*\)\s*\+\s*1/); + }); +});