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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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

Owner-facing reminder sweeps: lead time, due soon, and day one overdue#70
os-warren merged 2 commits into
mainfrom
claude/issue-11-reminders

Conversation

@os-warren

@os-warrenos-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:

flowsweepsfires
duly_task_lead_time_remindervisible_from, offsetDays: [0]the day a task appears on its owner's list
duly_task_due_soon_reminderdue_date, offsetDays: [2]two days before due
duly_task_overdue_owner_escalationdue_date, withinDays: -15day 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:

ablationpnpm validate
misspell record.due_daterecord.due_dat, objectName presentEXIT 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 nodesEXIT 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 hasjoinNonEmpty(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 mainSep 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 freeto 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

@os-warren