Skip to content

Commit 135e19e

Browse files
fix(example-todo): drop objectName from the two schedule start nodes (#24153)
Fixes #24067 Clause-②: no ## What `examples/app-todo`'s two daily sweeps, `task_reminder` and `overdue_escalation`, stored `objectName: 'todo_task'` on a `type: 'schedule'` start node, a key the schedule binding never reads. This PR drops the key from both start nodes and adds a pin over the example's shipped stack. Clause-② is `no`: an example app's metadata loses a key nothing reads, and no export, type or accept set moves. ## Premise verified first (on `origin/main` `9ffe8af094`) - `AutomationEngine.deriveTriggerBinding` (`packages/services/service-automation/src/engine.ts`) puts `config.objectName` into the record-change binding (`:3799`, `:3816`) and, as a fallback behind `timeRelative.object`, into the time-relative binding (`:3842`). Its `schedule` branch (`:3859` to `:3872`) builds `flowName`, `schedule`, `organization`, `condition` and `config`, and no object. - `ScheduleTrigger` (`packages/triggers/trigger-schedule/src/schedule-trigger.ts`) reads `binding.schedule` and `binding.organization`, and from `binding.config` only `schedule` and `organization`. It never reads `objectName`, and the run context it builds carries no object. - The spec's `resolveFlowTriggerKind` never reads `objectName`. Neither start node has a `record-*` `triggerType`, so both flows resolve to `schedule`. - Both sweeps name `todo_task` on their read steps: `get_upcoming_tasks` (`:61`) and `get_overdue_tasks` (`:166`). These are `get_record` nodes with `limit: 200`; the card calls them `get_records`, but they are the same nodes. They keep their `objectName`. - The only other readers are advisory: the engine's `validateFlowExpressions` schema hint (it logs and never throws) and `@objectstack/lint`'s `validate-expressions` hint. Under "Evidence" below, `objectstack validate` gives the same output before and after. ## Changes - `examples/app-todo/src/flows/task.flow.ts`: drop `objectName` from both schedule start nodes. A comment on the first one says why. - `examples/app-todo/test/schedule-start-no-object-name.test.ts` (new): reads the flows from the example's default `defineStack` export, not from a hand list, and keeps those whose `resolveFlowTriggerKind` answer is `schedule`. It then asserts that none of them has `objectName` on its start node's `config`. Anti-vacuity: the selection must be non-empty, and each selected start node must carry `schedule`. **Selection, as a deviation from the dispatch wording.** The claim describes the pin's population as "`type: 'schedule'` flows". The pin selects by the spec resolver's `schedule` answer instead, because that is the predicate the engine binds by. Every `type: 'schedule'` flow is included unless its start node routes it to another trigger. The case that matters is a `timeRelative` sweep: its binding does fall back to `objectName`, so pinning the key away there would be wrong. ## Evidence - Pin, on the fixed tree: `pnpm --filter @objectstack/example-todo exec vitest run --maxWorkers=2 test/schedule-start-no-object-name.test.ts` gives 2 passed. - Ablation through `scripts/ablation-replace.mjs` (wrap mode, with a restore trap). Each leg puts today's `objectName: 'todo_task'` back into one start node: - Leg `0 8 * * *`: anchor count 1 to 0, blob `bf97d6a9016d` to `ef4f5551ce62`. **Red**, offenders `flow 'task_reminder' · start node config.objectName`. Restored: blob equals HEAD `bf97d6a9016d`, `git diff HEAD` empty. - Leg `0 9 * * *`: blob `bf97d6a9016d` to `b6d8aab6cd15`. **Red**, offenders `flow 'overdue_escalation' · start node config.objectName`. Restored: blob equals HEAD, `git diff HEAD` empty. - Example suite on head `a60f8cde84`: `vitest run` gives 8 files and 240 tests passed. `typecheck` (`tsc --noEmit`) exits 0, and `tsc --listFiles` includes the new test. - `objectstack validate` on the example passes both before and after, and its 7 warnings are byte-identical. For the "before" reading, both keys were put back with nested `ablation-replace`, then restored to HEAD. - Same-family scan over `examples/`, `skills/`, `content/docs/` and `packages/create-objectstack/` found no other schedule start node with `objectName` (control: the same pattern finds 2 in the base tree's `task.flow.ts`). ## Gates (local, head `a60f8cde84`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 41 commands. All 41 were run, and every exit code was captured before any pipe: **41 exit 0**. - `--ran` reconciliation: `41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN`. - On the first pass (head `73f5bf9968`), `check:dual-build-cjs-loads` and `check:i18n-coverage` refused with PREREQUISITE NOT MET (exit 3, nothing measured). I built the packages they named, and both then measured exit 0. The whole union was then re-run on the merge head. - CI owns the full set of families, the type-check lanes and the jobs scheduled by these paths. ## Acceptance notes - No changeset: `@objectstack/example-todo` is `"private": true`, so this PR carries the `skip-changeset` label. - The card's last section leaves open whether the spec should refuse `objectName` on a schedule start node. That is the spec lane's call, and this PR does not touch it. - The head merged `origin/main` at `265ef64ba7`. Since then, `main` has advanced by one commit that touches only `plugin-approvals`, which this diff does not touch. --- _Generated by [Claude Code](https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f024d8 commit 135e19e

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

‎examples/app-todo/src/flows/task.flow.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ export const TaskReminderFlow: Flow = {
4949
],
5050

5151
nodes: [
52-
{ id: 'start', type: 'start', label: 'Start (Daily 8 AM)', config: { schedule: '0 8 * * *', objectName: 'todo_task' } },
52+
// [#24067] No `objectName` on a schedule start node: the schedule binding
53+
// carries no object (`AutomationEngine.deriveTriggerBinding` lifts
54+
// `objectName` into the record-change binding only), so the key would be
55+
// stored and never read. The sweep names its object where it reads, on the
56+
// `get_record` step below.
57+
{ id: 'start', type: 'start', label: 'Start (Daily 8 AM)', config: { schedule: '0 8 * * *' } },
5358
{
5459
id: 'get_upcoming_tasks', type: 'get_record', label: 'Get Tasks Due Tomorrow',
5560
// `limit > 1` is the declared way to make this a LIST read (`find`, not
@@ -158,7 +163,8 @@ export const OverdueEscalationFlow: Flow = {
158163
],
159164

160165
nodes: [
161-
{ id: 'start', type: 'start', label: 'Start (Daily 9 AM)', config: { schedule: '0 9 * * *', objectName: 'todo_task' } },
166+
// No `objectName` here either: see `task_reminder`'s start node.
167+
{ id: 'start', type: 'start', label: 'Start (Daily 9 AM)', config: { schedule: '0 9 * * *' } },
162168
{
163169
id: 'get_overdue_tasks', type: 'get_record', label: 'Get Severely Overdue Tasks',
164170
// `limit > 1` = LIST read; the undeclared `getAll` was never read (#4277).
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24067] No schedule flow this example ships stores `objectName` on its
5+
* start node.
6+
*
7+
* A schedule binding carries no object. `AutomationEngine.deriveTriggerBinding`
8+
* lifts the start node's `config.objectName` into the record-change binding
9+
* (and, as a fallback behind `timeRelative.object`, into the time-relative
10+
* one); its `schedule` branch hands the trigger `schedule` and `organization`
11+
* only, and `ScheduleTrigger` never reads `objectName`. On a schedule start
12+
* node the key is stored and never acted on, and the console's flow designer
13+
* labels it "kept, not in effect". An example is what an AI copies, so the
14+
* example must not model it: a sweep names its object where it reads, on its
15+
* `get_record` step, as both daily sweeps here do.
16+
*
17+
* The flows are read off the stack the example ships (its default
18+
* `defineStack` export), never a hand list, so a schedule flow added later is
19+
* covered on the day it lands. "Schedule" is the spec's
20+
* `resolveFlowTriggerKind` answer, the one predicate the engine binds by: a
21+
* time-relative sweep, whose binding DOES fall back to `objectName`, is
22+
* correctly left out, and every other `type: 'schedule'` flow is in.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
27+
28+
import TodoApp from '../objectstack.config.js';
29+
30+
type NodeLike = { id?: string; type?: string; config?: Record<string, unknown> };
31+
type FlowLike = { name?: string; type?: string; nodes?: NodeLike[] };
32+
33+
const shippedFlows = ((TodoApp as unknown as { flows?: FlowLike[] }).flows ?? []);
34+
const scheduleFlows = shippedFlows.filter((flow) => resolveFlowTriggerKind(flow) === 'schedule');
35+
36+
function startConfig(flow: FlowLike): Record<string, unknown> {
37+
const start = (flow.nodes ?? []).find((node) => node.type === 'start');
38+
if (!start) throw new Error(`harness: flow '${flow.name}' has no start node`);
39+
return start.config ?? {};
40+
}
41+
42+
describe('#24067: a schedule start node carries no objectName', () => {
43+
it('reads real schedule flows off the shipped stack (anti-vacuity)', () => {
44+
expect(scheduleFlows.length).toBeGreaterThan(0);
45+
// Each start node read here is the one the schedule binding reads: it
46+
// carries the cadence that binding DOES act on.
47+
for (const flow of scheduleFlows) {
48+
expect(startConfig(flow), `flow '${flow.name}' start node`).toHaveProperty('schedule');
49+
}
50+
});
51+
52+
it('no schedule flow stores objectName on its start node', () => {
53+
const offenders = scheduleFlows
54+
.filter((flow) => Object.hasOwn(startConfig(flow), 'objectName'))
55+
.map((flow) => `flow '${flow.name}' · start node config.objectName`);
56+
expect(offenders).toEqual([]);
57+
});
58+
});

0 commit comments

Comments
 (0)