From c3f8b3e29e273dc52a2144aeca8d0f35972b71f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 11:26:03 +0000 Subject: [PATCH 01/24] fix(triggers,spec,service-automation): a time-triggered flow declares its acting organization and runs as it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `type: 'schedule'` flow and a `time_relative` sweep launch their runs from a job tick, which carries no identity, so `AutomationContext.tenantId` was never set. On an install holding more than one `sys_organization` every tenant-scoped write beneath the run was then refused by the #8844 guard — the inbox rows a `notify` node emits and the `sys_automation_run` history row — while the tick still summarised itself healthy. - `@objectstack/spec` declares the start-node `config.organization` key, its value schema, the two kinds that owe it, the near-miss spellings an author reaches for, and the one refusal sentence every enforcement point says. - The engine lifts the declaration onto the schedule / time_relative binding. - Both triggers REFUSE to bind a flow that declares none, naming it at `error`, and thread the declared organization onto the run as `tenantId`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../services/service-automation/src/engine.ts | 37 +++- .../spec/json-schema.manifest/automation.json | 1 + packages/spec/src/automation/index.ts | 6 + .../src/automation/schedule-organization.ts | 200 ++++++++++++++++++ .../trigger-schedule/src/schedule-trigger.ts | 131 ++++++++++++ .../src/time-relative-trigger.ts | 39 +++- 6 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 packages/spec/src/automation/schedule-organization.ts diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index ecd3d23ae6..2a7f57528b 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,23 @@ 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 — see the schedule trigger's + * `reportMissingOrganization`. + */ + readonly organization?: string; /** The raw start-node `config`, for trigger-specific fields not modeled above. */ readonly config?: Record; } @@ -3046,6 +3063,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 +3079,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/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/src/automation/index.ts b/packages/spec/src/automation/index.ts index 92ebb2c1ad..28fe15fee7 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -41,6 +41,12 @@ 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 `FlowSchema`, the schedule trigger and the time-relative +// sweep all say it with (#16659). Named beside `flow-trigger-kind` because it is +// read through the same resolver: the two kinds that owe an organization are +// exactly the two that launch from a clock rather than from a session. +export * from './schedule-organization'; // `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.ts b/packages/spec/src/automation/schedule-organization.ts new file mode 100644 index 0000000000..622226fd44 --- /dev/null +++ b/packages/spec/src/automation/schedule-organization.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { resolveFlowTriggerKind } from './flow-trigger-kind'; + +/** + * 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 trigger kinds this declaration is required on — the two that launch a run + * from a clock rather than from a session. + * + * `record_change` and `api` are absent BY CONSTRUCTION, not by exemption: both + * are fired by a caller who already carries an organization, and threading a + * second, declared one would let a flow overrule the tenant of the very write + * that triggered it. + */ +export const TIME_TRIGGERED_FLOW_KINDS: readonly string[] = Object.freeze([ + 'schedule', + 'time_relative', +]); + +/** + * 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. + */ +export 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 flow actually wrote, if any — so the + * refusal can say "you wrote `organizationId`" instead of "you wrote nothing". + */ +export function findScheduleOrganizationNearMiss(flow: unknown): string | undefined { + const config = startConfigOf(flow); + return SCHEDULE_ORGANIZATION_NEAR_MISSES.find( + (k) => Object.prototype.hasOwnProperty.call(config, k) && config[k] != null && config[k] !== '', + ); +} + +/** + * Does this flow OWE an acting organization? True for the two time-triggered + * kinds, false for everything else. + */ +export function requiresScheduleOrganization(flow: unknown): boolean { + const kind = resolveFlowTriggerKind(flow); + return kind !== undefined && TIME_TRIGGERED_FLOW_KINDS.includes(kind); +} + +/** + * The one refusal sentence, so validation, the schedule trigger and the + * time-relative trigger all say the same thing about the same defect. + * + * 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/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index e91602a727..a746d2ec87 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, + SCHEDULE_ORGANIZATION_NEAR_MISSES, + 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,85 @@ 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), and say why at `error`. + * + * ## 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 reportMissingOrganization( + logger: TriggerLogger, + tag: 'schedule' | 'time-relative', + flowName: string, + binding: FlowTriggerBinding, +): void { + const config = binding.config ?? {}; + const nearMiss = SCHEDULE_ORGANIZATION_NEAR_MISSES.find( + (k) => Object.prototype.hasOwnProperty.call(config, k) && config[k] != null && config[k] !== '', + ); + const report = logger.error?.bind(logger) ?? logger.warn.bind(logger); + report( + `[${tag}] NOT BOUND — ` + + describeMissingScheduleOrganization(flowName, { + kind: tag === 'time-relative' ? 'time_relative' : 'schedule', + nearMiss, + }), + ); +} + /** * Report a scheduled flow that failed to bind to the job service. * @@ -403,6 +503,22 @@ 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) { + reportMissingOrganization(this.logger, 'schedule', binding.flowName, binding); + // Drop any prior binding for this flow. 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. + this.stop(binding.flowName); + return; + } + const jobService = this.getJobService(); if (!jobService || typeof jobService.schedule !== 'function') { this.logger.warn( @@ -442,6 +558,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.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 27138da89d..31251a901a 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, + reportMissingOrganization, + resolveBindingOrganization, +} from './schedule-trigger.js'; import type { FlowTrigger, FlowTriggerBinding, JobServiceSurface, TriggerLogger } from './schedule-trigger.js'; /** @@ -239,6 +244,21 @@ 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) { + reportMissingOrganization(this.logger, 'time-relative', binding.flowName, binding); + this.stop(binding.flowName); + return; + } + // 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 +302,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 +341,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 +411,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). From 434b518f82a4cb8f12b20cc2def97951e25bce55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 11:36:26 +0000 Subject: [PATCH 02/24] wip(qa): schedule acting-organization dogfood pins Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- packages/qa/dogfood/package.json | 3 +- .../fixtures/schedule-organization-fixture.ts | 96 ++++++ ...hedule-acting-organization.dogfood.test.ts | 308 ++++++++++++++++++ .../src/kernel-rebuild-rebind.test.ts | 6 +- .../src/schedule-dispatch-claim.test.ts | 2 + .../src/schedule-runas-e2e.test.ts | 5 +- .../src/schedule-trigger.test.ts | 3 + .../src/time-relative-trigger.test.ts | 2 + pnpm-lock.yaml | 7 +- 9 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts create mode 100644 packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts 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..f06bd7826b --- /dev/null +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -0,0 +1,308 @@ +// 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, + extraPlugins: [new MessagingServicePlugin()], + }); + 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. + 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 })) ?? []; + } + + 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 rows(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 rows(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 rows(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 rows(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); + }); + + // ── THE DIFFERENTIAL CONTROL ────────────────────────────────────────── + // + // The same flow, the same nodes, the same messaging chain — reached through + // `POST /api/v1/automation/:name/trigger` under a SESSION, which is the run + // shape the card reported as already working (`unmeasured=4`, every + // recipient sees the row). It is here so the pins above cannot pass + // vacuously: if delivery were broken for some reason unrelated to the + // organization, this would be red too, and the schedule pins' green would + // mean nothing. + it('differential control: the same flow via POST /automation/:name/trigger under a session delivers', async () => { + const before = (await rows(INBOX_OBJECT)).length; + 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 after = (await rows(INBOX_OBJECT)).length; + expect( + after, + 'the session-triggered run delivered nothing — delivery is broken for a reason unrelated to this card', + ).toBeGreaterThan(before); + }); + }); +} 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..09eff3fc0f 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts @@ -47,6 +47,9 @@ function binding(overrides: Partial = {}): FlowTriggerBindin 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, which is its own suite below. + organization: 'org_2mtx1w9d0k4bqf7v', ...overrides, }; } 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..e7bf791be2 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, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5fee5e654..b0c987b8d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,7 +380,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/setup: dependencies: @@ -402,7 +402,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/studio: dependencies: @@ -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 From 2a0a9e63587c73979f6a7bf5c3b9c4bae01479ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 11:47:50 +0000 Subject: [PATCH 03/24] test(qa): pin the three #16659 consequences on both drivers, with two differential controls Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- ...hedule-acting-organization.dogfood.test.ts | 167 ++++++++++++++++-- 1 file changed, 149 insertions(+), 18 deletions(-) diff --git a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts index f06bd7826b..72e20eb713 100644 --- a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -132,7 +132,29 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { stack = await bootStack(scheduleOrganizationStack as never, { automation: true, databaseDriver, - extraPlugins: [new MessagingServicePlugin()], + // `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'); @@ -144,6 +166,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { // 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); @@ -178,13 +201,37 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { 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 rows(RUN_HISTORY_OBJECT, { flow_name: DECLARED_FLOW }); + 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', @@ -200,14 +247,14 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { // 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 rows(NOTIFICATION_EVENT_OBJECT); + 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 rows(INBOX_OBJECT); + 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', @@ -233,7 +280,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { // 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 rows(RUN_HISTORY_OBJECT, { flow_name: DECLARED_FLOW }); + 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", @@ -282,27 +329,111 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { ).toBe(true); }); - // ── THE DIFFERENTIAL CONTROL ────────────────────────────────────────── + // ── THE DIFFERENTIAL CONTROLS ───────────────────────────────────────── // - // The same flow, the same nodes, the same messaging chain — reached through - // `POST /api/v1/automation/:name/trigger` under a SESSION, which is the run - // shape the card reported as already working (`unmeasured=4`, every - // recipient sees the row). It is here so the pins above cannot pass - // vacuously: if delivery were broken for some reason unrelated to the - // organization, this would be red too, and the schedule pins' green would - // mean nothing. - it('differential control: the same flow via POST /automation/:name/trigger under a session delivers', async () => { - const before = (await rows(INBOX_OBJECT)).length; + // 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** — `driver-memory` declares no row-level tenant isolation + * and refuses any tenant-scoped call (#16589 / #6915), so an org-bound + * session cannot make an HTTP request at all on this driver: the + * authorization resolver's own read is refused and the door answers 503 + * before any route runs. That is a property of the driver, not of this + * card. It is asserted rather than skipped so the day the driver gains + * isolation this pin goes RED and the control is enabled here too. + */ + it('control B: the same flow via POST /automation/:name/trigger under a session', async () => { + if (databaseDriver === 'memory') { + const res = await stack.apiAs(memberToken, 'POST', `/automation/${DECLARED_FLOW}/trigger`, {}); + expect( + res.status, + 'driver-memory served an org-bound HTTP request — it has gained tenant isolation, so enable the real control here (#16589 / #6915)', + ).toBeLessThan(300); + 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 after = (await rows(INBOX_OBJECT)).length; + + 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( - after, + fresh.length, 'the session-triggered run delivered nothing — delivery is broken for a reason unrelated to this card', - ).toBeGreaterThan(before); + ).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); }); }); } From 9a52eaaa4438fad7bdb54a11bf0c515d30dd9888 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 11:51:13 +0000 Subject: [PATCH 04/24] chore: changeset for the time-triggered acting-organization declaration Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../schedule-trigger-acting-organization.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .changeset/schedule-trigger-acting-organization.md diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md new file mode 100644 index 0000000000..5116e48e79 --- /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_KEY`, `ScheduleOrganizationSchema`, `TIME_TRIGGERED_FLOW_KINDS`, `SCHEDULE_ORGANIZATION_NEAR_MISSES`, `resolveScheduleOrganization`, `findScheduleOrganizationNearMiss`, `requiresScheduleOrganization`, and `describeMissingScheduleOrganization` — ONE refusal sentence, 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`. + +**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. + +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. From e234fe8ee27ccc3e175134e77a1d8b282e87a72d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:08:02 +0000 Subject: [PATCH 05/24] chore(spec): regenerate artifacts and register the #16659 proof Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- content/docs/references/automation/index.mdx | 1 + content/docs/references/automation/meta.json | 1 + content/docs/references/automation/misc.mdx | 28 +++++++++++++++++++ content/docs/references/index.mdx | 9 +++--- packages/spec/api-surface/automation.json | 9 ++++++ packages/spec/declaration-map/automation.json | 2 ++ packages/spec/export-origins/automation.json | 9 ++++++ .../spec/scripts/liveness/proof-registry.mts | 26 +++++++++++++++++ .../src/automation/schedule-organization.ts | 6 ++++ 9 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 content/docs/references/automation/misc.mdx diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 09525b3036..41f999a5b0 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -16,6 +16,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..0f6ee70614 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", + "misc", "schemaless-node-config" ] } \ No newline at end of file diff --git a/content/docs/references/automation/misc.mdx b/content/docs/references/automation/misc.mdx new file mode 100644 index 0000000000..aede625e20 --- /dev/null +++ b/content/docs/references/automation/misc.mdx @@ -0,0 +1,28 @@ +--- +title: Misc +description: Misc protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +## 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..001aa35de8 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. @@ -117,6 +117,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` | | [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect` | | [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` | +| [`misc`](/docs/references/automation/misc) *(no single source file)* | `ScheduleOrganization` | | [`node-executor.zod.ts`](/docs/references/automation/node-executor) | `ActionCategory`, `ActionDescriptor`, `ActionParadigm`, `NodeExecutorDescriptor`, `WaitEventType`, `WaitExecutorConfig`, `WaitResumePayload`, `WaitTimeoutBehavior` | | [`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` | diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 099a823985..d15e0e0f3c 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -199,8 +199,12 @@ "RetryPolicy (type)", "RetryPolicyParsed (type)", "RetryPolicySchema (const)", + "SCHEDULE_ORGANIZATION_KEY (const)", + "SCHEDULE_ORGANIZATION_NEAR_MISSES (const)", "SCHEMALESS_NODE_CONFIG_SCHEMAS (const)", "STRUCTURAL_CONDITION_SHAPE_REFUSAL (const)", + "ScheduleOrganization (type)", + "ScheduleOrganizationSchema (const)", "ScheduleState (type)", "ScheduleStateParsed (type)", "ScheduleStateSchema (const)", @@ -223,6 +227,7 @@ "SubflowConfigSchema (const)", "TIME_RELATIVE_DEFAULT_CRON (const)", "TIME_RELATIVE_DEFAULT_MAX_RECORDS (const)", + "TIME_TRIGGERED_FLOW_KINDS (const)", "TRY_CATCH_NODE_TYPE (const)", "TimeRelativeTrigger (type)", "TimeRelativeTriggerSchema (const)", @@ -259,8 +264,10 @@ "defineActionDescriptor (function)", "defineFlow (function)", "defineWebhook (function)", + "describeMissingScheduleOrganization (function)", "exportConstructsToBpmn (function)", "findRegionEntry (function)", + "findScheduleOrganizationNearMiss (function)", "flowForm (const)", "getApprovalNodeConfigJsonSchema (function)", "getSchemalessNodeConfigJsonSchemas (function)", @@ -271,8 +278,10 @@ "normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions (function)", "predicateSlotRefusal (function)", + "requiresScheduleOrganization (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..5d8da61fa0 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -194,8 +194,12 @@ "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.ts#SCHEDULE_ORGANIZATION_KEY (const)", + "SCHEDULE_ORGANIZATION_NEAR_MISSES": "src/automation/schedule-organization.ts#SCHEDULE_ORGANIZATION_NEAR_MISSES (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.ts#ScheduleOrganization (type)", + "ScheduleOrganizationSchema": "src/automation/schedule-organization.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)", @@ -218,6 +222,7 @@ "SubflowConfigSchema": "src/automation/schemaless-node-config.zod.ts#SubflowConfigSchema (const)", "TIME_RELATIVE_DEFAULT_CRON": "src/automation/time-relative-trigger.zod.ts#TIME_RELATIVE_DEFAULT_CRON (const)", "TIME_RELATIVE_DEFAULT_MAX_RECORDS": "src/automation/time-relative-trigger.zod.ts#TIME_RELATIVE_DEFAULT_MAX_RECORDS (const)", + "TIME_TRIGGERED_FLOW_KINDS": "src/automation/schedule-organization.ts#TIME_TRIGGERED_FLOW_KINDS (const)", "TRY_CATCH_NODE_TYPE": "src/automation/control-flow.zod.ts#TRY_CATCH_NODE_TYPE (const)", "TimeRelativeTrigger": "src/automation/time-relative-trigger.zod.ts#TimeRelativeTrigger (type)", "TimeRelativeTriggerSchema": "src/automation/time-relative-trigger.zod.ts#TimeRelativeTriggerSchema (const)", @@ -253,8 +258,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.ts#describeMissingScheduleOrganization (function)", "exportConstructsToBpmn": "src/automation/bpmn-mapping.ts#exportConstructsToBpmn (function)", "findRegionEntry": "src/automation/control-flow.zod.ts#findRegionEntry (function)", + "findScheduleOrganizationNearMiss": "src/automation/schedule-organization.ts#findScheduleOrganizationNearMiss (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)", @@ -265,8 +272,10 @@ "normalizeFlowFunctionEntry": "src/automation/flow-function.zod.ts#normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions": "src/automation/control-flow.zod.ts#parseFlowNodeRegions (function)", "predicateSlotRefusal": "src/automation/flow-node-expression-paths.ts#predicateSlotRefusal (function)", + "requiresScheduleOrganization": "src/automation/schedule-organization.ts#requiresScheduleOrganization (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.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/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/schedule-organization.ts b/packages/spec/src/automation/schedule-organization.ts index 622226fd44..ddbcd931aa 100644 --- a/packages/spec/src/automation/schedule-organization.ts +++ b/packages/spec/src/automation/schedule-organization.ts @@ -82,6 +82,12 @@ export const ScheduleOrganizationSchema = z '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. + */ +export type ScheduleOrganization = z.infer; + /** * The trigger kinds this declaration is required on — the two that launch a run * from a clock rather than from a session. From 08540df25980c3fe5f2b045c293406a9181f0f10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:20:26 +0000 Subject: [PATCH 06/24] =?UTF-8?q?docs:=20the=20automation=20reference=20tr?= =?UTF-8?q?ee=20gained=20a=20page=20=E2=80=94=20update=20the=20quick-refer?= =?UTF-8?q?ence=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- content/docs/getting-started/quick-reference.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 7dc451e65474c330e6466c949a0b1da715bd4e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:30:26 +0000 Subject: [PATCH 07/24] test(qa): resolve @objectstack/trigger-schedule from source in the dogfood pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin's subject is the trigger's own run-context construction, so a dist merely behind would run it green against the old one — the exact shape this card is about. check:test-source-alias and check:type-source-resolution both name this as the required spelling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- packages/qa/dogfood/tsconfig.json | 3 ++- packages/qa/dogfood/vitest.config.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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: { From 2cc91a3b49fa0008b7367e1d753a8776d552f009 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:52:55 +0000 Subject: [PATCH 08/24] fix(triggers,spec): the acting-organization refusal throws so the engine records it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FlowTrigger.start()` is `void`, so logging the refusal and returning left the engine free to run `boundFlowTriggers.set(...)` and log "bound" one line later: `getFlowRuntimeStates()` answered `bound: true`, `getTriggerBindingAudit()` skipped the flow, and the CLI startup summary said every triggered flow was wired. Both time triggers now throw the same sentence they log, which is the engine's designed catch path — the flow is never marked bound and the audit lists it with `binding failed — see earlier warnings`. Also folds the trigger's inline near-miss scan back into `packages/spec` (`findScheduleOrganizationNearMissInConfig`, which takes the start-node config a trigger actually holds), drops the two consumer-less exports (`TIME_TRIGGERED_FLOW_KINDS`, `requiresScheduleOrganization`), makes the near-miss vocabulary module-local, renames the module to `schedule-organization.zod.ts` so its source ships and the docs generator gives it a page, and corrects the two comments that claimed `FlowSchema` emits the refusal sentence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../services/service-automation/src/engine.ts | 6 +- packages/spec/src/automation/index.ts | 13 ++-- ...zation.ts => schedule-organization.zod.ts} | 65 ++++++++++--------- .../trigger-schedule/src/schedule-trigger.ts | 55 ++++++++++------ .../src/time-relative-trigger.ts | 11 +++- 5 files changed, 90 insertions(+), 60 deletions(-) rename packages/spec/src/automation/{schedule-organization.ts => schedule-organization.zod.ts} (80%) diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 2a7f57528b..b513482b46 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -440,8 +440,10 @@ export interface FlowTriggerBinding { * 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 — see the schedule trigger's - * `reportMissingOrganization`. + * 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. */ diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 28fe15fee7..5b57917337 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -42,11 +42,14 @@ export * from './approval.zod'; 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 `FlowSchema`, the schedule trigger and the time-relative -// sweep all say it with (#16659). Named beside `flow-trigger-kind` because it is -// read through the same resolver: the two kinds that owe an organization are -// exactly the two that launch from a clock rather than from a session. -export * from './schedule-organization'; +// 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.ts b/packages/spec/src/automation/schedule-organization.zod.ts similarity index 80% rename from packages/spec/src/automation/schedule-organization.ts rename to packages/spec/src/automation/schedule-organization.zod.ts index ddbcd931aa..45bd870ebb 100644 --- a/packages/spec/src/automation/schedule-organization.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { resolveFlowTriggerKind } from './flow-trigger-kind'; /** * The ACTING ORGANIZATION of a time-triggered flow — the one start-node key @@ -88,20 +87,6 @@ export const ScheduleOrganizationSchema = z */ export type ScheduleOrganization = z.infer; -/** - * The trigger kinds this declaration is required on — the two that launch a run - * from a clock rather than from a session. - * - * `record_change` and `api` are absent BY CONSTRUCTION, not by exemption: both - * are fired by a caller who already carries an organization, and threading a - * second, declared one would let a flow overrule the tenant of the very write - * that triggered it. - */ -export const TIME_TRIGGERED_FLOW_KINDS: readonly string[] = Object.freeze([ - 'schedule', - 'time_relative', -]); - /** * 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 @@ -109,8 +94,14 @@ export const TIME_TRIGGERED_FLOW_KINDS: readonly string[] = Object.freeze([ * 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. */ -export const SCHEDULE_ORGANIZATION_NEAR_MISSES: readonly string[] = Object.freeze([ +const SCHEDULE_ORGANIZATION_NEAR_MISSES: readonly string[] = Object.freeze([ 'organizationId', 'organization_id', 'organizationID', @@ -153,28 +144,40 @@ export function resolveScheduleOrganization(flow: unknown): string | undefined { } /** - * The near-miss key an organization-less flow actually wrote, if any — so the - * refusal can say "you wrote `organizationId`" instead of "you wrote nothing". + * 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 findScheduleOrganizationNearMiss(flow: unknown): string | undefined { - const config = startConfigOf(flow); +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] !== '', ); } /** - * Does this flow OWE an acting organization? True for the two time-triggered - * kinds, false for everything else. - */ -export function requiresScheduleOrganization(flow: unknown): boolean { - const kind = resolveFlowTriggerKind(flow); - return kind !== undefined && TIME_TRIGGERED_FLOW_KINDS.includes(kind); -} - -/** - * The one refusal sentence, so validation, the schedule trigger and the - * time-relative trigger all say the same thing about the same defect. + * 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 diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index a746d2ec87..cc127f2998 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -6,7 +6,7 @@ import type { JobSchedule, JobHandler } from '@objectstack/spec/contracts'; import { SCHEDULE_ORGANIZATION_KEY, ScheduleOrganizationSchema, - SCHEDULE_ORGANIZATION_NEAR_MISSES, + findScheduleOrganizationNearMissInConfig, describeMissingScheduleOrganization, } from '@objectstack/spec/automation'; @@ -273,7 +273,30 @@ export function resolveBindingOrganization(binding: FlowTriggerBinding): string /** * Refuse to bind a time-triggered flow that declares no acting organization - * (#16659), and say why at `error`. + * (#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 * @@ -302,24 +325,19 @@ export function resolveBindingOrganization(binding: FlowTriggerBinding): string * silently authoritative to every report, export and cleanup script that * filters by organization. */ -export function reportMissingOrganization( +export function refuseMissingOrganization( logger: TriggerLogger, tag: 'schedule' | 'time-relative', flowName: string, binding: FlowTriggerBinding, -): void { - const config = binding.config ?? {}; - const nearMiss = SCHEDULE_ORGANIZATION_NEAR_MISSES.find( - (k) => Object.prototype.hasOwnProperty.call(config, k) && config[k] != null && config[k] !== '', - ); +): 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 — ` + - describeMissingScheduleOrganization(flowName, { - kind: tag === 'time-relative' ? 'time_relative' : 'schedule', - nearMiss, - }), - ); + report(`[${tag}] NOT BOUND — ${sentence}`); + throw new Error(sentence); } /** @@ -510,13 +528,12 @@ export class ScheduleTrigger implements FlowTrigger { // remedy. const organization = resolveBindingOrganization(binding); if (organization === null) { - reportMissingOrganization(this.logger, 'schedule', binding.flowName, binding); - // Drop any prior binding for this flow. A hot re-publish that + // 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. + // refused — and the throw below leaves this method immediately. this.stop(binding.flowName); - return; + refuseMissingOrganization(this.logger, 'schedule', binding.flowName, binding); } const jobService = this.getJobService(); diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 31251a901a..1c422cfc72 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -10,7 +10,7 @@ import type { TimeRelativeTrigger as TimeRelativeDescriptor } from '@objectstack import { normalizeSchedule, reportBindFailure, - reportMissingOrganization, + refuseMissingOrganization, resolveBindingOrganization, } from './schedule-trigger.js'; import type { FlowTrigger, FlowTriggerBinding, JobServiceSurface, TriggerLogger } from './schedule-trigger.js'; @@ -254,9 +254,14 @@ export class TimeRelativeTrigger implements FlowTrigger { // that can write into none of them. const organization = resolveBindingOrganization(binding); if (organization === null) { - reportMissingOrganization(this.logger, 'time-relative', binding.flowName, binding); + // 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); - return; + refuseMissingOrganization(this.logger, 'time-relative', binding.flowName, binding); } // Cadence: the flow's start-node schedule descriptor, or a daily default. From 477a599098541ead1e546b526d39bf2f517c52f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:12:33 +0000 Subject: [PATCH 09/24] test(triggers,spec): pin the acting-organization refusal at unit level The fixture comment promised a refusal suite that did not exist. It exists now: the throw itself (the F1 contract), the `error` line carrying the same sentence the engine's audit points at, near-miss naming, the "hot re-publish removes the key" stop() limb, per-flow isolation, the no-`error`-channel fallback, and every limb of `resolveBindingOrganization`. The time-relative sweep gets the same two load-bearing pins, and `packages/spec` gets the unit test every sibling module in `src/automation` already had. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../automation/schedule-organization.test.ts | 154 ++++++++++++++ .../src/schedule-trigger.test.ts | 193 +++++++++++++++++- .../src/time-relative-trigger.test.ts | 59 ++++++ 3 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/automation/schedule-organization.test.ts 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/triggers/trigger-schedule/src/schedule-trigger.test.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts index 09eff3fc0f..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,12 +44,29 @@ 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, which is its own suite below. + // [#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, }; @@ -296,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/time-relative-trigger.test.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts index e7bf791be2..41d32c0694 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -729,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); + }); +}); From 1a58d810825115586905864a15cfe777615c86d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:20:23 +0000 Subject: [PATCH 10/24] test(qa): pin the refusal on the structured surfaces, and make control B honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consequence (3)'s dogfood pin asserted only that the job service was never asked. That says nothing about what the ENGINE recorded, which is where the refusal was being lost: `getFlowRuntimeStates()` answered `bound: true` and `getTriggerBindingAudit()` skipped the flow entirely. Both are now pinned, each with the declaring flow as its paired control. Control B's memory limb asserted `status < 300` under a message claiming it pinned a 503 refusal — opposite polarity, so it certified nothing. It now states plainly that the HTTP control is unavailable on this driver and pins the reason at the seam that makes it so: a tenant-scoped read is refused with MEMORY_MULTI_TENANT_UNSUPPORTED, so the exemption expires by itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- ...hedule-acting-organization.dogfood.test.ts | 104 ++++++++++++++++-- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts index 72e20eb713..5c059b728e 100644 --- a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -329,6 +329,56 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { ).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 @@ -393,21 +443,53 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { * organization, so the run delivers under THAT organization: a third * distinct id, and one more witness that `orgA` came from the * declaration. - * - **memory** — `driver-memory` declares no row-level tenant isolation - * and refuses any tenant-scoped call (#16589 / #6915), so an org-bound - * session cannot make an HTTP request at all on this driver: the - * authorization resolver's own read is refused and the door answers 503 - * before any route runs. That is a property of the driver, not of this - * card. It is asserted rather than skipped so the day the driver gains - * isolation this pin goes RED and the control is enabled here too. + * - **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') { - const res = await stack.apiAs(memberToken, 'POST', `/automation/${DECLARED_FLOW}/trigger`, {}); + // 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( - res.status, - 'driver-memory served an org-bound HTTP request — it has gained tenant isolation, so enable the real control here (#16589 / #6915)', - ).toBeLessThan(300); + 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; } From 2a34ddae9d7a04c0a3424c0dd00aea041946ad33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:25:11 +0000 Subject: [PATCH 11/24] docs(triggers,automation): the acting organization reaches every published surface The trigger-schedule README (shipped in files[]) still showed both worked examples without `organization` and said they auto-launch; after this change those exact flows are refused. Both now declare the key, and the README states the refusal and where it shows up. flows.mdx gains a dedicated "The acting organization" section and both worked schedule examples declare the key; hooks.mdx and capabilities.mdx point at it so "needs `triggers`" is no longer the whole story. The showcase digest's docstring stops promising that it fires: as a package-shipped flow it has no legal organization to name, no placeholder may be invented, and what such a flow should do instead is #17150's decision. Drops the unrelated esbuild@0.28.1/0.28.2 swap from pnpm-lock.yaml; the diff is now only the link: entry the new dev-dependency needs, and `pnpm install --frozen-lockfile` leaves it byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- content/docs/automation/flows.mdx | 55 +++++++++++++++++++ content/docs/automation/hooks.mdx | 2 +- content/docs/permissions/capabilities.mdx | 2 +- .../src/automation/flows/index.ts | 25 +++++++-- packages/triggers/trigger-schedule/README.md | 36 +++++++++++- pnpm-lock.yaml | 4 +- 6 files changed, 112 insertions(+), 12 deletions(-) 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/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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index b0c987b8d5..549d8b88fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,7 +380,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/setup: dependencies: @@ -402,7 +402,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/studio: dependencies: From ad367c1d517c82759d20f48736c42536d69f091b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:32:49 +0000 Subject: [PATCH 12/24] chore(spec): regenerate for the .zod.ts rename and the narrowed export set `schedule-organization` now has its own reference page instead of landing in the "Misc (no single source file)" bucket, and api-surface / export-origins drop the three consumer-less exports. The changeset's export list matches what the barrel actually publishes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../schedule-trigger-acting-organization.md | 6 +- content/docs/references/automation/index.mdx | 2 +- content/docs/references/automation/meta.json | 2 +- content/docs/references/automation/misc.mdx | 28 ------ .../automation/schedule-organization.mdx | 89 +++++++++++++++++++ content/docs/references/index.mdx | 2 +- packages/spec/api-surface/automation.json | 5 +- packages/spec/export-origins/automation.json | 15 ++-- 8 files changed, 102 insertions(+), 47 deletions(-) delete mode 100644 content/docs/references/automation/misc.mdx create mode 100644 content/docs/references/automation/schedule-organization.mdx diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md index 5116e48e79..145cead721 100644 --- a/.changeset/schedule-trigger-acting-organization.md +++ b/.changeset/schedule-trigger-acting-organization.md @@ -21,14 +21,14 @@ Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在 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_KEY`, `ScheduleOrganizationSchema`, `TIME_TRIGGERED_FLOW_KINDS`, `SCHEDULE_ORGANIZATION_NEAR_MISSES`, `resolveScheduleOrganization`, `findScheduleOrganizationNearMiss`, `requiresScheduleOrganization`, and `describeMissingScheduleOrganization` — ONE refusal sentence, so the engine's lift and both triggers cannot drift about what counts as declared. +- **`@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`. +- **`@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. +**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/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 41f999a5b0..85148c1ec9 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -16,8 +16,8 @@ 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 0f6ee70614..b32a7ec940 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -17,7 +17,7 @@ "builtin-node-config", "flow-function", "io-node-config", - "misc", + "schedule-organization", "schemaless-node-config" ] } \ No newline at end of file diff --git a/content/docs/references/automation/misc.mdx b/content/docs/references/automation/misc.mdx deleted file mode 100644 index aede625e20..0000000000 --- a/content/docs/references/automation/misc.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Misc -description: Misc protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -## 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/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 001aa35de8..e38cee0d0b 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -117,8 +117,8 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` | | [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect` | | [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` | -| [`misc`](/docs/references/automation/misc) *(no single source file)* | `ScheduleOrganization` | | [`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/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index d15e0e0f3c..6a7a38007b 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -200,7 +200,6 @@ "RetryPolicyParsed (type)", "RetryPolicySchema (const)", "SCHEDULE_ORGANIZATION_KEY (const)", - "SCHEDULE_ORGANIZATION_NEAR_MISSES (const)", "SCHEMALESS_NODE_CONFIG_SCHEMAS (const)", "STRUCTURAL_CONDITION_SHAPE_REFUSAL (const)", "ScheduleOrganization (type)", @@ -227,7 +226,6 @@ "SubflowConfigSchema (const)", "TIME_RELATIVE_DEFAULT_CRON (const)", "TIME_RELATIVE_DEFAULT_MAX_RECORDS (const)", - "TIME_TRIGGERED_FLOW_KINDS (const)", "TRY_CATCH_NODE_TYPE (const)", "TimeRelativeTrigger (type)", "TimeRelativeTriggerSchema (const)", @@ -267,7 +265,7 @@ "describeMissingScheduleOrganization (function)", "exportConstructsToBpmn (function)", "findRegionEntry (function)", - "findScheduleOrganizationNearMiss (function)", + "findScheduleOrganizationNearMissInConfig (function)", "flowForm (const)", "getApprovalNodeConfigJsonSchema (function)", "getSchemalessNodeConfigJsonSchemas (function)", @@ -278,7 +276,6 @@ "normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions (function)", "predicateSlotRefusal (function)", - "requiresScheduleOrganization (function)", "resolveFlowNodeExpressions (function)", "resolveFlowTriggerKind (function)", "resolveScheduleOrganization (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 5d8da61fa0..7ce939d126 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -194,12 +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.ts#SCHEDULE_ORGANIZATION_KEY (const)", - "SCHEDULE_ORGANIZATION_NEAR_MISSES": "src/automation/schedule-organization.ts#SCHEDULE_ORGANIZATION_NEAR_MISSES (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.ts#ScheduleOrganization (type)", - "ScheduleOrganizationSchema": "src/automation/schedule-organization.ts#ScheduleOrganizationSchema (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)", @@ -222,7 +221,6 @@ "SubflowConfigSchema": "src/automation/schemaless-node-config.zod.ts#SubflowConfigSchema (const)", "TIME_RELATIVE_DEFAULT_CRON": "src/automation/time-relative-trigger.zod.ts#TIME_RELATIVE_DEFAULT_CRON (const)", "TIME_RELATIVE_DEFAULT_MAX_RECORDS": "src/automation/time-relative-trigger.zod.ts#TIME_RELATIVE_DEFAULT_MAX_RECORDS (const)", - "TIME_TRIGGERED_FLOW_KINDS": "src/automation/schedule-organization.ts#TIME_TRIGGERED_FLOW_KINDS (const)", "TRY_CATCH_NODE_TYPE": "src/automation/control-flow.zod.ts#TRY_CATCH_NODE_TYPE (const)", "TimeRelativeTrigger": "src/automation/time-relative-trigger.zod.ts#TimeRelativeTrigger (type)", "TimeRelativeTriggerSchema": "src/automation/time-relative-trigger.zod.ts#TimeRelativeTriggerSchema (const)", @@ -258,10 +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.ts#describeMissingScheduleOrganization (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)", - "findScheduleOrganizationNearMiss": "src/automation/schedule-organization.ts#findScheduleOrganizationNearMiss (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)", @@ -272,10 +270,9 @@ "normalizeFlowFunctionEntry": "src/automation/flow-function.zod.ts#normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions": "src/automation/control-flow.zod.ts#parseFlowNodeRegions (function)", "predicateSlotRefusal": "src/automation/flow-node-expression-paths.ts#predicateSlotRefusal (function)", - "requiresScheduleOrganization": "src/automation/schedule-organization.ts#requiresScheduleOrganization (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.ts#resolveScheduleOrganization (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)" } From c1ee9287a51c3f064acaab571d77ca957bad2002 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:52:19 +0000 Subject: [PATCH 13/24] fix(spec): ADR-0122 alias state and the llms.txt inventory for the new module Two gate findings the .zod.ts rename brought into scope, both real: check:spec-parsed-alias only reads *.zod.ts, so `ScheduleOrganization` was declared with z.infer where ADR-0122 reserves the bare name for the author state; and llms.txt counts *.zod.ts modules, so the automation domain and the total were one short. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- packages/spec/llms.txt | 4 ++-- packages/spec/src/automation/schedule-organization.zod.ts | 7 ++++++- packages/spec/src/type-alias-convention.pin.test.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) 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/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index 45bd870ebb..87c07ce71a 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -84,8 +84,13 @@ export const ScheduleOrganizationSchema = z /** * 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.infer; +export type ScheduleOrganization = z.input; /** * Spellings an author reaches for that are NOT this key, in the order a diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 9a6b63e569..3cfcfa9e95 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'; @@ -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 > >>; From b998f58f3832d3f8f952172bbd9b28f668f18ed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 15:18:10 +0000 Subject: [PATCH 14/24] test(spec): the ADR-0122 isomorphic-pin count moves 815 -> 816 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ScheduleOrganizationSchema` is a bare `z.string().min(1)` — no coercion, no default, no transform — so input === infer and it takes a pin rather than a permanent `ScheduleOrganizationParsed` synonym. The note records that the module is not new, only its `.zod.ts` name is: this family of gates reads `*.zod.ts` only, so the violation sat green behind an extension. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../src/type-alias-convention.pin.test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 3cfcfa9e95..5a748c79fb 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -276,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 @@ -1694,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 @@ -2180,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 From 750c41505cad4efefe203987689cef66b30b91f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 09:10:22 +0000 Subject: [PATCH 15/24] chore(spec): regenerate the docs reference index on the merged tree Discharges the os-regen deferral recorded by the merge commit. `gen:schema` then `gen:docs` on the merged tree: totals 198/1583 -> 199/1584, automation 13/73 -> 14/74, and the `schedule-organization.zod.ts` row. Every other generated artifact re-derived byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- content/docs/references/index.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1baba330da..a6ec608f41 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 — 1583 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1584 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** | **1583** | 14 protocol modules | +| **Total** | **199** | **1584** | 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` | From 8b4557180f479292875ddbc4886299e251b75acf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 09:14:05 +0000 Subject: [PATCH 16/24] fix(trigger-schedule)!: the time-relative sweep SELECTS inside its declared organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration reached the run and never the query. A sweep declared for org A still matched rows in every tenant — `find` carried `context: { isSystem: true }` and nothing else — and then launched a run stamped A about B's record: the run's `update_record` matched nothing (silently, being scoped to A), `notify` posted into A's inbox about B's record, and the history row was stamped from the subject, landing under B. That is the cross-organization scheduled task the ruling forbids, with a declaration papering over it. The declared organization now rides the sweep's own query as `context.tenantId`, the same ExecutionContext axis the run already uses: `Engine.buildDriverOptions` turns it into `DriverOptions.tenantId` and the driver scopes the read. ⛔ Not a hand-built `organization_id` predicate — that would be a second implementation of tenancy inside a trigger, hardcoding a renameable column, selecting nothing on a platform-global object, breaking a federated one, and reading as scoped to a driver that never learned scoping was wanted. Elevation and tenancy stay independent: `isSystem` says what the sweep may see, `tenantId` says whose rows they are. Silence is closed on both new edges: a store that cannot honour the scope refuses the call and the sweep now logs that at `error` (stderr survives the CLI's boot-quiet window), and an object the engine exempts from scoping (`tenancy.enabled: false`, `external`) gets a bind-time warning saying the declaration cannot narrow this sweep. The bind line names the acting organization so the sweep's reach is readable from the boot log. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- packages/triggers/trigger-schedule/README.md | 35 +++- .../src/time-relative-trigger.ts | 163 ++++++++++++++++-- 2 files changed, 180 insertions(+), 18 deletions(-) diff --git a/packages/triggers/trigger-schedule/README.md b/packages/triggers/trigger-schedule/README.md index bb6b73afee..d9e4859850 100644 --- a/packages/triggers/trigger-schedule/README.md +++ b/packages/triggers/trigger-schedule/README.md @@ -120,10 +120,37 @@ schedule and launched **once per matching record**: ``` 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. +schedule flow does, and it is the value that BOUNDS THE QUERY. The sweep runs +elevated on purpose (`context: { isSystem: true }` — a background sweep must see +every row, not the RLS-scoped subset an absent user would see), so nothing else +keeps its selection inside one organization: the declared id is passed as +`context.tenantId` on the same query, the engine turns that into +`DriverOptions.tenantId`, and the driver scopes the read. Selection and +identity are then the same organization. A `timeRelative` flow that declares +none takes the same bind-time refusal. + +⚠️ Elevation and tenancy are **independent axes** — `isSystem` decides what the +sweep is allowed to see, `tenantId` decides whose rows they are. A sweep that +passed only the first was a cross-organization scheduled task even with a +declaration on the flow: it matched rows in every tenant and launched runs +stamped with one, so `update_record` matched nothing, `notify` posted into the +declared organization's inbox about another organization's record, and the +history row landed under the record's organization rather than the run's. + +Two consequences worth knowing before you declare one: + +- **A store that cannot scope refuses the sweep rather than answering it.** + `@objectstack/driver-memory` implements no row-level tenant isolation and + refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`), so + a scoped sweep there fails loudly every tick instead of quietly selecting + every organization's rows. Multi-organization deployments use + `@objectstack/driver-sql`. +- **On a platform-global or federated object the declaration cannot narrow + anything.** The engine drops the tenant scope for an object declaring + `tenancy: { enabled: false }` (ADR-0066) or carrying `external` (ADR-0015), so + such a sweep still selects across every organization while its runs act as the + declared one. The trigger says so at bind time, at `warn`, naming the object — + ⛔ it does not pretend the flow is contained. The matched record rides on the automation context (`event: 'time_relative'`, `record`, `params`), so the start-node `condition` gate and `{record.}` diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 1c422cfc72..385bb8ec55 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -29,8 +29,28 @@ export interface TimeRelativeDataEngine { where?: Record; fields?: string[]; limit?: number; - /** Elevated context — a background sweep must see all rows, not RLS-scoped ones. */ - context?: { isSystem?: boolean }; + /** + * The sweep's execution context. Two INDEPENDENT axes, and this + * sweep sets both (#16659): + * + * - `isSystem` is AUTHORIZATION — a background sweep must see + * every row the organization holds, not the RLS-scoped subset + * some absent user would see. + * - `tenantId` is TENANCY — which organization those rows belong + * to. The engine turns it into `DriverOptions.tenantId` and the + * driver scopes the read. + * + * The engine's own contract keeps them apart in as many words + * (`Engine.buildDriverOptions`: *"System / isSystem callers may + * still cross tenants by clearing `tenantId` themselves"*), so + * elevating a sweep has never implied unscoping it — the previous + * shape simply never passed the second one. Both members are + * `ExecutionContext` keys the engine's `find` already accepts + * (`EngineQueryOptions.context` is `ExecutionContextSchema.partial()`), + * so naming `tenantId` here widens no contract; it declares the + * slice of one this trigger uses. + */ + context?: { isSystem?: boolean; tenantId?: string }; }, ): Promise> | undefined>; /** @@ -169,6 +189,37 @@ export function buildWindowWhere(desc: TimeRelativeDescriptor, window: DateWindo }; } +/** + * [#16659] Why the engine will DROP this sweep's tenant scope for `schema`, or + * `null` when it will apply it. + * + * `Engine.buildDriverOptions` scopes a read by `context.tenantId` unless the + * object opts out, and it documents exactly two opt-outs: `tenancy.enabled: + * false` (ADR-0066 — a platform-global catalog, whose NULL-organization rows + * would vanish under a scope) and `external != null` (ADR-0015 — a federated + * object whose table belongs to a remote database, where the platform has no + * ground to guess a tenant column onto someone else's schema). + * + * ⛔ This is NOT a copy of that predicate for the sweep to act on — the sweep + * passes `tenantId` either way and lets the engine decide. It exists so the + * bind line can SAY that a declared organization is inert for this object, + * which is the one case where the ruling's containment is not achievable and + * the flow's declaration would otherwise imply it is. Read from the schema the + * engine's own `getObject` hands back; an unrecognised shape answers `null` + * (say nothing) rather than guessing. + * + * Module-private on purpose: its only consumer is the bind line below, and this + * package's barrel already states the rule that an export with no consumer + * outside its own package does not belong in it. + */ +function organizationScopeIsInertFor(schema: unknown): string | null { + if (!schema || typeof schema !== 'object') return null; + const s = schema as { tenancy?: { enabled?: unknown } | null; external?: unknown }; + if (s.tenancy?.enabled === false) return 'declares `tenancy: { enabled: false }` (ADR-0066, platform-global)'; + if (s.external != null) return 'is a federated object (ADR-0015 `external`), whose table the remote database owns'; + return null; +} + function errMessage(err: unknown): string { return (err as Error)?.message ?? String(err); } @@ -247,11 +298,13 @@ export class TimeRelativeTrigger implements FlowTrigger { // [#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. + // organization, it is the stronger one: the sweep runs ELEVATED + // (`isSystem`, deliberately — a background sweep must see all rows + // rather than RLS-scoped ones), so the declaration is the only thing + // that keeps its SELECTION inside one organization. Without it the + // sweep would match rows in every tenant and then launch runs able to + // write into none of them; with it the same value bounds the query and + // the run (see `sweep`'s `organization` parameter). const organization = resolveBindingOrganization(binding); if (organization === null) { // Drop any prior sweep FIRST: a hot re-publish that removes the key @@ -295,6 +348,30 @@ export class TimeRelativeTrigger implements FlowTrigger { `[time-relative] flow '${binding.flowName}' targets unknown object '${desc.object}' — the sweep is bound but will match nothing until that object is registered. ` + `Object names match exactly; check config.timeRelative.object.`, ); + } else { + const inertBecause = organizationScopeIsInertFor(known); + if (inertBecause) { + // [#16659] ⛔ A DISCLOSURE, never a narrowing. The sweep + // passes `context.tenantId` unconditionally and the ENGINE + // decides whether it applies; this branch re-reads the two + // declarations the engine documents as its exemptions + // (`tenancy.enabled: false`, ADR-0066; `external`, + // ADR-0015) purely so the operator is told when their + // declaration cannot narrow anything. Nothing here changes + // which rows come back, so if this predicate ever drifts + // from the engine's, the cost is a wrong WARNING — never a + // wrong row. That is the only reason a second reading of + // tenancy is tolerable in a trigger at all. + // + // Saying it matters because the quiet direction here is the + // dangerous one: the sweep keeps selecting across every + // organization, exactly as it did before this card, while + // the flow's `organization` line makes it LOOK contained. + this.logger.warn( + `[time-relative] flow '${binding.flowName}' sweeps '${desc.object}', which ${inertBecause} — the engine applies no tenant scope to such an object, so the declared organization does NOT narrow this sweep: it still selects rows in every organization, while each run it launches acts as the declared one. ` + + `If '${desc.object}' really is per-organization data, that declaration on the OBJECT is what to fix.`, + ); + } } } @@ -311,7 +388,18 @@ export class TimeRelativeTrigger implements FlowTrigger { } catch (err) { // Error isolation: a sweep failure must not crash the job // runner / ticker. Log and swallow. - this.logger.warn( + // + // [#16659] At `error` when the logger has one, for the reason + // {@link TriggerLogger.error} already states: the CLI's + // boot-quiet window swallows stdout, so a `warn` here can be + // the whole of what a broken sweep says and still be invisible. + // Since the query became organization-scoped, "this sweep can + // no longer see anything" is a REACHABLE state — a store that + // cannot honour the scope refuses the call rather than + // answering it unscoped — and a sweep that selects nothing for + // a structural reason must be as loud as one that crashed. + const log = this.logger.error?.bind(this.logger) ?? this.logger.warn.bind(this.logger); + log( `[time-relative] flow '${binding.flowName}' sweep failed: ${errMessage(err)}`, ); } @@ -325,10 +413,15 @@ export class TimeRelativeTrigger implements FlowTrigger { const mode = desc.offsetDays ? `offsets [${desc.offsetDays.join(', ')}]d` : `within ${desc.withinDays}d`; + // [#16659] The organization is on the BIND line, not only in + // the refusal: it is now the sweep's selection scope as well as + // the run's identity, so "which rows can this flow ever see" is + // answerable from the boot log instead of from the metadata. this.logger.info( `[time-relative] bound flow '${binding.flowName}' → sweep '${desc.object}.${desc.dateField}' ${mode} on ${schedule.type}` + (schedule.expression ? ` '${schedule.expression}'` : '') + - (schedule.intervalMs ? ` every ${schedule.intervalMs}ms` : ''), + (schedule.intervalMs ? ` every ${schedule.intervalMs}ms` : '') + + ` as organization '${organization}'`, ); }) .catch((err) => { @@ -347,9 +440,17 @@ export class TimeRelativeTrigger implements FlowTrigger { 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. + * [#16659] The declared organization. It bounds this sweep TWICE, and + * both halves are load-bearing: + * + * 1. SELECTION — it goes onto the `find` context as `tenantId`, so the + * rows this sweep can match are the declared organization's. Without + * it the sweep is a cross-organization scheduled task whatever the + * run is stamped with. + * 2. IDENTITY — every run launched from a matched row executes as it. + * + * 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, @@ -373,7 +474,41 @@ export class TimeRelativeTrigger implements FlowTrigger { (await engine.find(desc.object, { where, limit: maxRecords, - context: { isSystem: true }, + // [#16659] SELECTION is scoped to the declared organization, + // not just the run that follows it. + // + // `isSystem` alone was the whole context here, and it made + // this sweep a cross-organization scheduled task — the thing + // the ruling forbids — with the declaration papering over + // it: a sweep declared for A still MATCHED rows in B, then + // launched a run stamped A about B's record. Downstream that + // is worse than the original defect, not better: the run's + // `update_record` matches nothing (silently, because the run + // is scoped to A), `notify` posts into A's inbox about B's + // record, and the history row is stamped from the SUBJECT, + // so it lands under B. One run, three organizations' + // opinions about who it belonged to. + // + // ⛔ Not a hand-built `organization_id` predicate on + // `where`. That would be a SECOND implementation of tenancy + // living in a trigger: it would hardcode a column name the + // object is free to rename (`tenancy.tenantField`), select + // NOTHING on a platform-global object that carries no such + // column, break a federated object outright, and — worst — + // read as a scoped query to a driver that never learned the + // caller wanted scoping, so a driver with no isolation would + // answer it silently instead of refusing it. The platform + // already owns this: `Engine.buildDriverOptions` turns + // `context.tenantId` into `DriverOptions.tenantId`, drops it + // for the two postures where it must not apply + // (`tenancy.enabled: false`, ADR-0066; federated, ADR-0015), + // and every driver that CAN isolate then scopes, while + // `driver-memory` — which cannot — refuses the call by name + // (#16589). Refusal is the correct answer for a sweep that + // is required to stay inside one organization and is talking + // to a store that cannot keep it there, and it arrives as a + // logged sweep failure rather than as silence. + context: { isSystem: true, tenantId: organization }, })) ?? []; for (const row of rows) { const id = (row as { id?: unknown }).id; @@ -445,7 +580,7 @@ export class TimeRelativeTrigger implements FlowTrigger { } this.logger.debug?.( - `[time-relative] flow '${flowName}' swept '${desc.object}': ${matched.length} matched, ${launched} launched, ${deduped} already dispatched, ${failed} failed`, + `[time-relative] flow '${flowName}' swept '${desc.object}' as organization '${organization}': ${matched.length} matched, ${launched} launched, ${deduped} already dispatched, ${failed} failed`, ); } From 5cb5f96bd33df43e6700146bb25c70c950ec6123 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 09:26:55 +0000 Subject: [PATCH 17/24] test(trigger-schedule): pin the sweep's SELECTION scope, both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing pin read `expect(calls[0].context).toEqual({ isSystem: true })` — it was pinning the defect, so it is replaced rather than relaxed: exact equality against `{ isSystem: true, tenantId: }`, which reddens the moment the sweep asks for no scope. Four new pins: - every window query carries the scope, not just the first (offset mode issues one per offset), and `where` still holds only the date window, so no organization predicate was hand-built onto it; - DIFFERENTIAL — matching rows in TWO organizations, only the declared one's record launches a run. The double implements the driver's documented scope (`org = :tenant OR org IS NULL`) and, with no scope, no predicate at all, so its "absent" arm reproduces the defect instead of hiding it; - a store that cannot honour the scope (driver-memory's #16589 refusal) is reported at `error` naming the flow, never answered unscoped; - a `tenancy.enabled: false` object is disclosed at bind — the declaration cannot narrow that sweep — with a paired control that an ordinary object draws no such line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../src/time-relative-trigger.test.ts | 214 +++++++++++++++++- 1 file changed, 205 insertions(+), 9 deletions(-) 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 41d32c0694..526056d4f7 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -49,7 +49,7 @@ interface FindCall { objectName: string; where: Record; limit?: number; - context?: { isSystem?: boolean }; + context?: { isSystem?: boolean; tenantId?: string }; } /** @@ -74,6 +74,39 @@ function fakeDataEngine(rows: Row[], knownObjects: string[] = ['contracts']) { return { engine, calls }; } +/** + * [#16659] A fake ObjectQL surface that HONOURS `context.tenantId`, so the + * differential control can put matching rows in two organizations and observe + * which ones come back. + * + * The scope it implements is the SQL driver's documented one — `tenantId` + * present ⇒ `(organization_id = :tenant OR organization_id IS NULL)`; `tenantId` + * absent ⇒ no predicate at all, which is exactly how the unfixed sweep read. + * ⛔ Not a convenience double that filters whatever it is handed: the "absent" + * arm has to reproduce the DEFECT, or ablating the fix would still look scoped. + */ +function tenantScopedDataEngine(rows: Row[], knownObjects: string[] = ['contracts']) { + const calls: FindCall[] = []; + const engine: TimeRelativeDataEngine = { + async find(objectName, query) { + const where = (query?.where ?? {}) as Record; + calls.push({ objectName, where, limit: query?.limit, context: query?.context }); + const tenant = query?.context?.tenantId; + const scoped = rows.filter((row) => { + if (tenant === undefined) return true; + const org = row.organization_id; + return org === tenant || org == null; + }); + const out = scoped.filter((row) => matches(row, where)); + return typeof query?.limit === 'number' ? out.slice(0, query.limit) : out; + }, + getObject(name) { + return knownObjects.includes(name) ? { name } : undefined; + }, + }; + return { engine, calls }; +} + /** Minimal where matcher: temporal range on the date field + scalar equality. */ function matches(row: Row, where: Record): boolean { for (const [key, cond] of Object.entries(where)) { @@ -98,13 +131,21 @@ function silentLogger(): TriggerLogger { /** Fixed reference clock: 2026-07-18 (noon UTC). */ const NOW = () => new Date('2026-07-18T12:00:00.000Z'); +/** + * [#16659] The organization every fixture binding declares. Named rather than + * inlined because it is now asserted from two directions — the sweep's query + * scope and the launched run's identity — and a literal repeated at both ends + * of that pair can drift into agreeing with itself. + */ +const TEST_ORG = 'org_2mtx1w9d0k4bqf7v'; + function binding(timeRelative: unknown, overrides: Partial = {}): FlowTriggerBinding { return { flowName: 'renewal_alert', object: 'contracts', config: { timeRelative }, // [#16659] see the schedule trigger's fixture note. - organization: 'org_2mtx1w9d0k4bqf7v', + organization: TEST_ORG, ...overrides, }; } @@ -227,8 +268,14 @@ describe('TimeRelativeTrigger', () => { // Context is record-shaped (so `{record.x}` + start conditions work). expect(seen[0]).toMatchObject({ object: 'contracts', event: 'time_relative' }); expect(seen[0].record).toBe(seen[0].params); - // The sweep queries as a system op (sees all rows, RLS-bypassing). - expect(calls[0].context).toEqual({ isSystem: true }); + // The sweep queries as a system op (sees all rows, RLS-bypassing) AND + // inside its declared organization. [#16659] This assertion used to + // read `{ isSystem: true }` and it was pinning the defect: `isSystem` + // is AUTHORIZATION and `tenantId` is TENANCY, and a sweep carrying only + // the first selects across every tenant while its runs act as one. + // ⛔ Do not relax it back to a subset match — the exact-equality is + // what makes "the sweep asks for no scope" red. + expect(calls[0].context).toEqual({ isSystem: true, tenantId: TEST_ORG }); expect(calls[0].where).toEqual({ status: 'active', end_date: { $gte: '2026-07-18T00:00:00.000Z', $lte: '2026-09-16T23:59:59.999Z' }, @@ -733,23 +780,29 @@ describe('TimeRelativeTriggerPlugin', () => { // ─── 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. +// it is the stronger one: it runs ELEVATED on purpose (`isSystem` — a +// background sweep must see every row, not the RLS-scoped subset), so the +// declaration is the only thing keeping its SELECTION inside one organization. +// An org-less sweep selects across every tenant and then launches a run that +// can write into none of them; a sweep whose declaration reached only the run +// selects across every tenant and launches runs that write into ONE, which is +// worse. Both halves are pinned below. describe('TimeRelativeTrigger — the acting-organization refusal (#16659)', () => { const DESC = { object: 'contracts', dateField: 'end_date', withinDays: 60 }; - function recordingLogger(): { logger: TriggerLogger; errors: string[] } { + function recordingLogger(): { logger: TriggerLogger; errors: string[]; warns: string[] } { const errors: string[] = []; + const warns: string[] = []; return { logger: { info: () => {}, debug: () => {}, - warn: () => {}, + warn: (msg: string) => void warns.push(String(msg)), error: (msg: string) => void errors.push(String(msg)), }, errors, + warns, }; } @@ -787,4 +840,147 @@ describe('TimeRelativeTrigger — the acting-organization refusal (#16659)', () await flush(); expect(job.jobs.size).toBe(0); }); + + // ── the SELECTION half (#16659, F2) ─────────────────────────────────── + // + // Declaring an organization bounded the RUN and left the QUERY unbounded, + // so a sweep declared for A matched rows in every tenant and launched runs + // stamped A about other organizations' records. These pins are about the + // query. + + it('EVERY window query carries the declared organization, not just the first', async () => { + // Offset mode issues one query per offset — a scope threaded onto only + // the first would leave the rest crossing organizations, and a pin that + // read `calls[0]` alone would not notice. + const job = fakeJobService(); + const { engine, calls } = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + + trigger.start( + binding({ object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] }), + async () => {}, + ); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(calls.length, 'offset mode must issue one query per offset').toBe(3); + expect( + calls.map((c) => c.context?.tenantId ?? 'NO-SCOPE'), + 'an unscoped window query selects every organization\'s rows', + ).toEqual([TEST_ORG, TEST_ORG, TEST_ORG]); + // The scope is the ONLY thing tenancy contributes: the author's filter + // and the date window are untouched, so no organization predicate was + // hand-built onto `where` (which would hardcode a column name the + // object is free to rename, and select nothing where there is none). + for (const call of calls) { + expect(Object.keys(call.where)).toEqual(['end_date']); + } + }); + + it('DIFFERENTIAL: with matching rows in two organizations only the declared one is swept', async () => { + // The discriminating shape. A pin that only proved "A's rows are found" + // passes on the defect too — the defect FOUND them, alongside B's. + // + // The double implements the documented driver contract rather than a + // convenient one: `DriverOptions.tenantId` scopes to + // `(organization_id = :tenant OR organization_id IS NULL)` + // (sql-driver's own `tenantFieldByTable` note), and an ABSENT scope + // applies no predicate at all — which is precisely how the unfixed + // sweep read. + const ORG_B = 'org_beta_0000000000000'; + const rows: Row[] = [ + { id: 'a1', end_date: '2026-07-25T00:00:00.000Z', organization_id: TEST_ORG }, + { id: 'b1', end_date: '2026-07-25T00:00:00.000Z', organization_id: ORG_B }, + { id: 'b2', end_date: '2026-07-26T00:00:00.000Z', organization_id: ORG_B }, + ]; + const job = fakeJobService(); + const { engine, calls } = tenantScopedDataEngine(rows); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start( + binding({ object: 'contracts', dateField: 'end_date', withinDays: 60 }), + async (ctx) => void seen.push(ctx), + ); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect( + seen.map((c) => (c.record as Row).id), + 'the sweep launched a run for a record in an organization the flow never declared', + ).toEqual(['a1']); + expect( + seen.map((c) => c.tenantId), + 'and the run still acts as the declared organization', + ).toEqual([TEST_ORG]); + expect(calls[0].context?.tenantId, 'the scope must reach the engine, not be applied afterwards').toBe(TEST_ORG); + }); + + it('a store that CANNOT honour the scope is reported at `error`, never answered unscoped', async () => { + // `driver-memory` refuses any call handed a tenant scope (#16589). A + // sweep required to stay inside one organization, talking to a store + // that cannot keep it there, must be LOUD — "selected nothing this + // tick" and "cannot select at all" are different facts. + const job = fakeJobService(); + const engine: TimeRelativeDataEngine = { + async find(_objectName, query) { + if (query?.context?.tenantId !== undefined) { + throw Object.assign(new Error('[driver-memory] Refusing to answer: this driver has NO row-level tenant isolation.'), { + code: 'MEMORY_MULTI_TENANT_UNSUPPORTED', + }); + } + return []; + }, + getObject: () => ({ name: 'contracts' }), + }; + const log = recordingLogger(); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, log.logger, NOW); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 60 }), async () => {}); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + const failure = log.errors.find((l) => l.includes('sweep failed')); + expect(failure, `the sweep failed silently; errors seen: ${JSON.stringify(log.errors)}`).toBeTruthy(); + expect(failure, 'the failure must name the flow it belongs to').toContain('renewal_alert'); + expect(failure, "and carry the store's own reason").toContain('NO row-level tenant isolation'); + }); + + it('says so at bind when the swept object is one the engine will NOT scope', async () => { + // ⚠️ The quiet direction. A `tenancy.enabled: false` object (ADR-0066) + // is exempt from the engine's tenant scope, so the declaration cannot + // narrow this sweep at all — it still selects across every + // organization, while the flow's `organization` line makes it LOOK + // contained. Nothing here changes which rows come back; the pin is that + // the operator is TOLD. + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + engine.getObject = () => ({ name: 'contracts', tenancy: { enabled: false } }); + const log = recordingLogger(); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, log.logger, NOW); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 60 }), async () => {}); + await flush(); + + const said = log.warns.find((l) => l.includes('tenancy')); + expect(said, `nothing was said; warns seen: ${JSON.stringify(log.warns)}`).toBeTruthy(); + expect(said, 'the warning must name the object whose declaration makes the scope inert').toContain('contracts'); + expect(said, 'and state the consequence, not just the fact').toContain('does NOT narrow this sweep'); + expect(job.jobs.size, 'the sweep still binds — this is a disclosure, not a refusal').toBe(1); + }); + + it('control: a tenant-scoped object gets NO such warning', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const log = recordingLogger(); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, log.logger, NOW); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 60 }), async () => {}); + await flush(); + + expect( + log.warns.filter((l) => l.includes('does NOT narrow this sweep')), + 'an ordinary object must not be warned about — that would train operators to ignore the line', + ).toHaveLength(0); + }); }); From 4e7c8ffc7dfb61894c0cc15b181e1ed4d0f06d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 09:51:14 +0000 Subject: [PATCH 18/24] test(dogfood): the sweep's SELECTION scope on the real driver stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The differential control the branch never had: `sched_org_sweep`, a `time_relative` flow declared for org A, with matching rows in BOTH A (one) and B (two). The discriminating number is the count of LAUNCHED RUNS — 1 with the fix, 3 on the defect — because a pin asserting only "A's row was touched" passes on the defect too. Three properties, three producers: - selection: exactly one run, and it names A's record; - the data plane: `update_record` lands on A's row and on no other (the half the branch left unpinned — the earlier fixture flow was start -> notify -> end, so nothing would have gone red if the run's narrowing were wrong); - disclosure: no `sys_notification` names a B record. An unscoped sweep emits that notification BEFORE its write silently matches nothing, so a fix that narrowed only the writes would leave the leak open. The memory limb answers the other question the card is about: a store with no tenant isolation REFUSES the scoped sweep (#16589) and the failure is reported at `error` naming the flow, with a paired control proving the refusal is about the scope and not about the fixture (the same query unscoped still sees all three rows). The fixture object gains `due_date` as `datetime`, not `date`: the window is a pair of ISO instants, and a column the driver truncates to YYYY-MM-DD would put a per-driver truncation rule between the fixture and the property under test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../fixtures/schedule-organization-fixture.ts | 84 ++++- ...e-sweep-organization-scope.dogfood.test.ts | 309 ++++++++++++++++++ packages/qa/dogfood/vitest.config.ts | 11 +- 3 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts diff --git a/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts b/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts index 21cb2fe3a2..078386662d 100644 --- a/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts @@ -9,13 +9,23 @@ // `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. */ +/** + * Object the tick touches, so a run has a data write of its own to land. + * + * `due_date` is what the `time_relative` sweep selects on (#16659 F2). It is a + * `datetime` rather than a `date` deliberately: the window the trigger computes + * is a pair of ISO-8601 instants, and comparing them against a column the + * driver truncates to `YYYY-MM-DD` puts a per-driver truncation rule between + * the fixture and the property under test, which is WHICH ORGANIZATION's rows + * came back. + */ const SweepTargetObject = { name: 'sched_org_target', label: 'Sweep Target', fields: { name: { type: 'text', label: 'Name', required: true }, touched: { type: 'checkbox', label: 'Touched' }, + due_date: { type: 'datetime', label: 'Due' }, }, }; @@ -94,3 +104,75 @@ export function organizationLessScheduleFlow(recipientId: string): unknown { }); return { ...declared, name: 'sched_org_undeclared', nodes }; } + +/** + * [#16659 F2] The `time_relative` twin: a sweep that declares its acting + * organization, selects `sched_org_target` rows whose `due_date` falls in the + * next week, and — once per matched record — notifies and writes. + * + * Both trailing nodes are load-bearing and they measure DIFFERENT halves: + * + * - `notify` produces one tenant-scoped inbox row per LAUNCHED run, so the + * count of those rows is the count of records the sweep SELECTED. That is + * the F2 property: an unscoped sweep selects the other organization's rows + * too and posts about them into the declared organization's inbox. + * - `update_record` is the data-plane half the branch previously left unpinned + * (the fixture flow was `start → notify → end`). The run is scoped to the + * declared organization, so a write aimed at another organization's row + * matches nothing — silently. Asserting WHICH rows got `touched` is what + * makes that narrowing observable instead of assumed. + */ +export function declaringTimeRelativeFlow(organizationId: string, recipientId: string): unknown { + return { + name: 'sched_org_sweep', + label: 'Time-relative sweep (organization declared)', + type: 'schedule', + status: 'active', + runAs: 'system', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Daily sweep', + config: { + timeRelative: { + object: 'sched_org_target', + dateField: 'due_date', + withinDays: 7, + }, + organization: organizationId, + }, + }, + { + id: 'notify', + type: 'notify', + label: 'Due soon', + config: { + topic: 'sched.due', + recipients: [recipientId], + title: 'Due soon: {record.name}', + message: '{record.name} is due.', + channels: ['inbox'], + sourceObject: 'sched_org_target', + sourceId: '{record.id}', + }, + }, + { + id: 'touch', + type: 'update_record', + label: 'Mark touched', + config: { + objectName: 'sched_org_target', + filter: { id: '{record.id}' }, + fields: { touched: true }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'touch' }, + { id: 'e3', source: 'touch', target: 'end' }, + ], + }; +} diff --git a/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts new file mode 100644 index 0000000000..61ceb9f6ec --- /dev/null +++ b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#16659 F2] A `time_relative` sweep SELECTS inside its declared organization +// — proven on the real ObjectQL + driver stack, with matching rows in TWO +// organizations. +// +// @proof: schedule-acting-organization +// +// ## What this file measures that its sibling does not +// +// `schedule-acting-organization.dogfood.test.ts` proves the RUN carries the +// declared organization. That left the other half unmeasured, and it was wrong: +// the sweep's own query carried `context: { isSystem: true }` and nothing else, +// so a sweep declared for org A still MATCHED rows in org B and launched one run +// per match — each stamped A. Downstream that is worse than the defect the card +// opened on, not better: +// +// - the run is scoped to A, so its `update_record` on a B row matches nothing +// and reports success; +// - `notify` posts into A's inbox about B's record — a cross-tenant disclosure +// that was previously refused outright, because an org-less run could write +// nowhere; +// - the history row is stamped SUBJECT-first, so it lands under B while the +// inbox rows sit under A. +// +// ⚠️ THE DISCRIMINATING NUMBER IS THE COUNT OF LAUNCHED RUNS. A pin asserting +// only "A's row was touched" passes on the defect too — the defect touched it, +// alongside launching two runs about B's rows. So the fixture puts ONE matching +// row in A and TWO in B, and the pins read 1 vs 3. +// +// ## The multi-organization condition +// +// Two `sys_organization` rows under the DEFAULT `single` posture — the same +// install the card was measured on, and the state +// `system-write-organization.ts` calls `ambiguous-organization`. + +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 { TimeRelativeTrigger, type JobServiceSurface, type TriggerLogger } from '@objectstack/trigger-schedule'; +import type { JobHandler, JobSchedule } from '@objectstack/spec/contracts'; +import { + scheduleOrganizationStack, + declaringTimeRelativeFlow, +} from './fixtures/schedule-organization-fixture.js'; + +const TARGET_OBJECT = 'sched_org_target'; +const SWEEP_FLOW = 'sched_org_sweep'; +const SWEEP_JOB = `flow-time-relative:${SWEEP_FLOW}`; + +/** A job service the test fires by hand — the sweep's cadence is not the subject. */ +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); + }, + }; +} + +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, and they answer DIFFERENT questions here — the split is the + * point, not an exemption: + * + * - **sqlite-wasm** enforces tenant isolation, so it can be asked the real + * differential question: with rows in A and B, which come back? + * - **memory** implements none and REFUSES any call handed a tenant scope + * (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589). The question it answers is the + * one the card is really about: when a sweep required to stay inside one + * organization cannot be served, does it SAY SO or go quiet? + */ +for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { + describe(`dogfood [${databaseDriver}]: a time-relative sweep selects inside 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 rowA: string; + let rowsB: string[]; + + beforeAll(async () => { + stack = await bootStack(scheduleOrganizationStack as never, { + automation: true, + databaseDriver, + orgContext: databaseDriver === 'sqlite-wasm', + // ⛔ Reliable delivery OFF for the same reason the sibling suite turns + // it off: with the outbox + dispatcher on, `sys_inbox_message` is + // written by a background dispatcher on its own schedule, so a count + // taken right after the tick reads empty whatever the sweep selected. + extraPlugins: [new MessagingServicePlugin({ reliableDelivery: false })], + }); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + automation = stack.kernel.getService('automation'); + expect(automation?.registerFlow, 'automation engine must be wired').toBeTruthy(); + + 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).not.toBe(orgB); + + const admin = await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, ...SYS }); + const recipientId = String(admin?.id ?? 'usr_system'); + + // ── the differential fixture ────────────────────────────────────── + // One matching row in A, TWO in B. Every row is inside the window, so + // the ONLY thing that can keep B's out is the tenant scope. + const due = new Date(Date.now() + 2 * 86_400_000).toISOString(); + const inA = await ql.insert(TARGET_OBJECT, { name: 'A-1', due_date: due, organization_id: orgA }, SYS); + const inB1 = await ql.insert(TARGET_OBJECT, { name: 'B-1', due_date: due, organization_id: orgB }, SYS); + const inB2 = await ql.insert(TARGET_OBJECT, { name: 'B-2', due_date: due, organization_id: orgB }, SYS); + rowA = String(inA.id); + rowsB = [String(inB1.id), String(inB2.id)]; + + const seeded = (await ql.find(TARGET_OBJECT, { ...SYS })) ?? []; + expect( + seeded.length, + 'precondition: three rows must exist, or "B was not selected" proves nothing', + ).toBe(3); + expect( + seeded.map((r: Record) => String(r.organization_id ?? 'NULL')).sort(), + 'precondition: the rows must actually carry the two organizations — a NULL-org row is visible under ANY scope (`org = :tenant OR org IS NULL`), so a fixture that failed to stamp them would make this suite pass unfixed', + ).toEqual([orgA, orgB, orgB].sort()); + + automation.registerFlow(SWEEP_FLOW, declaringTimeRelativeFlow(orgA, recipientId)); + + job = fakeJobService(); + log = recordingLogger(); + automation.registerTrigger(new TimeRelativeTrigger(() => job.service, () => ql, log.logger)); + await new Promise((r) => setTimeout(r, 0)); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + async function rows(object: string, where: Record = {}): Promise>> { + return (await ql.find(object, { where, ...SYS })) ?? []; + } + + it('precondition: the sweep BOUND', () => { + expect( + job.has(SWEEP_JOB), + `the sweep did not bind — registered jobs: ${job.names().join(', ') || '(none)'}`, + ).toBe(true); + }); + + if (databaseDriver === 'memory') { + /** + * The store cannot honour the scope, so the sweep must be LOUD. + * + * PREDICTION, written before the run: `driver-memory` refuses the scoped + * `find` (#16589), the sweep's own error isolation catches it, and the + * failure is logged at `error` naming the flow. ⛔ What must NOT happen is + * the sweep quietly answering with every organization's rows — that is the + * silent non-isolation the driver's refusal exists to remove, and this + * card's whole subject is a tick that looks healthy while being wrong. + */ + it('a store with no tenant isolation REFUSES the sweep, loudly, and launches nothing', async () => { + await job.fire(SWEEP_JOB, 'tick-16659-f2'); + + const failure = log.errors.find((l) => l.includes('sweep failed')); + expect( + failure, + `the sweep did not report a failure; errors: ${JSON.stringify(log.errors)} · warns: ${JSON.stringify(log.warns)}`, + ).toBeTruthy(); + expect(failure, 'the failure must be attributable to a flow').toContain(SWEEP_FLOW); + expect( + failure, + "and carry the driver's own refusal, not some unrelated error that happens to throw", + ).toContain('NO row-level tenant isolation'); + + const touched = (await rows(TARGET_OBJECT)).filter((r) => r.touched === true || r.touched === 1); + expect( + touched.map((r) => String(r.id)), + 'a refused sweep must launch no runs at all — a partially-served sweep is the cross-organization task the ruling forbids', + ).toEqual([]); + }); + + it('control: the refusal is about the SCOPE, not about the object or the window', async () => { + // The same query without a tenant scope is served. So "nothing came + // back" above is attributable to the scope the sweep asked for, and not + // to a fixture whose rows never matched. + const unscoped = (await ql.find(TARGET_OBJECT, { where: {}, context: { isSystem: true } })) ?? []; + expect( + unscoped.length, + 'the unscoped read must still see all three rows, or the fixture — not the scope — is what this suite measured', + ).toBe(3); + }); + return; + } + + /** + * PREDICTION, written before the run: on the unfixed tree the sweep selects + * all THREE rows (the query carried no scope), launches three runs, and + * three inbox rows land under org A — two of them naming org B's records. + * With the fix it selects one, launches one, and one inbox row lands. + */ + it('DIFFERENTIAL: exactly ONE run is launched — the declared organization\'s row', async () => { + const before = new Set((await rows(INBOX_OBJECT)).map((r) => String(r.id))); + await job.fire(SWEEP_JOB, 'tick-16659-f2'); + + 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 sweep launched ${fresh.length} run(s); 3 is the unfixed reading (org B's two rows swept in), 0 means the sweep delivered nothing and the pin below would be vacuous`, + ).toBe(1); + }); + + it('the run acts on the DECLARED organization\'s record, and on no other', async () => { + const all = await rows(TARGET_OBJECT); + const dump = JSON.stringify(all.map((r) => ({ id: r.id, name: r.name, touched: r.touched, org: r.organization_id }))); + const touched = all.filter((r) => Boolean(r.touched)).map((r) => String(r.id)); + expect( + touched, + `the sweep's \`update_record\` must land on org A's row; rows: ${dump}`, + ).toEqual([rowA]); + for (const id of rowsB) { + expect( + touched, + 'a row in an organization this flow never declared was acted on — the cross-organization scheduled task the ruling forbids', + ).not.toContain(id); + } + }); + + it('no notification describes a record from an organization the flow never declared', async () => { + // ⭐ The DISCLOSURE half, and the one the write-side pin above cannot + // reach. An unscoped sweep launches a run per matched row whatever the + // run is then scoped to: the `update_record` on org B's row matches + // nothing — silently — but the `notify` node has already emitted, so B's + // record is named in a notification stamped with A's organization. A + // fix that only narrowed the WRITES would leave this leak open. + // + // Read off `sys_notification` rather than `sys_inbox_message`: the + // `sourceObject`/`sourceId` click-through pair writes + // `source_object`/`source_id` THERE (io-node-config.zod.ts), and the + // inbox row carries the rendered `action_url` instead. + const notifications = await rows(NOTIFICATION_EVENT_OBJECT); + const dump = JSON.stringify(notifications.map((r) => ({ org: r.organization_id, src: r.source_id, title: r.title }))); + const sourceIds = notifications + .map((r) => String((r as Record).source_id ?? '')) + .filter((v) => v !== ''); + for (const id of rowsB) { + expect( + sourceIds, + `a notification under the declared organization names another organization's record — a cross-tenant disclosure; notifications: ${dump}`, + ).not.toContain(id); + } + expect( + sourceIds, + `control: the declared organization's own record IS named, so the assertion above is not passing on an empty set; notifications: ${dump}`, + ).toContain(rowA); + expect( + notifications.map((r) => String(r.organization_id ?? 'NULL')), + 'and every one of them is stamped with the declared organization', + ).toEqual([orgA]); + }); + }); +} diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 6f1c21415b..ef6b2f3aba 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -151,10 +151,13 @@ 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 + // [#16659] `schedule-acting-organization.dogfood.test.ts` and + // `schedule-sweep-organization-scope.dogfood.test.ts` drive + // `ScheduleTrigger` / `TimeRelativeTrigger` themselves: the pins' + // whole subject is which + // organization the trigger puts on the run it launches, which + // organization its SWEEP QUERY is scoped to, 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 From e8ccb7c6f909a092040735d16036a05a14d0d8fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:02:09 +0000 Subject: [PATCH 19/24] feat(lint): authoring learns the acting-organization requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validate-flow-trigger-readiness.ts` contained zero occurrences of `organization`, against the invariant `engine.ts`'s own trigger-kind resolver states: it is shared with `defineStack`'s trigger-capability refusal and with this rule file, "so the runtime cannot drift from what authoring accepted". Both time triggers refuse to bind a flow declaring no `config.organization`, so `defineStack`, `os lint` and `verify_build` were all passing a flow the trigger then refused — an author's first signal was a production stderr line at boot. ⛔ No judgement is re-implemented: `resolveFlowTriggerKind` says which flows owe the key, `resolveScheduleOrganization` says whether one was declared (so a present-but-unusable value is judged identically here and at bind), and `describeMissingScheduleOrganization` writes the sentence. Severity is `warning`, and that is MEASURED rather than argued. On the family's own criterion (#5762 — is this stack enough to know the flow is dead?) it belongs at `error`. Flipping it to `error` and building the shipped example app was run: `objectstack build` on examples/app-showcase FAILS, naming `showcase_task_due_reminder` and `showcase_scheduled_digest` — and neither can be repaired by authoring, because the only legal value is a `sys_organization.id` minted per install at runtime and a placeholder id is strictly worse than the omission. Promoting the id is therefore a consequence of the open maintainer decision about package-shipped time-triggered flows, not a lint choice; the probe was restored byte-identically and the two example builds pass with the rule at `warning`, flagging exactly the four shipped flows. Fixture triage in the rule's own suite: every time-triggered fixture that is ABOUT another rule now declares an organization, so it keeps isolating its own subject; the fixtures that are about the missing key omit it deliberately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- packages/lint/src/authoring-rules.ts | 8 + packages/lint/src/index.ts | 1 + .../validate-flow-trigger-readiness.test.ts | 182 +++++++++++++++++- .../src/validate-flow-trigger-readiness.ts | 115 ++++++++++- 4 files changed, 297 insertions(+), 9 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9757f22c1c..719786c631 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -914,6 +914,14 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // (the object may come from another installed package — a hedge this rule // cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire; // that one is ambiguity of intent, not a dead flow). + // + // #16659 added a sixth id, `flow-schedule-organization-missing`, at + // `warning`: a time-triggered flow declaring no `config.organization` is + // refused at bind, so on the criterion above it belongs with the four — and + // it is held at `warning` because an `error` gates `objectstack build`, and + // the repo's own shipped example apps carry such flows with no authorable + // repair (the only legal value is a `sys_organization.id` minted per install + // at runtime). Its own docblock in the rule file records that. { name: 'validateFlowTriggerReadiness', tier: 'gating', diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 13589e4329..4a86872a97 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -120,6 +120,7 @@ export { FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, + FLOW_SCHEDULE_ORGANIZATION_MISSING, } from './validate-flow-trigger-readiness.js'; export type { FlowTriggerReadinessFinding, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 2e21b8f1e2..c5ca1ea145 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -10,6 +10,7 @@ import { FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, + FLOW_SCHEDULE_ORGANIZATION_MISSING, } from './validate-flow-trigger-readiness.js'; function recordFlow(overrides: Record = {}) { @@ -35,6 +36,20 @@ function recordFlow(overrides: Record = {}) { const candidateObject = { name: 'app_candidate', label: 'Candidate', fields: {} }; +/** + * [#16659] Every time-triggered fixture in this file that is ABOUT some other + * rule now declares an acting organization. + * + * `flow-schedule-organization-missing` fires on any `schedule` / + * `time_relative` flow without one, so a fixture that omits it would carry a + * second finding and stop isolating the rule it exists to pin — the tests below + * assert exact finding LISTS, which is what makes them worth having. ⛔ This is + * not a relaxation: the fixtures that are about the missing key omit it + * deliberately, in the `acting organization (#16659)` block and in the severity + * table. + */ +const FIXTURE_ORG = 'org_2mtx1w9d0k4bqf7v'; + describe('validateFlowTriggerReadiness', () => { it('passes a correctly wired, explicitly active record flow', () => { const findings = validateFlowTriggerReadiness({ @@ -118,7 +133,11 @@ describe('validateFlowTriggerReadiness', () => { name: 'digest', type: 'schedule', nodes: [ - { id: 'start', type: 'start', config: { schedule: { type: 'interval', intervalMs: 60000 } } }, + { + id: 'start', + type: 'start', + config: { schedule: { type: 'interval', intervalMs: 60000 }, organization: FIXTURE_ORG }, + }, { id: 'end', type: 'end' }, ], edges: [{ id: 'e1', source: 'start', target: 'end' }], @@ -141,6 +160,7 @@ describe('validateFlowTriggerReadiness', () => { type: 'start', config: { timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] }, + organization: FIXTURE_ORG, }, }, { id: 'end', type: 'end' }, @@ -166,6 +186,7 @@ describe('validateFlowTriggerReadiness', () => { type: 'start', config: { timeRelative: { object: 'contract', dateField: 'end_date', withinDays: 60 }, + organization: FIXTURE_ORG, }, }, { id: 'end', type: 'end' }, @@ -207,7 +228,7 @@ describe('validateFlowTriggerReadiness', () => { status: 'active', runAs: 'system', nodes: [ - { id: 'start', type: 'start', config: { timeRelative } }, + { id: 'start', type: 'start', config: { timeRelative, organization: FIXTURE_ORG } }, { id: 'end', type: 'end' }, ], edges: [{ id: 'e1', source: 'start', target: 'end' }], @@ -991,11 +1012,17 @@ describe('validateFlowTriggerReadiness', () => { // ── #5762 — the family's severity map ──────────────────────────────────── // // The rules in this file were reviewed as ONE family and split on a single - // question: is this stack enough to know the flow is dead? Three rules answer - // yes and gate; two hedge and advise. The split is the contract, so it is - // pinned as a map rather than as five scattered `severity` lines — a later - // rule added to this file has to decide which side it is on, and a later edit - // that quietly demotes one of the three has to come past this test. + // question: is this stack enough to know the flow is dead? Four rules answer + // yes and gate; three hedge and advise. The split is the contract, so it is + // pinned as a map rather than as scattered `severity` lines — a later rule + // added to this file has to decide which side it is on, and a later edit that + // quietly demotes one of the gating ones has to come past this test. + // + // [#16659] `flow-schedule-organization-missing` is the one entry whose side is + // NOT decided by that question: it answers YES and still advises, because an + // `error` gates `objectstack build` and the repo's own shipped example apps + // carry time-triggered flows with no authorable repair. Its docblock records + // that, and this table is where a later promotion has to come past. // // Every entry is provoked through a real stack, so an id whose criterion stops // firing fails here instead of passing vacuously (the empty-verdict trap: an @@ -1113,6 +1140,25 @@ describe('validateFlowTriggerReadiness', () => { flows: [recordFlow()], }, ], + [ + // [#16659] `warning`, and its own docblock records why the corpus, not + // the verdict, is what holds it there. + FLOW_SCHEDULE_ORGANIZATION_MISSING, + 'warning', + { + objects: [{ name: 'task', label: 'Task', fields: {} }], + flows: [ + { + name: 'orgless_digest', + type: 'schedule', + status: 'active', + nodes: [ + { id: 'start', type: 'start', config: { schedule: { type: 'cron', expression: '0 1 * * *' } } }, + ], + }, + ], + }, + ], ]; for (const [rule, severity, stack] of provoke) { @@ -1159,7 +1205,15 @@ describe('validateFlowTriggerReadiness', () => { { id: 'start', type: 'start', - config: { timeRelative: { object: 'task', dateField: 'due_at', withinDays: 30 } }, + // [#16659] The clean fixture gained `organization`: after this + // card a CORRECT time-triggered flow declares the organization + // it runs as, so a fixture without one is no longer clean — + // the trigger would refuse to bind it. ⛔ Not a relaxation of + // the floor this case guards; the floor moved. + config: { + timeRelative: { object: 'task', dateField: 'due_at', withinDays: 30 }, + organization: 'org_2mtx1w9d0k4bqf7v', + }, }, ], }, @@ -1169,6 +1223,118 @@ describe('validateFlowTriggerReadiness', () => { }); }); + // ─── the acting organization (#16659) ─────────────────────────────────── + // + // `engine.ts`'s trigger-kind resolver states the invariant: it is shared with + // `defineStack`'s trigger-capability refusal and with this file, "so the + // runtime cannot drift from what authoring accepted". A key the two triggers + // REFUSE to bind without, and that authoring never mentions, is that drift — + // an author's first signal was a production stderr line at boot. + describe('acting organization (#16659)', () => { + const taskObject = { name: 'task', label: 'Task', fields: {} }; + + function timeTriggered(config: Record, overrides: Record = {}) { + return { + objects: [taskObject], + flows: [ + { + name: 'digest', + type: 'schedule', + status: 'active', + nodes: [{ id: 'start', type: 'start', config }], + ...overrides, + }, + ], + }; + } + + const orgFindings = (stack: Record) => + validateFlowTriggerReadiness(stack).filter((f) => f.rule === FLOW_SCHEDULE_ORGANIZATION_MISSING); + + it('fires on a `schedule` flow that declares none', () => { + const findings = orgFindings(timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('flows[0].nodes[0].config.organization'); + expect(findings[0].message, 'the sentence is the trigger\'s own, so the two cannot drift').toContain( + 'declares no acting organization', + ); + expect(findings[0].message, 'the refusal names the flow — the ruling requires that').toContain('digest'); + }); + + it('fires on a `time_relative` sweep that declares none, and says WHICH kind', () => { + const findings = orgFindings( + timeTriggered({ timeRelative: { object: 'task', dateField: 'due_at', withinDays: 7 } }), + ); + expect(findings).toHaveLength(1); + expect( + findings[0].message, + 'a sweep must be named as one — the two kinds take the same refusal for different reasons', + ).toContain('time-relative flow'); + }); + + it('names the near-miss spelling the open `config` record accepted and ignored', () => { + const findings = orgFindings( + timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' }, organizationId: 'org_x' }), + ); + expect(findings).toHaveLength(1); + expect( + findings[0].message, + 'an author who wrote `organizationId` is told about THEIR spelling, not about "nothing"', + ).toContain('organizationId'); + }); + + it('judges a present-but-unusable value exactly as the trigger does', () => { + // ⛔ Not a separate opinion: both read `resolveScheduleOrganization`, so a + // flow admitted by one and refused by the other is the silent hole again. + for (const bad of ['', 123, { id: 'org_x' }, null]) { + expect( + orgFindings(timeTriggered({ schedule: '0 1 * * *', organization: bad })), + `organization: ${JSON.stringify(bad)} must be judged undeclared`, + ).toHaveLength(1); + } + }); + + it('is silent once the flow declares one', () => { + expect( + orgFindings(timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' }, organization: 'org_a' })), + ).toEqual([]); + }); + + it('never fires on a record_change, api, or manual flow', () => { + // Those bindings carry no organization BY CONSTRUCTION — they are fired by + // a caller who already holds one, and lifting a declared one onto them + // would let a flow overrule the tenant of the write that triggered it. A + // rule that asked them for the key would be asking for a defect. + expect(orgFindings({ objects: [candidateObject], flows: [recordFlow({ status: 'active' })] })).toEqual([]); + expect( + orgFindings({ + objects: [taskObject], + flows: [ + { + name: 'by_api', + type: 'api', + status: 'active', + nodes: [{ id: 'start', type: 'start', config: {} }], + }, + ], + }), + ).toEqual([]); + expect( + orgFindings({ + objects: [taskObject], + flows: [ + { + name: 'by_hand', + type: 'autolaunched', + status: 'active', + nodes: [{ id: 'start', type: 'start', config: {} }], + }, + ], + }), + ).toEqual([]); + }); + }); + it('handles map-keyed flows/objects and stacks with no flows', () => { expect(validateFlowTriggerReadiness({})).toEqual([]); const findings = validateFlowTriggerReadiness({ diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 9a03303a74..1d56180665 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -51,6 +51,18 @@ // silence — every named runtime channel skips it because they all key off // the same resolution that already gave up. // +// 6. A `schedule` or `time_relative` flow that declares no acting +// organization (`config.organization`, #16659). Both triggers REFUSE to +// bind one — thrown, so the engine records the flow as not bound — and +// until this rule the refusal existed only at BOOT: `defineStack`, `os +// lint` and `verify_build` all passed a flow the trigger then refused, and +// an author's first signal was a production stderr line. That is exactly +// the authoring/runtime drift `engine.ts`'s trigger-kind resolver says +// must not exist, which is why the rule reads the SAME resolver and the +// SAME `resolveScheduleOrganization` helper the triggers refuse with. +// Severity is `warning` and the reason is the shipped corpus, not the +// strength of the verdict — see the id's own docblock. +// // The spec import is deliberate and is what makes rule 3 possible without a // second copy of the descriptor's shape living in this file. It stays inside the // package's stated dependency direction — lint → `@objectstack/spec`, never onto @@ -81,6 +93,15 @@ // authored-token → resolved-type map is a private chain of literal // `startsWith` / `typeof` tests with no registry lookup anywhere in it. No // package can teach the engine a new authored token. +// - `warning` — `flow-schedule-organization-missing` (#16659). On this +// paragraph's own criterion it belongs in the family above: the verdict is +// `resolveScheduleOrganization`'s and nothing installable changes it. It is +// held at `warning` by the CORPUS — an `error` gates `objectstack build`, +// and the repo's own shipped example apps carry time-triggered flows that +// cannot be repaired by authoring, because the only legal value is minted +// per install at runtime. Promoting it is a consequence of the open +// maintainer decision about package-shipped time-triggered flows, not a +// lint choice. // - `warning` — `flow-trigger-unknown-object`, both halves. An object name // this stack does not define may be defined by another installed package, // and this rule cannot see that package's objects. The hedge is real, so the @@ -103,7 +124,14 @@ // flows keep being served. What IS refused is the dead flow's own publish — and, // on the CLI surface, a package build whose stack contains one. -import { TimeRelativeTriggerSchema, resolveFlowTriggerKind } from '@objectstack/spec/automation'; +import { + TimeRelativeTriggerSchema, + resolveFlowTriggerKind, + SCHEDULE_ORGANIZATION_KEY, + resolveScheduleOrganization, + findScheduleOrganizationNearMissInConfig, + describeMissingScheduleOrganization, +} from '@objectstack/spec/automation'; import { recordsOf } from './object-graph.js'; export type FlowTriggerReadinessSeverity = 'error' | 'warning'; @@ -173,6 +201,42 @@ export const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = 'flow-time-relative-desc * call sites that each skip it. */ export const FLOW_TRIGGER_UNROUTABLE = 'flow-trigger-unroutable'; +/** + * #16659 — a `schedule` or `time_relative` flow that declares no acting + * organization (`config.organization`). The trigger REFUSES to bind it, so the + * flow never fires; before this rule the author's first signal was a production + * stderr line at boot. + * + * It exists because `engine.ts`'s trigger-kind resolver states the invariant + * this rule keeps: the resolver is shared with `defineStack`'s + * trigger-capability refusal and this file, *"so the runtime cannot drift from + * what authoring accepted"*. A key required at bind and unknown to authoring is + * exactly that drift. + * + * ## Why `warning` and not `error`, when the never-fire family gates + * + * On the family's own criterion (#5762 — *is THIS STACK enough to know the flow + * is dead?*) this id belongs at `error`: the verdict is + * `resolveScheduleOrganization`'s, the same helper the two triggers refuse + * with, and no installed package changes it. + * + * What holds it at `warning` is the CORPUS, and it was measured rather than + * assumed. An `error` here is gating on the CLI surface too, so it refuses + * `objectstack build` — and the repo's own shipped example apps contain + * time-triggered flows that CANNOT be repaired by authoring: the only legal + * value is a `sys_organization.id`, minted per install at runtime, so a + * package-shipped flow has nothing to write there and ⛔ inventing a + * placeholder is worse than the omission (a value matching no row is silently + * authoritative). What a package-shipped time-triggered flow should do instead + * is an open maintainer decision, and promoting this id is that decision's + * consequence, not a lint choice: ⛔ do not raise it until the shipped corpus + * has an answer. + * + * The `warning` still discharges the invariant the rule exists for — the author + * learns at authoring time instead of at boot — and it is the same hedge + * `flow-trigger-unknown-object` carries, stated in the hint. + */ +export const FLOW_SCHEDULE_ORGANIZATION_MISSING = 'flow-schedule-organization-missing'; type AnyRec = Record; @@ -629,6 +693,55 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines }); } + // 1g. #16659 — a time-triggered flow that declares no acting organization. + // + // `ScheduleTrigger` and `TimeRelativeTrigger` refuse to bind one: the + // refusal is THROWN from `start()`, so the engine's catch records the + // flow as not bound, `getFlowRuntimeStates()` reports `bound: false` + // and `getTriggerBindingAudit()` lists it. That is a good runtime + // channel — and it is a BOOT-time one. Authoring said nothing at all: + // `defineStack`, `os lint` and `verify_build` all passed a flow the + // trigger then refused, which is precisely the drift `engine.ts`'s + // trigger-kind resolver says must not exist. + // + // ⛔ The judgement is NOT re-implemented here. `resolveFlowTriggerKind` + // answers WHICH flows owe the key (the engine's own precedence, the + // same resolver `isAutoTriggered` above already uses), + // `resolveScheduleOrganization` answers whether one was declared (the + // same helper both triggers refuse with, so a present-but-unusable + // value — `''`, a number — is judged identically here and there), and + // `describeMissingScheduleOrganization` writes the sentence, so this + // rule and the bind-time refusal cannot say different things about the + // same flow. + // + // `record_change` and `api` flows are outside it by construction: they + // are fired by a caller who already carries an organization, and the + // engine leaves `organization` undefined on their bindings. + const triggerKind = resolveFlowTriggerKind(flow); + if ( + start && + (triggerKind === 'schedule' || triggerKind === 'time_relative') && + resolveScheduleOrganization(flow) === undefined + ) { + const nearMiss = findScheduleOrganizationNearMissInConfig(config); + findings.push({ + // `warning`, and the reason is the shipped corpus rather than the + // strength of the verdict — see FLOW_SCHEDULE_ORGANIZATION_MISSING's + // own docblock, which is where that decision is recorded. + severity: 'warning', + rule: FLOW_SCHEDULE_ORGANIZATION_MISSING, + where: `flow "${flowName}" › start node`, + path: `flows[${flowIndex}].nodes[${start.index}].config.${SCHEDULE_ORGANIZATION_KEY}`, + message: describeMissingScheduleOrganization(flowName, { kind: triggerKind, nearMiss }), + hint: + `Add config.${SCHEDULE_ORGANIZATION_KEY}: '' to the start node. The id is minted ` + + `by the running install, so a flow shipped INSIDE a package cannot carry one — register such a flow ` + + `at runtime with an organization that install actually holds, and ⛔ never write a placeholder id: a ` + + `value matching no row is silently authoritative to every report, export and cleanup that filters by ` + + `organization, which is strictly worse than the refusal.`, + }); + } + // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) { From 6741bab5b89d060d5be7b0eb944da1992dbdfeba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:04:57 +0000 Subject: [PATCH 20/24] docs(changeset,examples): say what actually happens, and name the four flows that stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset asserted "Nothing that was already delivering stops delivering" and, three paragraphs later, that on a single-organization install such a flow "was working". Both cannot be true; the second one is, and `system-write-organization.ts`'s `single`-posture derivation is why. The banner now states the narrowing in two places instead of one — the bind-time accept set AND the run-time data plane, which no sentence in it previously mentioned — and carries the migration consequence an operator reads release notes for: a deployment running ONE time-triggered flow across ALL organizations must now declare one per organization. Also made true rather than absolute: "there is no fallback limb anywhere on this path" is now "nothing on this path ever CHOOSES an organization", with the trigger's second read of the SAME declared value (engine-version skew) named instead of denied. F5 — the four shipped example flows that stop firing are documented where an author meets them: `showcase_scheduled_digest`, `showcase_task_due_reminder`, `task_reminder`, `overdue_escalation`. None can be repaired by authoring, and ⛔ a placeholder organization id must not be invented; what a package-shipped time-triggered flow should do instead is an open maintainer decision. Its card was destroyed with a suspended account and is being re-filed, so the dead reference to it is replaced by the record itself rather than by a number this seat does not have. F8 — the `suspended-run-store` comment claiming "a plain scheduled sweep has neither and keeps NULL" was made false by this very change and now says what happens, keeping the SUBJECT-first precedence it documents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../schedule-trigger-acting-organization.md | 53 ++++++++++++++----- .../src/automation/flows/index.ts | 30 +++++++++-- examples/app-todo/src/flows/task.flow.ts | 39 +++++++++++++- .../src/suspended-run-store.ts | 15 ++++-- 4 files changed, 115 insertions(+), 22 deletions(-) diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md index 145cead721..6bde192c07 100644 --- a/.changeset/schedule-trigger-acting-organization.md +++ b/.changeset/schedule-trigger-acting-organization.md @@ -2,18 +2,39 @@ "@objectstack/spec": minor "@objectstack/service-automation": minor "@objectstack/trigger-schedule": minor +"@objectstack/lint": 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. +fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + +**BREAKING** in the accept-set sense, and in TWO places rather than one — +landing in the launch window as `minor` on all four packages (the lockstep +convention: during the window the bump level is not the carrier, this banner and +the disposition above are). Nothing that was refused becomes admitted. + +1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. +2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + +⚠️ **Read (2) as a narrowing that can stop something that was working**, because +it is one. Two shapes to plan for, and neither is hypothetical: + +- **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. +- **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. That install loses nothing at + run time (its one organization is the only scope there was), but the flow does + stop until it is declared. 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. @@ -22,13 +43,21 @@ Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在 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/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. - **`@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. +- **`@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`, **and onto the `time_relative` sweep's own query**. 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. +⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) 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. + +**What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + +- **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. +- **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + +**The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision whose tracking card is being re-filed; this changeset and those comments are the record until it has a number. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index ee32209e9e..2b4708eeed 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -366,10 +366,17 @@ export const TaskCompletedSlackFlow = defineFlow({ * 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. + * ⇒ What a package-shipped time-triggered flow should do INSTEAD is an open + * maintainer decision. Its tracking card was destroyed along with a suspended + * account and is being re-filed; until that card carries a number, this + * paragraph is the record. 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. + * + * `os lint` / `os validate` / `objectstack build` say so too, as a `warning` + * (`flow-schedule-organization-missing`) — deliberately not an `error`, because + * an `error` would refuse this package's own build for a defect it has no + * authorable way to repair. * * Install `requires: ['automation', 'triggers', 'job', 'messaging']` for the * binding machinery this example demonstrates. @@ -1671,6 +1678,21 @@ export const CommitteeQuorumFlow = defineFlow({ * 3 and 1 days before its `due_date`, with the task on the flow context. Swap * `offsetDays` for `withinDays: 7` to nudge everything due within a week * (negative = overdue lookback). + * + * ⛔ AS SHIPPED, THIS SWEEP DOES NOT FIRE — same reason as + * {@link ScheduledDigestFlow}, and it is worth stating separately because a + * sweep is the case where the consequence is largest. Since #16659 a + * `time_relative` flow must declare `config.organization`, and a flow that + * declares none is REFUSED at bind. The declaration is not only the run's + * identity: it is the SWEEP QUERY's scope, so a sweep without one would select + * rows across every organization on the install. That is why there is no + * "fall back to something" path for it to take instead, and why a placeholder + * id ⛔ must not be invented here — a value matching no row is silently + * authoritative. + * + * ⇒ Package-shipped time-triggered flows are the open decision described on + * {@link ScheduledDigestFlow}. Register this sweep at runtime with an + * `organization` your install holds to see it work. */ export const TaskDueReminderFlow = defineFlow({ name: 'showcase_task_due_reminder', diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index 7f233f399e..23933b6fa2 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -1,8 +1,38 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// ⛔ NEITHER FLOW IN THIS FILE FIRES AS SHIPPED (#16659). +// +// Both are `type: 'schedule'`. 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 appears in `getTriggerBindingAudit()` and the CLI's startup summary. +// +// A package-shipped flow has no legal value to write there: organization ids +// are minted at runtime, per install. ⛔ A placeholder id must NOT be invented +// — a value matching no row is silently authoritative to every report, export +// and cleanup that filters by organization, which is strictly worse than the +// refusal. +// +// ⇒ What a package-shipped time-triggered flow should do instead is an open +// maintainer decision. Its tracking card was destroyed along with a suspended +// account and is being re-filed; until that card carries a number, this note is +// the record. Until then these two are worked examples of the SHAPE: to run +// either end to end, register it at runtime with an `organization` the install +// actually holds. +// +// `os lint` / `os validate` / `objectstack build` report it as a `warning` +// (`flow-schedule-organization-missing`) — deliberately not an `error`, which +// would refuse this package's own build for a defect it cannot repair. import type { Flow } from '@objectstack/spec/automation'; -/** Task Reminder Flow — scheduled flow to send reminders for upcoming tasks */ +/** + * Task Reminder Flow — scheduled flow to send reminders for upcoming tasks. + * + * ⛔ Does not fire as shipped: it declares no `config.organization`. See the + * file header. + */ export const TaskReminderFlow: Flow = { name: 'task_reminder', label: 'Task Reminder Notification', @@ -54,7 +84,12 @@ export const TaskReminderFlow: Flow = { ], }; -/** Overdue Task Escalation Flow */ +/** + * Overdue Task Escalation Flow. + * + * ⛔ Does not fire as shipped: it declares no `config.organization`. See the + * file header. + */ export const OverdueEscalationFlow: Flow = { name: 'overdue_escalation', label: 'Overdue Task Escalation', diff --git a/packages/services/service-automation/src/suspended-run-store.ts b/packages/services/service-automation/src/suspended-run-store.ts index 1796a17c1a..8175538d79 100644 --- a/packages/services/service-automation/src/suspended-run-store.ts +++ b/packages/services/service-automation/src/suspended-run-store.ts @@ -641,10 +641,17 @@ export class ObjectStoreSuspendedRunStore implements SuspendedRunStore { // `sys_api_key`'s divergent `active_organization_id` included), falling // back to the acting context's tenant (`record.organizationId`) when the // trigger carries no record or the object has no organization of its - // own. A plain scheduled sweep has neither and keeps NULL — fabricating - // an acting organization stays vetoed (Option C). Same inputs and same - // precedence as `serialize()` below, so a run's paused row and its - // terminal row agree by construction. + // own. Fabricating an acting organization stays vetoed (Option C). Same + // inputs and same precedence as `serialize()` below, so a run's paused + // row and its terminal row agree by construction. + // + // [#16659] This used to end "a plain scheduled sweep has neither and + // keeps NULL", and that stopped being true when a time-triggered flow + // began declaring the organization it runs as: such a sweep now arrives + // with `record.organizationId` set, so the second limb answers and the + // row is stamped. What has NOT changed is the precedence — the SUBJECT's + // organization still wins over the acting one where both exist, which is + // deliberate (a history row belongs with the record it is about). organization_id: this.recordOrgResolver.organizationOf(record.triggerObject ?? '', record.triggerRecord) ?? record.organizationId ?? From 5ccaf78770b37b74c4e7da9bae8193cc9bfaa874 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:37:12 +0000 Subject: [PATCH 21/24] docs(automation): the declaration bounds the sweep's query, not only its run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flows guide said the run "executes as that organization" and stopped there, which is now only half of what a `timeRelative` declaration does. It also bounds the SELECTION: a sweep runs elevated on purpose, so nothing else keeps it inside one organization, and elevation and tenancy are independent axes. Both cases where a declaration cannot deliver containment are stated rather than left to be discovered — a store with no isolation refuses the scoped sweep, and a platform-global or federated object gets no scope from the engine at all — and so is the new authoring-time warning. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- content/docs/automation/flows.mdx | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index f02575bf84..d70268fa54 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2005,8 +2005,33 @@ config: { ``` 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. +notifications a `notify` node emits land in that organization's inboxes, the +`sys_automation_run` history row is stamped with it, and every record the run +reads, updates or deletes is confined to it. + +**For a `timeRelative` sweep the declaration also bounds the QUERY.** A sweep +runs elevated on purpose — a background sweep must see every row rather than the +RLS-scoped subset an absent user would see — so nothing else keeps its selection +inside one organization. The declared id rides the sweep's own query, and the +driver scopes the read: a sweep declared for one organization matches that +organization's rows and launches a run per match. Elevation and tenancy are +independent axes, and a sweep carrying only the first is a cross-organization +scheduled task however its runs are stamped. + + +Two cases where a declaration cannot deliver containment, and both say so rather +than going quiet. A store with no row-level tenant isolation +(`@objectstack/driver-memory`) **refuses** a scoped sweep instead of answering it +unscoped, and the failure is logged at `error` naming the flow — multi-organization +deployments use `@objectstack/driver-sql`. And on a platform-global object +(`tenancy: { enabled: false }`) or a federated one, the engine applies no tenant +scope at all, so the sweep still selects across every organization while its runs +act as the declared one; the trigger warns at bind, naming the object. + + +`os lint`, `os validate` and `objectstack build` report a missing declaration as +a warning (`flow-schedule-organization-missing`) so it is visible at authoring +time rather than only in a server log at boot. **A time-triggered flow that declares none is a declaration error**, refused at bind: From 5fb332bdcf4bc163b03f58093a5bf06d7661d7ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:59:57 +0000 Subject: [PATCH 22/24] =?UTF-8?q?test(trigger-schedule):=20the=20NEGATIVE?= =?UTF-8?q?=20CONTROL=20=E2=80=94=20a=20single-organization=20install=20lo?= =?UTF-8?q?ses=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim "a single-organization install behaves byte-identically" was an argument, not a measurement. It is now a measurement: two legs over one fixture, the sweep as it ships (scoped) and the same query with `isSystem` only (the pre-fix sweep, byte for byte), asserted EQUAL. The fixture carries a NULL-organization row on purpose — the driver's scope is `org = :tenant OR org IS NULL`, so a platform row stays visible to a scoped read, and that is part of "identical" rather than an exception to it. Non-vacuous in both directions: the selected set is asserted to be the three rows (an empty answer would satisfy the equality with everything broken), and leg A is asserted to have really asked for a scope — which is what makes this pin red under the same ablation as the differential, instead of a pin that can never fail. Its opposite limb is the DIFFERENTIAL above it: add a second organization and these two legs must diverge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../src/time-relative-trigger.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 526056d4f7..16b513da4e 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -916,6 +916,56 @@ describe('TimeRelativeTrigger — the acting-organization refusal (#16659)', () expect(calls[0].context?.tenantId, 'the scope must reach the engine, not be applied afterwards').toBe(TEST_ORG); }); + it('NEGATIVE CONTROL: a single-organization install selects exactly what it selected before', async () => { + // The narrowing must remove NOTHING where there is nothing to remove. + // Two legs over ONE fixture: + // A — the sweep as it ships, scoped to the declared organization; + // B — the SAME query with `context: { isSystem: true }` and no scope, + // which is the pre-fix sweep byte for byte. + // Equal answers is the claim; ⛔ the pair is what makes it a measurement + // rather than an argument, and the DIFFERENTIAL above is its opposite + // limb — add a second organization and these two legs must diverge. + // + // The NULL-organization row is in the fixture on purpose: the driver's + // scope is `org = :tenant OR org IS NULL`, so a platform row stays + // visible to a scoped read. That is part of "identical", not an + // exception to it. + const DESC = { object: 'contracts', dateField: 'end_date', withinDays: 60 }; + const rows: Row[] = [ + { id: 'c1', end_date: '2026-07-25T00:00:00.000Z', organization_id: TEST_ORG }, + { id: 'c2', end_date: '2026-08-01T00:00:00.000Z', organization_id: TEST_ORG }, + { id: 'c3', end_date: '2026-07-20T00:00:00.000Z', organization_id: null }, + ]; + const job = fakeJobService(); + const { engine, calls } = tenantScopedDataEngine(rows); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(binding(DESC), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + const scoped = seen.map((c) => (c.record as Row).id); + + // Leg B, through the same double: the query the sweep used to send. + const window = computeDateWindows(DESC, NOW())[0]; + const before = + (await engine.find('contracts', { + where: buildWindowWhere(DESC, window), + limit: 1000, + context: { isSystem: true }, + })) ?? []; + const unscoped = before.map((r) => r.id); + + expect( + scoped, + 'a single-organization install must see the same rows it saw before — the scope removes nothing there', + ).toEqual(unscoped); + // Non-vacuity, both directions: the set is not empty, and the scope was + // genuinely applied rather than quietly absent. + expect(scoped, 'an empty answer would make the equality above pass with everything broken').toEqual(['c1', 'c2', 'c3']); + expect(calls[0].context?.tenantId, 'leg A must really have asked for a scope').toBe(TEST_ORG); + }); + it('a store that CANNOT honour the scope is reported at `error`, never answered unscoped', async () => { // `driver-memory` refuses any call handed a tenant scope (#16589). A // sweep required to stay inside one organization, talking to a store From 323da7a30d0ea2db073227b25e374baa4e1aa974 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 13:51:08 +0000 Subject: [PATCH 23/24] fix(spec,lint,triggers): register the ADR-0087 semantic TODO, fold one export, and correct the banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerates the whole generated chain on the merged tree, discharging the os-regen deferral the merge commit recorded. M1 — ADR-0087 disposition corrected from `not-required (no-migration-prescription)` to `registered schedule-flow-acting-organization-required`. D3 covers exactly this case: a migration that cannot be expressed declaratively gets a structured TODO, not silence, and this changeset's body IS a prescription ("declare `config.organization` once per organization, no fan-out"). Direct precedent: `rest-requireauth-default-flip` (protocol 12) — behaviour-only, no shape moved, registered anyway. Filed under protocol 18, not 17: v17.0.0 was cut before this narrowing landed, so the enforcement rides the 17.x line by the launch-window convention while the prescription belongs at the major boundary where `migrate meta` users look (`registry.ts`, `step18`). M2 — the banner's single-organization sentence was false on `driver-memory`. `assertCallNotTenantScoped` refuses EVERY door handed a `tenantId` regardless of how many organizations the install holds, so a time-triggered flow that touches per-organization data on that driver is refused per call when it declares one and unarmed at boot when it does not. A1 — `findScheduleOrganizationNearMissInConfig` is no longer published. Both callers ran it only to hand the answer straight to `describeMissingScheduleOrganization` on the next line, so the scan moved inside that function (`{ kind, config }`) and the published surface is five names, not six. A `minor` freezes what it publishes. A2 — the name is KEPT, decided by measurement: `FlowSchema.type` is `z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])` with no `time_relative` member, and a time-relative sweep is authored as `type: 'schedule'`. Recorded in the module docblock so it is not re-litigated. A4/A5/A6 — three effects of the prescribed split, each verified against the tree and now named in the banner and in the migration entry: NULL-tenant rows fan out once per flow under `org = :tenant OR org IS NULL`; dispatch-claim keys embed the flow name so the current window's claims are abandoned; a run suspended before the upgrade rehydrates from `context_json` and resumes org-less. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- .../schedule-trigger-acting-organization.md | 55 ++++++++++++++-- .../automation/schedule-organization.mdx | 20 ++++++ content/docs/references/index.mdx | 9 +-- .../src/validate-flow-trigger-readiness.ts | 4 +- packages/spec/api-surface/automation.json | 1 - packages/spec/export-origins/automation.json | 1 - .../automation/schedule-organization.test.ts | 43 ++++++++---- .../automation/schedule-organization.zod.ts | 57 ++++++++++++---- ...edule-flow-acting-organization-required.ts | 66 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 62 +++++++++++++++++ .../trigger-schedule/src/schedule-trigger.ts | 6 +- 11 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md index 6bde192c07..b039d5700f 100644 --- a/.changeset/schedule-trigger-acting-organization.md +++ b/.changeset/schedule-trigger-acting-organization.md @@ -7,7 +7,7 @@ fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) - + **BREAKING** in the accept-set sense, and in TWO places rather than one — landing in the launch window as `minor` on all four packages (the lockstep @@ -30,11 +30,54 @@ it is one. Two shapes to plan for, and neither is hypothetical: work: there is no fan-out, and a sweep wanted in N organizations is N declarations. Nothing detects the shape for you — the flow simply starts seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. - **On a SINGLE-organization install a time-triggered flow WAS delivering** — the #8844 guard derives the only organization there — and after this change it - is unarmed at boot until someone adds one line. That install loses nothing at - run time (its one organization is the only scope there was), but the flow does - stop until it is declared. + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. 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. @@ -42,7 +85,7 @@ Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在 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/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. - **`@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`, **and onto the `time_relative` sweep's own query**. 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. @@ -60,4 +103,4 @@ No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. -**The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision whose tracking card is being re-filed; this changeset and those comments are the record until it has a number. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +**The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx index a46967c0c7..0932fd4caa 100644 --- a/content/docs/references/automation/schedule-organization.mdx +++ b/content/docs/references/automation/schedule-organization.mdx @@ -62,6 +62,26 @@ descriptor would make it invisible to the time-relative sweep, which carries its cadence in the same slot but binds through a different descriptor. One key, one layer, both time triggers. +## Why `ScheduleOrganization…` and not `FlowActingOrganization…` + +The key governs both trigger kinds, and `FlowTriggerKind` lists +`time_relative` and `schedule` as two of its four members — so the name looks +inaccurate for half its subjects. It is not, and the deciding reading is the +AUTHORABLE surface rather than the derived kind: `FlowSchema.type` is +`z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])` and +has no `time_relative` member at all. A time-relative sweep is authored as +`type: 'schedule'` with a `timeRelative` descriptor on its start node — the +docs say so in as many words ("a `schedule` flow whose `start` node declares +a `timeRelative` descriptor"), and `TimeRelativeTriggerSchema`'s own opening +line says the trigger "sweeps an object on a schedule". `FlowTriggerKind` +splits the two because the ENGINE routes them to different triggers; its +precedence note distinguishes a sweep from "a plain schedule flow", which is +a split inside the schedule family, not out of it. + +⇒ Every flow this key applies to declares `type: 'schedule'`. The name is +accurate for both subjects, and a `minor` freezes it, so this is recorded +rather than left to be re-litigated. + **Source:** `packages/spec/src/automation/schedule-organization.zod.ts` diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 8d8694f35d..c1ceafa3ec 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 — 1520 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1521 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 | 438 | 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. | | [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. | | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | @@ -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** | **191** | **1520** | 14 protocol modules | +| **Total** | **192** | **1521** | 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/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 1d56180665..7c34f359cd 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -129,7 +129,6 @@ import { resolveFlowTriggerKind, SCHEDULE_ORGANIZATION_KEY, resolveScheduleOrganization, - findScheduleOrganizationNearMissInConfig, describeMissingScheduleOrganization, } from '@objectstack/spec/automation'; import { recordsOf } from './object-graph.js'; @@ -723,7 +722,6 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines (triggerKind === 'schedule' || triggerKind === 'time_relative') && resolveScheduleOrganization(flow) === undefined ) { - const nearMiss = findScheduleOrganizationNearMissInConfig(config); findings.push({ // `warning`, and the reason is the shipped corpus rather than the // strength of the verdict — see FLOW_SCHEDULE_ORGANIZATION_MISSING's @@ -732,7 +730,7 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines rule: FLOW_SCHEDULE_ORGANIZATION_MISSING, where: `flow "${flowName}" › start node`, path: `flows[${flowIndex}].nodes[${start.index}].config.${SCHEDULE_ORGANIZATION_KEY}`, - message: describeMissingScheduleOrganization(flowName, { kind: triggerKind, nearMiss }), + message: describeMissingScheduleOrganization(flowName, { kind: triggerKind, config }), hint: `Add config.${SCHEDULE_ORGANIZATION_KEY}: '' to the start node. The id is minted ` + `by the running install, so a flow shipped INSIDE a package cannot carry one — register such a flow ` + diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 6a7a38007b..2af06dd3bb 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -265,7 +265,6 @@ "describeMissingScheduleOrganization (function)", "exportConstructsToBpmn (function)", "findRegionEntry (function)", - "findScheduleOrganizationNearMissInConfig (function)", "flowForm (const)", "getApprovalNodeConfigJsonSchema (function)", "getSchemalessNodeConfigJsonSchemas (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 7ce939d126..7d6a2788b8 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -259,7 +259,6 @@ "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)", diff --git a/packages/spec/src/automation/schedule-organization.test.ts b/packages/spec/src/automation/schedule-organization.test.ts index 90e196d45f..7e0891b84a 100644 --- a/packages/spec/src/automation/schedule-organization.test.ts +++ b/packages/spec/src/automation/schedule-organization.test.ts @@ -5,7 +5,6 @@ import { SCHEDULE_ORGANIZATION_KEY, ScheduleOrganizationSchema, describeMissingScheduleOrganization, - findScheduleOrganizationNearMissInConfig, resolveScheduleOrganization, } from './schedule-organization.zod'; @@ -91,32 +90,45 @@ describe('resolveScheduleOrganization', () => { }); }); -describe('findScheduleOrganizationNearMissInConfig', () => { +// The near-miss scan is NOT published — it folded into the sentence below, so +// every case that used to call it directly now goes through the one exported +// door. The vocabulary is still pinned member by member; what is no longer +// pinned is a NAME a consumer could import, which is the point of the fold. +describe('the near-miss scan, through the sentence that owns it', () => { 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(); + // ⛔ Not a flow. The engine hands a trigger the start node's `config`, + // never the flow, so a flow-shaped `config` must find nothing. + expect(describeMissingScheduleOrganization('f', { config: { organizationId: 'org_a' } })).toContain( + '`organizationId`', + ); + expect(describeMissingScheduleOrganization('f', { config: flow({ organizationId: 'org_a' }) })).not.toContain( + '`organizationId`', + ); }); it.each(['organizationId', 'organization_id', 'organizationID', 'orgId', 'org_id', 'org', 'tenantId', 'tenant_id', 'tenant'])( 'recognises `%s`', (key) => { - expect(findScheduleOrganizationNearMissInConfig({ [key]: 'org_a' })).toBe(key); + expect(describeMissingScheduleOrganization('f', { config: { [key]: 'org_a' } })).toContain(`\`${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(); + for (const value of ['', null]) { + const msg = describeMissingScheduleOrganization('f', { config: { organizationId: value } }); + expect(msg).not.toContain('`organizationId`'); + // Non-vacuity: the sentence itself was produced, so the absence above is + // "no near-miss clause", not "no message". + expect(msg).toContain('`organization`'); + } }); - it('answers undefined for anything that is not a record', () => { + it('answers with no near-miss clause 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(); + expect(() => describeMissingScheduleOrganization('f', { config: input })).not.toThrow(); + expect(describeMissingScheduleOrganization('f', { config: input })).not.toContain('which is not this key'); } }); }); @@ -131,9 +143,12 @@ describe('describeMissingScheduleOrganization', () => { }); it('names the near-miss spelling and never a value', () => { - const msg = describeMissingScheduleOrganization('nightly_sweep', { nearMiss: 'organizationId' }); + const msg = describeMissingScheduleOrganization('nightly_sweep', { config: { organizationId: 'org_a' } }); expect(msg).toContain('`organizationId`'); expect(msg).toContain('open'); + // The KEY, never what was written under it — a diagnostic that echoes the + // value puts an id into every log line that carries the refusal. + expect(msg).not.toContain('org_a'); }); it('says `time-relative` for the sweep and `scheduled` for the plain cadence', () => { @@ -145,7 +160,7 @@ describe('describeMissingScheduleOrganization', () => { 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' }); + const msg = describeMissingScheduleOrganization('nightly_sweep', { config: { orgId: 'org_a' } }); expect(msg).toContain(''); expect(msg).not.toMatch(/defaults? to/i); expect(msg).not.toMatch(/platform organization/i); diff --git a/packages/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index 87c07ce71a..be66393b39 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -59,6 +59,26 @@ import { z } from 'zod'; * descriptor would make it invisible to the time-relative sweep, which carries * its cadence in the same slot but binds through a different descriptor. One * key, one layer, both time triggers. + * + * ## Why `ScheduleOrganization…` and not `FlowActingOrganization…` + * + * The key governs both trigger kinds, and `FlowTriggerKind` lists + * `time_relative` and `schedule` as two of its four members — so the name looks + * inaccurate for half its subjects. It is not, and the deciding reading is the + * AUTHORABLE surface rather than the derived kind: `FlowSchema.type` is + * `z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])` and + * has no `time_relative` member at all. A time-relative sweep is authored as + * `type: 'schedule'` with a `timeRelative` descriptor on its start node — the + * docs say so in as many words ("a `schedule` flow whose `start` node declares + * a `timeRelative` descriptor"), and `TimeRelativeTriggerSchema`'s own opening + * line says the trigger "sweeps an object on a schedule". `FlowTriggerKind` + * splits the two because the ENGINE routes them to different triggers; its + * precedence note distinguishes a sweep from "a plain schedule flow", which is + * a split inside the schedule family, not out of it. + * + * ⇒ Every flow this key applies to declares `type: 'schedule'`. The name is + * accurate for both subjects, and a `minor` freezes it, so this is recorded + * rather than left to be re-litigated. */ /** The start-node `config` key naming a time-triggered flow's acting organization. */ @@ -100,11 +120,13 @@ export type ScheduleOrganization = z.input; * 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. + * Module-local on purpose, and so is the scan that reads it: every caller that + * needs the vocabulary needs the SENTENCE, and + * {@link describeMissingScheduleOrganization} is the one that writes it. Both + * callers of the scan did `find` then `describe` back to back, so publishing + * the finder froze an orphan diagnostic on the surface — a `minor` freezes what + * it publishes, and removing an export later is breaking where adding one is + * not. */ const SCHEDULE_ORGANIZATION_NEAR_MISSES: readonly string[] = Object.freeze([ 'organizationId', @@ -156,14 +178,19 @@ export function resolveScheduleOrganization(flow: unknown): string | undefined { * ⛔ 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. + * record. A flow-shaped input would answer `undefined` for the very shape the + * only caller has, which is the silent-acceptance this module exists to end. * * Anything that is not a record answers `undefined` rather than throwing, * matching {@link resolveScheduleOrganization}'s structural posture. + * + * ⛔ NOT exported. It was, briefly, and had two consumers that each called it + * only to hand the answer straight back to + * {@link describeMissingScheduleOrganization} on the next line. A published + * name is answerable forever after a `minor`, so the one that ships is the one + * a caller actually wants: the sentence. */ -export function findScheduleOrganizationNearMissInConfig( +function findScheduleOrganizationNearMissInConfig( startConfig: unknown, ): string | undefined { if (!startConfig || typeof startConfig !== 'object') return undefined; @@ -186,16 +213,22 @@ export function findScheduleOrganizationNearMissInConfig( * * 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 + * dropped. `options.config` is the START NODE's `config` record (what a trigger + * holds on its binding, and what the lint rule reads off the parsed start + * node); anything else, or nothing, simply yields no near-miss clause. 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 }, + options?: { readonly kind?: string; readonly config?: unknown }, ): string { const kind = options?.kind === 'time_relative' ? 'time-relative' : 'scheduled'; - const nearMiss = options?.nearMiss; + // The scan lives HERE rather than at the two call sites, which both ran it + // and passed the answer straight in. One published name, one place the + // near-miss vocabulary is consulted, and no way for a caller to describe a + // near-miss the scan would not have found. + const nearMiss = findScheduleOrganizationNearMissInConfig(options?.config); return ( `${kind} flow '${flowName}' declares no acting organization: its start node's \`config\` is ` + `missing the \`${SCHEDULE_ORGANIZATION_KEY}\` key` + diff --git a/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts b/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts new file mode 100644 index 0000000000..5ae53cd68f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'schedule-flow-acting-organization-required', + surface: + 'The START NODE `config.organization` key of every time-triggered flow — a `type: ' + + "'schedule'` flow carrying a `config.schedule` cadence, and the `timeRelative` sweep " + + 'that carries its cadence in the same slot (`FlowTriggerKind` `schedule` / ' + + '`time_relative`). Nothing is renamed, retired or re-typed: the start node\'s `config` ' + + 'is an OPEN record (ADR-0018), so the key is an ADDITION to a slot that already ' + + 'accepted it, and every flow that parses today parses byte-identically after the ' + + 'change. What narrows is the BIND-time accept set and the RUN-time data plane.', + replacement: + 'Declare the organization the flow runs as, on the start node beside the cadence: ' + + "`config: { schedule: { … }, organization: '' }`. There is " + + 'deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per ' + + 'organization — and deliberately no fallback: nothing on this path ever chooses an ' + + 'organization, because a wrong `organization_id` is silently authoritative to every ' + + 'report, export and cleanup that filters by organization, while a refusal is visible ' + + 'at boot and names its flow. ⚠️ Three consequences of the split that the declaration ' + + 'itself does not carry, and each is deployment work: (1) rows whose tenant column is ' + + 'NULL stay visible to a scoped read (`org = :tenant OR org IS NULL`), so after the ' + + 'split each such row is matched ONCE PER FLOW — N runs and N notifications for one ' + + 'row, each acting as a different organization; (2) the dispatch-claim key embeds the ' + + 'flow name (`schedule::`, ' + + '`time-relative:::`), so renaming one flow into N abandons ' + + "the current window's claims and a window already delivered under the old name can " + + 'deliver once more under the new ones; (3) a run SUSPENDED before the upgrade ' + + 'rehydrates its context from `context_json`, which carries no `tenantId`, so it ' + + 'resumes org-less — drain or accept in-flight suspended runs rather than assuming the ' + + 'upgrade confines them retroactively.', + reason: + 'Maintainer ruling, 2026-09-08, verbatim, untranslated: ' + + '「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered ' + + 'run is launched from a job tick and a job tick carries no identity, so the run reached ' + + 'the tenancy guard with nothing to offer it: the notification wrote ' + + '`organization_id = NULL`, every tenant-scoped row beneath it was refused, and the tick ' + + 'still summarised itself as healthy. ⛔ NOT losslessly convertible, and the reason is ' + + 'that the remedy is a value only the deployment holds: an organization id is minted per ' + + 'install at runtime, so there is no authored artifact and no stored representation a ' + + 'transform could rewrite — `objectstack migrate meta` cannot know which organization a ' + + 'given sweep belongs to, and inventing one is precisely what the ruling forbids. ' + + 'Registered under ADR-0087 D3 rather than left silent because the change DOES carry a ' + + 'prescription — "declare one flow per organization, no fan-out" is deployment work a ' + + 'human must do, which is what D3 says a structured TODO is for. The direct precedent is ' + + '`rest-requireauth-default-flip` (protocol 12): behaviour-only, no shape moved, a ' + + 'deployment judgement no transform can make, registered anyway.', + acceptanceCriteria: + 'Every `schedule` / `time_relative` flow in the stack declares a non-empty ' + + '`config.organization` on its start node. `os lint` reports ' + + '`flow-schedule-organization-missing` for none of them (severity `warning`, so it does ' + + 'NOT gate a build — an unfixed flow is silently unarmed, which is why the lint run is ' + + 'part of the criteria rather than the build), and boot logs no ' + + '`[schedule] NOT BOUND` / `[time-relative] NOT BOUND` line: ' + + '`getFlowRuntimeStates()` reports `bound: true` and `getTriggerBindingAudit()` lists ' + + 'no time-triggered flow. A deployment that ran ONE flow across all organizations has ' + + 'split it into one flow per organization and has re-checked the three consequences ' + + 'above — NULL-tenant rows, abandoned dispatch claims, suspended runs. ⚠️ ' + + '`@objectstack/driver-memory` has NO legal configuration for a time-triggered flow ' + + 'that touches per-organization data: it refuses any call handed a tenant scope ' + + '(`MEMORY_MULTI_TENANT_UNSUPPORTED`), so a declared flow is refused per call while an ' + + 'undeclared one is not armed at all. Multi-organization deployments use ' + + '`@objectstack/driver-sql`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index fe22322bf6..9cb707f8a9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9389,6 +9389,68 @@ const step18: MigrationStep = { + 'of the ten keys ever reached it. No code imports `CrudEndpointPattern(Schema)` from ' + '`@objectstack/spec/api` (TS2305 after upgrade).', }, + { + id: 'schedule-flow-acting-organization-required', + surface: + 'The START NODE `config.organization` key of every time-triggered flow — a `type: ' + + "'schedule'` flow carrying a `config.schedule` cadence, and the `timeRelative` sweep " + + 'that carries its cadence in the same slot (`FlowTriggerKind` `schedule` / ' + + '`time_relative`). Nothing is renamed, retired or re-typed: the start node\'s `config` ' + + 'is an OPEN record (ADR-0018), so the key is an ADDITION to a slot that already ' + + 'accepted it, and every flow that parses today parses byte-identically after the ' + + 'change. What narrows is the BIND-time accept set and the RUN-time data plane.', + replacement: + 'Declare the organization the flow runs as, on the start node beside the cadence: ' + + "`config: { schedule: { … }, organization: '' }`. There is " + + 'deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per ' + + 'organization — and deliberately no fallback: nothing on this path ever chooses an ' + + 'organization, because a wrong `organization_id` is silently authoritative to every ' + + 'report, export and cleanup that filters by organization, while a refusal is visible ' + + 'at boot and names its flow. ⚠️ Three consequences of the split that the declaration ' + + 'itself does not carry, and each is deployment work: (1) rows whose tenant column is ' + + 'NULL stay visible to a scoped read (`org = :tenant OR org IS NULL`), so after the ' + + 'split each such row is matched ONCE PER FLOW — N runs and N notifications for one ' + + 'row, each acting as a different organization; (2) the dispatch-claim key embeds the ' + + 'flow name (`schedule::`, ' + + '`time-relative:::`), so renaming one flow into N abandons ' + + "the current window's claims and a window already delivered under the old name can " + + 'deliver once more under the new ones; (3) a run SUSPENDED before the upgrade ' + + 'rehydrates its context from `context_json`, which carries no `tenantId`, so it ' + + 'resumes org-less — drain or accept in-flight suspended runs rather than assuming the ' + + 'upgrade confines them retroactively.', + reason: + 'Maintainer ruling, 2026-09-08, verbatim, untranslated: ' + + '「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered ' + + 'run is launched from a job tick and a job tick carries no identity, so the run reached ' + + 'the tenancy guard with nothing to offer it: the notification wrote ' + + '`organization_id = NULL`, every tenant-scoped row beneath it was refused, and the tick ' + + 'still summarised itself as healthy. ⛔ NOT losslessly convertible, and the reason is ' + + 'that the remedy is a value only the deployment holds: an organization id is minted per ' + + 'install at runtime, so there is no authored artifact and no stored representation a ' + + 'transform could rewrite — `objectstack migrate meta` cannot know which organization a ' + + 'given sweep belongs to, and inventing one is precisely what the ruling forbids. ' + + 'Registered under ADR-0087 D3 rather than left silent because the change DOES carry a ' + + 'prescription — "declare one flow per organization, no fan-out" is deployment work a ' + + 'human must do, which is what D3 says a structured TODO is for. The direct precedent is ' + + '`rest-requireauth-default-flip` (protocol 12): behaviour-only, no shape moved, a ' + + 'deployment judgement no transform can make, registered anyway.', + acceptanceCriteria: + 'Every `schedule` / `time_relative` flow in the stack declares a non-empty ' + + '`config.organization` on its start node. `os lint` reports ' + + '`flow-schedule-organization-missing` for none of them (severity `warning`, so it does ' + + 'NOT gate a build — an unfixed flow is silently unarmed, which is why the lint run is ' + + 'part of the criteria rather than the build), and boot logs no ' + + '`[schedule] NOT BOUND` / `[time-relative] NOT BOUND` line: ' + + '`getFlowRuntimeStates()` reports `bound: true` and `getTriggerBindingAudit()` lists ' + + 'no time-triggered flow. A deployment that ran ONE flow across all organizations has ' + + 'split it into one flow per organization and has re-checked the three consequences ' + + 'above — NULL-tenant rows, abandoned dispatch claims, suspended runs. ⚠️ ' + + '`@objectstack/driver-memory` has NO legal configuration for a time-triggered flow ' + + 'that touches per-organization data: it refuses any call handed a tenant scope ' + + '(`MEMORY_MULTI_TENANT_UNSUPPORTED`), so a declared flow is refused per call while an ' + + 'undeclared one is not armed at all. Multi-organization deployments use ' + + '`@objectstack/driver-sql`.', + }, { id: 'scim-provider-object-retired', surface: diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index cc127f2998..7d381a9cd7 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -6,7 +6,6 @@ import type { JobSchedule, JobHandler } from '@objectstack/spec/contracts'; import { SCHEDULE_ORGANIZATION_KEY, ScheduleOrganizationSchema, - findScheduleOrganizationNearMissInConfig, describeMissingScheduleOrganization, } from '@objectstack/spec/automation'; @@ -333,7 +332,10 @@ export function refuseMissingOrganization( ): never { const sentence = describeMissingScheduleOrganization(flowName, { kind: tag === 'time-relative' ? 'time_relative' : 'schedule', - nearMiss: findScheduleOrganizationNearMissInConfig(binding.config), + // The start node's `config` as the engine handed it over; the near-miss + // scan is `@objectstack/spec`'s and runs inside the sentence, so a + // trigger cannot report a spelling the scan would not have found. + config: binding.config, }); const report = logger.error?.bind(logger) ?? logger.warn.bind(logger); report(`[${tag}] NOT BOUND — ${sentence}`); From 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 13:52:43 +0000 Subject: [PATCH 24/24] fix(changeset): the `adr-0087: registered` marker takes bare ids, not prose The marker's remainder is parsed as a comma-separated id LIST, so the rationale that rode inside it read as 293 nonexistent migration ids and the gate refused. The reasoning moves into the body prose, where release notes read it anyway. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- .../schedule-trigger-acting-organization.md | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md index b039d5700f..e64819ac1b 100644 --- a/.changeset/schedule-trigger-acting-organization.md +++ b/.changeset/schedule-trigger-acting-organization.md @@ -7,7 +7,30 @@ fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) - + + +**Registered as an ADR-0087 semantic migration** +(`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable +is renamed, retired or re-typed — no `packages/spec` key changes its name, its +type or its optionality, no stored shape moves, and every flow, node and +start-node `config` that parses today parses byte-identically afterwards, +because the start node's `config` is an OPEN record (ADR-0018) and the new +`organization` key is an addition to a slot that already accepted anything. So +`objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a +value only the deployment holds, a `sys_organization.id` minted at runtime, with +no authored artifact and no stored representation a rewrite could act on — and +inventing one is precisely what the ruling forbids. ⚠️ That is the argument +against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a +migration that cannot be expressed declaratively gets a structured TODO +(surface, reason, acceptance criteria) rather than nothing, and what follows IS +a prescription in that sense — declare `config.organization` once per +organization, no fan-out, then act on the three consequences of the split named +below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — +behaviour-only, no shape moved, a deployment judgement no transform can make, +registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before +this narrowing landed, so the enforcement rides the 17.x line by the +launch-window convention while the prescription belongs at the major boundary +where `migrate meta` users look. **BREAKING** in the accept-set sense, and in TWO places rather than one — landing in the launch window as `minor` on all four packages (the lockstep