Schedule Organization — Automation Protocol
The ACTING ORGANIZATION of a time-triggered flow — the one start-node key that says which organization a scheduled run executes as.
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,不允许跨组织的定时任务。
Under the isolated tenancy posture 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 that requirement bites, and where it does not (#17396, #18378)
⚠️ The sentence above is scoped by POSTURE, and that scoping is the whole of
the 2026-09-12 and 2026-09-16 amendments. Two deployment facts decide whether
this key is required, and ⛔ neither of them is metadata — both are read from
the environment at boot, beside resolveTenancyPosture:
| deployment | is this key required? | where a bound run's writes get their organization |
|---|---|---|
| package-authored scheduled work switched OFF (the global default) | ⛔ nothing arms, so nothing is required — the flow is listed as disabled by deployment policy, never as a binding failure | — |
switched ON, posture single | no | the run carries none; every tenant-scoped insert beneath it resolves the install's one organization through the #8844 guard |
switched ON, posture group | no — optional | declared ⇒ the declaration, bounding SELECTION and identity alike; undeclared ⇒ the swept record's own organization |
switched ON, posture isolated | yes — declare or the flow is not armed | the declaration |
⇒ The key is never deprecated and its meaning never changes: where a run declares one, that is the organization it acts as, and nothing on this path ever chooses one out of the air. What changed is that a missing key is no longer a defect on every deployment — so ⛔ do not read the refusal sentence below as a universal authoring rule, and ⛔ do not re-add an authoring-time lint for it: at authoring time neither the switch nor the posture is knowable, which is why the diagnostic lives at BIND and only fires where the answer is settled.
Why group is optional rather than required (#18378, ruling A′, 2026-09-16)
The 2026-09-08 ruling was made for the MULTI-TENANT shape. #18378 asked
whether it binds a group — one legal group, one database, with group-wide
visibility and cross-org workflow inherent to the posture (ADR-0105 D1, the
multi-plant MES example is the ADR's own). It does not.
⚠️ The group row is not a fallback that guesses. It is the resolution order
sys_automation_run has ALREADY been ruled to use: ObjectStoreSuspendedRunStore
resolves a run's organization as organizationOf(<subject record>) ?? ctx.tenantId
— subject first, the acting context as the fallback and never the primary
(objectql/src/tenancy/platform-object-tenancy.ts, the sys_automation_run
evidence line). Before this card the two halves disagreed under group: the
history row was stamped from the record while the inbox and delivery rows
followed an acting context that could not exist there, so they were refused.
Filling the acting context from the record makes one run carry ONE
organization's opinion about who it belonged to — which is the defect commit
ecdfc9411 fixed, read from the other side.
⚠️ With ONE stated exception, so the sentence above is not read as a promise
it cannot keep. The two halves ask different questions and are answered by
different faces of the shared resolver: the history row is STAMPED (who is this row about — the platform stamp column wins there, by the cloud#1395
ruling), while the run's acting organization is a WALL reading
(what is this row scoped by, which never consults that column). They give
the same answer on every object where the two coincide — every ordinary
object, because a stamp column is what makes them differ and exactly one
shipped object has one (sys_api_key, deliberately unwalled, #8287; carried
by PLATFORM_STAMP_ORGANIZATION_COLUMNS in @objectstack/metadata-core
since the authorable tenancy.organizationField key was retired at protocol
18, #19054). Sweeping
THAT object under group stamps the history row from its stamp column while
the run itself acts as nothing and its inbox writes are refused. That is the
correct pair of answers rather than a residue of the old disagreement — a row
nothing walls has no organization for a run to act as, however clearly it
says who it is about — but it is a divergence, and it is recorded here rather
than smoothed over.
⛔ A record-less run under group that declared nothing still resolves NOTHING,
and takes the existing walled-posture refusal at the write
(resolveSystemWriteOrganization) — loud, by name, carrying the remedy. The
card's original option A would have fallen back to the bootstrap organization
(slug='default'); that arm was rejected on measurement. Under a wall
AuthPlugin skips its own default-organization bootstrap and the enterprise
organizations runtime mints one ADMIN-KEYED, so a group install with no
resolvable platform admin holds no such organization at all; where one does
exist it is whichever organization the platform owner registered under —
plausibly one plant of many, not the group's head office. Landing a group-wide
cron's notifications in one arbitrary plant's inbox is the wrong-owner failure
the paragraph above forbids, not a lesser version of it.
Where it lives, and why there
On the flow's START node config, beside the cadence it scopes:
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.
Why ScheduleOrganization… and not FlowActingOrganization…
The key governs both trigger kinds, and FlowTriggerKind lists
time_relative and schedule as two of its four members — so the name looks
inaccurate for half its subjects. It is not, and the deciding reading is the
AUTHORABLE surface rather than the derived kind: FlowSchema.type is
z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api']) and
has no time_relative member at all. A time-relative sweep is authored as
type: 'schedule' with a timeRelative descriptor on its start node — the
docs say so in as many words ("a schedule flow whose start node declares
a timeRelative descriptor"), and TimeRelativeTriggerSchema's own opening
line says the trigger "sweeps an object on a schedule". FlowTriggerKind
splits the two because the ENGINE routes them to different triggers; its
precedence note distinguishes a sweep from "a plain schedule flow", which is
a split inside the schedule family, not out of it.
⇒ Every flow this key applies to declares type: 'schedule'. The name is
accurate for both subjects, and a minor freezes it, so this is recorded
rather than left to be re-litigated.
Source: packages/spec/src/automation/schedule-organization.zod.ts
TypeScript Usage
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. A time-triggered run has no session to inherit a tenant from. Required under the isolated tenancy posture: a flow that declares none is not armed. Optional under group, where an undeclared run acts as the organization of the record it swept. Not required under single, where the run carries no organization.
Type: string
Io Node Config
Config contracts for the flat IO builtins — notify and http. Reference for HttpConfig, NotifyConfig: every property with its type and default.
Schemaless Node Config
Config contracts for the descriptor-schemaless builtins whose designer form lives ONLY in objectui's hand-written FLOW_NODE_CONFIG table — script.