Skip to content

Owner-facing reminder sweeps: lead time, due soon, and day one overdue - #70

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders
Sep 1, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warren os-warren commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

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_relative sweeps in src/flows/reminders.flow.ts, pushed into dulyFlows:

flow sweeps fires
duly_task_lead_time_reminder visible_from, offsetDays: [0] the day a task appears on its owner's list
duly_task_due_soon_reminder due_date, offsetDays: [2] two days before due
duly_task_overdue_owner_escalation due_date, withinDays: -15 day one past due_date + duty.grace_days

All 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), persisted sys_flow_dispatch ledger, objectstack#10220). The key is built from four parts — the literal time-relative, the flow name, the window scope (YYYY-MM-DD plus offsetN or withinN), 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, and grace_days lives on duly_duty. offsetDays is a static array authored at build time, so it cannot express a per-record offset — 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 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_days in 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 · P interpolates values, not CEL text. @objectstack/spec 17.2.0:

const X = 'has(record.duty)';
P`${X} && true`  →  { dialect: 'cel', source: '"has(record.duty)" && true' }

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/formula 17.2.0, task 7 days past due, 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 instead of throwing the no such overload it throws for two literals. A false gate on a notification flow is indistinguishable from "nothing was due", forever.

3 · has() before isBlank(), always. The time-relative trigger does no materializeDeclaredFields — only the record-change trigger does — so a NULL column can be absent from the swept row. 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.

objectName on the start node is load-bearing — ablation, both legs

On a time_relative flow the swept object lives at config.timeRelative.object, so it is easy to write the flow without config.objectName. Both objectstack validate and this repo's bare-identifier stopgap (test/flow-predicates.test.ts) anchor on objectName, and the stopgap's own "binds a declared object" assertion covers record_change flows only. Mutation and measurement in one shell, restored via trap, verified on disk by grepping for the injected and the removed text:

ablation pnpm validate
misspell record.due_date → record.due_dat, objectName present 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
same misspelling, objectName deleted from all three start nodes 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. Restored file verified byte-identical to the pre-ablation copy.

Volume discipline

  • done / skipped / cancelled are 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'] against duly_task.status, so a new terminal status cannot start receiving reminders unnoticed.
  • Every notification addresses {record.owner} and nobody else; exactly one notify node per flow; no config in this file mentions a manager.
  • Nothing fires outside the duty's effective_from / effective_to window, evaluated against today() — 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.
  • Standing duties: free, and asserted anyway. The test drives planDispatch with a standing duty (zero drafts, skip reason standing) and a control leg with the same duty as recurring that does draft — so the empty plan is the form being refused, not an inert fixture.
  • No daily digest, nothing about 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:

  • no aggregation / group-by node, and no way to accumulate across loop iterations (assignment sets, it cannot append);
  • notify.message is a flat string and an array token JSON.stringifys; sys_email_template holes are scalar-only (String(raw), no iteration) — a list of 30 rows has nowhere to render;
  • the CEL stdlib has joinNonEmpty(list, sep) and no authoring slot can call it: FLOW_NODE_EXPRESSION_PATHS declares only predicate and flow-template roles, and the assignment node interpolates rather than evaluating;
  • and the cron path gets no dispatch-claim ledger, while the time-relative path does.

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

Gates

Run at c4257a4, the branch head, after the final commit:

pnpm validate   → 0   ✓ Validation passed        (Logic: 4 Flows)
pnpm typecheck  → 0
pnpm test       → 0   Test Files 15 passed (15) · Tests 438 passed (438)
pnpm build      → 0   ✓ Build complete

validate prints one warning — the hierarchy-security capability-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/ and src/flows/assignment.flow.ts are untouched (src/jobs/dispatch.plan.ts is imported by the test, never edited).


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 08:27
@os-warren
os-warren merged commit e3d6c7f into main Sep 1, 2026
1 check passed
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>
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.

1 participant