Uh oh!
There was an error while loading. Please reload this page.
Owner-facing reminder sweeps: lead time, due soon, and day one overdue - #70
Merged
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
…L traps Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
os-warren
marked this pull request as ready for review
September 1, 2026 08:27
Uh oh!
There was an error while loading. Please reload this page.
os-warren pushed a commit
that referenced
this pull request
Sep 1, 2026
`objectstack.config.ts` declared `requires: ['automation', 'hierarchy-security']`. `automation` gives the app a flow ENGINE; it registers no TRIGGER. Every flow in the app was therefore inert: the assignment fan-out (#33) never fanned out and the three reminder sweeps (#70) never swept. Measured on @objectstack/cli 17.2.0, `PORT=3117 pnpm start`: before: Plugins: 35 loaded Flows: 4 flow(s) 0 bound to triggers + one "declares a '<type>' trigger but is NOT bound" warning per flow after: Plugins: 39 loaded (RecordChangeTriggerPlugin, ScheduleTriggerPlugin, TimeRelativeTriggerPlugin, ApiTriggerPlugin) Flows: 4 flow(s) 4 bound to triggers (record_change, schedule, time_relative, api) no unbound warnings; boot diagnostics 9 -> 5 One token covers all four kinds: `triggers` is the only trigger entry in `PLATFORM_CAPABILITY_TOKENS`, and the CLI keys it to @objectstack/trigger-record-change plus extras for the schedule, time-relative and api plugins. No second declaration is needed. `validate`, `typecheck`, `test` and `build` all exited 0 with every flow unbound, so `test/trigger-capability.test.ts` pins the invariant: it goes red if `triggers` is dropped while any flow declares a trigger, and it re-derives its assumptions (token spelling, one-token coverage, the flow `type` vocabulary) from the platform's own tables rather than restating them. It is a labelled stopgap over an author-time platform gap, filed as objectstack-ai/objectstack#14153, and is meant to be deleted when that lands. Part of #68
os-warren added a commit
that referenced
this pull request
Sep 1, 2026
`objectstack.config.ts` declared `requires: ['automation', 'hierarchy-security']`. `automation` gives the app a flow ENGINE; it registers no TRIGGER. Every flow in the app was therefore inert: the assignment fan-out (#33) never fanned out and the three reminder sweeps (#70) never swept. Measured on @objectstack/cli 17.2.0, `PORT=3117 pnpm start`: before: Plugins: 35 loaded Flows: 4 flow(s) 0 bound to triggers + one "declares a '<type>' trigger but is NOT bound" warning per flow after: Plugins: 39 loaded (RecordChangeTriggerPlugin, ScheduleTriggerPlugin, TimeRelativeTriggerPlugin, ApiTriggerPlugin) Flows: 4 flow(s) 4 bound to triggers (record_change, schedule, time_relative, api) no unbound warnings; boot diagnostics 9 -> 5 One token covers all four kinds: `triggers` is the only trigger entry in `PLATFORM_CAPABILITY_TOKENS`, and the CLI keys it to @objectstack/trigger-record-change plus extras for the schedule, time-relative and api plugins. No second declaration is needed. `validate`, `typecheck`, `test` and `build` all exited 0 with every flow unbound, so `test/trigger-capability.test.ts` pins the invariant: it goes red if `triggers` is dropped while any flow declares a trigger, and it re-derives its assumptions (token spelling, one-token coverage, the flow `type` vocabulary) from the platform's own tables rather than restating them. It is a labelled stopgap over an author-time platform gap, filed as objectstack-ai/objectstack#14153, and is meant to be deleted when that lands. Part of #68 Co-authored-by: Claude <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 freeto 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.
Part of #11
Deliberately
Part of, not a closing keyword. This lands the owner-facing half of the card; the two manager digests it also asks for are not here, so #11 must stay open after a merge. The reason is below, not buried.What landed
Three
time_relativesweeps insrc/flows/reminders.flow.ts, pushed intodulyFlows:duly_task_lead_time_remindervisible_from,offsetDays: [0]duly_task_due_soon_reminderdue_date,offsetDays: [2]duly_task_overdue_owner_escalationdue_date,withinDays: -15due_date + duty.grace_daysAll
type: 'schedule',runAs: 'system',status: 'active', daily at 08:00 UTC. Every gate is a flow node —get_record, conditional edges,notify. No script node, no handler, no new field on any object.Idempotency is the platform's, and it is already there
The card asks for a per-task marker. It is not needed, and adding one would have been a second writer for state the platform keeps: the time-relative trigger takes a dispatch claim through the automation service before launching a flow for a record (
claim(key), persistedsys_flow_dispatchledger, objectstack#10220). The key is built from four parts — the literaltime-relative, the flow name, the window scope (YYYY-MM-DDplusoffsetNorwithinN), and the record id — joined by colons.In offset mode the claim scope is the target day, so a record matches on exactly one calendar day and claims one key for good — that is "two notifications per task, maximum, ever", obtained from the trigger. In range mode the scope is the sweep day, which is why the overdue sweep pairs a daily lookback with an exact-day equality gate rather than a threshold.
(Angle-bracket placeholders were in that sentence on the first draft of this body, and GitHub's sanitizer removed them silently, leaving
time-relative:::offset:. Written out in words instead.)Why the overdue sweep is a range and the others are not
The escalation day is
due_date + grace_days + 1, andgrace_dayslives onduly_duty.offsetDaysis a static array authored at build time, so it cannot express a per-record offset — anoffsetDays: [-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 15-day range and the exact day is decided in the flow, where the duty is readable.That makes this the first consumer of
grace_daysin the app — #52 records that nothing reads it today, and #52 stays open: this reads it for escalation timing only and settles nothing about what "late" means in the analytics layer or in the "Late" view (#48).Three measured facts the code is written around
Each one produces a predicate that parses, ships, and means something else. All three are pinned in
test/reminders.test.ts.1 ·
Pinterpolates values, not CEL text.@objectstack/spec17.2.0:The fragment becomes a string literal. Composed predicates here use
expression(source), which splices text into the identical envelope.2 ·
int()goes around the FIELD, never the sum.@objectstack/formula17.2.0, task 7 days past due, grace 6:daysBetween()returns a CEL int; a host number makes the arithmetic a double, andint == doubleanswers false here instead of throwing theno such overloadit throws for two literals. A false gate on a notification flow is indistinguishable from "nothing was due", forever.3 ·
has()beforeisBlank(), always. The time-relative trigger does nomaterializeDeclaredFields— only the record-change trigger does — so a NULL column can be absent from the swept row.isBlank(record.duty)on a row with nodutykey throwsNo such key: duty, and a throwing predicate faults the run.has()is total over absent and null.objectNameon the start node is load-bearing — ablation, both legsOn a
time_relativeflow the swept object lives atconfig.timeRelative.object, so it is easy to write the flow withoutconfig.objectName. Bothobjectstack validateand this repo's bare-identifier stopgap (test/flow-predicates.test.ts) anchor onobjectName, and the stopgap's own "binds a declared object" assertion coversrecord_changeflows only. Mutation and measurement in one shell, restored viatrap, verified on disk by grepping for the injected and the removed text:pnpm validaterecord.due_date→record.due_dat,objectNamepresentunknown field `due_dat` on `duly_task` — did you mean `due_date`?, naming edgee_no_duty_day_oneand edgee_day_oneobjectNamedeleted from all three start nodes✓ Validation passed, no finding at allSo
objectNameis not redundant withtimeRelative.object; it is what keeps the whole predicate surface of these flows checked. Restored file verified byte-identical to the pre-ablation copy.Volume discipline
done/skipped/cancelledare excluded in the sweep filter, not in a gate — a completed task never launches a run and never consumes a claim, so "completing a task produces no further notifications of any kind" is true by construction. The test pins the complement of['open','in_progress']againstduly_task.status, so a new terminal status cannot start receiving reminders unnoticed.{record.owner}and nobody else; exactly onenotifynode per flow; no config in this file mentions a manager.effective_from/effective_towindow, evaluated againsttoday()— a duty retired last week stops nagging about the tasks it already produced. A task with no duty (an assignment fan-out row) has no window to be outside of, and that exemption is explicit.planDispatchwith a standing duty (zero drafts, skip reasonstanding) and a control leg with the same duty asrecurringthat does draft — so the empty plan is the form being refused, not an inert fixture.duly_log_entry, no count comparison between people.What is NOT here, and why it is filed rather than worked around
The day-seven manager escalation and the weekly stagnation digest both need "one message per manager listing their N tasks". That is not authorable in a flow at 17.2.0 — filed as objectstack-ai/objectstack#14149 with the measurements:
loopiterations (assignmentsets, it cannot append);notify.messageis a flat string and an array tokenJSON.stringifys;sys_email_templateholes are scalar-only (String(raw), no iteration) — a list of 30 rows has nowhere to render;joinNonEmpty(list, sep)and no authoring slot can call it:FLOW_NODE_EXPRESSION_PATHSdeclares onlypredicateandflow-templateroles, and theassignmentnode interpolates rather than evaluating;A count-only digest would have satisfied "one message, not thirty" while quietly dropping "listing 30" and ignoring grace at the manager stage. That is the workaround the card's own instruction rules out.
Two more findings
requires: ['triggers']is undeclared, and the boot summary says so on every start #68 — no flow in this app auto-launches.requires: ['triggers']is undeclared, so on a real bootFlows: 4 flow(s) 0 bound to triggers; the sweeps here and the Assignment fan-out — one piece of work becomes N independent tasks #33 fan-out are allNOT bound. The engine resolves each intended binding (time_relativefor all three of these), which is why it can name it in the warning. All four gates exit 0 regardless. Left alone on this branch:objectstack.config.tsis off this branch's surface, and the change affects the Assignment fan-out — one piece of work becomes N independent tasks #33 fan-out too.emailTemplatesbarrel, so only the explicitly non-localizable path exists #69 — flow notifications cannot be localized.defineStackacceptsemailTemplates; this app wires no barrel for one, so the only reachablenotifycontent path is the explicitly non-localizable inlinetitle/message. The strings here are a declared deviation from AGENTS.md §8, recorded in the flow header.Gates
Run at
c4257a4, the branch head, after the final commit:validateprints one warning — thehierarchy-securitycapability-provider notice that AGENTS.md rule 7 names as this repo's expected state.File surface:
src/flows/reminders.flow.ts(new),src/flows/index.ts(barrel entry),test/reminders.test.ts(new). No breach —objectstack.config.ts,src/jobs/andsrc/flows/assignment.flow.tsare untouched (src/jobs/dispatch.plan.tsis imported by the test, never edited).Generated by Claude Code