diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md new file mode 100644 index 0000000000..145cead721 --- /dev/null +++ b/.changeset/schedule-trigger-acting-organization.md @@ -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) + + + +**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. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 7140028510..f02575bf84 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -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: '', }, }, { id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' }, @@ -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: '', // schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC }, }, @@ -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. + + +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. + + ### Update-triggered flow Trigger on a record update and compare against the previous value: diff --git a/content/docs/automation/hooks.mdx b/content/docs/automation/hooks.mdx index 7cc7f7ef99..7cde45c793 100644 --- a/content/docs/automation/hooks.mdx +++ b/content/docs/automation/hooks.mdx @@ -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 | diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 61d46b0730..7bc1cd7f3b 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -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. diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx index 3612bbc92b..b093ce8940 100644 --- a/content/docs/permissions/capabilities.mdx +++ b/content/docs/permissions/capabilities.mdx @@ -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) | diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 09525b3036..85148c1ec9 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -17,6 +17,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta + diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index c9990e2740..b32a7ec940 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -17,6 +17,7 @@ "builtin-node-config", "flow-function", "io-node-config", + "schedule-organization", "schemaless-node-config" ] } \ No newline at end of file diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx new file mode 100644 index 0000000000..a46967c0c7 --- /dev/null +++ b/content/docs/references/automation/schedule-organization.mdx @@ -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. + + +**Source:** `packages/spec/src/automation/schedule-organization.zod.ts` + + +## 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` + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 2ee210f7c9..e38cee0d0b 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1582 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1583 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 31 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 13 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 173 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1582** | 14 protocol modules | +| **Total** | **199** | **1583** | 14 protocol modules | --- @@ -103,7 +103,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 73 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 74 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. @@ -118,6 +118,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect` | | [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` | | [`node-executor.zod.ts`](/docs/references/automation/node-executor) | `ActionCategory`, `ActionDescriptor`, `ActionParadigm`, `NodeExecutorDescriptor`, `WaitEventType`, `WaitExecutorConfig`, `WaitResumePayload`, `WaitTimeoutBehavior` | +| [`schedule-organization.zod.ts`](/docs/references/automation/schedule-organization) | `ScheduleOrganization` | | [`schemaless-node-config.zod.ts`](/docs/references/automation/schemaless-node-config) | `DecisionCondition`, `DecisionConfig`, `ScriptConfig`, `SubflowConfig` | | [`state-machine.zod.ts`](/docs/references/automation/state-machine) | `ActionRef`, `GuardRef`, `StateMachine`, `StateNode`, `Transition` | | [`time-relative-trigger.zod.ts`](/docs/references/automation/time-relative-trigger) | `TimeRelativeTrigger` | diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 40dc48db0a..ee32209e9e 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -352,12 +352,27 @@ export const TaskCompletedSlackFlow = defineFlow({ * A `type: 'schedule'` flow whose start node carries an interval descriptor. * The automation engine parses that into a schedule binding; the schedule * trigger plugin (`@objectstack/trigger-schedule`, paired with the job - * service) registers a job that fires this flow every interval. Each tick runs - * the `notify` node, dropping a fresh `sys_inbox_message` row — so the - * scheduled fire is observable end-to-end with no manual `engine.execute()`. + * service) registers a job that fires this flow every interval, and each tick + * runs the `notify` node. * - * Install `requires: ['automation', 'triggers', 'job', 'messaging']` and this - * flow auto-launches on the interval. + * ⛔ AS SHIPPED, THIS FLOW DOES NOT FIRE. Since #16659 a time-triggered flow + * must declare the organization it runs as (`config.organization`, a + * `sys_organization.id`), and a flow that declares none is REFUSED at bind: + * the trigger logs the reason at `error` and throws, the engine records the + * flow as not bound, and it is listed in the startup summary's + * trigger-binding audit. A package-shipped flow has no legal value to write + * there — organization ids are minted at runtime, per install — so this + * example cannot declare one and ⛔ a placeholder id must NOT be invented: a + * value matching no row is silently authoritative, which is strictly worse + * than the refusal. + * + * ⇒ What a package-shipped scheduled flow should do instead is an open + * maintainer decision, tracked in #17150. Until it is settled this flow is a + * worked example of the SHAPE, and running it end-to-end means registering it + * at runtime with an `organization` your install actually holds. + * + * Install `requires: ['automation', 'triggers', 'job', 'messaging']` for the + * binding machinery this example demonstrates. */ export const ScheduledDigestFlow = defineFlow({ name: 'showcase_scheduled_digest', diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 74c77214cb..5e6059d64e 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -16,10 +16,10 @@ "@objectstack/example-crm": "workspace:*", "@objectstack/example-multi-package": "workspace:*", "@objectstack/example-showcase": "workspace:*", + "@objectstack/formula": "workspace:*", "@objectstack/mcp": "workspace:*", "@objectstack/metadata": "workspace:*", "@objectstack/metadata-core": "workspace:*", - "@objectstack/formula": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-approvals": "workspace:*", @@ -34,6 +34,7 @@ "@objectstack/service-storage": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/trigger-record-change": "workspace:*", + "@objectstack/trigger-schedule": "workspace:*", "@objectstack/types": "workspace:*", "@objectstack/verify": "workspace:*" }, diff --git a/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts b/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts new file mode 100644 index 0000000000..21cb2fe3a2 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Fixture for the #16659 acting-organization pins: two `schedule` flows that +// differ in EXACTLY ONE key — the `organization` declaration on the start node +// — so the pins' red/green is attributable to that key and to nothing else. +// +// Both flows are built at TEST time rather than declared in the stack config, +// because both of their load-bearing values are minted by the running stack: +// `sys_organization` ids and the recipient's `sys_user` id. A fixture that +// baked either one in would assert against a row that does not exist. + +/** Object the tick touches, so a run has a data write of its own to land. */ +const SweepTargetObject = { + name: 'sched_org_target', + label: 'Sweep Target', + fields: { + name: { type: 'text', label: 'Name', required: true }, + touched: { type: 'checkbox', label: 'Touched' }, + }, +}; + +/** The stack both pins boot. Flows are registered after boot (see the header). */ +export const scheduleOrganizationStack = { + name: 'sched_org_fixture', + label: 'Schedule acting-organization fixture', + version: '1.0.0', + requires: ['automation', 'triggers', 'messaging'], + objects: [SweepTargetObject], +}; + +/** + * The organization-DECLARING flow: a `schedule` start node carrying a cadence + * and the `organization` key the ruling requires, then a `notify` node whose + * inbox rows are tenant-scoped. + * + * `runAs: 'system'` because a scheduled run has no trigger user (ADR-0049 / + * #1888) — the declaration every scheduled flow in this repo carries, and the + * one that makes the tenancy question live: an elevated write carries no + * session organization, so without the key below there is nothing to stamp and + * the #8844 guard refuses every tenant-scoped row beneath the run. + */ +export function declaringScheduleFlow(organizationId: string, recipientId: string): unknown { + return { + name: 'sched_org_declared', + label: 'Scheduled digest (organization declared)', + type: 'schedule', + status: 'active', + runAs: 'system', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Every minute', + config: { + schedule: { type: 'cron', expression: '* * * * *' }, + organization: organizationId, + }, + }, + { + id: 'notify', + type: 'notify', + label: 'Digest', + config: { + topic: 'sched.digest', + recipients: [recipientId], + title: 'Nightly digest', + message: 'Your digest is ready.', + channels: ['inbox'], + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'end' }, + ], + }; +} + +/** + * The organization-LESS twin: the flow above with the `organization` key + * deleted and its own name, derived from the same builder so the two can never + * drift into being two different flows that merely look alike. + */ +export function organizationLessScheduleFlow(recipientId: string): unknown { + const declared = declaringScheduleFlow('org_unused_placeholder', recipientId) as { + nodes: Array<{ id: string; config?: Record }>; + } & Record; + const nodes = declared.nodes.map((n) => { + if (n.id !== 'start') return n; + const config = { ...(n.config ?? {}) }; + delete config.organization; + return { ...n, config }; + }); + return { ...declared, name: 'sched_org_undeclared', nodes }; +} diff --git a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts new file mode 100644 index 0000000000..5c059b728e --- /dev/null +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -0,0 +1,521 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#16659] A time-triggered flow declares its acting organization and the run +// executes as it — proven end to end through the real automation + messaging + +// ObjectQL stack, on BOTH drivers. +// +// @proof: schedule-acting-organization +// +// ## What was green and wrong +// +// `ScheduleTrigger` built its `AutomationContext` with no `tenantId`, because a +// job tick carries no identity. Two consumers 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 — the +// inbox rows and the history row — one layer BELOW anything that summarises the +// run, so the tick reported `unmeasured=0` and read healthy. +// +// ⚠️ Every assertion in this file passes vacuously if the run never happens at +// all, which is why each pin also asserts a POSITIVE fact about the run +// (the flow bound, the tick fired, the notification carries the declared id) and +// why the DIFFERENTIAL CONTROL below is in the same file: the same flow, on the +// same stack, through `POST /api/v1/automation/:name/trigger` under a session. +// That run reaches the identical `notify` node through the identical messaging +// chain, and its organization comes from the SESSION rather than from the +// declaration — so if the schedule pin ever goes green for a reason that has +// nothing to do with the fix, the control goes green the same way and the +// contrast that carries the proof is gone. +// +// ## The multi-organization condition +// +// Two `sys_organization` rows under the DEFAULT `single` posture — which is +// exactly the install the card measured, and exactly the state +// `system-write-organization.ts` calls `ambiguous-organization`: the posture is +// what the deployment asked for, the count is what the data is, and where they +// disagree the guard refuses rather than guessing. No enterprise organization +// plugin and no walled posture are needed to reach it, and using one would test +// a different topology than the report. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MessagingServicePlugin, INBOX_OBJECT, NOTIFICATION_EVENT_OBJECT } from '@objectstack/service-messaging'; +import { ScheduleTrigger, type JobServiceSurface, type TriggerLogger } from '@objectstack/trigger-schedule'; +import type { JobHandler, JobSchedule } from '@objectstack/spec/contracts'; +import { + scheduleOrganizationStack, + declaringScheduleFlow, + organizationLessScheduleFlow, +} from './fixtures/schedule-organization-fixture.js'; + +const RUN_HISTORY_OBJECT = 'sys_automation_run'; +const DECLARED_FLOW = 'sched_org_declared'; +const UNDECLARED_FLOW = 'sched_org_undeclared'; + +/** + * A job service the test fires by hand. The platform's own adapter owns cron + * timing; what these pins need is a DETERMINISTIC tick, and a real cron would + * make the suite wait on a wall clock to observe a property that has nothing to + * do with when the tick happens. + */ +function fakeJobService(): { + service: JobServiceSurface; + has(name: string): boolean; + names(): string[]; + fire(name: string, jobId?: string): Promise; +} { + const jobs = new Map(); + return { + service: { + async schedule(name: string, schedule: JobSchedule, handler: JobHandler) { + jobs.set(name, { schedule, handler }); + }, + async cancel(name: string) { + jobs.delete(name); + }, + }, + has: (name) => jobs.has(name), + names: () => [...jobs.keys()], + async fire(name, jobId = 'tick-1') { + const job = jobs.get(name); + if (!job) throw new Error(`no job registered under '${name}' — registered: ${[...jobs.keys()].join(', ') || '(none)'}`); + await job.handler({ jobId, data: {} } as never); + }, + }; +} + +/** Records every line the trigger logs, so the refusal pin can read it. */ +function recordingLogger(): { logger: TriggerLogger; errors: string[]; warns: string[] } { + const errors: string[] = []; + const warns: string[] = []; + return { + logger: { + info: () => {}, + debug: () => {}, + warn: (msg: string) => { warns.push(String(msg)); }, + error: (msg: string) => { errors.push(String(msg)); }, + }, + errors, + warns, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Ql = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Engine = any; + +const SYS = { context: { isSystem: true } }; + +/** + * Both drivers. The organization a run carries is resolved by the ObjectQL + * engine's system-insert path and stamped by the driver, and the two drivers + * reach that path differently — the SQL driver through + * `injectTenantOnInsert` + the partitioned unique index, the memory driver + * through its own tenant scope. A property about which organization a row lands + * with cannot be measured on one of them. + */ +for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { + describe(`dogfood [${databaseDriver}]: a scheduled run executes as its declared organization (#16659)`, () => { + let stack: VerifyStack; + let ql: Ql; + let automation: Engine; + let job: ReturnType; + let log: ReturnType; + let orgA: string; + let orgB: string; + let recipientId: string; + let memberToken: string; + + beforeAll(async () => { + stack = await bootStack(scheduleOrganizationStack as never, { + automation: true, + databaseDriver, + // `orgContext` binds the harness admin to a default organization, which + // is what lets the HTTP differential control carry an organization of + // its OWN (a caller bound to none fails to deliver for the same reason + // the schedule path used to, leaving the contrast certifying nothing). + // + // ⚠️ sqlite-wasm ONLY, and the asymmetry is measured rather than + // assumed: `driver-memory` declares NO row-level tenant isolation and + // REFUSES any call the engine hands a tenant scope + // (`MemoryMultiTenantUnsupportedError`, #16589 / #6915). An org-bound + // session makes the authorization resolver's own `sys_position` read + // tenant-scoped, so on that driver every HTTP request from such a + // session 503s before reaching any route. The HTTP control is therefore + // structurally unavailable there — see the driver-split control below, + // which pins that refusal so this exemption expires by itself the day + // the driver gains isolation. + orgContext: databaseDriver === 'sqlite-wasm', + // ⛔ Reliable delivery OFF, and not as a convenience: with the outbox + + // dispatcher on, `sys_inbox_message` is written by a background + // dispatcher on its own schedule, so an assertion made right after the + // tick reads an empty table whether or not the organization threaded. + // The property under test is WHICH ORGANIZATION the row carries, not + // when the dispatcher gets to it. + extraPlugins: [new MessagingServicePlugin({ reliableDelivery: false })], + }); + memberToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + automation = stack.kernel.getService('automation'); + expect(automation?.registerFlow, 'automation engine must be wired').toBeTruthy(); + + // ── the multi-organization condition ────────────────────────────── + // TWO organizations, so "which organization owns this row" stops being + // derivable and the #8844 guard is live. One would make every pin below + // pass without the fix, because a single-organization install has a + // derivable answer and the guard supplies it. + // Two MORE organizations on top of whatever `orgContext` bootstrapped. + const a = await ql.insert('sys_organization', { name: 'Acme Employer' }, SYS); + const b = await ql.insert('sys_organization', { name: 'Beta Employer' }, SYS); + orgA = String(a.id); + orgB = String(b.id); + expect(orgA, 'organization A must have an id').toBeTruthy(); + expect(orgB, 'organization B must have an id').toBeTruthy(); + expect(orgA).not.toBe(orgB); + const orgs = await ql.find('sys_organization', { ...SYS }); + expect( + (orgs ?? []).length, + 'the guard only refuses when the install holds MORE THAN ONE organization — with one, every pin below passes unfixed', + ).toBeGreaterThanOrEqual(2); + + const admin = await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, ...SYS }); + recipientId = String(admin?.id ?? 'usr_system'); + + automation.registerFlow(DECLARED_FLOW, declaringScheduleFlow(orgA, recipientId)); + automation.registerFlow(UNDECLARED_FLOW, organizationLessScheduleFlow(recipientId)); + + job = fakeJobService(); + log = recordingLogger(); + automation.registerTrigger(new ScheduleTrigger(() => job.service, log.logger)); + await new Promise((r) => setTimeout(r, 0)); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + /** Rows of `object` this run wrote, read elevated so RLS never hides one. */ + async function rows(object: string, where: Record = {}): Promise>> { + return (await ql.find(object, { where, ...SYS })) ?? []; + } + + /** + * Wait for a row to appear, bounded. + * + * `recordTerminal` is a fire-and-forget write (`void this.store.recordTerminal(...)`), + * so the history row lands SHORTLY AFTER the tick's handler resolves. ⛔ This + * is a settle, never a retry that could paper over a refusal: a REFUSED + * insert never lands, so the bound expires and the assertion is red — which + * is exactly what it read on the unfixed tree. + */ + async function settleRows( + object: string, + where: Record = {}, + timeoutMs = 5_000, + ): Promise>> { + const deadline = Date.now() + timeoutMs; + let seen: Array> = []; + do { + seen = await rows(object, where); + if (seen.length > 0) return seen; + await new Promise((r) => setTimeout(r, 50)); + } while (Date.now() < deadline); + return seen; + } + + it('precondition: the declaring flow BOUND and the tick actually ran', async () => { + expect( + job.has(`flow-schedule:${DECLARED_FLOW}`), + `the declaring flow did not bind — registered jobs: ${job.names().join(', ') || '(none)'}`, + ).toBe(true); + await job.fire(`flow-schedule:${DECLARED_FLOW}`, 'tick-16659'); + const history = await settleRows(RUN_HISTORY_OBJECT, { flow_name: DECLARED_FLOW }); + expect( + history.length, + 'the tick produced no run at all — every pin below would then pass vacuously', + ).toBeGreaterThanOrEqual(1); + }); + + // ── CONSEQUENCE (1) — delivery ──────────────────────────────────────── + // + // PREDICTION, written before the run: on the unfixed tree the notification + // lands with `organization_id = NULL` and `sys_inbox_message` is EMPTY, + // because the inbox row is tenant-scoped and the guard refuses a + // system-context write that carries no organization on an install holding + // two. After the fix the notification carries `orgA` and the inbox row + // exists and carries `orgA`. + it('(1) the notification and its inbox row carry the DECLARED organization', async () => { + const notifications = await settleRows(NOTIFICATION_EVENT_OBJECT); + expect(notifications.length, 'the notify node emitted nothing').toBeGreaterThanOrEqual(1); + expect( + notifications.map((n) => n.organization_id ?? 'NULL'), + 'a scheduled run must stamp the organization it declared — NULL is the unfixed reading', + ).toContain(orgA); + + const inbox = await settleRows(INBOX_OBJECT); + expect( + inbox.length, + 'sys_inbox_message is EMPTY — the tenant-scoped write below the notification was refused, which is the defect', + ).toBeGreaterThanOrEqual(1); + expect(inbox.map((r) => r.organization_id ?? 'NULL')).toContain(orgA); + + // ⭐ Identity, not just presence: the declared organization is the one + // that landed, and the OTHER organization on this install never appears. + // A fix that stamped "some organization" would satisfy a presence check. + expect( + [...notifications, ...inbox].map((r) => r.organization_id).filter((v) => v === orgB), + 'a row landed in the organization the flow did NOT declare — cross-organization writes are exactly what the ruling forbids', + ).toHaveLength(0); + }); + + // ── CONSEQUENCE (2) — run history ───────────────────────────────────── + // + // Its OWN pin, deliberately not folded into (1): the history row is written + // by a different producer (`AutomationEngine.recordLog`) through a + // different consumer of the same key, and the card measured its refusal + // separately ("Insert on 'sys_automation_run' was REFUSED"). + // + // PREDICTION: unfixed, no `sys_automation_run` row exists for this flow at + // all. Fixed, exactly the scheduled run's row exists and carries `orgA`. + it('(2) the sys_automation_run history row persists, carrying the declared organization', async () => { + const history = await settleRows(RUN_HISTORY_OBJECT, { flow_name: DECLARED_FLOW }); + expect( + history.length, + "run history never persisted — the tick's sys_automation_run insert was refused", + ).toBeGreaterThanOrEqual(1); + expect( + history.map((r) => r.organization_id ?? 'NULL'), + 'the history row must carry the run\'s acting organization', + ).toContain(orgA); + expect( + history.map((r) => r.trigger_type), + 'the persisted row must still name WHAT fired the run (#7533)', + ).toContain('schedule'); + }); + + // ── CONSEQUENCE (3) — the declaration error ─────────────────────────── + // + // PREDICTION: unfixed, the organization-less flow binds exactly like the + // declaring one and its tick runs, delivering nothing. Fixed, it does NOT + // bind, the refusal is logged at `error`, and it names the flow. + // + // ⛔ The assertion is deliberately NOT "it logged something". It is: no job + // exists for it, so there is no path by which an organization-less + // time-triggered run reaches the data layer at all. + it('(3) an organization-less scheduled flow is REFUSED at bind, naming the flow', () => { + expect( + job.has(`flow-schedule:${UNDECLARED_FLOW}`), + 'the organization-less flow BOUND — it will tick, run, and deliver nothing, which is the defect', + ).toBe(false); + + const refusal = log.errors.find((l) => l.includes(UNDECLARED_FLOW)); + expect(refusal, `no refusal named '${UNDECLARED_FLOW}'; errors seen: ${JSON.stringify(log.errors)}`).toBeTruthy(); + expect(refusal, 'the refusal must be attributable to a flow, not to "a flow"').toContain(UNDECLARED_FLOW); + expect(refusal, 'the refusal must name the key the author has to write').toContain('organization'); + expect(refusal, 'a refused binding must say it is NOT BOUND').toContain('NOT BOUND'); + + // ⛔ And it must not have silently defaulted: neither organization on + // this install may appear in the refusal as a chosen value. + expect(refusal).not.toContain(orgA); + expect(refusal).not.toContain(orgB); + }); + + it('(3, control) refusing the organization-less flow did not disarm the declaring one', () => { + expect( + job.has(`flow-schedule:${DECLARED_FLOW}`), + 'the refusal took the sibling flow down with it — the refusal is per flow, not per trigger', + ).toBe(true); + }); + + // ⭐ (3) has a second half, and skipping it is how the first round of this + // card shipped a refusal the machine could not see. `job.has(...) === false` + // proves the JOB SERVICE was never asked; it says nothing about what the + // ENGINE recorded. `FlowTrigger.start()` returns `void`, so a trigger that + // logs and returns is indistinguishable from one that armed: the engine + // sets `boundFlowTriggers` and logs "bound" one line after the trigger said + // NOT BOUND, and every structured surface this repo built for "declared but + // not armed" then reports the opposite of the stderr line — Studio's badge + // via `getFlowRuntimeStates()`, and the silent-miss audit the automation + // plugin warns from at `kernel:bootstrapped` and the CLI prints in its + // startup summary via `getTriggerBindingAudit()`. + // + // PREDICTION, before the run: with a logged-and-returned refusal this pin + // is RED on both assertions (`bound: true`, audit empty of this flow); with + // the refusal thrown it is green, and the declaring flow stays out of the + // audit as the paired control. + it('(3, structured) the refused flow reads as NOT BOUND on every machine-readable surface', () => { + const states = automation.getFlowRuntimeStates() as Array<{ name: string; bound: boolean }>; + const refused = states.find((s) => s.name === UNDECLARED_FLOW); + expect(refused, `the refused flow is missing from getFlowRuntimeStates(): ${JSON.stringify(states.map((s) => s.name))}`).toBeTruthy(); + expect( + refused!.bound, + "Studio's status badge says this flow is armed while the trigger refused it — the loud channel and the structured channel disagree, which is the silent miss this card closes", + ).toBe(false); + expect( + states.find((s) => s.name === DECLARED_FLOW)?.bound, + 'control: the declaring flow must still read as bound, or this pin would pass with everything broken', + ).toBe(true); + + const audit = automation.getTriggerBindingAudit() as Array<{ + flowName: string; + triggerType: string; + reason: string; + }>; + const entry = audit.find((a) => a.flowName === UNDECLARED_FLOW); + expect( + entry, + `the silent-miss audit omits the refused flow, so the kernel:bootstrapped warning and the CLI startup summary both report every triggered flow as wired; audit: ${JSON.stringify(audit)}`, + ).toBeTruthy(); + expect(entry!.triggerType).toBe('schedule'); + expect( + entry!.reason, + 'the audit must say the binding FAILED (the trigger is registered), not that no trigger exists', + ).toContain('binding failed'); + expect( + audit.map((a) => a.flowName), + 'control: a flow that bound must not be listed as a silent miss', + ).not.toContain(DECLARED_FLOW); + }); + + // ── THE DIFFERENTIAL CONTROLS ───────────────────────────────────────── + // + // The pins above all assert that a row landed. Every one of them would also + // pass if delivery were simply broken in a way that happened to look like + // the fix working — so two controls run the SAME flow, the SAME nodes and + // the SAME messaging chain with the organization coming from somewhere + // OTHER than the start-node declaration. + + /** + * Control A — driver-portable, and the sharper of the two. + * + * The same flow, executed with an organization supplied by the CALLER's + * context (`tenantId`) instead of by the declaration: the record-change + * shape the card reports as unaffected ("the triggering session's + * organization is threaded, and delivery works on both drivers"). + * + * ⭐ It carries `orgB`, deliberately — the organization the flow does NOT + * declare. So it proves two things at once: the notify chain and the inbox + * write are live on this driver (the pins above are not vacuous), and the + * `orgA` those pins observed is attributable to the DECLARATION rather than + * to "whichever organization this install happens to have". + */ + it('control A: the same flow with a context-supplied organization delivers under THAT organization', async () => { + const before = new Set((await rows(INBOX_OBJECT)).map((r) => String(r.id))); + const result = await automation.execute(DECLARED_FLOW, { + event: 'api', + tenantId: orgB, + params: {}, + }); + expect(result?.success, `the control run failed: ${result?.error ?? '(no error)'}`).toBe(true); + + const deadline = Date.now() + 5_000; + let fresh: Array> = []; + do { + fresh = (await rows(INBOX_OBJECT)).filter((r) => !before.has(String(r.id))); + if (fresh.length > 0) break; + await new Promise((r) => setTimeout(r, 50)); + } while (Date.now() < deadline); + + expect( + fresh.length, + 'a context-supplied organization delivered nothing — delivery is broken for a reason unrelated to this card, and the pins above certify nothing', + ).toBeGreaterThanOrEqual(1); + expect( + fresh.map((r) => r.organization_id ?? 'NULL'), + 'the control row must carry the organization the CALLER supplied, not the one the flow declares', + ).toContain(orgB); + expect( + fresh.map((r) => r.organization_id), + "the caller's organization was overruled by the flow's declaration — a scheduled declaration must not reach a run it did not launch", + ).not.toContain(orgA); + }); + + /** + * Control B — the card's own control: the same flow through + * `POST /api/v1/automation/:name/trigger` under a session. + * + * Driver-split, because the drivers genuinely differ here and the split is + * pinned rather than papered over: + * + * - **sqlite-wasm** — the session is bound to the harness's default + * organization, so the run delivers under THAT organization: a third + * distinct id, and one more witness that `orgA` came from the + * declaration. + * - **memory** — the control is UNAVAILABLE, and this limb says so + * plainly rather than asserting something that cannot fail. + * + * All of control B's discriminating power comes from the session being + * bound to an organization OF ITS OWN: the run then delivers under that + * third id, and `orgA`'s absence is the witness. On `driver-memory` no + * session can be org-bound — the driver declares no row-level tenant + * isolation and refuses any tenant-scoped call + * (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589 / #6915), so the + * authorization resolver's own `sys_position` read is refused and the + * door answers 503 before any route runs. This suite therefore boots + * memory with `orgContext: false`, which leaves the HTTP caller carrying + * no organization at all — the very state the unfixed schedule path was + * in. A run triggered that way discriminates nothing. + * + * ⛔ The first shape of this limb asserted `status < 300` under a message + * claiming it pinned a 503 refusal: opposite polarity, no delivery check, + * so it certified nothing in either direction. What is pinned instead is + * the REASON the control is unavailable, measured at the seam that makes + * it so — a tenant-scoped read on this driver produces NO ANSWER. The + * day the driver gains isolation that goes red and whoever fixes it + * enables the real control here. + */ + it('control B: the same flow via POST /automation/:name/trigger under a session', async () => { + if (databaseDriver === 'memory') { + // Not the control — the control cannot run here. This pins the reason, + // so the exemption expires by itself. + let thrown: unknown = null; + let answered: unknown = null; + try { + answered = await ql.find(INBOX_OBJECT, { where: {}, context: { userId: recipientId, tenantId: orgA } }); + } catch (err) { + thrown = err; + } + + expect( + thrown, + `driver-memory ANSWERED a tenant-scoped read (${JSON.stringify(answered)}) — it has gained row-level isolation, so an org-bound session is now possible here: boot this driver with orgContext and enable the real control B (#16589 / #6915)`, + ).toBeTruthy(); + // ⛔ Taken as a nullable value, not with `.toHaveLength`: a refused call + // must produce NO row count at all, and `.not.toHaveLength` passes over + // a null target for the wrong reason. + expect(Array.isArray(answered) ? answered.length : null).toBeNull(); + expect( + String((thrown as { code?: string }).code ?? (thrown as Error).message), + 'the refusal must be the driver\'s own tenancy refusal, not some unrelated failure that happens to throw', + ).toContain('MULTI_TENANT_UNSUPPORTED'); + return; + } + + const before = new Set((await rows(INBOX_OBJECT)).map((r) => String(r.id))); + const res = await stack.apiAs(memberToken, 'POST', `/automation/${DECLARED_FLOW}/trigger`, {}); + expect( + res.status, + `the session-triggered run did not start (${res.status}) — the control cannot certify the pins above`, + ).toBeLessThan(300); + + const deadline = Date.now() + 5_000; + let fresh: Array> = []; + do { + fresh = (await rows(INBOX_OBJECT)).filter((r) => !before.has(String(r.id))); + if (fresh.length > 0) break; + await new Promise((r) => setTimeout(r, 50)); + } while (Date.now() < deadline); + + expect( + fresh.length, + 'the session-triggered run delivered nothing — delivery is broken for a reason unrelated to this card', + ).toBeGreaterThanOrEqual(1); + expect( + fresh.map((r) => r.organization_id), + "the session-triggered row must carry the SESSION's organization, not the schedule declaration's", + ).not.toContain(orgA); + }); + }); +} diff --git a/packages/qa/dogfood/tsconfig.json b/packages/qa/dogfood/tsconfig.json index 55825a9035..a0da68ba8d 100644 --- a/packages/qa/dogfood/tsconfig.json +++ b/packages/qa/dogfood/tsconfig.json @@ -60,7 +60,8 @@ "@objectstack/organizations": ["../../plugins/organizations/src/index.ts"], "@objectstack/formula": ["../../formula/src/index.ts"], "@objectstack/plugin-approvals": ["../../plugins/plugin-approvals/src/index.ts"], - "@objectstack/trigger-record-change": ["../../triggers/trigger-record-change/src/index.ts"] + "@objectstack/trigger-record-change": ["../../triggers/trigger-record-change/src/index.ts"], + "@objectstack/trigger-schedule": ["../../triggers/trigger-schedule/src/index.ts"] } }, "include": ["test/**/*"], diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index bfbcf3f1ee..6f1c21415b 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -151,6 +151,19 @@ export default defineConfig({ find: /^@objectstack\/trigger-record-change$/, replacement: path.resolve(__dirname, '../../triggers/trigger-record-change/src/index.ts'), }, + // [#16659] `schedule-acting-organization.dogfood.test.ts` drives + // `ScheduleTrigger` itself: the pin's whole subject is which + // organization the trigger puts on the run it launches, and that + // a flow declaring none is refused at bind. A dist merely behind + // would run the pin green against the trigger's OLD context + // construction — the exact shape this card is about, since the + // defect was a run that reported itself healthy while carrying + // nothing. Aliased to source so the verdict is about THIS + // checkout. + { + find: /^@objectstack\/trigger-schedule$/, + replacement: path.resolve(__dirname, '../../triggers/trigger-schedule/src/index.ts'), + }, ], }, test: { diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index ecd3d23ae6..b513482b46 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -24,7 +24,7 @@ import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlo // shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s // `validate-flow-trigger-readiness`, so the runtime cannot drift from what // authoring accepted. See `resolveTriggerBinding`. -import { resolveFlowTriggerKind } from '@objectstack/spec/automation'; +import { resolveFlowTriggerKind, resolveScheduleOrganization } from '@objectstack/spec/automation'; import { predicateSlotRefusal, resolveFlowNodeExpressions, structuralConditionRefusal } from '@objectstack/spec/automation'; // [#15137] The `value`-role half of the ledger. Both halves of "is this envelope // well-formed?" are IMPORTED, never re-spelled here: the shape rule is @@ -427,6 +427,25 @@ export interface FlowTriggerBinding { readonly condition?: string | { dialect?: string; source?: string; ast?: unknown }; /** schedule: cron/interval descriptor (parsed but not yet acted on here). */ readonly schedule?: unknown; + /** + * [#16659] schedule / time_relative: the ACTING ORGANIZATION the flow + * declares on its start node (`config.organization`), resolved through + * `@objectstack/spec`'s {@link resolveScheduleOrganization} so authoring, + * this lift and the triggers cannot disagree about what counts as declared. + * + * Populated only for the two time-triggered kinds. `record_change` and + * `api` bindings leave it `undefined` BY CONSTRUCTION rather than by + * omission: 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 very write that triggered it. + * + * `undefined` here is a REFUSAL condition for the trigger that receives it, + * never a default to be filled in downstream: the time triggers THROW from + * `start()` on it, so {@link activateFlowTrigger}'s catch records the flow + * as unbound and {@link getTriggerBindingAudit} lists it — see the schedule + * trigger's `refuseMissingOrganization`. + */ + readonly organization?: string; /** The raw start-node `config`, for trigger-specific fields not modeled above. */ readonly config?: Record; } @@ -3046,6 +3065,13 @@ export class AutomationEngine implements IAutomationService { ? config.objectName : undefined, schedule: config.schedule, + // [#16659] Lifted beside `schedule`, for the same + // reason `schedule` is lifted: it is a BINDING fact the + // trigger acts on, not a config value it interprets. + // `config` still carries it verbatim below, so a + // trigger built against the older binding shape reads + // the same declaration from the same place. + organization: resolveScheduleOrganization(flow), condition, config, }, @@ -3055,7 +3081,16 @@ export class AutomationEngine implements IAutomationService { case 'schedule': return { triggerType: kind, - binding: { flowName, schedule: config.schedule, condition, config }, + // [#16659] `organization` rides beside `schedule`: the two + // together ARE a scheduled flow's binding — when it fires, + // and which organization it fires as. + binding: { + flowName, + schedule: config.schedule, + organization: resolveScheduleOrganization(flow), + condition, + config, + }, }; // Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 099a823985..6a7a38007b 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -199,8 +199,11 @@ "RetryPolicy (type)", "RetryPolicyParsed (type)", "RetryPolicySchema (const)", + "SCHEDULE_ORGANIZATION_KEY (const)", "SCHEMALESS_NODE_CONFIG_SCHEMAS (const)", "STRUCTURAL_CONDITION_SHAPE_REFUSAL (const)", + "ScheduleOrganization (type)", + "ScheduleOrganizationSchema (const)", "ScheduleState (type)", "ScheduleStateParsed (type)", "ScheduleStateSchema (const)", @@ -259,8 +262,10 @@ "defineActionDescriptor (function)", "defineFlow (function)", "defineWebhook (function)", + "describeMissingScheduleOrganization (function)", "exportConstructsToBpmn (function)", "findRegionEntry (function)", + "findScheduleOrganizationNearMissInConfig (function)", "flowForm (const)", "getApprovalNodeConfigJsonSchema (function)", "getSchemalessNodeConfigJsonSchemas (function)", @@ -273,6 +278,7 @@ "predicateSlotRefusal (function)", "resolveFlowNodeExpressions (function)", "resolveFlowTriggerKind (function)", + "resolveScheduleOrganization (function)", "structuralConditionRefusal (function)", "validateControlFlow (function)" ] diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index d943434410..7436be618c 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -104,6 +104,8 @@ "ParallelBranchSchema": "automation/ParallelBranch", "ParallelConfig": "automation/ParallelConfig", "ParallelConfigSchema": "automation/ParallelConfig", + "ScheduleOrganization": "automation/ScheduleOrganization", + "ScheduleOrganizationSchema": "automation/ScheduleOrganization", "ScheduleState": "automation/ScheduleState", "ScheduleStateSchema": "automation/ScheduleState", "ScreenConfig": "automation/ScreenConfig", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index aadc165a7b..7ce939d126 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -194,8 +194,11 @@ "RetryPolicy": "src/shared/retry-policy.zod.ts#RetryPolicy (type)", "RetryPolicyParsed": "src/shared/retry-policy.zod.ts#RetryPolicyParsed (type)", "RetryPolicySchema": "src/shared/retry-policy.zod.ts#RetryPolicySchema (const)", + "SCHEDULE_ORGANIZATION_KEY": "src/automation/schedule-organization.zod.ts#SCHEDULE_ORGANIZATION_KEY (const)", "SCHEMALESS_NODE_CONFIG_SCHEMAS": "src/automation/schemaless-node-config.zod.ts#SCHEMALESS_NODE_CONFIG_SCHEMAS (const)", "STRUCTURAL_CONDITION_SHAPE_REFUSAL": "src/automation/flow-node-expression-paths.ts#STRUCTURAL_CONDITION_SHAPE_REFUSAL (const)", + "ScheduleOrganization": "src/automation/schedule-organization.zod.ts#ScheduleOrganization (type)", + "ScheduleOrganizationSchema": "src/automation/schedule-organization.zod.ts#ScheduleOrganizationSchema (const)", "ScheduleState": "src/automation/execution.zod.ts#ScheduleState (type)", "ScheduleStateParsed": "src/automation/execution.zod.ts#ScheduleStateParsed (type)", "ScheduleStateSchema": "src/automation/execution.zod.ts#ScheduleStateSchema (const)", @@ -253,8 +256,10 @@ "defineActionDescriptor": "src/automation/node-executor.zod.ts#defineActionDescriptor (function)", "defineFlow": "src/automation/flow.zod.ts#defineFlow (function)", "defineWebhook": "src/automation/webhook.zod.ts#defineWebhook (function)", + "describeMissingScheduleOrganization": "src/automation/schedule-organization.zod.ts#describeMissingScheduleOrganization (function)", "exportConstructsToBpmn": "src/automation/bpmn-mapping.ts#exportConstructsToBpmn (function)", "findRegionEntry": "src/automation/control-flow.zod.ts#findRegionEntry (function)", + "findScheduleOrganizationNearMissInConfig": "src/automation/schedule-organization.zod.ts#findScheduleOrganizationNearMissInConfig (function)", "flowForm": "src/automation/flow.form.ts#flowForm (const)", "getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)", "getSchemalessNodeConfigJsonSchemas": "src/automation/schemaless-node-config.zod.ts#getSchemalessNodeConfigJsonSchemas (function)", @@ -267,6 +272,7 @@ "predicateSlotRefusal": "src/automation/flow-node-expression-paths.ts#predicateSlotRefusal (function)", "resolveFlowNodeExpressions": "src/automation/flow-node-expression-paths.ts#resolveFlowNodeExpressions (function)", "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", + "resolveScheduleOrganization": "src/automation/schedule-organization.zod.ts#resolveScheduleOrganization (function)", "structuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#structuralConditionRefusal (function)", "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)" } diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 7a28689cb9..0ebb133885 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -57,6 +57,7 @@ "automation/ParallelBranch", "automation/ParallelConfig", "automation/RetryPolicy", + "automation/ScheduleOrganization", "automation/ScheduleState", "automation/ScreenConfig", "automation/ScreenFieldConfig", diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 40bc8372c6..077d225102 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (205 schemas) +## 3. Schema Inventory by Domain (206 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -90,7 +90,7 @@ here is verifiable from the installed package. | data | 30 | Object, Field, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | | api | 30 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | -| automation | 13 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook | +| automation | 14 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook, Schedule Organization | | shared | 14 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant (EpochMs) | | ai | 11 | Agent, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | cloud | 11 | Marketplace, Developer Portal, App Store, Environment, Package, Tenant | diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 9275be6657..bb651c9686 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -324,6 +324,32 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ // ── Registered, honestly unbound ──────────────────────────────────────── + { + id: 'schedule-acting-organization', + label: 'Schedule / time-relative acting organization', + summary: + 'a time-triggered flow launches from a job tick, which carries no identity, so the run ' + + 'reached the #8844 tenancy guard with no organization to offer it. On an install holding ' + + 'more than one `sys_organization` every tenant-scoped row beneath the run was refused — the ' + + 'inbox rows a `notify` node emits and the `sys_automation_run` history row — one layer below ' + + 'anything that summarises a run, so the tick reported `unmeasured=0` and delivered nothing. ' + + 'The flow now DECLARES its acting organization on the start node and the run executes as it; ' + + 'a flow declaring none is refused at bind, naming the flow (#16659).', + proofId: 'schedule-acting-organization', + proofRef: + 'packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts#schedule-acting-organization', + bound: false, + ledgerBindings: [], + blockedReason: + 'the property it guards — the start-node `config.organization` key — lives inside the flow ' + + "node `config` slot, which is an OPEN record by design (ADR-0018) and which the ledger " + + 'classifies as a CONTAINER rather than per key, so there is no `type.path` entry whose ' + + '`live` status this proof could gate. What it actually guards is a RUNTIME invariant: which ' + + 'organization a time-triggered run executes as, and that a flow declaring none is not armed. ' + + 'It runs unconditionally in the dogfood suite, on both drivers. ⛔ Not bound to `flow.runAs`, ' + + 'which it merely uses: binding a proof to a property it does not author is the false comfort ' + + 'the ledger exists to end.', + }, { id: 'flow-runas-userless', label: 'Flow runAs — the user-less run', diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 92ebb2c1ad..5b57917337 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -41,6 +41,15 @@ export * from './approval.zod'; // (Prime Directive #12); the #4480 template cluster fell the same way. export * from './time-relative-trigger.zod'; export * from './flow-trigger-kind'; +// The acting-organization declaration a time-triggered flow carries, and the +// one refusal sentence the schedule trigger and the time-relative sweep both +// say it with (#16659). ⛔ `FlowSchema` does NOT emit that sentence: the key is +// enforced at BIND, not at parse, because the start node's `config` is an open +// record and a parse-time requirement would make every package-shipped +// scheduled flow unparseable. Named beside `flow-trigger-kind` because the two +// kinds that owe an organization are exactly the two that flow-trigger-kind +// resolves to a clock rather than to a session. +export * from './schedule-organization.zod'; // `sync.zod.ts` (L1 "Simple Sync": DataSyncConfig, its ConflictResolution enum // and the Sync factory) was removed here (#4738, ledger #4535 C13+C15). The L1 // layer was narrative-only — zero importers across objectstack / cloud / diff --git a/packages/spec/src/automation/schedule-organization.test.ts b/packages/spec/src/automation/schedule-organization.test.ts new file mode 100644 index 0000000000..90e196d45f --- /dev/null +++ b/packages/spec/src/automation/schedule-organization.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { + SCHEDULE_ORGANIZATION_KEY, + ScheduleOrganizationSchema, + describeMissingScheduleOrganization, + findScheduleOrganizationNearMissInConfig, + resolveScheduleOrganization, +} from './schedule-organization.zod'; + +// [#16659] The declaration side of the acting-organization ruling. Two +// consumers read this module and they must not be able to disagree about what +// counts as DECLARED: the automation engine lifts the value onto the trigger +// binding (`resolveTriggerBinding`), and both time triggers refuse a binding +// that resolves to nothing. A value one layer calls usable and the other calls +// missing reopens the silent hole the card closed. + +function flow(config?: Record, extra: Record = {}) { + return { + name: 'nightly_sweep', + label: 'Nightly sweep', + type: 'schedule', + nodes: [ + { id: 'start', type: 'start', label: 'Start', ...(config ? { config } : {}) }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }; +} + +describe('SCHEDULE_ORGANIZATION_KEY', () => { + it('is the bare `organization` spelling the refusal tells authors to write', () => { + // The constant, the refusal sentence and the docs all have to name ONE + // spelling; every other spelling in this area is a near-miss by definition. + expect(SCHEDULE_ORGANIZATION_KEY).toBe('organization'); + expect(describeMissingScheduleOrganization('f')).toContain('`organization`'); + }); +}); + +describe('ScheduleOrganizationSchema', () => { + it('accepts any non-empty string, including a non-`org_` id', () => { + // Deliberately not a pattern: organization ids are minted at runtime and a + // deployment that migrated ids from elsewhere must not be refused by a + // shape this layer invented. What is checked is that a value was DECLARED. + expect(ScheduleOrganizationSchema.safeParse('org_msokm9oaz0cal87q').success).toBe(true); + expect(ScheduleOrganizationSchema.safeParse('7f3c1e00-0000-4000-8000-000000000001').success).toBe(true); + }); + + it.each([ + ['an empty string', ''], + ['undefined', undefined], + ['null', null], + ['a number', 42], + ['an object', { id: 'org_x' }], + ])('refuses %s', (_label, value) => { + expect(ScheduleOrganizationSchema.safeParse(value).success).toBe(false); + }); +}); + +describe('resolveScheduleOrganization', () => { + it('reads the start node config', () => { + expect(resolveScheduleOrganization(flow({ organization: 'org_a' }))).toBe('org_a'); + }); + + it('answers undefined for a flow that declares none', () => { + expect(resolveScheduleOrganization(flow({ schedule: { type: 'cron', expression: '0 1 * * *' } }))).toBeUndefined(); + }); + + it('answers undefined for a present-but-unusable value', () => { + // ⭐ The engine lifts this onto the binding and the trigger refuses on + // `undefined`. Reporting an empty string as "declared" would arm a flow + // with nothing to stamp — the exact green-and-wrong shape of the card. + expect(resolveScheduleOrganization(flow({ organization: '' }))).toBeUndefined(); + expect(resolveScheduleOrganization(flow({ organization: 7 }))).toBeUndefined(); + expect(resolveScheduleOrganization(flow({ organization: { id: 'org_a' } }))).toBeUndefined(); + }); + + it('is structural: anything that is not a flow answers undefined rather than throwing', () => { + for (const input of [undefined, null, 42, 'flow', {}, { nodes: 'not-an-array' }, { nodes: [] }]) { + expect(() => resolveScheduleOrganization(input)).not.toThrow(); + expect(resolveScheduleOrganization(input)).toBeUndefined(); + } + }); + + it('ignores an `organization` that is not on the START node', () => { + const f = flow(undefined) as { nodes: Array> }; + f.nodes[1].config = { organization: 'org_on_the_end_node' }; + expect(resolveScheduleOrganization(f)).toBeUndefined(); + }); +}); + +describe('findScheduleOrganizationNearMissInConfig', () => { + it('takes the START NODE CONFIG — the record a trigger actually holds', () => { + // ⛔ Not a flow. The only caller is a trigger, and the engine hands a + // trigger the start node's `config`, never the flow. + expect(findScheduleOrganizationNearMissInConfig({ organizationId: 'org_a' })).toBe('organizationId'); + expect(findScheduleOrganizationNearMissInConfig(flow({ organizationId: 'org_a' }))).toBeUndefined(); + }); + + it.each(['organizationId', 'organization_id', 'organizationID', 'orgId', 'org_id', 'org', 'tenantId', 'tenant_id', 'tenant'])( + 'recognises `%s`', + (key) => { + expect(findScheduleOrganizationNearMissInConfig({ [key]: 'org_a' })).toBe(key); + }, + ); + + it('ignores a near-miss key present but empty or null', () => { + // A key the author left blank is not evidence of the mistake the message + // describes ("you wrote X, which is not this key"). + expect(findScheduleOrganizationNearMissInConfig({ organizationId: '' })).toBeUndefined(); + expect(findScheduleOrganizationNearMissInConfig({ organizationId: null })).toBeUndefined(); + }); + + it('answers undefined for anything that is not a record', () => { + for (const input of [undefined, null, 42, 'org_a', []]) { + expect(() => findScheduleOrganizationNearMissInConfig(input)).not.toThrow(); + expect(findScheduleOrganizationNearMissInConfig(input)).toBeUndefined(); + } + }); +}); + +describe('describeMissingScheduleOrganization', () => { + it('names the flow, the key, and the consequence an operator already saw', () => { + const msg = describeMissingScheduleOrganization('nightly_sweep'); + expect(msg).toContain("'nightly_sweep'"); + expect(msg).toContain('`organization`'); + expect(msg).toContain('sys_automation_run'); + expect(msg).toContain('reports itself healthy'); + }); + + it('names the near-miss spelling and never a value', () => { + const msg = describeMissingScheduleOrganization('nightly_sweep', { nearMiss: 'organizationId' }); + expect(msg).toContain('`organizationId`'); + expect(msg).toContain('open'); + }); + + it('says `time-relative` for the sweep and `scheduled` for the plain cadence', () => { + expect(describeMissingScheduleOrganization('f', { kind: 'time_relative' })).toContain('time-relative flow'); + expect(describeMissingScheduleOrganization('f', { kind: 'schedule' })).toContain('scheduled flow'); + expect(describeMissingScheduleOrganization('f')).toContain('scheduled flow'); + }); + + it('⛔ never offers a fallback: no organization is ever chosen for the author', () => { + // The ruling forbids a silent default and forbids the platform + // organization. The sentence must ASK for a value, not supply one. + const msg = describeMissingScheduleOrganization('nightly_sweep', { nearMiss: 'orgId' }); + expect(msg).toContain(''); + expect(msg).not.toMatch(/defaults? to/i); + expect(msg).not.toMatch(/platform organization/i); + expect(msg).toContain('no organization is ever chosen for it'); + }); +}); diff --git a/packages/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts new file mode 100644 index 0000000000..87c07ce71a --- /dev/null +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * 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 {@link 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. + */ + +/** The start-node `config` key naming a time-triggered flow's acting organization. */ +export const SCHEDULE_ORGANIZATION_KEY = 'organization'; + +/** + * The value shape: an organization id — `sys_organization.id`, the same string + * a session write carries as `ExecutionContext.tenantId` and the same string + * the tenancy guard stamps onto `organization_id`. + * + * A bare non-empty string rather than a pattern: organization ids are minted at + * runtime (`org_…` today) and a deployment that has migrated ids from elsewhere + * must not be refused by a shape this layer invented. What is checked is that a + * value was DECLARED — which is the whole of what the ruling asks for. + */ +export const ScheduleOrganizationSchema = z + .string() + .min(1) + .describe( + '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.', + ); + +/** + * The declared value's type — the alias the machine-readable surface needs + * beside the schema, and the name a reference page's import example carries. + * + * `z.input` per ADR-0122: the bare name is reserved for the AUTHOR state. No + * `ScheduleOrganizationParsed` sibling exists because the two states are the + * same type here — the schema neither transforms nor defaults, it only refuses + * what cannot be acted on. + */ +export type ScheduleOrganization = z.input; + +/** + * Spellings an author reaches for that are NOT this key, in the order a + * diagnostic should try them. The start node's `config` is an OPEN record by + * design (ADR-0018), so none of these is refused by any schema — a flow + * carrying `organizationId` parses, binds, and runs with no organization at + * all. Naming them in the refusal is the only place the mistake becomes + * visible, so this list is load-bearing rather than decorative. + * + * Module-local on purpose: its only reader is + * {@link findScheduleOrganizationNearMissInConfig} in this file, and an export + * whose consumers all live inside its own package does not belong on a + * published barrel. A caller that needs the vocabulary needs the ANSWER, which + * that function gives. + */ +const SCHEDULE_ORGANIZATION_NEAR_MISSES: readonly string[] = Object.freeze([ + 'organizationId', + 'organization_id', + 'organizationID', + 'orgId', + 'org_id', + 'org', + 'tenantId', + 'tenant_id', + 'tenant', +]); + +/** The start node's `config`, or `{}` for a flow shaped like anything else. */ +function startConfigOf(flow: unknown): Record { + if (!flow || typeof flow !== 'object') return {}; + const nodes = (flow as { nodes?: unknown }).nodes; + if (!Array.isArray(nodes)) return {}; + const start = nodes.find( + (n): n is { config?: unknown } => + !!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start', + ); + return start?.config && typeof start.config === 'object' + ? (start.config as Record) + : {}; +} + +/** + * The acting organization a flow declares, or `undefined`. + * + * Structural, like {@link resolveFlowTriggerKind}: it reads a raw authored + * object, a `defineFlow` result and a parsed stack's flow alike, and answers + * `undefined` for anything else rather than throwing. A present-but-unusable + * value (empty string, a number, an object) answers `undefined` too — the + * caller's next step is the refusal either way, and reporting "declared" for a + * value nothing can act on is the silent-acceptance this key exists to end. + */ +export function resolveScheduleOrganization(flow: unknown): string | undefined { + const raw = startConfigOf(flow)[SCHEDULE_ORGANIZATION_KEY]; + const parsed = ScheduleOrganizationSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} + +/** + * The near-miss key an organization-less START NODE `config` actually wrote, if + * any — so the refusal can say "you wrote `organizationId`" instead of "you + * wrote nothing". + * + * ⛔ Takes the start node's `config` record, NOT a flow — hence the name. The + * caller that needs this is a TRIGGER, and a trigger never holds the flow: the + * engine parses the start node and hands it a binding whose `config` is that + * record. A flow-shaped overload would answer `undefined` for the very input + * the only caller has, which is the silent-acceptance this module exists to + * end, so the argument it wants is the one the name asks for. + * + * Anything that is not a record answers `undefined` rather than throwing, + * matching {@link resolveScheduleOrganization}'s structural posture. + */ +export function findScheduleOrganizationNearMissInConfig( + startConfig: unknown, +): string | undefined { + if (!startConfig || typeof startConfig !== 'object') return undefined; + const config = startConfig as Record; + return SCHEDULE_ORGANIZATION_NEAR_MISSES.find( + (k) => Object.prototype.hasOwnProperty.call(config, k) && config[k] != null && config[k] !== '', + ); +} + +/** + * The one refusal sentence, so the schedule trigger and the time-relative + * trigger say the same thing about the same defect. + * + * ⛔ `FlowSchema` does NOT emit it, and deliberately does not: the start node's + * `config` is an open record (ADR-0018) and every flow this repo's own packages + * ship would become unparseable if the key were required at parse time. + * Enforcement is at BIND — the two triggers below — which is where the + * consequence lives: there is no path by which an organization-less + * time-triggered run reaches the data layer once bind refuses. + * + * It names the flow (the ruling requires that), the key, where the key goes, + * and — when the author wrote a near-miss — which spelling of theirs was + * dropped. It states the consequence rather than only the rule, because the + * consequence is the part an operator has already seen: this is the flow whose + * tick delivered nothing. + */ +export function describeMissingScheduleOrganization( + flowName: string, + options?: { readonly kind?: string; readonly nearMiss?: string }, +): string { + const kind = options?.kind === 'time_relative' ? 'time-relative' : 'scheduled'; + const nearMiss = options?.nearMiss; + return ( + `${kind} flow '${flowName}' declares no acting organization: its start node's \`config\` is ` + + `missing the \`${SCHEDULE_ORGANIZATION_KEY}\` key` + + (nearMiss + ? ` (it carries \`${nearMiss}\`, which is not this key — the start node's \`config\` is an open ` + + `record, so that spelling was accepted and then ignored)` + : '') + + `. A time-triggered run has no session to inherit a tenant from, so without this key the run ` + + `executes with no organization: on an install holding more than one \`sys_organization\` every ` + + `tenant-scoped write beneath it is refused — the inbox rows a \`notify\` node emits and the ` + + `\`sys_automation_run\` history row — while the tick still reports itself healthy. ` + + `Declare the organization the sweep runs in: \`config: { ${SCHEDULE_ORGANIZATION_KEY}: '' }\`. ` + + `A sweep wanted in several organizations is declared once per organization — ` + + `a single flow is never fanned out across them, and no organization is ever chosen for it.` + ); +} diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 9a6b63e569..5a748c79fb 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -205,6 +205,7 @@ import type * as M177 from './data/date-macros.zod.js'; import type * as M178 from './data/field-value.zod.js'; import type * as M179 from './data/mapping.zod.js'; import type * as M180 from './security/sharing.zod.js'; +import type * as M186 from './automation/schedule-organization.zod.js'; import type * as M114 from './shared/metadata-types.zod.js'; import type * as M115 from './shared/protection.zod.js'; import type * as M116 from './stack.zod.js'; @@ -275,7 +276,7 @@ import type * as M184 from './shared/value-domain.zod.js'; import type * as M185 from './shared/epoch.zod.js'; // --------------------------------------------------------------------------- -// 815 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 816 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -632,6 +633,13 @@ export type Iso246 = Assert, z.infe // automation/time-relative-trigger.zod.ts export type Iso247 = Assert, z.infer< typeof M43.TimeRelativeTriggerSchema > >>; +// automation/schedule-organization.zod.ts +// [#16659] A bare non-empty string: no transform, no default, no coercion — an +// organization id is written exactly as it is stored. So input === infer, and an +// `XParsed` here would be a permanent synonym. The day this schema learns to +// normalize an id, this line goes red and the ADR's remedy applies. +export type Iso871 = Assert, z.infer< typeof M186.ScheduleOrganizationSchema > >>; + // automation/webhook.zod.ts export type Iso248 = Assert, z.infer< typeof M44.WebhookTriggerType > >>; @@ -1686,7 +1694,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 815 isomorphic pins', () => { + it('still declares all 816 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2172,7 +2180,21 @@ describe('ADR-0122 type-alias convention', () => { // `AnalyticsDateRangeSchema` (its union with `z.array(z.string())`) — no // default, no transform on either arm, two new pins (`Iso869` / `Iso870`). // +2 added. - expect(pins).toHaveLength(815); + // + // 815 -> 816 is #16659's `ScheduleOrganizationSchema` + // (automation/schedule-organization.zod.ts, new module slot M186): the + // acting organization a time-triggered flow declares, a bare + // `z.string().min(1)` — no coercion, no default, no transform, because an + // organization id is written exactly as it is stored. The (RISE) case, one + // new pin (`Iso871`). +1 added. + // + // ⚠️ Worth one line on how it ARRIVED, because the module is not new — only + // its NAME is. It shipped in the same card as `schedule-organization.ts`, + // and every gate in this family reads `*.zod.ts` only, so neither this pin + // file nor `check:spec-parsed-alias` could see it. Renaming the file to + // `.zod.ts` is what asked the question, and the answer was a real ADR-0122 + // violation (`z.infer` on the bare alias) sitting green behind an extension. + expect(pins).toHaveLength(816); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/triggers/trigger-schedule/README.md b/packages/triggers/trigger-schedule/README.md index 1de14fe7db..bb6b73afee 100644 --- a/packages/triggers/trigger-schedule/README.md +++ b/packages/triggers/trigger-schedule/README.md @@ -14,13 +14,15 @@ engine baseline, a different event source. ## What it does -A flow whose `start` node declares a schedule: +A flow whose `start` node declares a schedule **and the organization it runs +as**: ```ts { type: 'start', config: { schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' }, + organization: '', // REQUIRED — see below condition: "...", // optional start-condition gate }, } @@ -28,8 +30,29 @@ A flow whose `start` node declares a schedule: ``` auto-launches on that schedule — no manual `engine.execute()`. When it fires, -the flow runs with `event: 'schedule'` and `params: { jobId, flowName, schedule }` -in its context. +the flow runs with `event: 'schedule'`, `tenantId` set to the declared +organization, and `params: { jobId, flowName, schedule }` in its context. + +### The acting organization is required + +A scheduled run has no session to inherit a tenant from, so it carries no +organization unless the flow declares one. Without it every tenant-scoped write +beneath the run — the inbox rows a `notify` node emits, the +`sys_automation_run` history row — is refused on any install holding more than +one `sys_organization`, while the tick still reports itself healthy. + +So `config.organization` is **required on every `schedule` and `timeRelative` +flow**, and a flow that omits it is **refused at bind**: the trigger logs the +reason at `error` naming the flow, and throws, so the engine records the flow as +NOT bound — it appears in `getTriggerBindingAudit()` and in the CLI's startup +summary, and `getFlowRuntimeStates()` reports `bound: false`. There is +deliberately no fallback: no platform organization, no "the install's only one". +A sweep wanted in several organizations is declared once per organization; a +single flow is never fanned out across them. + +⚠️ The start node's `config` is an open record, so a near-miss spelling +(`organizationId`, `org_id`, `tenantId`, …) parses and is then ignored. The +refusal names the spelling you wrote. ### Schedule shapes @@ -89,12 +112,19 @@ schedule and launched **once per matching record**: filter: { status: 'active' }, // optional, ANDed with the date window maxRecords: 1000, // optional per-sweep cap (default 1000) }, + organization: '', // REQUIRED — same refusal as above schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC condition: '...', // optional per-record start-condition gate }, } ``` +The sweep owes the acting organization for a **stronger** reason than a plain +schedule flow does: it queries with `context: { isSystem: true }` on purpose, so +that without a declared organization it would select rows across every tenant +and then launch a run able to write into none of them. A `timeRelative` flow +that declares none takes the same bind-time refusal. + The matched record rides on the automation context (`event: 'time_relative'`, `record`, `params`), so the start-node `condition` gate and `{record.}` interpolation work exactly as for a record-change flow. Because the window is diff --git a/packages/triggers/trigger-schedule/src/kernel-rebuild-rebind.test.ts b/packages/triggers/trigger-schedule/src/kernel-rebuild-rebind.test.ts index 8ce6bc7d9c..9a952f0052 100644 --- a/packages/triggers/trigger-schedule/src/kernel-rebuild-rebind.test.ts +++ b/packages/triggers/trigger-schedule/src/kernel-rebuild-rebind.test.ts @@ -103,7 +103,7 @@ describe('#8362 — a rebuilt kernel re-binds scheduled flows (both triggers)', const FLOW = 'nightly_contract_rollup'; const JOB = `flow-schedule:${FLOW}`; const fired: string[] = []; - const binding: FlowTriggerBinding = { flowName: FLOW, schedule: DAILY }; + const binding: FlowTriggerBinding = { flowName: FLOW, schedule: DAILY, organization: 'org_2mtx1w9d0k4bqf7v' }; // ── kernel 1 ────────────────────────────────────────────────────── const k1 = cronBackedJobService(); @@ -144,6 +144,7 @@ describe('#8362 — a rebuilt kernel re-binds scheduled flows (both triggers)', const binding: FlowTriggerBinding = { flowName: FLOW, schedule: DAILY, + organization: 'org_2mtx1w9d0k4bqf7v', config: { timeRelative: { object: 'xqao_contract', dateField: 'expiry_date', offsetDays: [3] }, }, @@ -186,7 +187,7 @@ describe('#8362 — a failed bind is reported where an operator sees it', () => it('ScheduleTrigger reports at ERROR, naming the consequence and the remedy', async () => { const logger = recordingLogger(); const trigger = new ScheduleTrigger(() => rejectingService(), logger); - trigger.start({ flowName: 'nightly_rollup', schedule: DAILY }, async () => {}); + trigger.start({ flowName: 'nightly_rollup', schedule: DAILY, organization: 'org_2mtx1w9d0k4bqf7v' }, async () => {}); await flush(); expect(logger.errors).toHaveLength(1); @@ -209,6 +210,7 @@ describe('#8362 — a failed bind is reported where an operator sees it', () => { flowName: 'xqao_contract_expiry_reminder_flow', schedule: DAILY, + organization: 'org_2mtx1w9d0k4bqf7v', config: { timeRelative: { object: 'xqao_contract', dateField: 'expiry_date', offsetDays: [3] }, }, diff --git a/packages/triggers/trigger-schedule/src/schedule-dispatch-claim.test.ts b/packages/triggers/trigger-schedule/src/schedule-dispatch-claim.test.ts index c0bc68c329..d96380f18b 100644 --- a/packages/triggers/trigger-schedule/src/schedule-dispatch-claim.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-dispatch-claim.test.ts @@ -42,6 +42,8 @@ const JOB = `flow-schedule:${FLOW}`; const CRON: FlowTriggerBinding = { flowName: FLOW, schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' }, + // [#16659] the acting organization every tick of this flow runs as. + organization: 'org_2mtx1w9d0k4bqf7v', }; /** Inside the 2026-09-07T01:00Z window of `0 1 * * *`. */ diff --git a/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts b/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts index dfc8805744..4709dd5659 100644 --- a/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts @@ -58,7 +58,10 @@ function scheduledDataFlow(name: string, runAs?: 'system' | 'user') { type: 'schedule', ...(runAs ? { runAs } : {}), nodes: [ - { id: 'start', type: 'start', label: 'Start', config: { schedule: { type: 'interval', intervalMs: 1000 } } }, + // [#16659] The acting organization a time-triggered flow declares. The + // engine lifts it onto the binding and the trigger threads it onto the + // run as `tenantId`; a flow without it is refused at bind. + { id: 'start', type: 'start', label: 'Start', config: { schedule: { type: 'interval', intervalMs: 1000 }, organization: 'org_2mtx1w9d0k4bqf7v' } }, { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'thing', fields: { a: 1 } } }, { id: 'end', type: 'end', label: 'End' }, ], diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts index 502245cd7f..57a010645a 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts @@ -5,6 +5,7 @@ import type { AutomationContext, JobSchedule, JobHandler } from '@objectstack/sp import { ScheduleTrigger, normalizeSchedule, + resolveBindingOrganization, type FlowTriggerBinding, type JobServiceSurface, type TriggerLogger, @@ -43,10 +44,30 @@ function silentLogger(): TriggerLogger { return { info: () => {}, warn: () => {}, debug: () => {} }; } +/** Keeps every line, so the refusal suite can read the `error` channel. */ +function recordingLogger(): { logger: TriggerLogger; errors: string[]; warns: string[] } { + const errors: string[] = []; + const warns: string[] = []; + return { + logger: { + info: () => {}, + debug: () => {}, + warn: (msg: string) => void warns.push(String(msg)), + error: (msg: string) => void errors.push(String(msg)), + }, + errors, + warns, + }; +} + function binding(overrides: Partial = {}): FlowTriggerBinding { return { flowName: 'nightly_health_sweep', schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' }, + // [#16659] A time-triggered binding carries its acting organization; a + // binding without one is refused — see + // `ScheduleTrigger — the acting-organization refusal (#16659)` below. + organization: 'org_2mtx1w9d0k4bqf7v', ...overrides, }; } @@ -293,3 +314,174 @@ describe('ScheduleTriggerPlugin', () => { expect(job.jobs.size).toBe(1); }); }); + +// ─── The acting-organization refusal (#16659) ─────────────────────── +// +// The unit half of the card's consequence (3): a time-triggered flow that +// declares no acting organization is REFUSED at bind, and the refusal reaches +// the engine rather than only stderr. +// +// ⚠️ Every assertion here would pass vacuously against a trigger that refused +// EVERYTHING, so each limb that expects a refusal is paired with the declaring +// binding from `binding()` above, which must still arm. + +describe('ScheduleTrigger — the acting-organization refusal (#16659)', () => { + const orgLess = () => binding({ organization: undefined, config: {} }); + + it('THROWS from start(), so the engine cannot record the flow as bound', () => { + const job = fakeJobService(); + const log = recordingLogger(); + const trigger = new ScheduleTrigger(() => job.service, log.logger); + + // ⭐ The whole of F1: `FlowTrigger.start` is `void`, so a logged-and- + // returned refusal is indistinguishable from a successful arm and the + // engine sets `boundFlowTriggers` anyway. The throw is the engine's + // designed catch path. + expect(() => trigger.start(orgLess(), async () => {})).toThrow(/declares no acting organization/); + expect(job.jobs.size, 'a refused flow must have no job at all').toBe(0); + }); + + it('logs the same sentence at `error`, naming the flow, the key and NOT BOUND', () => { + const job = fakeJobService(); + const log = recordingLogger(); + const trigger = new ScheduleTrigger(() => job.service, log.logger); + + let thrown = ''; + try { + trigger.start(orgLess(), async () => {}); + } catch (err) { + thrown = String((err as Error).message); + } + + expect(log.errors, 'the refusal is an `error`, not a `warn`').toHaveLength(1); + const line = log.errors[0]; + expect(line).toContain('NOT BOUND'); + expect(line, 'the refusal must be attributable to a flow, not to "a flow"').toContain( + 'nightly_health_sweep', + ); + expect(line, 'it must name the key the author has to write').toContain('organization'); + // The loud channel and the thrown text the engine's audit points at + // must not be able to drift apart. + expect(line).toContain(thrown); + }); + + it('names the near-miss spelling the author actually wrote', () => { + const job = fakeJobService(); + const log = recordingLogger(); + const trigger = new ScheduleTrigger(() => job.service, log.logger); + + expect(() => + trigger.start( + binding({ organization: undefined, config: { organizationId: 'org_written_wrong' } }), + async () => {}, + ), + ).toThrow(); + + // The start node's `config` is an open record, so `organizationId` was + // accepted and then ignored — the refusal is the only place that + // becomes visible. + expect(log.errors[0]).toContain('organizationId'); + }); + + it('⛔ never picks an organization for the author', () => { + const job = fakeJobService(); + const log = recordingLogger(); + const trigger = new ScheduleTrigger(() => job.service, log.logger); + + expect(() => + trigger.start( + binding({ organization: undefined, config: { organizationId: 'org_written_wrong' } }), + async () => {}, + ), + ).toThrow(); + + // The refusal names the KEY the author misspelt and never their VALUE: + // echoing an id back is one edit away from acting on it, and the one + // value in scope here is precisely the one nothing may adopt. + expect(log.errors[0]).not.toContain('org_written_wrong'); + expect(job.jobs.size).toBe(0); + }); + + it('a hot re-publish that REMOVES the key drops the prior job', async () => { + const job = fakeJobService(); + const log = recordingLogger(); + const trigger = new ScheduleTrigger(() => job.service, log.logger); + + trigger.start(binding(), async () => {}); + await flush(); + expect(job.jobs.size, 'precondition: the declaring binding armed').toBe(1); + + // ⭐ Without the `stop()` that precedes the throw, the previous, still + // armed job keeps firing org-less ticks behind an error saying the flow + // was refused — the exact "armed, listed and inert" shape this card closes. + expect(() => trigger.start(orgLess(), async () => {})).toThrow(); + await flush(); + expect(job.jobs.size).toBe(0); + }); + + it('refusing one flow does not disarm a sibling', async () => { + const job = fakeJobService(); + const log = recordingLogger(); + const trigger = new ScheduleTrigger(() => job.service, log.logger); + + trigger.start(binding({ flowName: 'declares_one' }), async () => {}); + await flush(); + expect(() => + trigger.start(binding({ flowName: 'declares_none', organization: undefined, config: {} }), async () => {}), + ).toThrow(); + await flush(); + + expect(job.jobs.has('flow-schedule:declares_one')).toBe(true); + expect(job.jobs.has('flow-schedule:declares_none')).toBe(false); + }); + + it('falls back to `warn` when the logger has no `error` channel', () => { + const job = fakeJobService(); + const warn = vi.fn(); + const trigger = new ScheduleTrigger(() => job.service, { info: () => {}, warn, debug: () => {} }); + + expect(() => trigger.start(orgLess(), async () => {})).toThrow(); + expect(warn, 'the refusal must still be said, not swallowed').toHaveBeenCalled(); + }); +}); + +describe('resolveBindingOrganization (#16659)', () => { + it('reads the lifted binding field first', () => { + expect(resolveBindingOrganization(binding())).toBe('org_2mtx1w9d0k4bqf7v'); + }); + + it('falls back to the raw start-node config, for an engine that predates the lift', () => { + // ⭐ Not redundancy: the binding is a STRUCTURAL mirror, so a host on an + // older engine hands this trigger no `organization` field and a `config` + // that still carries the author's declaration. Refusing there would + // report an engine-version skew as an authoring error. + expect( + resolveBindingOrganization( + binding({ organization: undefined, config: { organization: 'org_from_config' } }), + ), + ).toBe('org_from_config'); + }); + + it('the lifted field wins over the raw config', () => { + expect( + resolveBindingOrganization(binding({ config: { organization: 'org_stale' } })), + ).toBe('org_2mtx1w9d0k4bqf7v'); + }); + + it.each([ + ['absent', undefined], + ['an empty string', ''], + ['a number', 42], + ['an object', { id: 'org_x' }], + ['null', null], + ])('answers null for a value that is %s', (_label, value) => { + // A present-but-unusable value takes the refusal path: "declared" must + // mean "usable", or a flow admitted by one layer and refused by the next + // is the silent hole again. + expect( + resolveBindingOrganization( + binding({ organization: undefined, config: { organization: value } as Record }), + ), + ).toBeNull(); + }); +}); diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index e91602a727..cc127f2998 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -3,6 +3,12 @@ import { Cron } from 'croner'; import type { AutomationContext } from '@objectstack/spec/contracts'; import type { JobSchedule, JobHandler } from '@objectstack/spec/contracts'; +import { + SCHEDULE_ORGANIZATION_KEY, + ScheduleOrganizationSchema, + findScheduleOrganizationNearMissInConfig, + describeMissingScheduleOrganization, +} from '@objectstack/spec/automation'; /** * Structural mirror of the automation engine's `FlowTriggerBinding` @@ -18,6 +24,21 @@ export interface FlowTriggerBinding { readonly event?: string; readonly condition?: string | { dialect?: string; source?: string; ast?: unknown }; readonly schedule?: unknown; + /** + * [#16659] The ACTING ORGANIZATION a time-triggered flow declares on its + * start node (`config.organization`), lifted onto the binding by the + * engine's `resolveTriggerBinding` the same way `schedule` is. + * + * Optional on this interface and REQUIRED by the two time triggers — the + * split is deliberate. The interface is the structural mirror of the + * engine's binding, which is shared with `record_change` and `api` flows + * that legitimately carry none (their trigger threads the firing session's + * own tenant). "Absent" is therefore a real state the type must be able to + * express; what must not exist is a time-triggered run that PROCEEDS + * without it, and that verdict is {@link resolveBindingOrganization}'s, + * one layer down. + */ + readonly organization?: string; readonly config?: Record; } @@ -222,6 +243,103 @@ export interface TriggerLogger { const JOB_PREFIX = 'flow-schedule'; +/** + * Resolve the acting organization of a time-triggered binding (#16659), or + * `null` when the flow declared none. + * + * Reads the binding's lifted `organization` first and the raw start-node + * `config` second. The second read is not redundancy for its own sake: the + * binding is a STRUCTURAL mirror of the engine's type, so a host running an + * engine that predates the lift hands this trigger a binding with no + * `organization` field and a `config` that still carries the author's + * declaration. Reading only the lifted field there would report a correctly + * declared flow as organization-less and refuse it — turning an engine-version + * skew into an authoring error, which is the wrong diagnosis pointed at the + * wrong person. + * + * A present-but-unusable value (empty string, a number) resolves to `null` and + * takes the refusal path, exactly as {@link resolveScheduleOrganization} does + * at validation: this trigger and the validator must agree about what counts + * as declared, or a flow refused by one and admitted by the other is the + * silent hole again. + */ +export function resolveBindingOrganization(binding: FlowTriggerBinding): string | null { + const lifted = ScheduleOrganizationSchema.safeParse(binding.organization); + if (lifted.success) return lifted.data; + const raw = binding.config?.[SCHEDULE_ORGANIZATION_KEY]; + const declared = ScheduleOrganizationSchema.safeParse(raw); + return declared.success ? declared.data : null; +} + +/** + * Refuse to bind a time-triggered flow that declares no acting organization + * (#16659): say why at `error`, then THROW so the engine records the refusal. + * + * ## Why it throws, and does not merely log and return + * + * `FlowTrigger.start` returns `void`, so a trigger that logs and returns is + * indistinguishable — to the engine — from one that armed successfully. The + * engine's `activateFlowTrigger` then runs `boundFlowTriggers.set(flowName, …)` + * and logs `Flow '' bound to trigger 'schedule'` one line after this + * function said NOT BOUND, and every structured surface built for exactly this + * state reports the opposite of it: `getFlowRuntimeStates()` (Studio's status + * badge) answers `bound: true`, and `getTriggerBindingAudit()` — the silent-miss + * audit the automation plugin warns from at `kernel:bootstrapped` and the CLI + * prints in its startup summary — skips the flow because it is in + * `boundFlowTriggers`. A refusal only an operator reading stderr can see, in a + * repo that built three machine-readable channels to say "declared but not + * armed", is the same silent-miss shape this card exists to close. + * + * Throwing is the engine's DESIGNED path for this: `activateFlowTrigger` wraps + * `trigger.start(...)` in a `try/catch` whose `catch` logs the plugin-supplied + * thrown text and — because the `set` is inside the `try`, after the call — never + * marks the flow bound. The audit then lists it with `binding failed — see + * earlier warnings`, which points at the `error` line this function already + * emitted. The message is the same sentence both times, so the loud channel and + * the structured channel cannot drift. + * + * ## Why this REFUSES rather than binding and degrading + * + * The whole defect this closes is a run that looked healthy while delivering + * nothing: the tick selected its rows, landed its `update_record` steps, + * reported `unmeasured=0`, and every tenant-scoped write beneath it — the + * inbox rows and the `sys_automation_run` history row — was refused one layer + * down where nothing summarised it. Binding such a flow and warning once at + * boot would reproduce exactly that shape: a flow that is armed, listed, and + * inert. So the flow is NOT bound, and the reason names it. + * + * ## Why `error` and not `warn` + * + * The repo's degradation-log-level rule asks one question: after the + * degradation, does the system still look normal from the outside while + * something it claims is in place has not landed? It does, completely — the + * flow stays published and active in `sys_metadata`, Studio lists it, the + * metadata API serves it and `verify_build` passes — which is the same + * reasoning {@link reportBindFailure} records for its own branch, and the same + * `error` class. + * + * ⛔ There is deliberately no limb here that picks an organization. Not the + * install's only one, not the platform organization, not the first row of + * `sys_organization`. 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 script that + * filters by organization. + */ +export function refuseMissingOrganization( + logger: TriggerLogger, + tag: 'schedule' | 'time-relative', + flowName: string, + binding: FlowTriggerBinding, +): never { + const sentence = describeMissingScheduleOrganization(flowName, { + kind: tag === 'time-relative' ? 'time_relative' : 'schedule', + nearMiss: findScheduleOrganizationNearMissInConfig(binding.config), + }); + const report = logger.error?.bind(logger) ?? logger.warn.bind(logger); + report(`[${tag}] NOT BOUND — ${sentence}`); + throw new Error(sentence); +} + /** * Report a scheduled flow that failed to bind to the job service. * @@ -403,6 +521,21 @@ export class ScheduleTrigger implements FlowTrigger { return; } + // [#16659] The acting organization is part of the BINDING, so it is + // checked before the job service is even resolved: a flow that cannot + // legally run must not be reported as "not scheduled because the job + // service is missing", which is a different defect with a different + // remedy. + const organization = resolveBindingOrganization(binding); + if (organization === null) { + // Drop any prior binding for this flow FIRST. A hot re-publish that + // REMOVES the organization must not leave the previous, still-armed + // job firing org-less ticks behind an error that says it was + // refused — and the throw below leaves this method immediately. + this.stop(binding.flowName); + refuseMissingOrganization(this.logger, 'schedule', binding.flowName, binding); + } + const jobService = this.getJobService(); if (!jobService || typeof jobService.schedule !== 'function') { this.logger.warn( @@ -442,6 +575,21 @@ export class ScheduleTrigger implements FlowTrigger { try { const ctx: AutomationContext = { event: 'schedule', + // [#16659] The run executes AS this organization. This is + // the one line the whole card is about: `tenantId` is the + // acting run's organization, and every consumer already + // reads it — `notify-node.ts` threads it onto the + // notification it emits (#11303), and the engine copies it + // onto the `sys_automation_run` history row (#10101). The + // producer was simply never supplying a value, so both + // consumers resolved NULL and the tenancy guard refused the + // rows beneath them. + // + // ⛔ Never conditional. `organization` is non-null here by + // construction — the bind above refused the flow otherwise + // — and spelling this `...(organization ? {…} : {})` would + // re-open the org-less run as a silent state. + tenantId: organization, params: { jobId, flowName: binding.flowName, diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts index 3a02867ac8..41d32c0694 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -103,6 +103,8 @@ function binding(timeRelative: unknown, overrides: Partial = flowName: 'renewal_alert', object: 'contracts', config: { timeRelative }, + // [#16659] see the schedule trigger's fixture note. + organization: 'org_2mtx1w9d0k4bqf7v', ...overrides, }; } @@ -727,3 +729,62 @@ describe('TimeRelativeTriggerPlugin', () => { await expect(fake.readyHandlers[0]()).resolves.toBeUndefined(); }); }); + +// ─── The acting-organization refusal (#16659) ─────────────────────── +// +// The time-relative sweep is NOT the weaker case for carrying an organization, +// it is the stronger one: it queries with `context: { isSystem: true }` on +// purpose, so an org-less sweep selects across every tenant and then launches a +// run that can write into none of them. + +describe('TimeRelativeTrigger — the acting-organization refusal (#16659)', () => { + const DESC = { object: 'contracts', dateField: 'end_date', withinDays: 60 }; + + function recordingLogger(): { logger: TriggerLogger; errors: string[] } { + const errors: string[] = []; + return { + logger: { + info: () => {}, + debug: () => {}, + warn: () => {}, + error: (msg: string) => void errors.push(String(msg)), + }, + errors, + }; + } + + it('THROWS from start() and arms no sweep, naming the flow and the key', () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const log = recordingLogger(); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, log.logger, NOW); + + expect(() => + trigger.start(binding(DESC, { organization: undefined, config: { timeRelative: DESC } }), async () => {}), + ).toThrow(/declares no acting organization/); + + expect(job.jobs.size, 'a refused sweep must have no job at all').toBe(0); + expect(log.errors).toHaveLength(1); + expect(log.errors[0]).toContain('[time-relative] NOT BOUND'); + expect(log.errors[0]).toContain('renewal_alert'); + // The sentence is the time-relative one, not the plain-schedule one. + expect(log.errors[0]).toContain('time-relative flow'); + }); + + it('a hot re-publish that REMOVES the key drops the prior sweep', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const log = recordingLogger(); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, log.logger, NOW); + + trigger.start(binding(DESC), async () => {}); + await flush(); + expect(job.jobs.size, 'precondition: the declaring binding armed').toBe(1); + + expect(() => + trigger.start(binding(DESC, { organization: undefined, config: { timeRelative: DESC } }), async () => {}), + ).toThrow(); + await flush(); + expect(job.jobs.size).toBe(0); + }); +}); diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 27138da89d..1c422cfc72 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -7,7 +7,12 @@ import { TIME_RELATIVE_DEFAULT_MAX_RECORDS, } from '@objectstack/spec/automation'; import type { TimeRelativeTrigger as TimeRelativeDescriptor } from '@objectstack/spec/automation'; -import { normalizeSchedule, reportBindFailure } from './schedule-trigger.js'; +import { + normalizeSchedule, + reportBindFailure, + refuseMissingOrganization, + resolveBindingOrganization, +} from './schedule-trigger.js'; import type { FlowTrigger, FlowTriggerBinding, JobServiceSurface, TriggerLogger } from './schedule-trigger.js'; /** @@ -239,6 +244,26 @@ export class TimeRelativeTrigger implements FlowTrigger { } const desc = parsed.data; + // [#16659] A time-relative sweep launches from a clock, exactly as a + // plain schedule flow does, so it owes the same declaration and takes + // the same refusal. It is NOT the weaker case for carrying an + // organization, it is the stronger one: the sweep queries with + // `context: { isSystem: true }` — deliberately, so a background sweep + // sees all rows rather than RLS-scoped ones — so without a declared + // organization it selects across every tenant and then launches a run + // that can write into none of them. + const organization = resolveBindingOrganization(binding); + if (organization === null) { + // Drop any prior sweep FIRST: a hot re-publish that removes the key + // must not leave the previous, still-armed job sweeping org-less + // behind an error saying it was refused. The call below throws, so + // the engine's catch records the refusal instead of marking this + // flow bound — see `refuseMissingOrganization`'s header for why a + // logged-and-returned refusal is invisible to every audit surface. + this.stop(binding.flowName); + refuseMissingOrganization(this.logger, 'time-relative', binding.flowName, binding); + } + // Cadence: the flow's start-node schedule descriptor, or a daily default. // A daily sweep is the whole point (evaluate the window every day so a // threshold day is never missed), so an omitted schedule means "daily", @@ -282,7 +307,7 @@ export class TimeRelativeTrigger implements FlowTrigger { const handler: JobHandler = async () => { try { - await this.sweep(binding.flowName, desc, maxRecords, callback); + await this.sweep(binding.flowName, desc, maxRecords, organization, callback); } catch (err) { // Error isolation: a sweep failure must not crash the job // runner / ticker. Log and swallow. @@ -321,6 +346,12 @@ export class TimeRelativeTrigger implements FlowTrigger { flowName: string, desc: TimeRelativeDescriptor, maxRecords: number, + /** + * [#16659] The acting organization every run this sweep launches + * executes as. Required, not optional: `start()` refuses the binding + * without one, so a sweep can never be reached with nothing to pass. + */ + organization: string, callback: (ctx: AutomationContext) => Promise, ): Promise { const engine = this.getDataEngine(); @@ -385,6 +416,15 @@ export class TimeRelativeTrigger implements FlowTrigger { record, object: desc.object, event: 'time_relative', + // [#16659] The declared acting organization — the same key + // a record-change run inherits from its triggering session, + // and the one `notify-node.ts` and the run-history writer + // already read. ⛔ Never derived from the swept RECORD's + // own `organization_id`: the sweep runs elevated and can + // match rows in any tenant, so keying on the row would let + // one flow write into organizations it never declared — + // the cross-organization scheduled task the ruling forbids. + tenantId: organization, // Expose the record as params too, so flows with named `isInput` // variables matching record fields get them seeded (parity with // the record-change trigger). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5fee5e654..549d8b88fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2071,6 +2071,9 @@ importers: '@objectstack/trigger-record-change': specifier: workspace:* version: link:../../triggers/trigger-record-change + '@objectstack/trigger-schedule': + specifier: workspace:* + version: link:../../triggers/trigger-schedule '@objectstack/types': specifier: workspace:* version: link:../../types