Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/schedule-trigger-acting-organization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
"@objectstack/trigger-schedule": minor
---

fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it (#16659)

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: no `packages/spec` key changes its name, its type or its optionality, no stored shape moves, and every flow, node and start-node `config` that parses today parses byte-identically after this change — the start node's `config` is an OPEN record (ADR-0018), so the new `organization` key is an addition to a slot that already accepts anything. What narrows is the BIND-TIME accept set: a `schedule` or `time_relative` flow that declares no `organization` is no longer armed. The remedy is a value that only the deployment holds — a `sys_organization.id`, minted at runtime — so there is no authored artifact and no stored representation a rewrite could act on, and `objectstack migrate meta` has nothing mechanical to prescribe: it cannot know which organization a given sweep belongs to, and inventing one is precisely what the ruling forbids. The refusal names the flow and the key, which is the migration instruction, delivered where the deployment can act on it. -->

**BREAKING** in the accept-set sense — a bind-time narrowing on the two
time-triggered flow kinds — landing in the launch window as `minor` on all
three packages (the lockstep convention: during the window the bump level is
not the carrier, this banner and the disposition above are). Nothing that was
already delivering stops delivering; what stops is a flow that was armed and
inert. Nothing that was refused becomes admitted.

A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization.

Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」

A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing.

- **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization`, `findScheduleOrganizationNearMissInConfig`, and `describeMissingScheduleOrganization` — ONE refusal sentence and ONE near-miss scan, so the engine's lift and both triggers cannot drift about what counts as declared.
- **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it.
- **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it.

**What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later.

⛔ There is no fallback limb anywhere on this path — not the install's only organization, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them.

**Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness.

No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands.
55 changes: 55 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1922,6 +1922,9 @@ export const contractExpirationCheck: Flow = {
config: {
triggerType: 'schedule',
schedule: { type: 'cron', expression: '0 0 * * *', timezone: 'UTC' },
// REQUIRED on every time-triggered flow — see "The acting
// organization" below. A flow that omits it is refused at bind.
organization: '<sys_organization.id>',
},
},
{ id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' },
Expand Down Expand Up @@ -1963,6 +1966,9 @@ export const renewalReminder: Flow = {
offsetDays: [60, 30, 7], // — or — withinDays: 30 (negative = overdue lookback)
filter: { status: 'active' }, // optional, ANDed with the date window
},
// REQUIRED, and for a stronger reason than a plain schedule flow —
// see "The acting organization" below.
organization: '<sys_organization.id>',
// schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC
},
},
Expand All @@ -1981,6 +1987,55 @@ required. Requires the `triggers` **and** `job` capabilities. The record is on
the flow context (`record.*`), so the start `condition` and `{record.*}`
interpolation work as in a record-change flow.

### The acting organization

A `record_change` or `api` flow inherits its organization from whoever triggered
it: the caller's session rides into the run and every tenant-scoped write below
resolves the same organization a normal write would. A **time-triggered** flow
has no such caller — a job tick carries no identity at all.

So a `schedule` or `timeRelative` flow **declares the organization it runs as**,
on the start node's `config`, beside the cadence it scopes:

```typescript
config: {
schedule: { type: 'cron', expression: '0 8 * * *' },
organization: 'org_msokm9oaz0cal87q', // a sys_organization.id
}
```

The run then executes as that organization: `tenantId` carries it, the
notifications a `notify` node emits land in that organization's inboxes, and the
`sys_automation_run` history row is stamped with it.

**A time-triggered flow that declares none is a declaration error**, refused at
bind:

- the trigger logs the reason at `error`, naming the flow;
- the flow is recorded as **not bound** — it is listed by
`getTriggerBindingAudit()`, warned at `kernel:bootstrapped`, printed in the
`os dev` / `os start` startup summary, and `getFlowRuntimeStates()` reports
`bound: false`;
- nothing fires it.

There is deliberately **no fallback** — not the platform organization, not "the
install's only one". Without the declaration the run would reach every
tenant-scoped write with nothing to offer, and on an install holding more than
one `sys_organization` each of those writes is refused one layer below anything
that summarises the run: the tick reports itself healthy and delivers nothing.
A wrong `organization_id` is worse still, because it is silently authoritative
to every report, export and cleanup script that filters by organization.

**No fan-out.** A single flow belongs to one organization. A sweep wanted in
several organizations is declared once per organization.

<Callout type="warn">
The start node's `config` is an open record, so a near-miss spelling —
`organizationId`, `organization_id`, `orgId`, `org_id`, `tenantId` — parses
happily and is then ignored. The bind-time refusal names the spelling you
actually wrote.
</Callout>

### Update-triggered flow

Trigger on a record update and compare against the previous value:
Expand Down
2 changes: 1 addition & 1 deletion content/docs/automation/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ cannot express.
| :--- | :--- |
| Side effects after a save — create records, notify, call HTTP, request approval | **Flow** (`record_change`) |
| Anything that pauses: approvals, screens, timers, signals | **Flow** — a hook runs inline and cannot pause |
| Scheduled or date-relative sweeps ("30 days before `end_date`") | **Flow** (`schedule` / `timeRelative`) |
| Scheduled or date-relative sweeps ("30 days before `end_date`") | **Flow** (`schedule` / `timeRelative`) — which [declares the organization it runs as](/docs/automation/flows#the-acting-organization) |
| Mutating the pending record in the same write, before it is saved | **Before hook** |
| An invariant enforced on every write path, across objects, no matter who writes | **Hook** — the backstop duty itself |
| Read-side interception (`beforeFind` / `afterFind`) | **Hook** — flows have no read events |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ REST endpoints, real-time subscriptions, and discovery.
| **[Metadata](/docs/references/api/metadata)** | `metadata.zod.ts` | Metadata | API metadata endpoints |
| **[Storage](/docs/references/api/storage)** | `storage.zod.ts` | Storage | API storage operations |

## Automation Protocol (4 of 13 schemas)
## Automation Protocol (4 of 14 schemas)

Flows, state machines, approvals, and integrations.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Read the next section before you write either.
| **Vocabulary** | Author-chosen names, `^[a-z][a-z0-9_.]*$` — `export_data`, `billing.refund` | A **closed** vocabulary: canonical kebab-case tokens from `PLATFORM_CAPABILITY_TOKENS` — `ai`, `automation`, `hierarchy-security` |
| **Entry shape** | `defineCapability({ name, label, description, scope })` (`CapabilityDeclarationSchema`) | A plain `string` |
| **Unknown value** | There is no "unknown" — you are minting the name | A `defineStack` **error** at authoring time (a typo, or a token no runtime provides) |
| **Needed but undeclared** | Nothing to detect — a name is minted here, then granted | A `defineStack` **error** too: a hierarchy scope (`unit` / `unit_and_below` / `own_and_reports`) needs `hierarchy-security`, and a `record_change` / `schedule` / `time_relative` / `api` flow needs `triggers` — without them the runtime fails closed (owner-only visibility) or, for flows, silently never fires |
| **Needed but undeclared** | Nothing to detect — a name is minted here, then granted | A `defineStack` **error** too: a hierarchy scope (`unit` / `unit_and_below` / `own_and_reports`) needs `hierarchy-security`, and a `record_change` / `schedule` / `time_relative` / `api` flow needs `triggers` — without them the runtime fails closed (owner-only visibility) or, for flows, silently never fires. ⚠️ `triggers` is a *capability*, not the whole declaration: a `schedule` / `time_relative` flow also [declares the organization it runs as](/docs/automation/flows#the-acting-organization), and one that does not is refused at bind rather than fired org-less |
| **Consumed by** | `systemPermissions` (grant) and `requiredPermissions` (requirement), by name string | The runtime capability loader, which resolves each token to a service plugin |
| **When it bites** | Never at boot — an ungranted capability is simply held by nobody | **Fail-fast at startup**: a declared-but-missing provider aborts boot instead of degrading silently |
| **Spec** | ADR-0066 D1 | Platform service vocabulary — see the [CLI reference](/docs/deployment/cli) |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/automation/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
<Card href="/docs/references/automation/flow-function" title="Flow Function" description="Source: packages/spec/src/automation/flow-function.zod.ts" />
<Card href="/docs/references/automation/io-node-config" title="Io Node Config" description="Source: packages/spec/src/automation/io-node-config.zod.ts" />
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
<Card href="/docs/references/automation/schedule-organization" title="Schedule Organization" description="Source: packages/spec/src/automation/schedule-organization.zod.ts" />
<Card href="/docs/references/automation/schemaless-node-config" title="Schemaless Node Config" description="Source: packages/spec/src/automation/schemaless-node-config.zod.ts" />
<Card href="/docs/references/automation/state-machine" title="State Machine" description="Source: packages/spec/src/automation/state-machine.zod.ts" />
<Card href="/docs/references/automation/time-relative-trigger" title="Time Relative Trigger" description="Source: packages/spec/src/automation/time-relative-trigger.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"builtin-node-config",
"flow-function",
"io-node-config",
"schedule-organization",
"schemaless-node-config"
]
}
89 changes: 89 additions & 0 deletions content/docs/references/automation/schedule-organization.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
title: Schedule Organization
description: Schedule Organization protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

The ACTING ORGANIZATION of a time-triggered flow — the one start-node key
that says which organization a scheduled run executes as.

## Why the key exists

A record-change flow inherits its organization from the write that fired it:
the triggering session's `tenantId` rides the `AutomationContext` into
the run, so every tenant-scoped write below it — `sys_inbox_message`,
`sys_notification_delivery`, `sys_automation_run` — resolves an organization
the way a session write does. A TIME-triggered flow has no such session. The
schedule trigger and the time-relative sweep launch their runs from a job
tick, and a job tick carries no identity at all, so the run reached the
tenancy guard (`system-write-organization.ts`) with nothing to offer it. On
an install holding more than one `sys_organization` that guard refuses,
correctly and by design — and the refusal landed on rows the run never
reported: the notification wrote with `organization_id = NULL`, every
tenant-scoped row beneath it was refused, and the tick still summarised
itself as healthy.

## The ruling this key implements

Maintainer, 2026-09-08, verbatim:

> 多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。

A time-triggered flow is **organization-scoped by construction**: it names
one organization and the run executes as that organization. There is
deliberately no fan-out — a tenant that wants the same sweep in N
organizations declares it N times — and there is deliberately no fallback: a
flow that names none is a DECLARATION ERROR, not a run that quietly picks
one. Guessing is the failure this key exists to prevent, and the platform
organization is not a safe guess: a wrong `organization_id` is worse than a
null, because a null is visibly missing while a wrong value is silently
authoritative to every report, export and cleanup script that filters by
organization.

## Where it lives, and why there

On the flow's START node `config`, beside the cadence it scopes:

```ts
config: {
schedule: { type: 'cron', expression: '0 8 * * *' },
organization: 'org_msokm9oaz0cal87q',
}
```

The start node is where every other trigger-binding fact already lives —
`FlowSchema` refuses a top-level `schedule` in as many words ("a schedule
flow declares its cron/interval as `config.schedule` on the START node, not
at the flow top level"), and `resolveTriggerBinding` hands the whole start
`config` to the trigger. Putting the organization at the flow top level would
split one binding across two layers; putting it inside the `schedule`
descriptor would make it invisible to the time-relative sweep, which carries
its cadence in the same slot but binds through a different descriptor. One
key, one layer, both time triggers.

<Callout type="info">
**Source:** `packages/spec/src/automation/schedule-organization.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { ScheduleOrganizationSchema } from '@objectstack/spec/automation';
import type { ScheduleOrganization } from '@objectstack/spec/automation';

// Validate data
const result = ScheduleOrganizationSchema.parse(data);
```

---

## ScheduleOrganization

Organization id (sys_organization.id) this scheduled/time-relative flow runs as. Required: a time-triggered run has no session to inherit a tenant from.

**Type:** `string`


---

Loading
Loading