Repository navigation
Lead-time reminders, overdue escalation and stagnation alerts #11
Description
The notifications that replace the status meeting. Get the volume wrong and users filter the whole product to a folder, after which every downstream metric is measuring a dataset nobody maintains.
Blocked-by: #1 (period engine), #3 (last_update_at must actually be stamped, and stamped correctly).
Files you own
src/flows/reminders.flow.ts(new), pushed intodulyFlowsinsrc/flows/index.tstest/reminders.test.ts(new)
Three flows, type: 'schedule', runAs: 'system'
1 · Lead-time reminder — to the owner.
Fires once when a task crosses visible_from, and once more 2 days before due_date. Two notifications per task, maximum, ever.
2 · Overdue escalation — to the owner, then the manager.
Day 1 past due_date + grace_days: the owner. Day 7: the owner's manager, as a digest of everything of theirs that is overdue, not one message per task. A manager with 30 late tasks under them must receive one mail, not 30.
3 · Stagnation alert — to the manager, weekly digest only.
Open tasks whose last_update_at is older than 14 days. Never to the owner: "you have not touched this" reads as surveillance, and the owner already sees it in their own list. This one is for the person who can actually unblock it.
Volume discipline — the acceptance criteria, not advice
- Digest by recipient, never fan out per record. Every manager-facing message is one message per recipient per run.
- Idempotent per task per stage. Re-running a flow, or a retry after a partial failure, must not re-notify. Record what was sent — a per-task marker or a notification-log row — and check it before sending.
- Nothing fires for
status in ('done', 'skipped', 'cancelled'). - Nothing fires for duties whose
formisstanding— they have no tasks, so this should fall out for free; assert it anyway. - Nothing fires outside a duty's
effective_from/effective_towindow. - A weekly digest with nothing in it is not sent. An empty status report every Monday is exactly the ritual this product exists to remove.
Do not
- add a daily digest of any kind
- notify anyone about
duly_log_entry - include a count comparison between people in any digest
Acceptance
- a task crossing
visible_fromproduces exactly one owner notification; re-running the flow that day produces zero more - a manager with 30 overdue tasks beneath them receives one digest listing 30, not 30 messages
- completing a task before its due date produces no further notifications of any kind
- the stagnation digest never addresses the task owner
- an empty digest window sends nothing
- flow conditions are all
record.<field>-qualified;pnpm validatepasses
Gates
pnpm validate && pnpm typecheck && pnpm test && pnpm build.
Activity
os-warren commentedon Sep 1, 2026
Metadata-first — maintainer instruction, applies to this card before it is dispatched. PM seat, round 2.
"这是一个元数据应用,应该利用平台的元数据能力开发,如果平台有问题可以报 issue."
The three flows on this card are the other place where the instruction bites hardest, because "digest by recipient" is the kind of requirement that invites a hand-written loop over query results and a hand-assembled message body.
Build them out of flow nodes.
type: 'schedule',timeRelativestart config ({ object, dateField, offsetDays, withinDays, filter }) is exactly the shape the lead-time reminder wants — a declarative "fire N days before this date field" rather than a job that re-derives the window every run.loop,decisionandnotifynodes carry the rest. Ascriptnode with a pure function is the escape hatch, not the starting point.Two specific things to establish declaratively before writing any code, and to file upstream if the platform cannot express them:
notifynode can only fan out per record, that is a real gap and it is worth an issue — every application that sends a manager a summary hits it.Do not quietly write around a gap — file it against
objectstack-ai/objectstackand name it in your report. Three such issues came out of round 1 (#14087, #14088, #14089).One dependency note: #29 lands a stopgap guard against bare identifiers in flow predicates this round, because
pnpm validatedoes not catch them (upstream: objectstack#14089). Rebase onto it before you write conditions — and be aware the real failure mode in a flow is not the documented one: a bare name does not evaluate tonull, it resolves through record flattening or throws.The volume discipline already on this card is unchanged and is the acceptance criteria, not advice: no daily digest, nothing for
done/skipped/cancelled, nothing for standing duties, and an empty digest window sends nothing.Generated by Claude Code
os-warren commentedon Sep 1, 2026
Claiming this card — dev agent, session
session_01SqkTcrxUFci7nqXdbBSe2p, branchclaude/issue-11-reminders(pushed, empty, offde28918). Assignee left as PM set it.File surface per dispatch:
src/flows/reminders.flow.ts,src/flows/index.ts(barrel entry),test/reminders.test.ts. Not touchingobjectstack.config.ts,src/jobs/, orsrc/flows/assignment.flow.ts.Generated by Claude Code
os-warren commentedon Sep 1, 2026
os-dev-report
{ "issue": 11, "status": "rework", "branch": "claude/issue-11-reminders", "pr": "https://github.com/objectstack-ai/duly/pull/70", "premise_still_valid": true, "summary": "Shipped the three OWNER-facing sweeps as declarative time_relative flows in src/flows/reminders.flow.ts: lead-time (visible_from offsetDays [0]), due-soon (due_date offsetDays [2]), and day-one overdue past due_date + duty.grace_days (due_date withinDays -15 plus an exact-day CEL gate, because offsetDays is static and grace is per-duty). Every gate is a flow node - get_record, conditional edges, notify - no script node, no handler, no new object field. Idempotency needed no app marker: the time-relative trigger takes a persisted dispatch claim (sys_flow_dispatch, objectstack#10220) per flow/window/record before launching, and in offset mode the scope is the target day, so each reminder claims one key for good. The two MANAGER digests (day-seven overdue, weekly stagnation) are deliberately absent - digest-by-recipient is not authorable at 17.2.0 and is filed as objectstack#14149 rather than worked around; see open_questions for the call the maintainer owns. WOULD THESE FIRE ON A REAL BOOT? No - measured, PORT=3117 pnpm start prints 'Flows: 4 flow(s) 0 bound to triggers' and names all three sweeps 'declares a time_relative trigger but is NOT bound - add requires: [triggers]'. The flows are authored correctly (the engine resolves the intended time_relative binding, which is why it can name it); the app declares requires: [automation, hierarchy-security] and never declares triggers, so the #33 fan-out is dark too. Filed as #68; not fixed here because objectstack.config.ts is off surface. DEVIATIONS, all declared: (a) 'Part of #11' not 'Fixes #11' - half the card ships, a merge must leave it open, and the standing dispatch clause makes Part of the correct form; (b) four flow objects rather than the card's 'three flows' for the owner half - a timeRelative descriptor carries exactly one dateField, and lead-time needs visible_from while due-soon needs due_date, so the split is the descriptor's shape (three shipped here, the fourth and fifth would have been the digests); (c) inline English notify title/message, which the notify schema calls the explicitly non-localizable path - AGENTS.md rule 8 cannot be followed because the app wires no emailTemplates barrel, filed as #69; (d) grace beyond 14 days falls outside the overdue lookback, pinned by a test that fails if duly_duty.grace_days ever declares a max.", "tests": "All four gates run at branch head c4257a4, AFTER the final commit, exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?): validate=0 typecheck=0 test=0 build=0. Quoting each gate's own verdict line: validate '✓ Validation passed (286ms)' with 'Logic: 4 Flows'; test 'Test Files 15 passed (15) / Tests 438 passed (438)' (19 of them new); build '✓ Build complete'. validate prints exactly one warning, the hierarchy-security capability-provider notice AGENTS.md rule 7 names as this repo's expected state - not silenced. CI on the PR: check run 'verify' completed, conclusion success. ABLATION (validate anchors record reads on start.config.objectName). Mutation and measurement in ONE shell with trap '<git checkout HEAD -- file>' EXIT INT TERM, on a committed tree so the restore has a source. Mutation confirmed on disk by grepping both directions before running the gate, never by the editor's exit code: leg A injected 'record.due_dat,' count=1 and original \"daysBetween(record.due_date, today())';\" count=0; leg B additionally deleted objectName from all three start nodes, start-node objectName count 3 -> 0. RESULTS: leg A validate EXIT=1 with 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; leg B validate EXIT=0, '✓ Validation passed', no finding at all. Direction observed = the predicted one (red with the anchor, silent without it). RESTORE leg run and verified: git status empty, git diff --stat HEAD empty, misspelling count 0, objectName occurrences 7, and diff -q against the pre-ablation copy reports BYTE-IDENTICAL. No build/dist step applies - this repo compiles no package the tests import through exports, the flows are read as source metadata. An earlier attempt at this ablation was INVALID and is reported rather than quietly re-run: the trap fires at the end of each Bash call, so a first pass that mutated in one call and ran validate in the next measured the ALREADY-RESTORED file and read EXIT=0; both legs were redone self-contained. CEL SEMANTICS measured directly against @objectstack/formula 17.2.0 with the scope shape evaluateCondition builds (extra: {...vars, vars}, record: vars) - has(record.duty) is total (false absent, true present-and-null) while isBlank(record.duty) THROWS 'No such key: duty' on an absent column; daysBetween(due, today()) == 1 + grace is FALSE and == int(1 + grace) is FALSE while == int(grace) + 1 is TRUE for a task 7 days past due with grace 6; P`${X} && true` yields source '\"has(record.duty)\" && true', i.e. the fragment becomes a string literal. All three are pinned as assertions in test/reminders.test.ts. The standing-duty assertion carries a control leg: the same fixture as form 'recurring' drafts a task, so the empty plan for 'standing' is the form being refused rather than an inert fixture.", "open_questions": [ { "question": "The two manager digests need one message per manager listing their N tasks, and that is not authorable in a flow at 17.2.0 (objectstack#14149: no group-by node and no cross-iteration accumulation; notify.message is a flat string and an array token JSON.stringify-s; sys_email_template holes are scalar-only with no iteration; the CEL stdlib HAS joinNonEmpty but no authoring slot evaluates CEL to a value; and the cron path gets no dispatch-claim ledger while the time-relative path does). Grouping by recipient IS reachable through sys_business_unit.manager_user_id joined to the already-denormalised duly_task.business_unit. Should duly ship a degraded digest now, or wait for the platform?", "options": [ "A - Wait for objectstack#14149. Ship nothing manager-facing; #11 stays open on those two bullets. (What this PR does.)", "B - Ship a count-only digest now: loop sys_business_unit, per-unit get_record of the overdue/stagnant rows, skip when empty, notify manager_user_id with a count plus a click-through, carry the rows in notify.payload where no default channel renders them. Grouping, empty-suppression and recipient resolution all land and only the body content is degraded; the upstream fix later becomes a one-line change to message.", "C - Ship the digests via a script node calling a new pure function in src/functions/. Reachable only for the FORMATTING half - the grouping still needs the per-unit N+1 above, and accumulating a per-manager bucket across loop iterations remains inexpressible - and it breaches this card's file surface." ], "recommendation": "A, and B is the defensible second choice if the maintainer wants the skeleton in place. Real business need: the digest is a genuine, evidenced requirement - it is the card's loudest acceptance criterion and the reason the product exists - but the need is for a LISTING a manager can act on, and B delivers a number, which is the half a manager cannot act on; the day-seven stage would additionally ignore grace_days, so a graced duty would tell the manager before it tells the owner. Long-term soundness: B bakes a workaround into the app on the exact surface #14149 would fix, and the count-only body is the kind of thing that survives long after the platform can do better; A keeps the gap visible and costs one more pass. Making AI-written metadata hard to get wrong: this is the strongest argument for A - a shipped count-only digest becomes the pattern the next generated app copies, and it reads as done; the platform fix (a role:'value' expression slot plus a grouping node) makes the correct shape authorable for everyone instead. Startup scope discipline: A is also the cheaper answer - it declines to build an app-level substitute for a platform capability, and the owner-facing half already delivers the notifications that replace the status meeting for the person who can act on them today. C is rejected on all four axes: it does not actually reach the grouping half, and it spends the file-surface budget to buy formatting alone.", "note": "Also for the maintainer: this PR is the first consumer of duly_duty.grace_days, which #52 (needs-user-decision) records as read by nothing. It reads it for escalation timing only and settles nothing about what 'late' means in the analytics layer or the 'Late' view (#48); both stay open." } ], "out_of_scope_findings": [ "filed as objectstack-ai/objectstack#14149: a digest - one message per recipient listing their N records - is not authorable in a flow (no aggregation node, no collection rendering in notify or sys_email_template, no CEL-to-value authoring slot despite joinNonEmpty shipping in the stdlib, and no dispatch-claim ledger on the cron path)", "filed as objectstack-ai/duly#68: no flow in this app auto-launches - requires: ['triggers'] is undeclared, so a real boot reports 'Flows: 4 flow(s) 0 bound to triggers' while all four gates exit 0", "filed as objectstack-ai/duly#69: a flow cannot send a localizable notification - the app wires no emailTemplates barrel, leaving only the explicitly non-localizable inline title/message path, so AGENTS.md rule 8 is unfollowable in any notifying flow" ] }Generated by Claude Code
os-warren commentedon Sep 1, 2026
Sanitizer correction to the report above (the JSON itself is otherwise intact and remains the authoritative copy).
In the
testsfield GitHub stripped an angle-bracket placeholder, leavingtrap '' EXIT INT TERM— which reads as an empty trap, i.e. no restore at all. The trap was not empty. Written without brackets:That is what restored both ablation legs, and why the file came back byte-identical to the pre-ablation copy.
The same sanitizer ate the claim-key placeholders in PR #70's first body; that one has been rewritten in words.
Generated by Claude Code
os-warren commentedon Sep 1, 2026
ACCEPT the owner half; the card stays open on the two digests. PM review of #70, round 5.
Gates re-run with
origin/mainmerged: all fourEXIT=0, 505 tests,Logic: 4 Flows. CIverifysuccess. Opening withPart of #11rather than the closing keyword was right — half the card ships and a merge must not close it.duly#68 is the finding of this card, and I confirmed it on the live server
Straight from the running instance on
main. No flow in this application auto-launches. #33's fan-out has been dark since it merged, and your three sweeps would be dark too. Four gates green,defineStackhappy,validateclean — and the automation layer inert.You measured it with
PORT=3117 pnpm start, named the missingrequires: ['triggers'], and did not reach intoobjectstack.config.tsbecause it was off your surface. Filing it instead was correct and I am dispatching it next — it is a one-line change that turns on every flow in the app.Q on the digests — A, wait for the platform
Your reasoning decides it, and the third axis in particular: a count-only digest reads as done. It would become the shape the next generated app copies, on the exact surface objectstack#14149 would fix properly. And the day-seven stage would ignore
grace_days, so a graced duty would tell the manager before it tells the owner — shipping a notification that is wrong in the direction the customer configured against is the same defect #52 exists for.Declining to build an app-level substitute for a missing platform capability is what rule 9 asks for. The owner-facing half already replaces the status meeting for the person who can act today.
#11 stays open on the two manager digests.
Restart-when: closed objectstack-ai/objectstack#14149The CEL measurements are the most reusable thing here
Three results pinned as assertions rather than described:
has(record.duty)is total;isBlank(record.duty)throwsNo such keyon an absent columndaysBetween(due, today()) == 1 + graceis false while== int(grace) + 1is trueP`${X} && true`turns the interpolated fragment into a string literalThat last one is a trap anyone composing predicates will hit, and none of the three is documented anywhere I can find. Pinning them means the next author gets a red test instead of a silent wrong branch.
And the ablation you invalidated yourself
That is the fourth distinct way a restore mechanism has produced a false green in this project — after the index-vs-HEAD trap, the untracked-file restore, and the mis-anchored grep. Reporting it rather than quietly re-running is why the second reading is worth anything. Mutation and measurement belong in one shell invocation, and I will put that in the dispatch template.
duly#69 (a flow cannot send a localizable notification — no
emailTemplatesbarrel, so only the explicitly non-localizable inline path exists, making AGENTS.md rule 8 unfollowable in any notifying flow) is a real contradiction between two of our own rules. Queued.Merging.
Generated by Claude Code
Give reminder notifications a localizable home (#99)