From 01042799dbfb6f2e70c405963a37a7c5ad6b7d86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:36:19 +0000 Subject: [PATCH 01/11] wip(17396): deployment switch for package-authored scheduled work Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- packages/lint/src/authoring-rules.ts | 12 +- packages/lint/src/index.ts | 1 - .../src/validate-flow-trigger-readiness.ts | 141 +++++------------ .../services/service-automation/package.json | 3 +- .../services/service-automation/src/engine.ts | 68 +++++++- .../triggers/trigger-schedule/package.json | 1 + .../trigger-schedule/src/schedule-trigger.ts | 148 ++++++++++++++++-- .../src/time-relative-trigger.ts | 85 ++++++++-- packages/types/src/env.ts | 144 +++++++++++++++++ 9 files changed, 463 insertions(+), 140 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 83fe7775c1..fca5ac86ac 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -933,12 +933,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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. + // `warning`; #17396 RETIRED it. The criterion above is what retired it: this + // stack is not enough to know the flow is dead, because a deployment-level + // switch and the tenancy posture decide whether the key is required, and + // neither is metadata this file can read. The bind-time near-miss diagnostic + // stays — it fires only under a walled posture with the switch on, where the + // key really is required. { name: 'validateFlowTriggerReadiness', tier: 'gating', diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index c77c6a04ed..4ed37c7fbd 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -120,7 +120,6 @@ 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.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 7c34f359cd..73ab3bfff6 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -51,17 +51,16 @@ // 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. +// ⚠️ A sixth rule lived here and is RETIRED (#17396): +// `flow-schedule-organization-missing`, a `warning` on a time-triggered +// flow declaring no `config.organization`. It was true while every such +// flow owed the key. It is not true now: a deployment-level switch gates +// time-triggered flows, and under the `single` posture with that switch on +// the key is not required at all. Neither the switch nor the posture is +// metadata — both are read from the environment at boot — so this file +// cannot tell the deployments apart, and a rule that fires on the default +// one is noise. The near-miss diagnostic it shared with the triggers stays +// where the question IS answerable: bind time, under a wall. See 1g. // // 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 @@ -93,15 +92,12 @@ // 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. +// ⚠️ `flow-schedule-organization-missing` was the family's one measured +// exception — `error` on the criterion, held at `warning` by the shipped +// corpus — and it is retired (#17396) rather than re-severitied. The +// criterion is what disqualified it in the end: this stack is NOT enough to +// know the flow is dead, because the deciding facts (the deployment switch, +// the tenancy posture) are not in any stack. See 1g. // - `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 @@ -127,9 +123,6 @@ import { TimeRelativeTriggerSchema, resolveFlowTriggerKind, - SCHEDULE_ORGANIZATION_KEY, - resolveScheduleOrganization, - describeMissingScheduleOrganization, } from '@objectstack/spec/automation'; import { recordsOf } from './object-graph.js'; @@ -200,42 +193,6 @@ 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; @@ -692,53 +649,29 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines }); } - // 1g. #16659 — a time-triggered flow that declares no acting organization. + // 1g. ⚠️ RETIRED, with its reason (#17396). `flow-schedule-organization-missing` + // lived here: a `warning` on every `schedule` / `time_relative` flow + // declaring no `config.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. + // It is deleted rather than re-severitied because under ruling G the + // missing key is not a defect in the DEFAULT posture. A deployment-level + // switch now gates time-triggered flows, and under `single` with that + // switch on a flow that declares nothing binds and runs correctly — the + // run carries no organization and the #8844 guard resolves the + // deployment's only one beneath it. Lint cannot see the switch or the + // posture: both are deployment facts, read from the environment at boot, + // and ⛔ deliberately not metadata. A rule that is false for the default + // deployment and unable to tell which deployment it is looking at is + // noise, and a `warning` nobody can act on is how the whole family + // stops being read. // - // ⛔ 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 - ) { - 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, 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 ` + - `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.`, - }); - } + // ⛔ What did NOT move: the BIND-time near-miss diagnostic. The + // `describeMissingScheduleOrganization` sentence and its `organizationId` + // / `tenantId` / … scan still fire at the one door where the key really + // is required — a walled posture with the switch on — and that door + // knows both facts. Authoring-time silence here is not a loss of the + // diagnostic, it is the diagnostic moving to where the question is + // answerable. // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 4d387a6be3..495fa8939e 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -29,7 +29,8 @@ "@objectstack/formula": "workspace:*", "@objectstack/metadata-core": "workspace:*", "@objectstack/platform-objects": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" }, "devDependencies": { "@objectstack/driver-sql": "workspace:*", diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 86fede46cf..085fcd414c 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -25,6 +25,10 @@ import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlo // `validate-flow-trigger-readiness`, so the runtime cannot drift from what // authoring accepted. See `resolveTriggerBinding`. import { resolveFlowTriggerKind, resolveScheduleOrganization } from '@objectstack/spec/automation'; +import { + resolveScheduledWorkPolicy, + SCHEDULED_WORK_DISABLED_REASON, +} from '@objectstack/types'; 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 @@ -2064,6 +2068,26 @@ export interface FlowShadowingRecord { shadowed: FlowContender[]; } +/** + * [#17396] The two trigger kinds the deployment's scheduled-work switch + * governs: the ones launched by a CLOCK rather than by a caller. + * + * `record_change` and `api` are deliberately absent and the line is not + * arbitrary — both are fired by a request that already exists and already + * carries an identity, so neither is the unbounded background load the switch + * exists to bound. A kind added to `FlowTriggerKind` later is OUTSIDE the + * switch until someone decides otherwise, which is the safe default: a new kind + * silently falling under a default-OFF switch would be a capability that + * disappears on arrival. + * + * Module-local: the same two tokens are the trigger package's own subject by + * construction (it implements exactly these two), so publishing a shared + * predicate would add a public name with one caller. + */ +function isTimeTriggeredKind(triggerType: string): boolean { + return triggerType === 'schedule' || triggerType === 'time_relative'; +} + export class AutomationEngine implements IAutomationService { /** * ADR-0044: maximum times a single node may be (re-)entered at the top @@ -3336,6 +3360,33 @@ export class AutomationEngine implements IAutomationService { if (!resolved) return; const trigger = this.triggers.get(resolved.triggerType); if (!trigger) return; + // [#17396] The deployment gate, read HERE rather than only inside the + // trigger. The trigger has its own copy of this gate and throws, which + // is what protects a host that binds without this engine — but a + // refusal that arrives as a THROW can only be reported through this + // method's catch, and that catch says "Failed to bind", which is the + // one thing ruled item 6 forbids this state from reading as. Asking the + // policy before `start()` keeps the two apart at the source: nothing is + // called, nothing throws, nothing is logged as a failure, and + // `getTriggerBindingAudit()` reports the policy reason from the same + // resolver. + // + // ⛔ Not cached on the instance. The resolver reads `process.env` live, + // and a host that rebinds after changing the environment — the CLI's + // `--fresh` harness, a test flipping the switch between kernels in one + // process — must see the value current at the bind. + if (isTimeTriggeredKind(resolved.triggerType) && !resolveScheduledWorkPolicy().enabled) { + // Said once per flow, at `info`, for the reason the trigger's own + // refusal records: this is the DEFAULT state of every deployment + // and the deployment declared it, so nothing is wrong and nothing + // looks normal-but-broken. The structured channel is the audit + // below, which the `kernel:bootstrapped` hook and the CLI startup + // summary both read. + this.logger.info( + `Flow '${flowName}' is not armed on trigger '${resolved.triggerType}' — ${SCHEDULED_WORK_DISABLED_REASON}`, + ); + return; + } try { // A trigger-fired run's result must not vanish (2026-07-17 eval: // a failing record-change flow produced zero output — the failure @@ -4028,9 +4079,20 @@ export class AutomationEngine implements IAutomationService { if (this.boundFlowTriggers.has(name)) continue; const resolved = this.resolveTriggerBinding(name); if (!resolved) continue; // manual / screen flow — nothing to bind - const reason = this.triggers.has(resolved.triggerType) - ? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings` - : `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`; + // [#17396] The POLICY branch outranks both binding branches, and + // deliberately so. When package-authored scheduled work is off, + // neither of the other two reasons is true in any useful sense: the + // trigger was never called, so nothing "failed", and registering + // the missing trigger would change nothing, so "add + // requires: ['triggers']" is a remedy that does not work. ⛔ Never + // reported as "binding failed" — a binding failure is a defect with + // an engineering remedy, while this is a deployment policy with an + // operator remedy, and the two send the reader to different places. + const reason = isTimeTriggeredKind(resolved.triggerType) && !resolveScheduledWorkPolicy().enabled + ? SCHEDULED_WORK_DISABLED_REASON + : this.triggers.has(resolved.triggerType) + ? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings` + : `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`; audit.push({ flowName: name, triggerType: resolved.triggerType, reason }); } return audit; diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 920006c94e..fbab456258 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -20,6 +20,7 @@ "dependencies": { "@objectstack/core": "workspace:*", "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "croner": "^10.0.1" }, "devDependencies": { diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index 7d381a9cd7..f697036f9b 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -8,6 +8,11 @@ import { ScheduleOrganizationSchema, describeMissingScheduleOrganization, } from '@objectstack/spec/automation'; +import { + resolveScheduledWorkPolicy, + SCHEDULED_WORK_DISABLED_REASON, + type ScheduledWorkPolicy, +} from '@objectstack/types'; /** * Structural mirror of the automation engine's `FlowTriggerBinding` @@ -270,10 +275,86 @@ export function resolveBindingOrganization(binding: FlowTriggerBinding): string return declared.success ? declared.data : null; } +/** + * [#17396] The deployment's scheduled-work policy, read once per bind. + * + * ⛔ Not memoised at module scope on purpose. {@link resolveScheduledWorkPolicy} + * reads `process.env` live, and a host that rebinds its flows after changing + * the environment (the CLI's `--fresh` harness, a test that flips the switch + * between kernels in one process) must get the value that is current at the + * bind, not the one the first import happened to see. + */ +function readScheduledWorkPolicy(): ScheduledWorkPolicy { + return resolveScheduledWorkPolicy(); +} + +/** + * Refuse to bind ANY time-triggered flow because package-authored scheduled + * work is switched off on this deployment (#17396): say so once, then THROW so + * the flow is never recorded as bound. + * + * ## Why this is a distinct refusal and not a variant of the one below + * + * The two refusals answer to different people. An undeclared acting + * organization is an AUTHORING defect with an authoring remedy — write the key. + * A deployment that has not switched scheduled work on has no defect at all: + * it is running the configuration it asked for, and the flow it ships is + * perfectly well-formed. Reporting the second as "binding failed" sends an + * operator to look for a broken flow, and sends an author to look for a key + * they may already have written. So the sentence is + * {@link SCHEDULED_WORK_DISABLED_REASON}, it names the switch and its remedy, + * and the automation engine's binding audit reports it under its own branch — + * ⛔ never as "binding failed — see earlier warnings", which is ruled item 6. + * + * ## Why `info` and not `warn` or `error` + * + * The repo's degradation-log-level rule asks whether the system still looks + * normal from the outside while something it claims is in place has not + * landed. Nothing here is claimed: the deployment declared this state, the + * global default IS this state, and every structured surface reports it. It is + * the rule's own functional class — "a trigger is not armed" — and it is + * DELIBERATE, so it sits one step below even that: escalating the default + * configuration of every deployment to `warn` is how a `warn` stops being read. + * + * ## Why it still throws + * + * `FlowTrigger.start` returns `void`, so a trigger that logs and returns is + * indistinguishable to its host from one that armed. Throwing is the engine's + * designed path for "not bound" — see {@link refuseMissingOrganization}'s + * header for the full mechanism. In the engine's own composition this is + * belt-and-braces rather than the primary gate: `activateFlowTrigger` reads the + * same policy and does not call `start()` at all when it is off, which is what + * keeps the audit's reason precise. This gate is what makes the guarantee hold + * for a host that drives the trigger directly. + */ +export function refuseScheduledWorkDisabled( + logger: TriggerLogger, + tag: 'schedule' | 'time-relative', + flowName: string, +): never { + const sentence = `${tag} flow '${flowName}' is not armed: ${SCHEDULED_WORK_DISABLED_REASON}`; + logger.info(`[${tag}] NOT ARMED — ${sentence}`); + throw new Error(sentence); +} + /** * Refuse to bind a time-triggered flow that declares no acting organization * (#16659): say why at `error`, then THROW so the engine records the refusal. * + * ## When this fires, after #17396 + * + * ⚠️ Under a WALLED posture (`group` / `isolated`) with scheduled work switched + * on, and nowhere else. The 2026-09-08 ruling this implements is unchanged + * where it applies — a flow declares its organization or it is not armed, no + * fan-out, no organization is ever chosen for it — but it applies to the + * postures that have a wall to be crossed. On a `single` deployment with the + * switch on there is exactly one organization (PR #17476 refuses a second), the + * run carries none, and every tenant-scoped insert beneath it resolves that one + * through the #8844 guard, so there is no cross-organization task to forbid and + * nothing for an author to declare. With the switch OFF this refusal is not + * reached at all: {@link refuseScheduledWorkDisabled} answers first, because a + * deployment that runs no scheduled work owes no authoring remedy. + * * ## Why it throws, and does not merely log and return * * `FlowTrigger.start` returns `void`, so a trigger that logs and returns is @@ -514,6 +595,22 @@ export class ScheduleTrigger implements FlowTrigger { } start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise): void { + // [#17396] The DEPLOYMENT gate comes first — before the descriptor, the + // declaration and the job service. All three of those describe the + // flow; this one describes the deployment, and on a deployment that + // runs no package-authored scheduled work the other three verdicts are + // not reached and must not be reported: an operator told that a flow + // has "no recognizable schedule descriptor" would go and fix a + // descriptor that was never going to be read. + const policy = readScheduledWorkPolicy(); + if (!policy.enabled) { + // Same ordering reason as the declaration refusal below: drop any + // prior binding before throwing, so a rebind under a switch that + // has since been turned off cannot leave the previous job armed. + this.stop(binding.flowName); + refuseScheduledWorkDisabled(this.logger, 'schedule', binding.flowName); + } + const raw = binding.schedule ?? (binding.config as Record | undefined)?.schedule; const schedule = normalizeSchedule(raw); if (!schedule) { @@ -528,8 +625,15 @@ export class ScheduleTrigger implements FlowTrigger { // legally run must not be reported as "not scheduled because the job // service is missing", which is a different defect with a different // remedy. + // + // [#17396] …and only where the wall makes it answerable. Under + // `single` the run carries NO organization and the #8844 guard resolves + // the deployment's one organization beneath it, so a missing key is not + // a defect there — `policy.requiresActingOrganization` is the whole of + // that distinction and it is resolved once, centrally, so this trigger, + // the sweep trigger and the engine's audit cannot disagree about it. const organization = resolveBindingOrganization(binding); - if (organization === null) { + if (policy.requiresActingOrganization && organization === null) { // Drop any prior binding for this flow FIRST. A hot re-publish that // REMOVES the organization must not leave the previous, still-armed // job firing org-less ticks behind an error that says it was @@ -577,21 +681,35 @@ 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. + // [#16659] When the flow declares one, the run executes AS + // that organization: `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, + // [#17396] ⚠️ RETIRED PIN, with its reason. This spread + // replaces an unconditional `tenantId: organization` whose + // comment read "⛔ Never conditional", on the argument + // that an org-less run is a silent state. That argument was + // sound while EVERY time-triggered run owed a declaration: + // conditional there meant "sometimes we forgot". Under + // ruling G an absent `tenantId` is a DECLARED state rather + // than a forgotten one — the `single` posture with the + // switch on, where the deployment holds exactly one + // organization (PR #17476 refuses a second) and the #8844 + // guard resolves it for every tenant-scoped insert beneath + // the run. It is reached only through that gate: under a + // wall the bind above still refuses an undeclared flow, and + // with the switch off nothing binds at all. ⛔ The key is + // OMITTED rather than set to `undefined` — the ruling says + // the run carries no organization, and a present-but- + // undefined `tenantId` is a different thing to every + // consumer that asks `in`. + ...(organization !== null ? { 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 385bb8ec55..2b07f9db3a 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -11,8 +11,10 @@ import { normalizeSchedule, reportBindFailure, refuseMissingOrganization, + refuseScheduledWorkDisabled, resolveBindingOrganization, } from './schedule-trigger.js'; +import { resolveScheduledWorkPolicy } from '@objectstack/types'; import type { FlowTrigger, FlowTriggerBinding, JobServiceSurface, TriggerLogger } from './schedule-trigger.js'; /** @@ -283,6 +285,19 @@ export class TimeRelativeTrigger implements FlowTrigger { } start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise): void { + // [#17396] The DEPLOYMENT gate first, for the reason + // `ScheduleTrigger.start` states: every other verdict in this method + // describes the flow, and none of them is reached on a deployment that + // runs no package-authored scheduled work. A sweep is the case where + // that matters most — its descriptor diagnostics are long and specific, + // and sending an operator to fix one on a deployment that was never + // going to run it is the wrong remedy at the wrong door. + const policy = resolveScheduledWorkPolicy(); + if (!policy.enabled) { + this.stop(binding.flowName); + refuseScheduledWorkDisabled(this.logger, 'time-relative', binding.flowName); + } + const raw = (binding.config as Record | undefined)?.timeRelative; const parsed = TimeRelativeTriggerSchema.safeParse(raw); if (!parsed.success) { @@ -305,8 +320,18 @@ export class TimeRelativeTrigger implements FlowTrigger { // 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). + // + // [#17396] …under a WALL. With the switch on and posture `single` the + // sweep runs unscoped and its runs carry no organization — which is not + // the cross-organization task the ruling forbids, because there is only + // one organization there to cross to (PR #17476 refuses a second). The + // elevation argument above is why the `single` case is still safe: an + // unscoped `isSystem` read on a one-organization install selects that + // organization's rows and the platform's NULL-tenant rows, which is + // exactly what it selected before #16659 and what the #8844 guard + // resolves beneath it. const organization = resolveBindingOrganization(binding); - if (organization === null) { + if (policy.requiresActingOrganization && organization === null) { // Drop any prior sweep FIRST: a hot re-publish that removes the key // must not leave the previous, still-armed job sweeping org-less // behind an error saying it was refused. The call below throws, so @@ -349,7 +374,12 @@ export class TimeRelativeTrigger implements FlowTrigger { `Object names match exactly; check config.timeRelative.object.`, ); } else { - const inertBecause = organizationScopeIsInertFor(known); + // [#17396] Only when the flow declared one. With no + // declaration the sweep passes no scope at all, so "your + // declaration cannot narrow this" has no declaration to be + // about — printing it would invent a containment claim the + // author never made. + const inertBecause = organization !== null ? organizationScopeIsInertFor(known) : null; if (inertBecause) { // [#16659] ⛔ A DISCLOSURE, never a narrowing. The sweep // passes `context.tenantId` unconditionally and the ENGINE @@ -421,7 +451,16 @@ export class TimeRelativeTrigger implements FlowTrigger { `[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` : '') + - ` as organization '${organization}'`, + // [#17396] Which of the two legal shapes this bind is, + // said on the bind line rather than left to be inferred + // from the metadata: an unscoped sweep is a deployment + // state (`single` + the switch on), not an omission, + // and an operator reading the boot log is owed the + // difference between "sees one organization's rows" and + // "sees every row this install holds". + (organization !== null + ? ` as organization '${organization}'` + : ` with NO acting organization (tenancy posture '${policy.posture}') — the sweep is unscoped and its runs carry no organization`), ); }) .catch((err) => { @@ -440,8 +479,10 @@ export class TimeRelativeTrigger implements FlowTrigger { desc: TimeRelativeDescriptor, maxRecords: number, /** - * [#16659] The declared organization. It bounds this sweep TWICE, and - * both halves are load-bearing: + * [#16659] The declared organization, or `null`. + * + * When declared 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 @@ -449,10 +490,15 @@ export class TimeRelativeTrigger implements FlowTrigger { * 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. + * [#17396] `null` is now a REACHABLE and legal value, and exactly one + * gate admits it: the switch on, tenancy posture `single`. There is no + * second organization to cross to there (PR #17476 refuses one), so an + * unscoped sweep is not the cross-organization task the ruling forbids + * — it is the shape a single-organization install had before #16659. + * Under a wall `start()` still refuses an undeclared binding, and with + * the switch off nothing binds, so `null` cannot arrive from either. */ - organization: string, + organization: string | null, callback: (ctx: AutomationContext) => Promise, ): Promise { const engine = this.getDataEngine(); @@ -508,7 +554,15 @@ export class TimeRelativeTrigger implements FlowTrigger { // 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 }, + // + // [#17396] The scope is OMITTED, not nulled, when the flow + // declares no organization. `buildDriverOptions` reads + // `context.tenantId`; handing it an explicit `undefined` and + // handing it nothing are the same to that reader today, but + // the ruling's words are that the sweep's own query carries + // no scope, and the spelling that cannot be misread as + // "scoped to nothing" is the one that omits the key. + context: { isSystem: true, ...(organization !== null ? { tenantId: organization } : {}) }, })) ?? []; for (const row of rows) { const id = (row as { id?: unknown }).id; @@ -559,7 +613,18 @@ export class TimeRelativeTrigger implements FlowTrigger { // 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, + // That prohibition is untouched by #17396: the `single` + // branch below omits the key, it does not fill it from the + // row. + // + // [#17396] ⚠️ RETIRED PIN, with its reason — the same one + // `ScheduleTrigger`'s handler records. The unconditional + // spelling here carried "⛔ Never conditional", which was + // correct while every time-triggered run owed a + // declaration. Under ruling G the absent key is a declared + // deployment state (`single` + the switch on), not a + // forgotten one, and it is unreachable from any other gate. + ...(organization !== null ? { tenantId: organization } : {}), // Expose the record as params too, so flows with named `isInput` // variables matching record fields get them seeded (parity with // the record-change trigger). diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 2b5d97ce4c..953addfece 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -19,6 +19,7 @@ import { normalizeTenancyPosture, + postureEnforcesWall, TENANCY_POSTURES, type TenancyPosture, } from '@objectstack/spec/security'; @@ -161,6 +162,149 @@ export function resolveTenancyPosture(): TenancyPosture { return resolveMultiOrgEnabled() ? 'isolated' : 'single'; } +/** + * The env variable gating PACKAGE-AUTHORED SCHEDULED WORK — every time-triggered + * flow and every declarative `defineJob` a package ships (#17396). + * + * Exported as a constant so every surface that names it quotes exactly one + * spelling: both time triggers, the automation engine's binding audit, the + * AppPlugin job loop, `os doctor` and the four deployment docs pages. + */ +export const SCHEDULED_WORK_ENV = 'OS_AUTOMATION_SCHEDULED_WORK_ENABLED'; + +/** + * Whether this DEPLOYMENT runs package-authored scheduled work at all. + * + * ## What it gates + * + * Everything a package ships that fires on a clock rather than on a caller: + * + * - time-triggered FLOWS — 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`); + * - package-authored declarative JOBS — `defineJob` entries reaching the job + * service through `defineStack({ jobs })` / a package bundle. + * + * ⛔ It does NOT gate platform-internal jobs — approvals escalation, the + * lifecycle Reaper, the messaging dispatch loop, membership backfill. The + * boundary is **authored by a package**, not "runs on the job service": the + * platform's own maintenance work is part of the runtime a deployment asked + * for, while package-authored scheduled work is arbitrary tenant-supplied load + * on a clock the operator never sized. + * + * ## Why a deployment variable and not metadata + * + * Maintainer ruling, 2026-09-12, verbatim, untranslated: + * + * > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + * + * > group 默认也关,云端每库一租户全局默认关 + * + * Whether a clock-driven workload is affordable is a fact about the DEPLOYMENT + * — its database, its tenants, its budget — not about the flow. An author + * cannot know it and a metadata key would ask them to; so this is read from the + * environment at boot, beside {@link resolveTenancyPosture}, and there is + * deliberately no spec key for it. + * + * ## Default OFF, in every posture and every kernel + * + * Unset means off. A deployment that wants package-authored scheduled work + * turns it on explicitly — including a `single` private install and a `group` + * one. `group` is not free today and is off for a measured reason rather than + * by analogy: it is a WALLED posture, so `resolveSystemWriteOrganization` + * refuses an organization-less system insert under it and + * `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3). Which + * organization a group-wide sweep's inserts belong to is not yet decided, and + * until it is, `group` behaves as walled. + * + * Accepts `true`/`1`/`on`/`yes`, case-insensitive; anything else — including an + * unset variable and an empty string — is off. ⚠️ Deliberately NOT the + * `!== 'false'` shape {@link resolveMultiOrgEnabled} uses: that one is opt-OUT + * and reads a typo as "on", which for this switch would arm exactly the + * workload the operator meant to refuse. + * + * Reads `process.env` live on each call; memoise at the call site if the result + * must be stable for the process lifetime. + */ +export function resolveScheduledWorkEnabled(): boolean { + const raw = readEnvWithDeprecation(SCHEDULED_WORK_ENV, [], { silent: true }); + if (raw == null) return false; + return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase()); +} + +/** + * The deployment's scheduled-work policy as one reading — the three states + * every binder and every audit surface must agree about (#17396). + * + * One resolver rather than two reads at each call site, because the three + * states are not independent and spelling them apart is how they drift: + * + * | state | `enabled` | `requiresActingOrganization` | what binds | + * |:--|:--|:--|:--| + * | OFF (default) | `false` | `false` | nothing — no time trigger arms, no package job schedules | + * | ON under `single` | `true` | `false` | every time-triggered flow, carrying NO organization | + * | ON under a wall (`group` / `isolated`) | `true` | `true` | only a flow that declares `config.organization` | + * + * `requiresActingOrganization` is `false` when the switch is OFF because + * nothing binds there at all: reporting a declaration requirement for a flow + * that is not going to arm either way would put the operator on the authoring + * remedy for a deployment decision. The OFF state has its own reason — + * {@link SCHEDULED_WORK_DISABLED_REASON} — and it is the one that must be + * reported. + * + * ⚠️ `posture` is what the deployment ASKED FOR, exactly as + * {@link resolveTenancyPosture} answers it — whether the wall is actually + * ENFORCED is the `tenancy` service's answer. That is the right authority here: + * a deployment that asked for `isolated` owes the declaration whether or not + * its isolation is currently degraded, and a flow that binds while the wall is + * down would otherwise re-arm org-less the moment the wall came back. + * + * @throws the same refusal {@link resolveTenancyPosture} throws on an + * unrecognized `OS_TENANCY_POSTURE` — a typo'd posture must not silently + * resolve to `single` and drop the declaration requirement with it. + */ +export interface ScheduledWorkPolicy { + /** Whether package-authored scheduled work runs on this deployment at all. */ + readonly enabled: boolean; + /** The deployment's REQUESTED tenancy posture. */ + readonly posture: TenancyPosture; + /** + * Whether an armed time-triggered flow must declare `config.organization`. + * True only under a walled posture with the switch on — the 2026-09-08 + * ruling on cross-organization scheduled tasks, unchanged. + */ + readonly requiresActingOrganization: boolean; +} + +/** Resolve {@link ScheduledWorkPolicy} from the environment. */ +export function resolveScheduledWorkPolicy(): ScheduledWorkPolicy { + const enabled = resolveScheduledWorkEnabled(); + const posture = resolveTenancyPosture(); + return { + enabled, + posture, + requiresActingOrganization: enabled && postureEnforcesWall(posture), + }; +} + +/** + * The one sentence a surface prints when package-authored scheduled work is + * OFF — so the bind refusal, the engine's binding audit, the CLI startup + * summary and Studio cannot drift about WHY a flow is not armed. + * + * ⛔ It must never read as "binding failed". A binding failure is a defect with + * an engineering remedy; this is a deployment POLICY with an operator remedy, + * and the two send the reader to different places. The distinction is the whole + * of ruled item 6. + */ +export const SCHEDULED_WORK_DISABLED_REASON = + `disabled by deployment policy — package-authored scheduled work is off on this deployment ` + + `(${SCHEDULED_WORK_ENV} is unset or not truthy), so no time trigger arms and no packaged ` + + `\`defineJob\` is scheduled. This is not a binding failure and nothing about the flow needs ` + + `fixing: set ${SCHEDULED_WORK_ENV}=true to run package-authored scheduled work on this ` + + `deployment. It is OFF by default in every posture — a clock-driven workload's cost is a ` + + `fact about the deployment, not about the flow.`; + /** * The env variable naming the deployment's PLATFORM OWNER account * (#11184, the framework leg of cloud#1509). From 9d73075ae76ff9bda8d06fbd3d2b3a94ed57cc33 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:53:09 +0000 Subject: [PATCH 02/11] wip(17396): spec entry 18, Q3 job gate, os doctor row Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../automation/schedule-organization.mdx | 43 +++++-- packages/cli/src/commands/doctor.ts | 73 ++++++++++- packages/runtime/src/app-plugin.ts | 31 ++++- .../automation/schedule-organization.zod.ts | 53 ++++++-- ...edule-flow-acting-organization-required.ts | 119 ++++++++++++------ packages/spec/src/migrations/registry.ts | 119 ++++++++++++------ 6 files changed, 332 insertions(+), 106 deletions(-) diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx index 0932fd4caa..826e96f6bc 100644 --- a/content/docs/references/automation/schedule-organization.mdx +++ b/content/docs/references/automation/schedule-organization.mdx @@ -30,16 +30,37 @@ 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. +Under a WALLED tenancy posture (`group` / `isolated`) 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 +under a wall there is deliberately no fallback: a flow that names none is a +DECLARATION ERROR, not a run that quietly picks one. Guessing is the failure +this key exists to prevent, and the platform organization is not a safe +guess: a wrong `organization_id` is worse than a null, because a null is +visibly missing while a wrong value is silently authoritative to every +report, export and cleanup script that filters by organization. + +## Where that requirement bites, and where it does not (#17396) + +⚠️ The sentence above is scoped to walled postures, and the scoping is the +whole of the 2026-09-12 amendment. Two deployment facts decide whether this +key is required, and ⛔ neither of them is metadata — both are read from the +environment at boot, beside `resolveTenancyPosture`: + +| deployment | is this key required? | +|:--|:--| +| package-authored scheduled work switched OFF (the global default) | ⛔ nothing arms, so nothing is required — the flow is listed as *disabled by deployment policy*, never as a binding failure | +| switched ON, posture `single` | **no** — the deployment holds exactly one organization, the run carries none, and every tenant-scoped insert beneath it resolves that one through the #8844 guard | +| switched ON, posture `group` / `isolated` | **yes** — declare or the flow is not armed, exactly as above | + +⇒ The key is never *deprecated* and its meaning never changes: it is the only +way a run under a wall gets an organization, and nothing on this path ever +chooses one. What changed is that a missing key is no longer a defect on +every deployment — so ⛔ do not read the refusal sentence below as a universal +authoring rule, and ⛔ do not re-add an authoring-time lint for it: at +authoring time neither the switch nor the posture is knowable, which is why +the diagnostic lives at BIND and only fires where the answer is settled. ## Where it lives, and why there @@ -100,7 +121,7 @@ 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. +Organization id (sys_organization.id) this scheduled/time-relative flow runs as. A time-triggered run has no session to inherit a tenant from, so under a walled tenancy posture (group/isolated) a flow that declares none is not armed; under the single posture it is not required and the run carries no organization. **Type:** `string` diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 57caef8987..f133913085 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -19,6 +19,8 @@ import { TENANCY_POSTURE_FIX_HINTS } from '../utils/tenancy-posture-hints.js'; import { validateWidgetBindings } from '@objectstack/lint'; import { resolveTenancyPosture, + resolveScheduledWorkEnabled, + SCHEDULED_WORK_ENV, collectGlobalUniques, unconfirmedGlobalUniques, describeGlobalUniqueFinding, @@ -115,7 +117,7 @@ function renderHealthCheckResult(result: HealthCheckResult, verbose: boolean): v * Reading `.env` into a check that reports no attribution is precisely the * defect #5387 closed, and it would otherwise creep back one variable at a time. */ -export const DOCTOR_ENV_INPUTS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED'] as const; +export const DOCTOR_ENV_INPUTS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', SCHEDULED_WORK_ENV] as const; /** Where one environment value actually came from. */ export interface EnvValueProvenance { @@ -224,6 +226,71 @@ export function nodeEnvCheck(env: NodeJS.ProcessEnv = process.env): HealthCheckR }; } +/** + * [#17396] The EFFECTIVE value of the package-authored scheduled-work switch, + * reported unconditionally. + * + * ## Why this row always appears, when `nodeEnvCheck` above appears only when + * unset + * + * The two rows answer different questions. `NODE_ENV` has a documented default + * everyone knows, so only the unset case carries information. This switch is + * OFF by default in every posture and every kernel, and OFF is the state in + * which a shipped capability silently does not run — a packaged flow that an + * operator expects to fire, a `defineJob` an app declared. The whole reason the + * ruling put a DISTINCT reason on the binding audit is that "not running" and + * "broken" must be tellable apart; a doctor that printed nothing in the default + * state would leave the operator to discover the switch from the audit line, or + * not at all. + * + * ⇒ Both states print, and each names the other as its remedy. + * + * ## `ok`, never `warning` or `error` + * + * Neither state is a defect. OFF is the declared default and ON is a deliberate + * operator decision; a `warning` on the default configuration of every + * deployment is how doctor's warnings stop being read, and `error` is what + * makes doctor exit non-zero. `ok` is this file's only informational status + * (`HealthCheckResult.status` is `'ok' | 'warning' | 'error'`) and widening the + * union for one row would re-render every other check. This row informs, and + * that is its whole job — its `fix` text carries the operator's next step in + * both directions, printed under `--verbose` like every other non-error fix. + * + * ⚠️ Read through the `.env*` overlay like every other `DOCTOR_ENV_INPUTS` + * entry: `os serve` resolves this variable from the same cascade, so a value + * set in `.env.production` must be reported as the effective one. Reading + * `process.env` directly here would report OFF for a deployment that has turned + * it on in a file — the exact "diagnostic disagrees with the runtime" defect + * the posture reader above exists to have fixed. + */ +export function scheduledWorkCheck(reading: DotenvReading): HealthCheckResult { + const enabled = withDotenvOverlay(reading, () => resolveScheduledWorkEnabled()); + const provenance = provenanceOf(reading, SCHEDULED_WORK_ENV); + return { + name: 'Package-authored scheduled work', + status: 'ok', + message: enabled + ? `ON — packaged time-triggered flows and packaged \`defineJob\` cron jobs are armed (${SCHEDULED_WORK_ENV})` + : `OFF (the default) — no packaged time-triggered flow and no packaged \`defineJob\` runs on this deployment`, + fix: enabled + ? `Unset ${SCHEDULED_WORK_ENV} to turn it back off. While it is on, a time-triggered\n` + + ' flow under a WALLED tenancy posture (group/isolated) must declare\n' + + ' config.organization on its start node or it is not armed; under `single` it\n' + + ' needs no declaration and its runs carry no organization.\n' + + ` ${envSourceSentence(reading, provenance)}` + : `Set ${SCHEDULED_WORK_ENV}=true to run package-authored scheduled work here.\n` + + ' OFF is the global default in every posture and every kernel: a clock-driven\n' + + " workload's cost is a fact about the deployment, not about the flow. Flows in\n" + + ' this state are listed by the startup summary and getTriggerBindingAudit() as\n' + + ' "disabled by deployment policy" — NOT as a binding failure, and nothing about\n' + + ' them needs fixing.\n' + + ' ⛔ Platform-internal scheduled work (approvals escalation, the lifecycle\n' + + ' Reaper, the messaging dispatch loop, membership backfill) is NOT gated by this\n' + + ' switch and runs either way.\n' + + ` ${envSourceSentence(reading, provenance)}`, + }; +} + /** * Read — without loading — the `.env*` files `os serve` would load from `cwd`. * @@ -1984,6 +2051,10 @@ export default class Doctor extends Command { results.push(nodeEnvFinding); } + // [#17396] The deployment's scheduled-work switch, unconditionally — see + // `scheduledWorkCheck` for why both states print where NODE_ENV prints one. + results.push(scheduledWorkCheck(dotenvReading)); + // #5382 — the posture verdict resolved at the top of `run()`, reported here // among the other environment facts. Only an unrecognized value produces a // row: a valid posture is not a finding, and doctor's output for every diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 37fce89a82..02d93395c8 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -13,7 +13,11 @@ import { type ArtifactGrantBinding, } from './security/artifact-granted-permissions.js'; import { applyArtifactForwardConversions, assertProtocolCompat } from '@objectstack/metadata-core'; -import { resolveTenancyPosture } from '@objectstack/types'; +import { + resolveTenancyPosture, + resolveScheduledWorkEnabled, + SCHEDULED_WORK_DISABLED_REASON, +} from '@objectstack/types'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; @@ -1166,6 +1170,31 @@ export class AppPlugin implements Plugin { : []; if (jobs.length > 0) { ctx.hook('kernel:ready', async () => { + // [#17396] The DEPLOYMENT gate, ahead of the job service + // probe. Every `defineJob` reaching this loop is + // PACKAGE-AUTHORED — it arrived through `defineStack({ jobs })` + // or a package bundle — which is exactly the boundary the + // switch draws. ⛔ Platform-internal scheduled work is NOT + // gated and does not pass through here: approvals + // escalation, the lifecycle Reaper, the messaging dispatch + // loop and membership backfill each schedule themselves + // from their own service plugin, because they are part of + // the runtime a deployment asked for rather than arbitrary + // load a package put on its clock. + // + // `info`, not `warn`: this is the default state of every + // deployment and the deployment declared it, so nothing is + // wrong and nothing looks normal-but-broken. Said once per + // app with the job count, rather than once per job — the + // remedy is one variable, and repeating it N times is how a + // line stops being read. + if (!resolveScheduledWorkEnabled()) { + ctx.logger.info( + `[AppPlugin] declarative jobs NOT scheduled — ${SCHEDULED_WORK_DISABLED_REASON}`, + { appId, jobCount: jobs.length }, + ); + return; + } let svc: IJobService | undefined; try { svc = ctx.getService('job'); } catch { /* not installed */ } if (!svc || typeof svc.schedule !== 'function') { diff --git a/packages/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index be66393b39..b26d5eca81 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -28,16 +28,37 @@ import { z } from 'zod'; * * > 多组织定时任务本来只能在组织内运行,应该带组织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. + * Under a WALLED tenancy posture (`group` / `isolated`) 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 + * under a wall there is deliberately no fallback: a flow that names none is a + * DECLARATION ERROR, not a run that quietly picks one. Guessing is the failure + * this key exists to prevent, and the platform organization is not a safe + * guess: a wrong `organization_id` is worse than a null, because a null is + * visibly missing while a wrong value is silently authoritative to every + * report, export and cleanup script that filters by organization. + * + * ## Where that requirement bites, and where it does not (#17396) + * + * ⚠️ The sentence above is scoped to walled postures, and the scoping is the + * whole of the 2026-09-12 amendment. Two deployment facts decide whether this + * key is required, and ⛔ neither of them is metadata — both are read from the + * environment at boot, beside `resolveTenancyPosture`: + * + * | deployment | is this key required? | + * |:--|:--| + * | package-authored scheduled work switched OFF (the global default) | ⛔ nothing arms, so nothing is required — the flow is listed as *disabled by deployment policy*, never as a binding failure | + * | switched ON, posture `single` | **no** — the deployment holds exactly one organization, the run carries none, and every tenant-scoped insert beneath it resolves that one through the #8844 guard | + * | switched ON, posture `group` / `isolated` | **yes** — declare or the flow is not armed, exactly as above | + * + * ⇒ The key is never *deprecated* and its meaning never changes: it is the only + * way a run under a wall gets an organization, and nothing on this path ever + * chooses one. What changed is that a missing key is no longer a defect on + * every deployment — so ⛔ do not read the refusal sentence below as a universal + * authoring rule, and ⛔ do not re-add an authoring-time lint for it: at + * authoring time neither the switch nor the posture is knowable, which is why + * the diagnostic lives at BIND and only fires where the answer is settled. * * ## Where it lives, and why there * @@ -98,7 +119,7 @@ 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.', + 'Organization id (sys_organization.id) this scheduled/time-relative flow runs as. A time-triggered run has no session to inherit a tenant from, so under a walled tenancy posture (group/isolated) a flow that declares none is not armed; under the single posture it is not required and the run carries no organization.', ); /** @@ -211,6 +232,16 @@ function findScheduleOrganizationNearMissInConfig( * consequence lives: there is no path by which an organization-less * time-triggered run reaches the data layer once bind refuses. * + * ⚠️ [#17396] BIND is also the only door that knows whether the key is required + * at all. This sentence is emitted by exactly one gate — a walled tenancy + * posture (`group` / `isolated`) with package-authored scheduled work switched + * on — because those are the two deployment facts that decide it, and a trigger + * binding inside a booted kernel is the first place both are readable. Under + * `single` the sentence is never emitted and must not be: nothing is missing + * there. It is written unconditionally as a requirement because every reader + * that receives it IS under that gate; ⛔ do not reuse it to describe a flow on + * a deployment where the key is optional. + * * 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. `options.config` is the START NODE's `config` record (what a trigger 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 index 5ae53cd68f..cbc29af02b 100644 --- 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 @@ -8,59 +8,96 @@ export const entry: SemanticMigration = { '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.', + + '`time_relative`) — TOGETHER WITH the deployment variable that decides whether such a ' + + 'flow arms at all, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`. 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 — and what the 2026-09-12 amendment narrows further is WHERE that ' + + 'narrowing applies: the declaration is required under a walled tenancy posture ' + + '(`group` / `isolated`) only, and no time-triggered flow arms anywhere until the ' + + 'deployment switches package-authored scheduled work on.', replacement: - 'Declare the organization the flow runs as, on the start node beside the cadence: ' + 'Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS ' + + 'PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` ' + + 'arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global ' + + 'default, in every posture and every kernel — arms neither, and every such flow is ' + + 'listed by `getTriggerBindingAudit()`, the CLI startup summary and Studio as DISABLED ' + + 'BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + + '(approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership ' + + 'backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on ' + + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS WALLED, declare the ' + + 'organization each 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.', + + 'at boot and names its flow. Under the `single` posture with the switch on, declare ' + + 'NOTHING: the run carries no organization and every tenant-scoped insert beneath it ' + + 'resolves the deployment\'s one organization through the #8844 guard. ⚠️ Three ' + + 'consequences apply to a WALLED deployment that splits one flow into N, 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: ' + 'Two maintainer rulings, both verbatim and untranslated, in the order they were given. ' + + '2026-09-08: ' + '「多组织定时任务本来只能在组织内运行,应该带组织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 ' + + 'still summarised itself as healthy. 2026-09-12, on the same surface: ' + + '「schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。' + + '对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,' + + '有没有可能定义为一个环境变量,根据环境变量控制?」 and 「group 默认也关,云端每库一租户全局默认关」. ' + + 'Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, ' + + 'its tenants, its budget — that no author can know and no metadata key should ask them ' + + 'for, so the gate is a deployment variable read at boot and the global default is OFF. ' + + 'Where the switch is on, the 2026-09-08 ruling stands unchanged under a wall and is ' + + 'moot under `single`, which holds exactly one organization and therefore has no ' + + 'cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is ' + + 'that both remedies are values only the deployment holds: an organization id is minted ' + + 'per install at runtime and the switch is an operator decision about cost, 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, ' + + 'nor whether this deployment wants scheduled work at all, and inventing either is ' + + 'precisely what the rulings forbid. Registered under ADR-0087 D3 rather than left ' + + 'silent because the change DOES carry a prescription — "decide the switch, then declare ' + + 'one flow per organization under a wall" 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`.', + 'The deployment has DECIDED the switch, and the decision is visible: `os doctor` prints ' + + 'the effective `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` value. A deployment that leaves ' + + 'it unset — the default — accepts that no packaged time-triggered flow and no packaged ' + + '`defineJob` runs, and confirms that every such flow appears in ' + + '`getTriggerBindingAudit()` and the CLI startup summary with the reason DISABLED BY ' + + 'DEPLOYMENT POLICY and NOT as "binding failed"; no boot line reads ' + + '`[schedule] NOT BOUND` / `[time-relative] NOT BOUND`, because nothing was refused for ' + + 'a declaration. A deployment that sets it to `true` under posture `single` confirms ' + + 'that its time-triggered flows are armed while declaring no `config.organization`, and ' + + 'that the runs they launch carry none. A deployment that sets it to `true` under a ' + + 'walled posture (`group` / `isolated`) confirms that every `schedule` / `time_relative` ' + + 'flow in the stack declares a non-empty `config.organization` on its start node, that ' + + 'boot logs no `NOT BOUND` line, that `getFlowRuntimeStates()` reports `bound: true` and ' + + 'that `getTriggerBindingAudit()` lists no time-triggered flow — and, where it ran ONE ' + + 'flow across all organizations, that it has split it into one flow per organization and ' + + 're-checked the three consequences above (NULL-tenant rows, abandoned dispatch claims, ' + + 'suspended runs). ⛔ There is NO authoring-time lint for the declaration: it was ' + + 'retired with this amendment because neither the switch nor the posture is knowable ' + + 'from a stack, so `os lint` reporting nothing is the criterion being met, not a check ' + + 'that was skipped. ⚠️ `@objectstack/driver-memory` has NO legal configuration for a ' + + 'time-triggered flow that touches per-organization data under a wall: 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 4d504ab5ef..c892369a01 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10109,61 +10109,98 @@ const step18: MigrationStep = { '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.', + + '`time_relative`) — TOGETHER WITH the deployment variable that decides whether such a ' + + 'flow arms at all, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`. 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 — and what the 2026-09-12 amendment narrows further is WHERE that ' + + 'narrowing applies: the declaration is required under a walled tenancy posture ' + + '(`group` / `isolated`) only, and no time-triggered flow arms anywhere until the ' + + 'deployment switches package-authored scheduled work on.', replacement: - 'Declare the organization the flow runs as, on the start node beside the cadence: ' + 'Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS ' + + 'PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` ' + + 'arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global ' + + 'default, in every posture and every kernel — arms neither, and every such flow is ' + + 'listed by `getTriggerBindingAudit()`, the CLI startup summary and Studio as DISABLED ' + + 'BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + + '(approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership ' + + 'backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on ' + + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS WALLED, declare the ' + + 'organization each 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.', + + 'at boot and names its flow. Under the `single` posture with the switch on, declare ' + + 'NOTHING: the run carries no organization and every tenant-scoped insert beneath it ' + + 'resolves the deployment\'s one organization through the #8844 guard. ⚠️ Three ' + + 'consequences apply to a WALLED deployment that splits one flow into N, 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: ' + 'Two maintainer rulings, both verbatim and untranslated, in the order they were given. ' + + '2026-09-08: ' + '「多组织定时任务本来只能在组织内运行,应该带组织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 ' + + 'still summarised itself as healthy. 2026-09-12, on the same surface: ' + + '「schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。' + + '对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,' + + '有没有可能定义为一个环境变量,根据环境变量控制?」 and 「group 默认也关,云端每库一租户全局默认关」. ' + + 'Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, ' + + 'its tenants, its budget — that no author can know and no metadata key should ask them ' + + 'for, so the gate is a deployment variable read at boot and the global default is OFF. ' + + 'Where the switch is on, the 2026-09-08 ruling stands unchanged under a wall and is ' + + 'moot under `single`, which holds exactly one organization and therefore has no ' + + 'cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is ' + + 'that both remedies are values only the deployment holds: an organization id is minted ' + + 'per install at runtime and the switch is an operator decision about cost, 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, ' + + 'nor whether this deployment wants scheduled work at all, and inventing either is ' + + 'precisely what the rulings forbid. Registered under ADR-0087 D3 rather than left ' + + 'silent because the change DOES carry a prescription — "decide the switch, then declare ' + + 'one flow per organization under a wall" 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`.', + 'The deployment has DECIDED the switch, and the decision is visible: `os doctor` prints ' + + 'the effective `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` value. A deployment that leaves ' + + 'it unset — the default — accepts that no packaged time-triggered flow and no packaged ' + + '`defineJob` runs, and confirms that every such flow appears in ' + + '`getTriggerBindingAudit()` and the CLI startup summary with the reason DISABLED BY ' + + 'DEPLOYMENT POLICY and NOT as "binding failed"; no boot line reads ' + + '`[schedule] NOT BOUND` / `[time-relative] NOT BOUND`, because nothing was refused for ' + + 'a declaration. A deployment that sets it to `true` under posture `single` confirms ' + + 'that its time-triggered flows are armed while declaring no `config.organization`, and ' + + 'that the runs they launch carry none. A deployment that sets it to `true` under a ' + + 'walled posture (`group` / `isolated`) confirms that every `schedule` / `time_relative` ' + + 'flow in the stack declares a non-empty `config.organization` on its start node, that ' + + 'boot logs no `NOT BOUND` line, that `getFlowRuntimeStates()` reports `bound: true` and ' + + 'that `getTriggerBindingAudit()` lists no time-triggered flow — and, where it ran ONE ' + + 'flow across all organizations, that it has split it into one flow per organization and ' + + 're-checked the three consequences above (NULL-tenant rows, abandoned dispatch claims, ' + + 'suspended runs). ⛔ There is NO authoring-time lint for the declaration: it was ' + + 'retired with this amendment because neither the switch nor the posture is knowable ' + + 'from a stack, so `os lint` reporting nothing is the criterion being met, not a check ' + + 'that was skipped. ⚠️ `@objectstack/driver-memory` has NO legal configuration for a ' + + 'time-triggered flow that touches per-organization data under a wall: 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', From 13a08fba60fbaff535bf640ddd9523b645e42de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:27:31 +0000 Subject: [PATCH 03/11] wip(17396): retire flow-schedule-organization-missing, pin its absence Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../validate-flow-trigger-readiness.test.ts | 197 ++++++++---------- pnpm-lock.yaml | 6 + 2 files changed, 89 insertions(+), 114 deletions(-) diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index c5ca1ea145..1aad0afb73 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -10,7 +10,6 @@ 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 = {}) { @@ -38,15 +37,15 @@ 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. + * rule 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. + * [#17396] ⚠️ The reason it had to is GONE — `flow-schedule-organization-missing` + * is retired, so an undeclared fixture no longer carries a second finding. The + * declarations are KEPT rather than stripped, deliberately: the key is still + * real and still required under a walled posture, these fixtures still read as + * well-formed time-triggered flows, and a sweep that deleted them would be a + * large diff whose only effect is to make the fixtures less representative. The + * absence pin below is what proves the rule is gone; these fixtures are not it. */ const FIXTURE_ORG = 'org_2mtx1w9d0k4bqf7v'; @@ -1018,11 +1017,12 @@ describe('validateFlowTriggerReadiness', () => { // 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. + // [#16659 / #17396] `flow-schedule-organization-missing` was the one entry + // whose side that question did not decide, and it is RETIRED rather than + // promoted: under a deployment-level switch the key is not required in the + // default posture at all, and a stack cannot see which deployment it is. So + // the table is back to "the question decides every row", and the absence pin + // further down is what keeps the id from creeping back. // // 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 @@ -1140,25 +1140,6 @@ 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) { @@ -1205,11 +1186,12 @@ describe('validateFlowTriggerReadiness', () => { { id: 'start', type: 'start', - // [#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. + // [#16659 / #17396] The clean fixture carries `organization`. + // It no longer HAS to — lint stopped asking — but a correct + // time-triggered flow under a walled posture still declares + // the organization it runs as, so this is what a clean one + // looks like. ⛔ Not a relaxation of the floor this case + // guards either way. config: { timeRelative: { object: 'task', dateField: 'due_at', withinDays: 30 }, organization: 'org_2mtx1w9d0k4bqf7v', @@ -1223,14 +1205,24 @@ describe('validateFlowTriggerReadiness', () => { }); }); - // ─── the acting organization (#16659) ─────────────────────────────────── + // ─── the acting organization: RETIRED as an authoring rule (#17396) ────── + // + // #16659 put `flow-schedule-organization-missing` here, on the argument that + // a key the two triggers REFUSE to bind without, and that authoring never + // mentions, is authoring/runtime drift. Ruling G removed the premise: a + // deployment-level switch now gates time-triggered flows, and under the + // `single` posture with that switch on a flow that declares NOTHING binds and + // runs correctly. Neither the switch nor the posture is metadata — both are + // read from the environment at boot — so this rule could not tell the two + // deployments apart and fired on the default one. // - // `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)', () => { + // ⇒ What is pinned now is the ABSENCE, and it is pinned positively: the + // fixtures below are the exact ones the deleted rule fired on, and the + // assertion is that `validateFlowTriggerReadiness` reports nothing for them. + // ⛔ Not `findings.filter(rule === id).toEqual([])` — that spelling passes + // just as well when the whole function has stopped working, which is the + // empty-verdict trap this file's severity table already names. + describe('acting organization is NOT an authoring rule (#17396)', () => { const taskObject = { name: 'task', label: 'Task', fields: {} }; function timeTriggered(config: Record, overrides: Record = {}) { @@ -1248,90 +1240,67 @@ describe('validateFlowTriggerReadiness', () => { }; } - 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('reports NOTHING for a `schedule` flow that declares no organization', () => { + expect( + validateFlowTriggerReadiness(timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' } })), + 'the deleted rule fired here; under the single posture with the switch on this flow is correct', + ).toEqual([]); }); - 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); + it('reports NOTHING for a `time_relative` sweep that declares no organization', () => { 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'); + validateFlowTriggerReadiness( + timeTriggered({ timeRelative: { object: 'task', dateField: 'due_at', withinDays: 7 } }), + ), + ).toEqual([]); }); - 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); + it('reports NOTHING for a near-miss spelling the open `config` record accepted', () => { + // The near-miss vocabulary did NOT die with the rule — it still names the + // author's own spelling in the BIND-time refusal, at the one door that + // knows the posture and the switch. What is gone is reporting it from a + // layer that knows neither. expect( - findings[0].message, - 'an author who wrote `organizationId` is told about THEIR spelling, not about "nothing"', - ).toContain('organizationId'); + validateFlowTriggerReadiness( + timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' }, organizationId: 'org_x' }), + ), + ).toEqual([]); }); - 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. + it('reports NOTHING for a present-but-unusable organization value', () => { 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); + validateFlowTriggerReadiness(timeTriggered({ schedule: '0 1 * * *', organization: bad })), + `organization: ${JSON.stringify(bad)} is the trigger's judgement to make, not lint's`, + ).toEqual([]); } }); - it('is silent once the flow declares one', () => { - expect( - orgFindings(timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' }, organization: 'org_a' })), - ).toEqual([]); + it('still reports the OTHER rules on a time-triggered flow — the absence is scoped', () => { + // The control for every assertion above: this file did not simply stop + // seeing `schedule` flows. A `timeRelative` descriptor the spec refuses + // is still an `error` on the same fixture shape. + const findings = validateFlowTriggerReadiness( + timeTriggered({ timeRelative: { object: 'task', dateField: 'due_at' } }), + ); + expect(findings.map((f) => f.rule)).toContain(FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID); }); - 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('the retired id is not exported from the rule module', async () => { + // A published id is answerable forever; this one was never released (its + // changeset is still unconsumed), so removing it is the whole point. + const mod = await import('./validate-flow-trigger-readiness.js'); + expect(Object.keys(mod)).not.toContain('FLOW_SCHEDULE_ORGANIZATION_MISSING'); + // Widened to `readonly string[]` on purpose. Left at its inferred type + // the comparison is a TS2367 "no overlap" error — which is the module's + // own type system agreeing with this assertion, and therefore useless as + // a RUNTIME pin: it would stop compiling long before it could ever fail. + // The pin has to survive a future edit that re-adds the slug under some + // other name, and only an untyped read can catch that. + const exportedValues: readonly unknown[] = Object.values(mod); + expect(exportedValues, 'the slug itself must be gone, not merely renamed').not.toContain( + 'flow-schedule-organization-missing', + ); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 981a61066d..c4ce2a1d81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2426,6 +2426,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types devDependencies: '@objectstack/driver-sql': specifier: workspace:* @@ -2942,6 +2945,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types croner: specifier: ^10.0.1 version: 10.0.1 From 5678b560c9aca0c99bf5295db1e1ed9ee3470a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:41:47 +0000 Subject: [PATCH 04/11] wip(17396): three-state bind gate pinned in trigger-schedule Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../src/deployment-switch.test-support.ts | 82 +++++++ .../src/kernel-rebuild-rebind.test.ts | 7 + .../src/schedule-dispatch-claim.test.ts | 7 + .../src/schedule-runas-e2e.test.ts | 7 + .../src/schedule-trigger.test.ts | 169 ++++++++++++++ .../src/time-relative-trigger.test.ts | 215 ++++++++++++++++++ 6 files changed, 487 insertions(+) create mode 100644 packages/triggers/trigger-schedule/src/deployment-switch.test-support.ts diff --git a/packages/triggers/trigger-schedule/src/deployment-switch.test-support.ts b/packages/triggers/trigger-schedule/src/deployment-switch.test-support.ts new file mode 100644 index 0000000000..02cdab3ffc --- /dev/null +++ b/packages/triggers/trigger-schedule/src/deployment-switch.test-support.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17396] Test-only helpers for the deployment facts both triggers now read. + * + * ## Why a helper and not a `setupFiles` entry + * + * Arming the switch globally would make every suite in this package run in a + * NON-default deployment while reading as if it ran in the default one — and + * the default is precisely the state the ruling made interesting (nothing arms, + * with a distinct reason). Each suite that needs the switch on says so, in one + * line, at the top of the file. A reader who opens any of these files can see + * which deployment the assertions below are about. + * + * ⛔ Not exported from `index.ts`. This file is outside the tsup entry + * (`src/index.ts`), so it is compiled by the test program and shipped by + * nothing. + * + * ⚠️ Mutates `process.env` and restores the PREVIOUS value rather than deleting + * the key: a suite running under a CI environment that has already set one of + * these variables must leave it exactly as it found it, and `delete` would be a + * different end state from "was unset". + */ + +import { afterEach, beforeEach } from 'vitest'; +import { SCHEDULED_WORK_ENV } from '@objectstack/types'; + +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; + +function setOrUnset(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +/** + * Run this file's suites on a deployment that HAS package-authored scheduled + * work switched on, at the given tenancy posture. + * + * `posture` defaults to `'single'` — the platform default, and the state in + * which a time-triggered flow binds while declaring no organization. A suite + * about the declaration refusal passes `'isolated'`, because that refusal only + * exists behind a wall. + */ +export function withScheduledWorkOn(posture: 'single' | 'group' | 'isolated' = 'single'): void { + let priorSwitch: string | undefined; + let priorPosture: string | undefined; + beforeEach(() => { + priorSwitch = process.env[SCHEDULED_WORK_ENV]; + priorPosture = process.env[POSTURE_ENV]; + process.env[SCHEDULED_WORK_ENV] = 'true'; + process.env[POSTURE_ENV] = posture; + }); + afterEach(() => { + setOrUnset(SCHEDULED_WORK_ENV, priorSwitch); + setOrUnset(POSTURE_ENV, priorPosture); + }); +} + +/** + * Run this file's suites on a deployment that has NOT switched it on — the + * global default, in every posture and every kernel. + * + * Both variables are cleared rather than merely left alone: a CI environment + * that exported either one would otherwise make this suite assert about a + * deployment it did not choose, which is the failure mode a default-OFF switch + * makes easiest to miss (the suite would go green for the wrong reason on a box + * where the switch happened to be off anyway, and red only elsewhere). + */ +export function withScheduledWorkOff(posture?: 'single' | 'group' | 'isolated'): void { + let priorSwitch: string | undefined; + let priorPosture: string | undefined; + beforeEach(() => { + priorSwitch = process.env[SCHEDULED_WORK_ENV]; + priorPosture = process.env[POSTURE_ENV]; + delete process.env[SCHEDULED_WORK_ENV]; + setOrUnset(POSTURE_ENV, posture); + }); + afterEach(() => { + setOrUnset(SCHEDULED_WORK_ENV, priorSwitch); + setOrUnset(POSTURE_ENV, priorPosture); + }); +} 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 9a952f0052..ed7b44e429 100644 --- a/packages/triggers/trigger-schedule/src/kernel-rebuild-rebind.test.ts +++ b/packages/triggers/trigger-schedule/src/kernel-rebuild-rebind.test.ts @@ -30,6 +30,7 @@ import { Cron, scheduledJobs } from 'croner'; import type { AutomationContext, JobSchedule, JobHandler } from '@objectstack/spec/contracts'; import { ScheduleTrigger, type FlowTriggerBinding, type JobServiceSurface, type TriggerLogger } from './schedule-trigger.js'; import { TimeRelativeTrigger, type TimeRelativeDataEngine } from './time-relative-trigger.js'; +import { withScheduledWorkOn } from './deployment-switch.test-support.js'; const flush = () => new Promise((r) => setTimeout(r, 0)); @@ -98,6 +99,12 @@ function oneRowEngine(): TimeRelativeDataEngine { }; } +// [#17396] Every assertion in this file is about a deployment that RUNS +// package-authored scheduled work. The switch is OFF by default in every +// posture, so without this line nothing here binds and every case below +// would fail for a reason that has nothing to do with what it pins. +withScheduledWorkOn('single'); + describe('#8362 — a rebuilt kernel re-binds scheduled flows (both triggers)', () => { it('ScheduleTrigger: bind -> evict -> re-bind is scheduled exactly once and fires the NEW kernel', async () => { const FLOW = 'nightly_contract_rollup'; 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 d96380f18b..66285b3c67 100644 --- a/packages/triggers/trigger-schedule/src/schedule-dispatch-claim.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-dispatch-claim.test.ts @@ -34,6 +34,7 @@ import { type ScheduleDispatchLedger, type TriggerLogger, } from './schedule-trigger.js'; +import { withScheduledWorkOn } from './deployment-switch.test-support.js'; // ─── Harness ──────────────────────────────────────────────────────── @@ -133,6 +134,12 @@ async function rig(opts: { now?: Date; store?: InMemoryFlowDispatchStore; throws // ─── The window key ───────────────────────────────────────────────── +// [#17396] Every assertion in this file is about a deployment that RUNS +// package-authored scheduled work. The switch is OFF by default in every +// posture, so without this line nothing here binds and every case below +// would fail for a reason that has nothing to do with what it pins. +withScheduledWorkOn('single'); + describe('computeTickWindow — one notion of "window", derived from the schedule itself', () => { it('cron: every instant inside one occurrence maps to the same window start', () => { const schedule = { type: 'cron' as const, expression: '0 1 * * *', timezone: 'UTC' }; 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 4709dd5659..c12ae7bbf7 100644 --- a/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts @@ -19,6 +19,7 @@ import { describe, it, expect } from 'vitest'; import { AutomationEngine } from '@objectstack/service-automation'; import type { AutomationContext, JobSchedule, JobHandler } from '@objectstack/spec/contracts'; import { ScheduleTrigger } from './schedule-trigger.js'; +import { withScheduledWorkOn } from './deployment-switch.test-support.js'; function recordingLogger(): { logger: any; warns: string[] } { const warns: string[] = []; @@ -85,6 +86,12 @@ function captureDataContext(engine: AutomationEngine): AutomationContext[] { return seen; } +// [#17396] Every assertion in this file is about a deployment that RUNS +// package-authored scheduled work. The switch is OFF by default in every +// posture, so without this line nothing here binds and every case below +// would fail for a reason that has nothing to do with what it pins. +withScheduledWorkOn('single'); + describe('schedule trigger -> engine: user-less runAs fail-open via the REAL cron path (#1888)', () => { it('a fired scheduled job runs the flow UNSCOPED (user-less) and the engine warns', async () => { const { logger, warns } = recordingLogger(); diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts index 57a010645a..c7253f8288 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts @@ -11,6 +11,8 @@ import { type TriggerLogger, } from './schedule-trigger.js'; import { ScheduleTriggerPlugin } from './plugin.js'; +import { withScheduledWorkOff, withScheduledWorkOn } from './deployment-switch.test-support.js'; +import { SCHEDULED_WORK_ENV } from '@objectstack/types'; // ─── Test doubles ─────────────────────────────────────────────────── @@ -121,6 +123,11 @@ describe('normalizeSchedule', () => { // ─── ScheduleTrigger ──────────────────────────────────────────────── describe('ScheduleTrigger', () => { + // [#17396] Every assertion in this suite is about a deployment that RUNS + // package-authored scheduled work. Without the switch nothing binds — which + // is its own suite further down, not a wrinkle in these. + withScheduledWorkOn(); + it('schedules a job for the flow with the normalized schedule', async () => { const job = fakeJobService(); const trigger = new ScheduleTrigger(() => job.service, silentLogger()); @@ -233,6 +240,8 @@ describe('ScheduleTrigger', () => { // ─── ScheduleTriggerPlugin ────────────────────────────────────────── describe('ScheduleTriggerPlugin', () => { + withScheduledWorkOn(); + interface FakeCtx { readyHandlers: Array<() => Promise | void>; ctx: { @@ -326,6 +335,11 @@ describe('ScheduleTriggerPlugin', () => { // binding from `binding()` above, which must still arm. describe('ScheduleTrigger — the acting-organization refusal (#16659)', () => { + // [#17396] `isolated`, and the posture is now load-bearing: this refusal + // exists behind a WALL. The same binding under `single` is armed, not + // refused — see the deployment-switch suite below, which pins exactly that. + withScheduledWorkOn('isolated'); + const orgLess = () => binding({ organization: undefined, config: {} }); it('THROWS from start(), so the engine cannot record the flow as bound', () => { @@ -485,3 +499,158 @@ describe('resolveBindingOrganization (#16659)', () => { ).toBeNull(); }); }); + +// ─── the deployment switch: the three bind states (#17396) ────────── +// +// Ruling G, recorded on #17396 (director seat, decision batch #116 item 4, +// amended by batch #118): a deployment-level variable gates time-triggered +// flows, the global default is OFF in every posture and every kernel, and when +// it is ON the 2026-09-08 declaration requirement applies behind a WALL only. +// +// Three states, three suites, and each one asserts what BINDS rather than only +// what is logged: a refusal that logs correctly and arms the job anyway is the +// exact defect #16659's own refusal was shaped to avoid. +describe('ScheduleTrigger — the deployment switch is OFF (#17396)', () => { + withScheduledWorkOff(); + + it('arms nothing, whatever the flow declares', () => { + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + + // Declares an organization AND a valid cadence — nothing about this + // flow is wrong. The deployment simply does not run scheduled work. + expect(() => trigger.start(binding(), async () => {})).toThrow(/deployment policy/); + expect(job.jobs.size, 'a policy-disabled flow must have no job at all').toBe(0); + }); + + it('names the switch and its remedy, and ⛔ never says the binding failed', () => { + const job = fakeJobService(); + const infos: string[] = []; + const log = recordingLogger(); + const trigger = new ScheduleTrigger(() => job.service, { + ...log.logger, + info: (msg: string) => void infos.push(String(msg)), + }); + + expect(() => trigger.start(binding(), async () => {})).toThrow(); + + const said = infos.join('\n'); + expect(said, 'the operator is owed the variable by name').toContain(SCHEDULED_WORK_ENV); + expect(said, 'and the flow it is about').toContain('nightly_health_sweep'); + expect(said, 'and that this is policy, not a defect').toMatch(/deployment policy/); + expect(said, 'the remedy is the switch, not the flow').toMatch(/nothing about the flow needs fixing/); + // ⭐ The distinction ruled item 6 is entirely about. + expect(said).not.toMatch(/binding failed/); + expect( + log.errors.concat(log.warns).join('\n'), + 'the DEFAULT configuration of every deployment must not print a warning or an error', + ).toBe(''); + }); + + it('refuses BEFORE the descriptor and the declaration are judged', () => { + // Otherwise an operator on a deployment that was never going to run + // this flow is sent to fix a descriptor nothing would have read, or to + // write a key nothing would have wanted. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + const broken = binding({ organization: undefined, config: {}, schedule: 'not-a-cron-…' }); + + expect(() => trigger.start(broken, async () => {})).toThrow(/deployment policy/); + }); + + it('drops a job armed while the switch was on, so flipping it off disarms', () => { + // The switch is read at BIND, so this is what a rebind after an + // operator turned it off has to do: the previous job must not survive + // behind a refusal that says the flow is not armed. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + + process.env[SCHEDULED_WORK_ENV] = 'true'; + trigger.start(binding(), async () => {}); + expect(job.jobs.size, 'control: it really did arm while the switch was on').toBe(1); + + delete process.env[SCHEDULED_WORK_ENV]; + expect(() => trigger.start(binding(), async () => {})).toThrow(/deployment policy/); + expect(job.jobs.size, 'the prior job must be gone, not left ticking').toBe(0); + }); +}); + +describe('ScheduleTrigger — switched ON under `single` (#17396)', () => { + withScheduledWorkOn('single'); + + const orgLess = () => binding({ organization: undefined, config: {} }); + + it('arms a flow that declares NO organization', () => { + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + + // ⭐ The widening the whole card turns on: this exact binding is + // REFUSED under a wall (the #16659 suite above) and armed here. + trigger.start(orgLess(), async () => {}); + expect(job.jobs.size).toBe(1); + }); + + it('the run carries NO organization — the key is absent, not undefined', async () => { + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-schedule:nightly_health_sweep'); + + expect(seen).toHaveLength(1); + // ⛔ `'tenantId' in ctx` rather than `ctx.tenantId === undefined`: the + // ruling says the run carries no organization, and a present-but- + // undefined key is a different thing to every consumer that asks `in`. + expect('tenantId' in seen[0], 'no tenantId key at all').toBe(false); + }); + + it('still threads a DECLARED organization onto the run', async () => { + // `single` removes the REQUIREMENT, not the capability: a deployment + // that declares one still gets it, so nothing that worked stops. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + const seen: AutomationContext[] = []; + + trigger.start(binding(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-schedule:nightly_health_sweep'); + + expect(seen[0]?.tenantId).toBe('org_2mtx1w9d0k4bqf7v'); + }); + + it('⛔ still never invents one', () => { + // The one limb the 2026-09-08 ruling forbids outright. `single` omits + // the key; it does not fill it from the install, the platform + // organization, or anything else. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + expect(resolveBindingOrganization(orgLess())).toBeNull(); + trigger.start(orgLess(), async () => {}); + expect(job.jobs.size).toBe(1); + }); +}); + +describe('ScheduleTrigger — switched ON under a wall (#17396)', () => { + withScheduledWorkOn('group'); + + it('`group` is walled: an undeclared flow is refused there too', () => { + // Ruled explicitly — 「group 默认也关」 for the default, and `group` + // behaves as walled while the question of which organization a + // group-wide sweep's inserts belong to is unanswered. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + expect(() => + trigger.start(binding({ organization: undefined, config: {} }), async () => {}), + ).toThrow(/declares no acting organization/); + expect(job.jobs.size).toBe(0); + }); + + it('and a declared one binds', () => { + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + trigger.start(binding(), async () => {}); + expect(job.jobs.size).toBe(1); + }); +}); 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 16b513da4e..81055f76a1 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -14,6 +14,8 @@ import { type TriggerLogger, } from './index.js'; import { TimeRelativeTriggerPlugin } from './time-relative-plugin.js'; +import { withScheduledWorkOff, withScheduledWorkOn } from './deployment-switch.test-support.js'; +import { SCHEDULED_WORK_ENV } from '@objectstack/types'; // ─── Test doubles ─────────────────────────────────────────────────── @@ -208,6 +210,10 @@ describe('buildWindowWhere', () => { // ─── TimeRelativeTrigger ───────────────────────────────────────────── describe('TimeRelativeTrigger', () => { + // [#17396] These assertions are about a deployment that RUNS + // package-authored scheduled work; the OFF state is its own suite. + withScheduledWorkOn('single'); + it('schedules a daily sweep with the explicit schedule descriptor', async () => { const job = fakeJobService(); const { engine } = fakeDataEngine([]); @@ -498,6 +504,10 @@ function fakeClaimLedger() { } describe('TimeRelativeTrigger dispatch idempotency (#10220)', () => { + // [#17396] These assertions are about a deployment that RUNS + // package-authored scheduled work; the OFF state is its own suite. + withScheduledWorkOn('single'); + const JOB = 'flow-time-relative:renewal_alert'; it('offset mode: two sweeps over the same window dispatch once', async () => { @@ -700,6 +710,10 @@ describe('computeWindowClaimScopes', () => { // ─── TimeRelativeTriggerPlugin ────────────────────────────────────── describe('TimeRelativeTriggerPlugin', () => { + // [#17396] These assertions are about a deployment that RUNS + // package-authored scheduled work; the OFF state is its own suite. + withScheduledWorkOn('single'); + function fakePluginCtx(services: Record) { const readyHandlers: Array<() => Promise | void> = []; return { @@ -789,6 +803,10 @@ describe('TimeRelativeTriggerPlugin', () => { // worse. Both halves are pinned below. describe('TimeRelativeTrigger — the acting-organization refusal (#16659)', () => { + // [#17396] `isolated` — this refusal exists behind a WALL, and under + // `single` the same binding is armed instead. See the switch suite below. + withScheduledWorkOn('isolated'); + const DESC = { object: 'contracts', dateField: 'end_date', withinDays: 60 }; function recordingLogger(): { logger: TriggerLogger; errors: string[]; warns: string[] } { @@ -1034,3 +1052,200 @@ describe('TimeRelativeTrigger — the acting-organization refusal (#16659)', () ).toHaveLength(0); }); }); + +// ─── the deployment switch: the sweep's three bind states (#17396) ── +// +// The sweep's stakes differ from the plain schedule flow's, and both are +// pinned. Its SELECTION is the thing the organization bounds, so an unscoped +// sweep has to be a deployment state someone chose rather than a state the +// trigger fell into — and when the deployment has not chosen scheduled work at +// all, no query is issued and no window is claimed. +describe('TimeRelativeTrigger — the deployment switch is OFF (#17396)', () => { + withScheduledWorkOff(); + + const DESC = { object: 'contracts', dateField: 'end_date', withinDays: 60 }; + + it('arms no sweep and issues no query, whatever the flow declares', async () => { + const job = fakeJobService(); + const data = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.engine, silentLogger(), NOW); + + expect(() => trigger.start(binding(DESC), async () => {})).toThrow(/deployment policy/); + await flush(); + expect(job.jobs.size, 'no job').toBe(0); + expect(data.calls, 'and no read — the sweep never ran a tick').toHaveLength(0); + }); + + it('refuses BEFORE the descriptor is judged', () => { + // A sweep's descriptor diagnostics are long and specific; sending an + // operator to fix one on a deployment that was never going to run the + // sweep is the wrong remedy at the wrong door. + const job = fakeJobService(); + const data = fakeDataEngine([]); + const warns: string[] = []; + const trigger = new TimeRelativeTrigger( + () => job.service, + () => data.engine, + { info: () => {}, debug: () => {}, warn: (m: string) => void warns.push(String(m)) }, + NOW, + ); + + expect(() => trigger.start(binding({ object: 'contracts' }), async () => {})).toThrow(/deployment policy/); + expect( + warns.filter((w) => w.includes('no valid `timeRelative` descriptor')), + 'the descriptor verdict must not be reported — it was never reached', + ).toHaveLength(0); + }); + + it('names the switch and ⛔ never says the binding failed', () => { + const job = fakeJobService(); + const infos: string[] = []; + const trigger = new TimeRelativeTrigger( + () => job.service, + () => fakeDataEngine([]).engine, + { info: (m: string) => void infos.push(String(m)), warn: () => {}, debug: () => {} }, + NOW, + ); + + expect(() => trigger.start(binding(DESC), async () => {})).toThrow(); + const said = infos.join('\n'); + expect(said).toContain(SCHEDULED_WORK_ENV); + expect(said).toContain('renewal_alert'); + expect(said).not.toMatch(/binding failed/); + }); +}); + +describe('TimeRelativeTrigger — switched ON under `single` (#17396)', () => { + withScheduledWorkOn('single'); + + const DESC = { object: 'contracts', dateField: 'end_date', withinDays: 60 }; + const orgLess = () => binding(DESC, { organization: undefined, config: { timeRelative: DESC } }); + + it('arms a sweep that declares NO organization', () => { + const job = fakeJobService(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => fakeDataEngine([]).engine, + silentLogger(), + NOW, + ); + trigger.start(orgLess(), async () => {}); + expect(job.jobs.size).toBe(1); + }); + + it("the sweep's own query carries NO scope — the key is absent, not undefined", async () => { + const job = fakeJobService(); + const data = fakeDataEngine([{ id: 'c1', end_date: '2026-07-25T00:00:00.000Z' }]); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.engine, silentLogger(), NOW); + + trigger.start(orgLess(), async () => {}); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(data.calls.length, 'control: the sweep really did query').toBeGreaterThan(0); + for (const call of data.calls) { + expect(call.context?.isSystem, 'the sweep still runs elevated').toBe(true); + // ⛔ Not `toBeUndefined()`: the ruling says the query carries no + // scope, and "scoped to nothing" is a spelling a reader can + // misread. The key must not be there. + expect('tenantId' in (call.context ?? {}), 'no tenantId key on the find context').toBe(false); + } + }); + + it('the launched run carries no organization either', async () => { + const job = fakeJobService(); + const data = fakeDataEngine([{ id: 'c1', end_date: '2026-07-25T00:00:00.000Z' }]); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(seen).toHaveLength(1); + expect('tenantId' in seen[0]).toBe(false); + expect(seen[0].record, 'the matched record still reaches the run').toMatchObject({ id: 'c1' }); + }); + + it('⛔ never fills the organization from the swept RECORD', async () => { + // The limb the 2026-09-08 ruling forbids outright, and the one an + // unscoped sweep makes reachable for the first time: rows from several + // organizations can now match, and none of their `organization_id` + // values may become the run's identity. + const job = fakeJobService(); + const data = fakeDataEngine([ + { id: 'a1', end_date: '2026-07-25T00:00:00.000Z', organization_id: TEST_ORG }, + { id: 'b1', end_date: '2026-07-26T00:00:00.000Z', organization_id: 'org_other' }, + ]); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(seen.length, 'both rows matched — the sweep is unscoped here').toBe(2); + for (const ctx of seen) expect('tenantId' in ctx).toBe(false); + }); + + it('a DECLARED organization still scopes the query and the run', async () => { + // `single` removes the requirement, not the capability. + const job = fakeJobService(); + const data = tenantScopedDataEngine([ + { id: 'a1', end_date: '2026-07-25T00:00:00.000Z', organization_id: TEST_ORG }, + { id: 'b1', end_date: '2026-07-26T00:00:00.000Z', organization_id: 'org_other' }, + ]); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.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'); + + expect(seen.map((c) => (c.record as { id?: unknown }).id)).toEqual(['a1']); + expect(seen[0].tenantId).toBe(TEST_ORG); + }); + + it('says on the BIND line that the sweep is unscoped, and names the posture', async () => { + // An operator reading the boot log is owed the difference between + // "sees one organization's rows" and "sees every row this install + // holds" — it is not inferable from the metadata, because the metadata + // is identical in both deployments. + const job = fakeJobService(); + const infos: string[] = []; + const trigger = new TimeRelativeTrigger( + () => job.service, + () => fakeDataEngine([]).engine, + { info: (m: string) => void infos.push(String(m)), warn: () => {}, debug: () => {} }, + NOW, + ); + + trigger.start(orgLess(), async () => {}); + await flush(); + + const bindLine = infos.find((l) => l.includes('bound flow')); + expect(bindLine, 'the bind line is the channel this fact belongs on').toBeDefined(); + expect(bindLine).toContain('NO acting organization'); + expect(bindLine).toContain("posture 'single'"); + }); + + it("⛔ does not warn that a declaration 'does NOT narrow this sweep' when there is none", async () => { + // That warning is a DISCLOSURE about a declaration the author made. On + // a flow that declared nothing it would invent a containment claim + // nobody made, which is the direction this file already refuses to + // train operators in. + const job = fakeJobService(); + const warns: string[] = []; + const data = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => data.engine, + { info: () => {}, debug: () => {}, warn: (m: string) => void warns.push(String(m)) }, + NOW, + ); + + trigger.start(orgLess(), async () => {}); + await flush(); + expect(warns.filter((w) => w.includes('does NOT narrow this sweep'))).toHaveLength(0); + }); +}); From 67dbef6e52cfbbcb83c3b50432caf981c1e6fc1e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:55:02 +0000 Subject: [PATCH 05/11] wip(17396): docs, examples, changesets, dogfood posture split Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../schedule-trigger-acting-organization.md | 16 +- .../scheduled-work-deployment-switch.md | 40 +++++ content/docs/automation/flows.mdx | 75 +++++++-- content/docs/automation/jobs.mdx | 28 ++++ .../docs/deployment/environment-variables.mdx | 1 + .../docs/deployment/production-readiness.mdx | 11 ++ content/docs/deployment/tenancy-modes.mdx | 26 +++ .../src/automation/flows/index.ts | 72 +++++---- examples/app-todo/src/flows/task.flow.ts | 48 +++--- ...hedule-acting-organization.dogfood.test.ts | 82 +++++++++- .../src/deployment-switch.test-support.ts | 40 +++++ .../service-automation/src/engine.test.ts | 150 ++++++++++++++++++ .../src/flow-activation-ledger.test.ts | 7 + .../src/flow-hot-reload.test.ts | 7 + 14 files changed, 526 insertions(+), 77 deletions(-) create mode 100644 .changeset/scheduled-work-deployment-switch.md create mode 100644 packages/services/service-automation/src/deployment-switch.test-support.ts diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md index e64819ac1b..92b0230f38 100644 --- a/.changeset/schedule-trigger-acting-organization.md +++ b/.changeset/schedule-trigger-acting-organization.md @@ -5,10 +5,12 @@ "@objectstack/lint": minor --- -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) +fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization behind a tenancy wall, and both its query and its run are confined to it (#16659, narrowed by #17396) +> ⚠️ **Read this banner with #17396's ruling applied — it NARROWS everything below, and the narrowing shipped in the same launch window, so no released version ever saw the wider rule.** Two deployment facts now sit in front of every statement here, and neither is metadata: (1) package-authored scheduled work is gated by `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` and is **OFF by default in every tenancy posture and every kernel** — while it is off NOTHING below happens, because nothing arms; (2) with it on, the declaration requirement below applies under a **walled** posture (`group` / `isolated`) only. Under `single` an armed time-triggered flow declares nothing, carries no organization, and resolves the deployment's one organization beneath it exactly as it did before #16659. ⇒ Wherever this banner says "a time-triggered flow MUST declare", read "under a wall, with scheduled work switched on". The lint finding it announces, `flow-schedule-organization-missing`, is **deleted**: lint can see neither fact. + **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 @@ -35,7 +37,11 @@ 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 convention: during the window the bump level is not the carrier, this banner and -the disposition above are). Nothing that was refused becomes admitted. +the disposition above are). Nothing that was refused becomes admitted. ⚠️ #17396 +changes that last sentence in one direction: under `single` with the switch on, +a flow that this changeset would have left unarmed **binds and runs**. That is a +widening, it lands in the same window, and it is why #17396's own changeset is +also a `minor`. 1. **Bind time.** A `schedule` or `time_relative` flow that declares no `organization` is no longer armed. @@ -109,7 +115,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` 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/lint`** ⚠️ **nothing, after #17396.** This changeset originally added `flow-schedule-organization-missing` at `warning`; that id is deleted in the same window and was never published. The reason is the rule family's own criterion — *is this stack enough to know the flow is dead?* — answered honestly: it is not, because the deployment switch and the tenancy posture decide it and neither is in any stack. The near-miss diagnostic it shared with the triggers stays at BIND, where both facts are readable. - **`@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. @@ -126,4 +132,6 @@ 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, 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. +**The four flows this repo itself ships** — ⚠️ this paragraph is superseded by #17396 and kept for the record of what was measured. Their answer is now the deployment switch, not an authoring repair: off, they are listed as *disabled by deployment policy*; on under `single`, they run as written; on under a wall, they still need a declaration no package can carry. The original measurement follows. + +**They 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/.changeset/scheduled-work-deployment-switch.md b/.changeset/scheduled-work-deployment-switch.md new file mode 100644 index 0000000000..5ef3510bed --- /dev/null +++ b/.changeset/scheduled-work-deployment-switch.md @@ -0,0 +1,40 @@ +--- +"@objectstack/types": minor +"@objectstack/spec": minor +"@objectstack/trigger-schedule": minor +"@objectstack/service-automation": minor +"@objectstack/runtime": minor +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + +Maintainer ruling, 2026-09-12, verbatim, untranslated: + +> schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + +> 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + +> group 默认也关,云端每库一租户全局默认关 + +**A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + +**OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + +⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + +**BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + +1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()`, the `os dev` / `os start` startup summary and Studio with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. +2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + +**Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + +**`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + +**ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + +**Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index c65998223c..2ba9795851 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2071,8 +2071,10 @@ 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. + // Required under a WALLED tenancy posture, and for a stronger reason + // than a plain schedule flow — it bounds the sweep's query as well as + // its runs. Not required under `single`. See "The acting organization" + // below. organization: '', // schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC }, @@ -2092,6 +2094,32 @@ 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. +### Does this deployment run scheduled work at all? + +Before anything below applies: a time-triggered flow arms only on a deployment +that has switched **package-authored scheduled work** on — +`OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`. It is **off by default in every +tenancy posture and every kernel**, and the same switch governs packaged +`defineJob` cron jobs. + +Whether a clock-driven workload is affordable is a fact about the deployment — +its database, its tenants, its budget — that no package author can know. So it +is a deployment variable rather than metadata, and there is deliberately no spec +key for it. + + +While the switch is off, such a flow is listed in the `os dev` / `os start` +startup summary, in `getTriggerBindingAudit()` and in Studio as **disabled by +deployment policy** — a distinct reason, never "binding failed". Nothing about +the flow needs fixing; the deployment has not asked for it. `os doctor` prints +the effective value. + +⛔ Platform-internal scheduled work — approvals escalation, the lifecycle +Reaper, the messaging dispatch loop, membership backfill — is **not** gated by +this switch. The boundary is *authored by a package*, not *runs on the job +service*. + + ### The acting organization A `record_change` or `api` flow inherits its organization from whoever triggered @@ -2099,8 +2127,10 @@ 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: +Under a **walled** tenancy posture (`group` or `isolated`) that is a question +only the author can answer, so a `schedule` or `timeRelative` flow there +**declares the organization it runs as**, on the start node's `config`, beside +the cadence it scopes: ```typescript config: { @@ -2134,12 +2164,22 @@ scope at all, so the sweep still selects across every organization while its run 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. + +**Under `single` the declaration is not required, and `os lint` says nothing +about it.** That posture holds exactly one organization — a second is refused — +so an armed time-triggered flow there carries **no** organization, its +`timeRelative` sweep runs unscoped, and every tenant-scoped write beneath it +resolves the deployment's one organization exactly as a single-organization +install always did. + +There is no authoring-time check for the key, and that is deliberate rather than +an omission: whether it is required depends on the deployment's posture and its +scheduled-work switch, and neither is visible from a stack. A lint rule that +fired on the default posture would be wrong more often than right. + -**A time-triggered flow that declares none is a declaration error**, refused at -bind: +**Under a wall, 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 @@ -2149,12 +2189,14 @@ bind: - 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. +first row of `sys_organization`", and never the swept record's own +`organization_id`. Behind a wall, a run that reached every tenant-scoped write +with nothing to offer would have each of those writes 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. ⛔ This is unchanged by the posture split above: `single` **omits** +the organization, it never invents one. **No fan-out.** A single flow belongs to one organization. A sweep wanted in several organizations is declared once per organization. @@ -2163,7 +2205,8 @@ 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. +actually wrote — under a wall, which is the only place the key is required and +therefore the only place a near-miss is a mistake. ### Update-triggered flow diff --git a/content/docs/automation/jobs.mdx b/content/docs/automation/jobs.mdx index 7c035aceb1..15d322d2cd 100644 --- a/content/docs/automation/jobs.mdx +++ b/content/docs/automation/jobs.mdx @@ -77,6 +77,34 @@ key, the `sys_job` row key, and the `jobId` stamped on each execution. There is no separate `id` key: it was removed in `@objectstack/spec` 17.0.0 because nothing read it, and two jobs differing only in `id` were one job declared twice. +## Does this deployment run packaged jobs at all? + +A job declared by a **package** — every `defineJob` reaching the runtime through +`defineStack({ jobs })` or a package bundle — is scheduled only on a deployment +that has switched package-authored scheduled work on: + +```bash +OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true +``` + +It is **off by default in every tenancy posture and every kernel**, and it is +the same switch that gates time-triggered flows — one deployment decision for +all package-authored scheduled work, because the resource risk is the same and a +second switch would be a special case. Whether a clock-driven workload is +affordable is a fact about the deployment, not about the job, so it is a +deployment variable rather than metadata. + +While it is off, the boot says so once per app, at `info`, with the count of +jobs it did not schedule; `os doctor` prints the effective value. + + +⛔ **Platform-internal scheduled work is not gated by this switch** and runs +either way: approvals escalation, the lifecycle Reaper, the messaging dispatch +loop, membership backfill. The boundary is *authored by a package*, not *runs on +the job service* — the platform's own maintenance is part of the runtime a +deployment asked for. + + ## Schedule forms `schedule` is a discriminated union on `type`. Three forms, and the schema diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 1929832643..6997a89640 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -87,6 +87,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. | | `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. | | `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. | +| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()`, the CLI startup summary and Studio as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, a time-triggered flow under a walled posture (`group` / `isolated`) must declare `config.organization` on its start node or it is not armed; under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. | | `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). | | `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. | | `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). | diff --git a/content/docs/deployment/production-readiness.mdx b/content/docs/deployment/production-readiness.mdx index 0384394453..d8cf785df1 100644 --- a/content/docs/deployment/production-readiness.mdx +++ b/content/docs/deployment/production-readiness.mdx @@ -132,6 +132,17 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/ma the deploy instead of leaving a server where most pages work and that one subset errors. Setting the flag boots without the database and never reconnects; never set it in production. +- [ ] `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` is a **decision**, not a default. + Package-authored scheduled work — time-triggered flows and packaged + `defineJob` cron jobs — is OFF in every posture until this is set, so a + deployment that expects a packaged digest, sweep or reminder to fire must + set it explicitly. Confirm the effective value with `os doctor`, and read + the startup summary: anything listed there as *disabled by deployment + policy* is not broken, it is simply not switched on. Leaving it off is + also a legitimate answer — an unsized clock-driven workload is a real cost + — but it should be one somebody made. Under `group` / `isolated`, every + flow you do arm must declare `config.organization`; see + [Tenancy Modes & Membership](/docs/deployment/tenancy-modes). - [ ] Backup / restore drill documented and tested. - [ ] Data-retention windows reviewed (ADR-0057): the platform's default `lifecycle` declarations bound telemetry (activity 14d, job runs 30d, diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 45d2360350..1dc1de85ae 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -307,6 +307,31 @@ the enterprise runtime. --- +## Scheduled work is off until the deployment says otherwise + +Time-triggered flows and packaged `defineJob` cron jobs do not run on a +deployment that has not switched them on +(`OS_AUTOMATION_SCHEDULED_WORK_ENABLED`), and that is true in **every** posture, +including `single` and `group`. Whether a clock-driven workload is affordable is +a fact about the deployment — its database, its tenants, its budget — that no +package author can know, so it is not metadata and there is no spec key for it. + +Once it **is** on, the posture decides what a time-triggered flow owes: + +| posture | what an armed time-triggered flow needs | +|---|---| +| `single` | nothing. The deployment holds exactly one organization, the run carries **no** organization, and every tenant-scoped insert beneath it resolves that one organization the way a single-organization install always did. | +| `group` · `isolated` | `config.organization` on the start node, naming the organization the run executes as. A flow that declares none is **not armed** and is named in the boot summary. There is no fan-out: a sweep wanted in N organizations is declared N times, and nothing ever chooses an organization for a flow. | + +`group` is walled here like `isolated`, and not by analogy: an organization-less +system insert is refused under any wall, and which organization a group-wide +sweep's own inserts belong to is not yet decided. Until it is, `group` behaves +as walled. + +While the switch is off, such a flow is listed in the startup summary and in +`getTriggerBindingAudit()` as **disabled by deployment policy** — not as a +binding failure. Nothing about the flow needs fixing. + ## Environment variables | Variable | Default | Effect | @@ -316,6 +341,7 @@ the enterprise runtime. | `OS_ALLOW_DEGRADED_TENANCY` | `false` | Boot even when a walled posture is requested but the runtime is absent (degraded). Accepts `1` / `true` / `on` / `yes`. Does not cover a runtime that refused to mount. | | `OS_ORG_LIMIT` | unset (unlimited) | Cap on organizations a single user may **own**; organizations they were merely invited into never count against it. Only meaningful under a walled posture, since org creation is refused otherwise. | | `OS_AUTH_MEMBERSHIP_POLICY` | `auto` | Env override for the `auth.membership_policy` setting — `auto` or `invite-only`. | +| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | `false` | Whether package-authored scheduled work runs here at all — time-triggered flows and packaged `defineJob` cron jobs. OFF in **every** posture until set. Under `single` a time-triggered flow that is armed by it needs no `config.organization` and its runs carry none; under `group` / `isolated` it must declare one or it is not armed. See [The acting organization](/docs/automation/flows). | | `OS_SKIP_MEMBERSHIP_BACKFILL` | unset | Set to `1` to skip the boot-time membership backfill. | See [Environment variables](/docs/deployment/environment-variables) for the full diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 7213ede113..980da179c7 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -355,28 +355,31 @@ export const TaskCompletedSlackFlow = defineFlow({ * service) registers a job that fires this flow every interval, and each tick * runs the `notify` node. * - * ⛔ 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. + * ⚠️ WHETHER THIS FLOW FIRES IS A DEPLOYMENT DECISION, not something this file + * can settle — and it declares no organization on purpose. #17396 ruled how a + * package-shipped time-triggered flow works; the two facts that decide it are + * both read from the environment at boot, and neither is metadata: * - * ⇒ 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. + * 1. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` — package-authored scheduled work + * is OFF by default in every tenancy posture. Unset, this flow is not + * armed, and the startup summary lists it as *disabled by deployment + * policy* — ⛔ not as a binding failure, and nothing here needs fixing. + * 2. The tenancy posture, once the switch is ON. Under `single` this flow + * binds and runs exactly as written: the run carries NO organization, and + * every tenant-scoped write beneath it resolves the deployment's one + * organization. Under a wall (`group` / `isolated`) it declares + * `config.organization` or it is not armed (#16659, unchanged). * - * `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. + * ⛔ So this example deliberately declares none, and a placeholder id must NOT + * be invented for the walled case: organization ids are minted at runtime, per + * install, and a value matching no row is silently authoritative — strictly + * worse than the refusal. To run this flow on a walled deployment, register it + * at runtime with an `organization` that install actually holds. + * + * `os lint` / `os validate` / `objectstack build` say NOTHING about the missing + * key, and that is the ruling rather than an omission: lint cannot see the + * switch or the posture, so a finding here would be false for the default + * deployment. * * Install `requires: ['automation', 'triggers', 'job', 'messaging']` for the * binding machinery this example demonstrates. @@ -1793,20 +1796,23 @@ export const CommitteeQuorumFlow = defineFlow({ * `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. + * ⚠️ WHETHER THIS SWEEP FIRES IS THE SAME DEPLOYMENT DECISION described on + * {@link ScheduledDigestFlow} — the switch first, then the posture — and it is + * worth stating separately because a sweep is the case where the consequence is + * largest. For a `time_relative` flow the declaration is not only the run's + * identity: it is the SWEEP QUERY's scope. + * + * - Switch OFF (the default): no sweep, no query, nothing armed. Listed as + * *disabled by deployment policy*. + * - Switch ON under `single`: the sweep runs UNSCOPED and its runs carry no + * organization. That is the shape a one-organization install always had — + * there is no second organization for an unscoped read to reach. + * - Switch ON under a wall: `config.organization` or not armed (#16659). The + * declaration bounds the query and the run together. * - * ⇒ 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. + * ⛔ A placeholder id must not be invented for the walled case — a value + * matching no row is silently authoritative. Register this sweep at runtime + * with an `organization` your install holds to see the walled shape 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 23933b6fa2..03ea85ac79 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -1,37 +1,39 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. // -// ⛔ NEITHER FLOW IN THIS FILE FIRES AS SHIPPED (#16659). +// ⚠️ WHETHER EITHER FLOW IN THIS FILE FIRES IS A DEPLOYMENT DECISION (#17396). // -// 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. +// Both are `type: 'schedule'` and neither declares `config.organization`, on +// purpose. Two environment facts decide what happens, and neither is metadata: // -// 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. +// 1. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` — package-authored scheduled work +// is OFF by default in every tenancy posture. Unset, neither flow is armed, +// and both are listed in `getTriggerBindingAudit()` and the CLI startup +// summary as *disabled by deployment policy* — ⛔ NOT as a binding failure. +// Nothing in this file needs fixing for that state. +// 2. The tenancy posture, once the switch is ON. Under `single` both bind and +// run as written, carrying NO organization; every tenant-scoped write +// beneath them resolves the deployment's one organization. Under a wall +// (`group` / `isolated`) a time-triggered flow declares the organization it +// runs as or it is not armed (#16659, unchanged behind the wall). // -// ⇒ 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 +// ⛔ A placeholder id must NOT be invented for the walled case: organization ids +// are minted at runtime, per install, and a value matching no row is silently +// authoritative to every report, export and cleanup that filters by +// organization — strictly worse than the refusal. To run either flow on a +// walled deployment, register it at runtime with an `organization` that 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. +// `os lint` / `os validate` / `objectstack build` say NOTHING about the missing +// key: lint can see neither the switch nor the posture, so a finding here would +// be false for the default deployment. import type { Flow } from '@objectstack/spec/automation'; /** * 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. + * ⚠️ Whether it fires is a deployment decision — it declares no + * `config.organization`. See the file header. */ export const TaskReminderFlow: Flow = { name: 'task_reminder', @@ -87,8 +89,8 @@ export const TaskReminderFlow: Flow = { /** * Overdue Task Escalation Flow. * - * ⛔ Does not fire as shipped: it declares no `config.organization`. See the - * file header. + * ⚠️ Whether it fires is a deployment decision — it declares no + * `config.organization`. See the file header. */ export const OverdueEscalationFlow: Flow = { name: 'overdue_escalation', 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 5c059b728e..847156a5c3 100644 --- a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -43,6 +43,7 @@ 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 { SCHEDULED_WORK_ENV } from '@objectstack/types'; import { scheduleOrganizationStack, declaringScheduleFlow, @@ -127,6 +128,8 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { let orgB: string; let recipientId: string; let memberToken: string; + let priorSwitch: string | undefined; + let priorPosture: string | undefined; beforeAll(async () => { stack = await bootStack(scheduleOrganizationStack as never, { @@ -186,6 +189,36 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { automation.registerFlow(DECLARED_FLOW, declaringScheduleFlow(orgA, recipientId)); automation.registerFlow(UNDECLARED_FLOW, organizationLessScheduleFlow(recipientId)); + // ── [#17396] The DEPLOYMENT the pins below are about ─────────────── + // + // Ruling G put two deployment facts in front of every bind, and both are + // set HERE, for the bind only, rather than at boot: + // + // 1. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` — OFF by default in every + // posture, so without it NOTHING in this suite arms and every pin + // below would go red for a reason that has nothing to do with the + // acting organization. + // 2. `OS_TENANCY_POSTURE=isolated` — the declaration requirement these + // pins exist for now lives behind a WALL. Under `single` the very + // same organization-less flow is ARMED (pinned below, as the other + // half of the same fact), so leaving the posture at its default would + // turn pin (3) into an assertion about a state that no longer exists. + // + // ⚠️ Set around the BIND, not around `bootStack`. Both triggers read + // these live, at `start()`, so the bind sees what is set here — while + // booting the STACK under a wall would demand the enterprise + // organizations plugin this suite deliberately does not install + // (ADR-0093 D5 refuses to boot a walled posture it cannot enforce), and + // that is a different topology from the one the card measured. What the + // pins are about is unchanged by the flip: which organization a run's + // writes carry is decided by the two `sys_organization` rows above and by + // the declaration, and the posture only decides whether the declaration + // is required. + priorSwitch = process.env[SCHEDULED_WORK_ENV]; + priorPosture = process.env.OS_TENANCY_POSTURE; + process.env[SCHEDULED_WORK_ENV] = 'true'; + process.env.OS_TENANCY_POSTURE = 'isolated'; + job = fakeJobService(); log = recordingLogger(); automation.registerTrigger(new ScheduleTrigger(() => job.service, log.logger)); @@ -193,6 +226,12 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { }, 120_000); afterAll(async () => { + // [#17396] Restore the PREVIOUS values rather than deleting the keys — a + // CI box that exported either one must be left exactly as it was found. + if (priorSwitch === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = priorSwitch; + if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = priorPosture; await stack?.stop(); }); @@ -304,7 +343,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { // ⛔ 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', () => { + it('(3) an organization-less scheduled flow is REFUSED at bind UNDER A WALL, 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', @@ -322,6 +361,47 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { expect(refusal).not.toContain(orgB); }); + // ⭐ [#17396] The other half of pin (3), and the reason (3) had to gain the + // words "under a wall". Ruling G leaves the 2026-09-08 refusal exactly as + // it is where a wall exists, and removes it where one does not: under + // `single` the deployment holds exactly one organization by contract (PR + // #17476 refuses a second), so there is no cross-organization task to + // forbid and nothing an author could usefully declare. + // + // ⛔ Not a relaxation pinned by its absence. The SAME flow object and the + // SAME trigger class are bound a second time, with the posture as the only + // thing that differs, so the contrast is attributable to the posture and to + // nothing else — and the tick is deliberately NOT fired: what is pinned is + // that the flow ARMS, and this suite's two-organization data is out of + // contract for `single`, so running it would assert about a deployment the + // platform refuses to create. + it('(3, the other half) under `single` the very same flow ARMS instead', async () => { + const posture = process.env.OS_TENANCY_POSTURE; + const singleJob = fakeJobService(); + const singleLog = recordingLogger(); + try { + process.env.OS_TENANCY_POSTURE = 'single'; + const trigger = new ScheduleTrigger(() => singleJob.service, singleLog.logger); + trigger.start( + { flowName: UNDECLARED_FLOW, config: { schedule: { type: 'cron', expression: '0 8 * * *' } } }, + async () => {}, + ); + await new Promise((r) => setTimeout(r, 0)); + } finally { + if (posture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = posture; + } + + expect( + singleJob.has(`flow-schedule:${UNDECLARED_FLOW}`), + 'under `single` an undeclared time-triggered flow is armed — that widening is the whole of ruling G item 4', + ).toBe(true); + expect( + singleLog.errors.filter((l) => l.includes('declares no acting organization')), + 'and it must not be refused, nor warned about, for a key it does not owe', + ).toHaveLength(0); + }); + it('(3, control) refusing the organization-less flow did not disarm the declaring one', () => { expect( job.has(`flow-schedule:${DECLARED_FLOW}`), diff --git a/packages/services/service-automation/src/deployment-switch.test-support.ts b/packages/services/service-automation/src/deployment-switch.test-support.ts new file mode 100644 index 0000000000..c9d6e41d0d --- /dev/null +++ b/packages/services/service-automation/src/deployment-switch.test-support.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17396] Test-only helper for the deployment switch the engine reads before + * arming a time-triggered flow. + * + * ⛔ Not exported from `index.ts` and outside the tsup entry (`src/index.ts`), + * so it is compiled by the test program and shipped by nothing. It deliberately + * mirrors `@objectstack/trigger-schedule`'s helper of the same name rather than + * being shared across the two packages: the dependency direction is + * service-automation → nothing-that-is-a-trigger, and a shared test fixture is + * not worth inverting it. + * + * Restores the PREVIOUS value rather than deleting the key — "was unset" and + * "deleted" are the same state only when the suite started from unset. + */ + +import { afterEach, beforeEach } from 'vitest'; +import { SCHEDULED_WORK_ENV } from '@objectstack/types'; + +/** + * Run this file's suites on a deployment that HAS package-authored scheduled + * work switched on — the state in which the engine arms `schedule` / + * `time_relative` flows at all. + * + * ⚠️ A suite about trigger WIRING needs this. Without it the engine's audit + * reports every such flow as *disabled by deployment policy* and binds none, + * which is correct behaviour and a total failure of a wiring assertion. + */ +export function withScheduledWorkOn(): void { + let prior: string | undefined; + beforeEach(() => { + prior = process.env[SCHEDULED_WORK_ENV]; + process.env[SCHEDULED_WORK_ENV] = 'true'; + }); + afterEach(() => { + if (prior === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = prior; + }); +} diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index e7f53e02e2..60f602c4bd 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -16,6 +16,8 @@ import { defineActionDescriptor } from '@objectstack/spec/automation'; // program, where `rootDir` rejects it. That module's header carries the full // argument and the measurements behind it. import { recordGuards, stillPinningTheLoop } from '@objectstack/refd-timer-testkit'; +import { withScheduledWorkOn } from './deployment-switch.test-support.js'; +import { SCHEDULED_WORK_ENV, SCHEDULED_WORK_DISABLED_REASON } from '@objectstack/types'; /** * A pausing fixture's `resumeAuthority: 'any'` declaration (#5561). @@ -2819,6 +2821,14 @@ function recordChangeFlow(name: string, overrides?: Record) { } describe('AutomationEngine - Flow Trigger Wiring', () => { + // [#17396] Time-triggered flows arm only where the deployment runs + // package-authored scheduled work, and that switch is OFF by default in + // every posture — so without this line the two time-triggered cases below + // fail for a reason that has nothing to do with wiring. Scoped to this + // suite rather than the file: everything else here is trigger-kind + // agnostic and must keep running in the DEFAULT deployment. + withScheduledWorkOn(); + let engine: AutomationEngine; beforeEach(() => { @@ -3291,3 +3301,143 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor expect(ok.status).toBeUndefined(); }); }); + +// ─── the deployment switch: what the engine does and says (#17396) ── +// +// Ruling G, item 6, in one sentence: when package-authored scheduled work is +// off, every time-triggered flow is listed in `getTriggerBindingAudit()` with a +// DISTINCT reason — *disabled by deployment policy* — and ⛔ never as "binding +// failed". A binding failure is a defect with an engineering remedy; this is a +// deployment policy with an operator remedy, and reporting one as the other +// sends the reader to the wrong place. +describe('AutomationEngine - the deployment switch (#17396)', () => { + const PRIOR = process.env[SCHEDULED_WORK_ENV]; + afterEach(() => { + if (PRIOR === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = PRIOR; + }); + + function scheduleFlow(name: string) { + return { + name, + label: name, + type: 'schedule' as const, + status: 'active', + nodes: [ + { + id: 'start', + type: 'start' as const, + label: 'Start', + config: { schedule: { type: 'cron', expression: '0 8 * * *' }, organization: 'org_a' }, + }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; + } + + it('OFF: never calls the trigger at all — so nothing can throw and nothing can "fail"', () => { + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + const rec = recordingTrigger('schedule'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('digest', scheduleFlow('digest')); + + expect(rec.started, 'the trigger is not asked to arm work the deployment refused').toHaveLength(0); + expect(engine.getActiveTriggerBindings()).toHaveLength(0); + expect(engine.getFlowRuntimeStates().find((s) => s.name === 'digest')?.bound).toBe(false); + }); + + it('OFF: the audit names the policy, and ⛔ NOT a binding failure', () => { + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + engine.registerTrigger(recordingTrigger('schedule').trigger); + engine.registerFlow('digest', scheduleFlow('digest')); + + const audit = engine.getTriggerBindingAudit(); + expect(audit.map((a) => a.flowName), 'ruled item 6: the flow IS listed').toEqual(['digest']); + expect(audit[0].triggerType).toBe('schedule'); + expect(audit[0].reason).toBe(SCHEDULED_WORK_DISABLED_REASON); + // ⭐ The distinction the ruling is entirely about. The registered-trigger + // branch would have said exactly this, which is why it is pinned by + // absence rather than left to the reason's own wording. + expect(audit[0].reason).not.toMatch(/binding failed/); + expect(audit[0].reason).toContain(SCHEDULED_WORK_ENV); + }); + + it('OFF: the policy branch outranks "no trigger is registered", because that remedy does not work', () => { + // Registering the trigger would change nothing while the switch is off, + // so telling the operator to add `requires: ['triggers']` is a remedy + // that cannot succeed. + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + engine.registerFlow('digest', scheduleFlow('digest')); + + const audit = engine.getTriggerBindingAudit(); + expect(audit).toHaveLength(1); + expect(audit[0].reason).toBe(SCHEDULED_WORK_DISABLED_REASON); + expect(audit[0].reason).not.toMatch(/requires: \['triggers'\]/); + }); + + it('OFF: a record_change flow is untouched — the switch is scoped to the clock-driven kinds', () => { + // The control. `record_change` and `api` are fired by a caller that + // already exists and already carries an identity; they are not the + // unbounded background load the switch exists to bound. + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + const rec = recordingTrigger('record_change'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('rc_flow', recordChangeFlow('rc_flow')); + + expect(rec.started).toHaveLength(1); + expect(engine.getTriggerBindingAudit()).toHaveLength(0); + }); + + it('ON: the same flow binds and leaves the audit empty', () => { + // Non-vacuity for every assertion above: the fixture really is armable, + // so "not bound" up there is the switch and not a broken fixture. + process.env[SCHEDULED_WORK_ENV] = 'true'; + const engine = new AutomationEngine(createTestLogger()); + const rec = recordingTrigger('schedule'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('digest', scheduleFlow('digest')); + + expect(rec.started.map((b) => b.flowName)).toEqual(['digest']); + expect(engine.getTriggerBindingAudit()).toHaveLength(0); + }); + + it('ON: a genuine bind failure still reads as one — the two reasons do not collapse', () => { + // The other half of the distinction. With the switch on, a trigger that + // throws is reported exactly as it was before this card. + process.env[SCHEDULED_WORK_ENV] = 'true'; + const engine = new AutomationEngine(createTestLogger()); + engine.registerTrigger({ + type: 'schedule', + start() { + throw new Error('the job service refused'); + }, + stop() {}, + }); + engine.registerFlow('digest', scheduleFlow('digest')); + + const audit = engine.getTriggerBindingAudit(); + expect(audit).toHaveLength(1); + expect(audit[0].reason).toMatch(/binding failed/); + expect(audit[0].reason).not.toBe(SCHEDULED_WORK_DISABLED_REASON); + }); + + it('is read at BIND, not cached, so flipping the switch changes the next registration', () => { + // The CLI's `--fresh` harness and any test that flips the switch + // between kernels in one process depend on this. + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + const rec = recordingTrigger('schedule'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('a', scheduleFlow('a')); + expect(rec.started).toHaveLength(0); + + process.env[SCHEDULED_WORK_ENV] = 'true'; + engine.registerFlow('b', scheduleFlow('b')); + expect(rec.started.map((s) => s.flowName)).toEqual(['b']); + }); +}); diff --git a/packages/services/service-automation/src/flow-activation-ledger.test.ts b/packages/services/service-automation/src/flow-activation-ledger.test.ts index cd158ec866..6266fbca34 100644 --- a/packages/services/service-automation/src/flow-activation-ledger.test.ts +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -30,6 +30,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts'; import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { withScheduledWorkOn } from './deployment-switch.test-support.js'; function createTestLogger(): any { const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; @@ -94,6 +95,12 @@ function engineWithLedger() { // §4 — absence of a row means ACTIVE // ───────────────────────────────────────────────────────────────────────────── +// [#17396] Time-triggered flows arm only where the deployment runs +// package-authored scheduled work, and the switch is OFF by default in every +// posture. Without this, every trigger-wiring assertion below fails for a +// reason that has nothing to do with wiring. +withScheduledWorkOn(); + describe('ADR-0126 §4 — absence of a row = active (an empty ledger changes nothing)', () => { it('a stock boot with an EMPTY ledger arms and runs every flow', async () => { const { engine, triggers } = engineWithLedger(); diff --git a/packages/services/service-automation/src/flow-hot-reload.test.ts b/packages/services/service-automation/src/flow-hot-reload.test.ts index c2d5f8146e..21d2fece03 100644 --- a/packages/services/service-automation/src/flow-hot-reload.test.ts +++ b/packages/services/service-automation/src/flow-hot-reload.test.ts @@ -23,6 +23,7 @@ import { AutomationEngine } from './engine.js'; import { AutomationServicePlugin } from './plugin.js'; import type { FlowTrigger, FlowTriggerBinding } from './engine.js'; import type { AutomationContext } from '@objectstack/spec/contracts'; +import { withScheduledWorkOn } from './deployment-switch.test-support.js'; const flush = () => new Promise((r) => setTimeout(r, 0)); @@ -125,6 +126,12 @@ async function bootKernel(proto: { service: unknown }) { const reload = (kernel: LiteKernel) => (kernel as any).context.trigger('metadata:reloaded', {}); +// [#17396] Time-triggered flows arm only where the deployment runs +// package-authored scheduled work, and the switch is OFF by default in every +// posture. Without this, every trigger-wiring assertion below fails for a +// reason that has nothing to do with wiring. +withScheduledWorkOn(); + describe("scheduled flow hot-reload re-bind (metadata:reloaded re-sync)", () => { it('re-binds an edited scheduled flow to its NEW definition without a restart', async () => { const proto = fakeProtocolService([scheduledFlow('sweep', 'user', 1000)]); From e26ac002da6858e3e06c4779cd795c1d220a91ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:55:17 +0000 Subject: [PATCH 06/11] fix(17396): ADR-0087 disposition is already-registered, not registered Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .changeset/scheduled-work-deployment-switch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/scheduled-work-deployment-switch.md b/.changeset/scheduled-work-deployment-switch.md index 5ef3510bed..af66504327 100644 --- a/.changeset/scheduled-work-deployment-switch.md +++ b/.changeset/scheduled-work-deployment-switch.md @@ -10,7 +10,7 @@ feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) - + Maintainer ruling, 2026-09-12, verbatim, untranslated: From 9e65cbede4d187ec4e72b51676052fcaf7105f57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 14:05:00 +0000 Subject: [PATCH 07/11] fix(17396): alias @objectstack/types to source in both test packages Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../service-automation/vitest.config.ts | 14 ++++++++++++ .../trigger-schedule/vitest.config.ts | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index b10e9eeaed..f70ede4e51 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -46,6 +46,20 @@ export default defineConfig({ find: /^@objectstack\/platform-objects$/, replacement: path.resolve(__dirname, '../../platform-objects/src/index.ts'), }, + { + // [#17396] `engine.ts` reads the deployment's scheduled-work switch + // through `@objectstack/types`, and `engine.test.ts` asserts on the + // exact reason string that package exports. Unaliased, the workspace + // link resolves to `dist/` and the verdict becomes a function of build + // state: a `dist` merely BEHIND would pin the audit's reason against a + // sentence no longer shipping — which is the one thing those + // assertions exist to catch. Same anchored-regex rule as the entry + // above; a bare string `find` matches by PREFIX and would resolve any + // subpath to `…/types/src/index.ts/` (ENOTDIR at run time, from a + // config that reads as correct). + find: /^@objectstack\/types$/, + replacement: path.resolve(__dirname, '../../types/src/index.ts'), + }, ], }, }); diff --git a/packages/triggers/trigger-schedule/vitest.config.ts b/packages/triggers/trigger-schedule/vitest.config.ts index 5e0591efc4..6782ab1444 100644 --- a/packages/triggers/trigger-schedule/vitest.config.ts +++ b/packages/triggers/trigger-schedule/vitest.config.ts @@ -5,6 +5,7 @@ // for every test file in the package (packages/cli/vitest.config.ts's header // records the incident that taught that). import { defineConfig } from 'vitest/config'; +import path from 'node:path'; export default defineConfig({ test: { @@ -16,4 +17,25 @@ export default defineConfig({ // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. disableConsoleIntercept: true, }, + resolve: { + alias: [ + { + // [#17396] Both triggers read the deployment's scheduled-work switch + // through `@objectstack/types`, and the suites assert on the exact + // reason string that package exports. Unaliased, the workspace link + // resolves to `dist/` and the verdict becomes a function of build state + // rather than of the source in this checkout — a `dist` merely BEHIND + // would run the switch pins green against a sentence no longer + // shipping. `pnpm check:test-source-alias` is the gate. + // + // ANCHORED regex, array form, deliberately: a bare string `find` + // matches by PREFIX, so with a FILE replacement it would also swallow + // any subpath and resolve it to `…/types/src/index.ts/` — + // `ENOTDIR` at run time, from a config that reads as correct. Same rule + // as `packages/services/service-automation/vitest.config.ts`. + find: /^@objectstack\/types$/, + replacement: path.resolve(__dirname, '../../types/src/index.ts'), + }, + ], + }, }); From c80021e503f722367384aabdd619e0fad0345365 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 14:23:34 +0000 Subject: [PATCH 08/11] fix(17396): option-B reader probe arms the scheduled-work switch Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../test/fixtures/option-b-reader-probe.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/cli/test/fixtures/option-b-reader-probe.ts b/packages/cli/test/fixtures/option-b-reader-probe.ts index 8568066414..c380406c28 100644 --- a/packages/cli/test/fixtures/option-b-reader-probe.ts +++ b/packages/cli/test/fixtures/option-b-reader-probe.ts @@ -52,6 +52,7 @@ import { join } from 'node:path'; import { LiteKernel } from '@objectstack/core'; import { ObjectQLPlugin } from '@objectstack/objectql'; +import { SCHEDULED_WORK_ENV } from '@objectstack/types'; import { AppPlugin, collectBundleActions, @@ -266,6 +267,25 @@ function makeRecorder(rec: Recording) { * registry at all, which happens either way. */ async function bootAndRecord(bundle: unknown): Promise { + // [#17396] `AppPlugin` schedules package-authored jobs only where the + // deployment runs package-authored scheduled work, and that switch is OFF by + // default in every posture. This probe is not about the deployment: the row + // it feeds asks whether the READER saw the `jobs` collection at all, so with + // the switch unset the loop returns before reading anything and the row goes + // to zero on BOTH shapes — a baseline break that says nothing about option B. + // Armed here, around the boot, and restored after: the resolver reads + // `process.env` live, so this is the whole of what it takes. + const priorScheduledWork = process.env[SCHEDULED_WORK_ENV]; + process.env[SCHEDULED_WORK_ENV] = 'true'; + try { + return await bootAndRecordUnderPolicy(bundle); + } finally { + if (priorScheduledWork === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = priorScheduledWork; + } +} + +async function bootAndRecordUnderPolicy(bundle: unknown): Promise { const rec: Recording = { scheduledJobs: [], connectedDatasources: [], From 38eb66625252a4be3dfbb744688cc3533cc7bc97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 14:50:51 +0000 Subject: [PATCH 09/11] fix(17396): audit reports the recorded policy refusal, not a live env re-read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason was re-derived inside getTriggerBindingAudit() from a live resolveScheduledWorkPolicy() read. The audit is consumed long after the bind, so an environment that moved in between made it report 'binding failed' for a flow whose trigger was never called — the exact reading ruled item 6 forbids. Record the refusal at the gate; clear it the moment a flow gets past. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- ...e-sweep-organization-scope.dogfood.test.ts | 130 +++++++++++++++++- .../service-automation/src/engine.test.ts | 56 ++++++++ .../services/service-automation/src/engine.ts | 95 ++++++++++--- 3 files changed, 261 insertions(+), 20 deletions(-) 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 index 61ceb9f6ec..6270e8b3dd 100644 --- a/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts @@ -39,6 +39,7 @@ 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 { SCHEDULED_WORK_ENV, SCHEDULED_WORK_DISABLED_REASON } from '@objectstack/types'; import { scheduleOrganizationStack, declaringTimeRelativeFlow, @@ -119,6 +120,9 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { let orgB: string; let rowA: string; let rowsB: string[]; + let recipientId: string; + let priorSwitch: string | undefined; + let priorPosture: string | undefined; beforeAll(async () => { stack = await bootStack(scheduleOrganizationStack as never, { @@ -143,7 +147,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { 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'); + recipientId = String(admin?.id ?? 'usr_system'); // ── the differential fixture ────────────────────────────────────── // One matching row in A, TWO in B. Every row is inside the window, so @@ -165,6 +169,36 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { '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()); + // ── [#17396] The DEPLOYMENT this suite is about ─────────────────── + // + // Ruling G put two environment facts in front of every bind, and both + // are set HERE, around the bind, rather than at boot: + // + // 1. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` — package-authored + // scheduled work is OFF by default in every posture, so without it + // NOTHING arms and the `precondition: the sweep BOUND` case below + // fails, taking every assertion built on it with it. ⛔ It is a + // PRECONDITION of this file's subject, not a convenience: what these + // pins measure is which rows an ARMED sweep selects, and an unarmed + // sweep selects nothing for a reason that has nothing to do with + // tenancy. + // 2. `OS_TENANCY_POSTURE=isolated` — the acting-organization + // declaration this sweep carries is REQUIRED only behind a wall. + // Under `single` the same flow arms while declaring nothing and + // sweeps unscoped, which is a different subject with a different + // correct answer. + // + // ⚠️ Set around the BIND, not around `bootStack`: both triggers read + // these live at `start()`, while booting the STACK under a wall would + // demand the enterprise organizations plugin this suite deliberately + // does not install (ADR-0093 D5 refuses to boot a wall it cannot + // enforce). Nothing the pins measure moves: which rows the sweep selects + // is decided by the two `sys_organization` rows and the declaration. + priorSwitch = process.env[SCHEDULED_WORK_ENV]; + priorPosture = process.env.OS_TENANCY_POSTURE; + process.env[SCHEDULED_WORK_ENV] = 'true'; + process.env.OS_TENANCY_POSTURE = 'isolated'; + automation.registerFlow(SWEEP_FLOW, declaringTimeRelativeFlow(orgA, recipientId)); job = fakeJobService(); @@ -174,6 +208,12 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { }, 120_000); afterAll(async () => { + // [#17396] Restore the PREVIOUS values rather than deleting the keys — a + // CI box that exported either one must be left exactly as it was found. + if (priorSwitch === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = priorSwitch; + if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = priorPosture; await stack?.stop(); }); @@ -184,8 +224,94 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { it('precondition: the sweep BOUND', () => { expect( job.has(SWEEP_JOB), - `the sweep did not bind — registered jobs: ${job.names().join(', ') || '(none)'}`, + `the sweep did not bind — registered jobs: ${job.names().join(', ') || '(none)'}` + + ` (⚠️ #17396: this is also the case that fails when ${SCHEDULED_WORK_ENV} is not set —` + + ' package-authored scheduled work is off by default in every posture, and an unarmed' + + ' sweep selects nothing for a reason that has nothing to do with tenancy)', + ).toBe(true); + }); + + // ── [#17396] The OTHER deployment state, which ruling G item 6 requires + // and nothing measured before this card ──────────────────────────── + // + // With the switch OFF neither trigger arms anything, and every such flow is + // listed in `getTriggerBindingAudit()` — the surface the automation + // plugin's `kernel:bootstrapped` warning, the CLI startup summary and + // Studio all read — with a DISTINCT reason: *disabled by deployment + // policy*, ⛔ NEVER "binding failed". + // + // ⭐ That distinction is the whole of the ruled item, and it is not + // cosmetic: a binding failure is a defect with an engineering remedy, while + // this is a deployment policy with an operator remedy, and the two send + // whoever reads the boot summary to different places. It is pinned HERE, + // on the real engine with a real registered trigger, because the engine's + // own catch — the one that writes "binding failed" — is the thing that must + // NOT be reached. + it('[#17396] switch OFF: the sweep does not arm, and the audit says disabled by deployment policy', async () => { + const OFF_FLOW = `${SWEEP_FLOW}_policy_off`; + const OFF_JOB = `flow-time-relative:${OFF_FLOW}`; + const restore = process.env[SCHEDULED_WORK_ENV]; + try { + delete process.env[SCHEDULED_WORK_ENV]; + automation.registerFlow(OFF_FLOW, declaringTimeRelativeFlow(orgA, recipientId)); + await new Promise((r) => setTimeout(r, 0)); + } finally { + if (restore === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = restore; + } + + // ⛔ The flow is well-formed and DECLARES its organization — the same + // fixture the armed sweep above uses. Nothing about it is wrong; the + // deployment simply has not asked for scheduled work. + expect( + job.has(OFF_JOB), + `a policy-disabled flow must have no job at all — registered: ${job.names().join(', ') || '(none)'}`, + ).toBe(false); + + const states = automation.getFlowRuntimeStates() as Array<{ name: string; bound: boolean }>; + expect( + states.find((st: { name: string }) => st.name === OFF_FLOW)?.bound, + "Studio's status badge must not report this flow as armed", + ).toBe(false); + expect( + states.find((st: { name: string }) => st.name === SWEEP_FLOW)?.bound, + 'control: the sweep armed while the switch was ON 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: { flowName: string }) => a.flowName === OFF_FLOW); + expect( + entry, + `ruled item 6: the flow must be LISTED, so the boot summary names it; audit: ${JSON.stringify(audit)}`, + ).toBeTruthy(); + expect(entry!.triggerType).toBe('time_relative'); + expect( + entry!.reason, + 'the reason must be the one sentence every surface shares, so the audit, the CLI summary and Studio cannot drift', + ).toBe(SCHEDULED_WORK_DISABLED_REASON); + expect(entry!.reason, 'and it must name the switch the operator has to set').toContain(SCHEDULED_WORK_ENV); + // ⭐ The prohibition, pinned by absence because the branch it must not + // take produces exactly this phrase. + expect( + entry!.reason, + 'ruled item 6: a policy-disabled flow is ⛔ NEVER reported as a binding failure', + ).not.toMatch(/binding failed/); + expect( + audit.map((a: { flowName: string }) => a.flowName), + 'control: the armed sweep must not be listed as a silent miss', + ).not.toContain(SWEEP_FLOW); + + // And the trigger was never asked: with the switch off the engine does + // not call `start()` at all, so nothing threw and nothing was logged as + // a failure. + expect( + log.errors.filter((l) => l.includes(OFF_FLOW)), + 'a deployment running the configuration it asked for must not print an error', + ).toEqual([]); }); if (databaseDriver === 'memory') { diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 60f602c4bd..073547774c 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -3426,6 +3426,62 @@ describe('AutomationEngine - the deployment switch (#17396)', () => { expect(audit[0].reason).not.toBe(SCHEDULED_WORK_DISABLED_REASON); }); + it('the audit reports what HAPPENED, not what the environment says when it is read', () => { + // ⭐ REGRESSION PIN. The first spelling re-derived the reason inside + // `getTriggerBindingAudit()` from a live `resolveScheduledWorkPolicy()` + // read. The audit is read long after the bind — `kernel:bootstrapped`, + // the CLI startup summary, every Studio poll — so an environment that + // moved in between made it report `binding failed — see earlier + // warnings` for a flow whose trigger was NEVER CALLED, pointing the + // reader at warnings that do not exist. That is precisely the reading + // ruled item 6 forbids, reached by a route the ruling's own words do + // not describe. Caught by the dogfood sweep suite, pinned here. + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + const rec = recordingTrigger('schedule'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('digest', scheduleFlow('digest')); + expect(rec.started, 'control: the flow really was refused by policy').toHaveLength(0); + + // The environment moves, and nothing re-registers the flow. + process.env[SCHEDULED_WORK_ENV] = 'true'; + + const audit = engine.getTriggerBindingAudit(); + expect(audit.map((a) => a.flowName)).toEqual(['digest']); + expect( + audit[0].reason, + 'the flow is still unarmed because the policy refused it — the switch moving later does not turn that into a binding failure', + ).toBe(SCHEDULED_WORK_DISABLED_REASON); + expect(audit[0].reason).not.toMatch(/binding failed/); + }); + + it('a flow that gets past the gate drops the record, so the reason is its own', () => { + // The other direction, and what keeps the record from becoming a + // permanent label: once the switch is on and the flow is registered + // again, whatever happens next owns the reason. + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + engine.registerFlow('digest', scheduleFlow('digest')); + expect(engine.getTriggerBindingAudit()[0]?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON); + + process.env[SCHEDULED_WORK_ENV] = 'true'; + // Registering the trigger re-attempts activation for every flow. + engine.registerTrigger({ + type: 'schedule', + start() { + throw new Error('the job service refused'); + }, + stop() {}, + }); + + const audit = engine.getTriggerBindingAudit(); + expect(audit).toHaveLength(1); + expect( + audit[0].reason, + 'a real bind failure after the gate opened must read as one — the policy record must not outlive the policy', + ).toMatch(/binding failed/); + }); + it('is read at BIND, not cached, so flipping the switch changes the next registration', () => { // The CLI's `--fresh` harness and any test that flips the switch // between kernels in one process depend on this. diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 085fcd414c..44bafc71ef 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -2222,6 +2222,34 @@ export class AutomationEngine implements IAutomationService { * which trigger to `stop()` when a flow is unregistered/disabled. */ private boundFlowTriggers = new Map(); + /** + * [#17396] Flows {@link activateFlowTrigger} refused to arm because + * package-authored scheduled work is switched off on this deployment. + * + * ## Why a record and not a re-read + * + * `getTriggerBindingAudit()` runs long after the bind — at + * `kernel:bootstrapped`, in the CLI startup summary, on every Studio poll — + * and the switch is an ENVIRONMENT value that can move in between. Asking + * the environment again at read time was the first spelling and it is + * measurably wrong: a flow the engine never even offered to its trigger was + * reported as `binding failed — see earlier warnings`, which is the one + * thing ruled item 6 forbids this state from reading as, and it pointed at + * warnings that do not exist. The record answers the question the audit + * actually asks — *why is this flow not armed* — with what happened. + * + * ## Why this is not a recorded startup verdict (AGENTS.md) + * + * The repo's rule forbids recording a conclusion drawn from a registry that + * is still filling, because a provider registering a moment later cannot + * undo the record. None of that applies: the input is an environment + * variable, not a registry, and the record is not terminal — + * {@link activateFlowTrigger} DELETES the entry the moment a flow gets past + * the gate, and {@link unregisterFlow} drops it with the flow. A later + * registration under a switched-on deployment clears it by the ordinary + * path. + */ + private readonly policyDisabledFlows = new Set(); /** Connectors registered by integration plugins, keyed by connector name (ADR-0018 §Addendum). */ private connectors = new Map(); /** Connector provider factories keyed by provider name (ADR-0097 §2 — `openapi`/`mcp`/`rest`/…). */ @@ -3358,8 +3386,6 @@ export class AutomationEngine implements IAutomationService { if (this.boundFlowTriggers.has(flowName)) return; const resolved = this.resolveTriggerBinding(flowName); if (!resolved) return; - const trigger = this.triggers.get(resolved.triggerType); - if (!trigger) return; // [#17396] The deployment gate, read HERE rather than only inside the // trigger. The trigger has its own copy of this gate and throws, which // is what protects a host that binds without this engine — but a @@ -3367,26 +3393,46 @@ export class AutomationEngine implements IAutomationService { // method's catch, and that catch says "Failed to bind", which is the // one thing ruled item 6 forbids this state from reading as. Asking the // policy before `start()` keeps the two apart at the source: nothing is - // called, nothing throws, nothing is logged as a failure, and - // `getTriggerBindingAudit()` reports the policy reason from the same - // resolver. + // called, nothing throws, nothing is logged as a failure. // - // ⛔ Not cached on the instance. The resolver reads `process.env` live, - // and a host that rebinds after changing the environment — the CLI's + // ⚠️ AHEAD of the trigger lookup, deliberately. With the switch off, + // registering the missing trigger would change nothing, so reporting + // "no 'schedule' trigger is registered — add requires: ['triggers']" + // hands the operator a remedy that cannot work. The switch outranks it + // because it is the fact that decides the outcome. + // + // ⛔ The POLICY is not cached — the resolver reads `process.env` live, + // so a host that rebinds after changing the environment (the CLI's // `--fresh` harness, a test flipping the switch between kernels in one - // process — must see the value current at the bind. + // process) sees the value current at the bind. What IS recorded is the + // REFUSAL, on {@link policyDisabledFlows}, and that is the difference + // between the two: see its own docblock for why the audit must read + // what happened rather than re-derive it from an environment that may + // have moved since. if (isTimeTriggeredKind(resolved.triggerType) && !resolveScheduledWorkPolicy().enabled) { - // Said once per flow, at `info`, for the reason the trigger's own - // refusal records: this is the DEFAULT state of every deployment - // and the deployment declared it, so nothing is wrong and nothing - // looks normal-but-broken. The structured channel is the audit - // below, which the `kernel:bootstrapped` hook and the CLI startup - // summary both read. - this.logger.info( - `Flow '${flowName}' is not armed on trigger '${resolved.triggerType}' — ${SCHEDULED_WORK_DISABLED_REASON}`, - ); + if (!this.policyDisabledFlows.has(flowName)) { + this.policyDisabledFlows.add(flowName); + // Said once per flow while it stays refused, at `info`, for the + // reason the trigger's own refusal records: this is the DEFAULT + // state of every deployment and the deployment declared it, so + // nothing is wrong and nothing looks normal-but-broken. The + // structured channel is the audit below, which the + // `kernel:bootstrapped` hook and the CLI startup summary read. + this.logger.info( + `Flow '${flowName}' is not armed on trigger '${resolved.triggerType}' — ${SCHEDULED_WORK_DISABLED_REASON}`, + ); + } return; } + // Past the gate the refusal no longer describes this flow: either it + // binds below, or it fails/waits for its trigger with a reason of its + // own. Cleared BEFORE the trigger lookup so a flow whose trigger has + // not arrived yet is reported as waiting for a trigger, not as + // policy-disabled — with the switch on, the missing trigger really is + // the reason. + this.policyDisabledFlows.delete(flowName); + const trigger = this.triggers.get(resolved.triggerType); + if (!trigger) return; try { // A trigger-fired run's result must not vanish (2026-07-17 eval: // a failing record-change flow produced zero output — the failure @@ -4019,6 +4065,10 @@ export class AutomationEngine implements IAutomationService { // of names; a genuinely deleted flow leaves one harmless string. this.flowStatusDisabled.delete(name); this.flowVersionHistory.delete(name); + // [#17396] Dropped with the flow, unlike `flowLedgerDisabled` above: + // that one mirrors a DURABLE row and must survive, while this records + // an in-process bind attempt that no longer has a subject. + this.policyDisabledFlows.delete(name); this.logger.info(`Flow unregistered: ${name}`); } @@ -4088,7 +4138,16 @@ export class AutomationEngine implements IAutomationService { // reported as "binding failed" — a binding failure is a defect with // an engineering remedy, while this is a deployment policy with an // operator remedy, and the two send the reader to different places. - const reason = isTimeTriggeredKind(resolved.triggerType) && !resolveScheduledWorkPolicy().enabled + // + // ⛔ Read from the RECORD, never re-derived from the environment + // here. Re-deriving was the first spelling and it was measured + // wrong: the audit is read long after the bind, so an environment + // that moved in between — an operator setting the switch, a test + // restoring it — makes this method report *binding failed* for a + // flow whose trigger was never called. The record says what + // HAPPENED; `activateFlowTrigger` clears it the moment the flow + // gets past the gate. + const reason = this.policyDisabledFlows.has(name) ? SCHEDULED_WORK_DISABLED_REASON : this.triggers.has(resolved.triggerType) ? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings` From d3597d9ff0ff7fe4657a47a9b6a2c9bb9b9f0050 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:08:05 +0000 Subject: [PATCH 10/11] test(17396): re-target the AppPlugin job suites and pin the Q3 OFF state The four declarative-job suites measure the READER, not the deployment, so they arm the switch. Adds the OFF-state pins ruling G Q3 implies: nothing schedules, said once per app at info with the count, and the job-service verdict is not reported because it was never reached. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../src/app-plugin.job-data-reach.test.ts | 8 ++ .../app-plugin.job-degraded-outcome.test.ts | 8 ++ packages/runtime/src/app-plugin.jobs.test.ts | 120 ++++++++++++++++++ .../src/app-plugin.option-b-packages.test.ts | 8 ++ .../src/scheduled-work.test-support.ts | 42 ++++++ 5 files changed, 186 insertions(+) create mode 100644 packages/runtime/src/scheduled-work.test-support.ts diff --git a/packages/runtime/src/app-plugin.job-data-reach.test.ts b/packages/runtime/src/app-plugin.job-data-reach.test.ts index 8a4e33ff82..5a36208156 100644 --- a/packages/runtime/src/app-plugin.job-data-reach.test.ts +++ b/packages/runtime/src/app-plugin.job-data-reach.test.ts @@ -72,6 +72,7 @@ import { captureExpectedReadRefusals, type ExpectedReadRefusalCapture, } from './expected-read-refusal-noise.js'; +import { withScheduledWorkOn } from './scheduled-work.test-support.js'; /** The record a scheduled sweep is supposed to be able to write. */ const NOTE = { @@ -213,6 +214,13 @@ async function sweepHandler(jobCtx: JobHandlerContext): Promise { const rowsOf = (result: unknown): Array> => Array.isArray(result) ? result as Array> : []; +// [#17396] `AppPlugin` schedules package-authored jobs only where the +// deployment runs package-authored scheduled work, and that switch is OFF by +// default in every posture. These suites measure the READER, not the +// deployment, so without this line every assertion below fails for a reason +// that has nothing to do with its subject. +withScheduledWorkOn(); + describe('#14094 — a declarative job handler has data reach (TS-config path)', () => { it('reads and writes a record through the context it is invoked with', async () => { const h = await harness(); diff --git a/packages/runtime/src/app-plugin.job-degraded-outcome.test.ts b/packages/runtime/src/app-plugin.job-degraded-outcome.test.ts index 561bf198a8..318721c211 100644 --- a/packages/runtime/src/app-plugin.job-degraded-outcome.test.ts +++ b/packages/runtime/src/app-plugin.job-degraded-outcome.test.ts @@ -58,6 +58,7 @@ import { DbJobAdapter } from '@objectstack/service-job'; import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit'; import { AppPlugin } from './app-plugin.js'; import type { JobHandlerContext } from './job-handler-context.js'; +import { withScheduledWorkOn } from './scheduled-work.test-support.js'; /** The reason a #5529-shaped handler reports when its store is unreachable. */ const REASON = 'STORE_UNAVAILABLE'; @@ -180,6 +181,13 @@ function stackWith(jobName: string, handlerKey: string, fn: unknown) { }; } +// [#17396] `AppPlugin` schedules package-authored jobs only where the +// deployment runs package-authored scheduled work, and that switch is OFF by +// default in every posture. These suites measure the READER, not the +// deployment, so without this line every assertion below fails for a reason +// that has nothing to do with its subject. +withScheduledWorkOn(); + describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => { it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => { const h = await harness(); diff --git a/packages/runtime/src/app-plugin.jobs.test.ts b/packages/runtime/src/app-plugin.jobs.test.ts index 915277c9c6..3846b90155 100644 --- a/packages/runtime/src/app-plugin.jobs.test.ts +++ b/packages/runtime/src/app-plugin.jobs.test.ts @@ -6,6 +6,7 @@ import { defineJob } from '@objectstack/spec/system'; import { InMemoryMetricsRegistry, OBSERVABILITY_METRICS_SERVICE, SEMCONV } from '@objectstack/observability'; import { CronJobAdapter } from '@objectstack/service-job'; import { AppPlugin } from './app-plugin.js'; +import { withScheduledWorkOn } from './scheduled-work.test-support.js'; /** * #4567 — declarative cron jobs must actually reach the scheduler. @@ -15,6 +16,13 @@ import { AppPlugin } from './app-plugin.js'; * *inside* the adapter and the throw was swallowed, so a double that records * whatever it is handed cannot see it. */ +// [#17396] `AppPlugin` schedules package-authored jobs only where the +// deployment runs package-authored scheduled work, and that switch is OFF by +// default in every posture. These suites measure the READER, not the +// deployment, so without this line every assertion below fails for a reason +// that has nothing to do with its subject. +withScheduledWorkOn(); + describe('AppPlugin — declarative background jobs (#4567)', () => { let adapter: CronJobAdapter; let metrics: InMemoryMetricsRegistry; @@ -88,6 +96,118 @@ describe('AppPlugin — declarative background jobs (#4567)', () => { expect(metrics.totalCounter(SEMCONV.jobScheduleFailuresTotal)).toBe(0); }); + // ── [#17396 · ruled Q3] the deployment switch ─────────────────────── + // + // Package-authored `defineJob` cron jobs fall under the SAME deployment + // switch as time-triggered flows: one switch for all package-authored + // scheduled work, because the resource risk is the same and a second switch + // would be a special case. The boundary is *authored by a package*, ⛔ NOT + // *runs on the job service* — platform-internal jobs (approvals escalation, + // the lifecycle Reaper, the messaging dispatch loop, membership backfill) + // schedule themselves from their own service plugins and never reach this + // loop, so they are outside it by construction rather than by an exemption + // this file could weaken. + describe('the deployment switch is OFF (#17396)', () => { + const OFF = { ...process.env }; + beforeEach(() => { delete process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; }); + afterEach(() => { + if (OFF.OS_AUTOMATION_SCHEDULED_WORK_ENABLED === undefined) { + delete process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + } else { + process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = OFF.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + } + }); + + it('schedules NOTHING — a perfectly well-formed packaged job simply does not run here', async () => { + const sweep = vi.fn(async () => { /* handler body */ }); + const plugin = new AppPlugin({ + id: 'com.test.jobs', + jobs: [defineJob({ + name: 'health_sweep', + schedule: { type: 'cron', expression: '0 1 * * *' }, + handler: 'sweep', + })], + functions: { sweep }, + }); + + await plugin.start!(ctx); + await fireReady(); + + // Scheduler state, like the end-to-end case above: nothing was + // handed to the adapter at all. + expect(await adapter.listJobs()).toEqual([]); + expect(sweep, 'and nothing ran').not.toHaveBeenCalled(); + }); + + it('says so ONCE per app, at `info`, with the count — and ⛔ not at `warn` or `error`', async () => { + const plugin = new AppPlugin({ + id: 'com.test.jobs', + jobs: [ + defineJob({ name: 'a', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep' }), + defineJob({ name: 'b', schedule: { type: 'cron', expression: '0 2 * * *' }, handler: 'sweep' }), + ], + functions: { sweep: vi.fn(async () => { /* noop */ }) }, + }); + + await plugin.start!(ctx); + await fireReady(); + + const infos = vi.mocked(ctx.logger.info).mock.calls; + const said = infos.filter((c) => String(c[0]).includes('declarative jobs NOT scheduled')); + expect( + said.length, + 'the remedy is ONE variable — repeating it per job is how a line stops being read', + ).toBe(1); + expect(String(said[0][0])).toContain('OS_AUTOMATION_SCHEDULED_WORK_ENABLED'); + expect(String(said[0][0]), 'and it must say this is policy, not a defect').toContain('deployment policy'); + expect( + (said[0][1] as { jobCount?: number } | undefined)?.jobCount, + 'the count is what tells an operator how much is not running', + ).toBe(2); + + // ⛔ The DEFAULT configuration of every deployment must not print a + // warning or an error ABOUT ITS JOBS. Nothing is wrong: the + // deployment declared this state and the operator can see it in + // `os doctor`. + // + // ⚠️ Scoped to job/schedule lines rather than asserting an empty + // `warnLogs()`. This harness's `ctx` has no `ql.bindHooks`, so it + // always warns once about declarative hooks — a line this suite + // does not own and must not start owning. A bare `toEqual([])` + // here would be an assertion about the fixture, and it would go red + // the day an unrelated warn is added. + const jobNoise = (lines: string[]) => + lines.filter((m) => /job|schedule/i.test(m)); + expect(jobNoise(warnLogs())).toEqual([]); + expect(jobNoise(errorLogs())).toEqual([]); + expect(metrics.totalCounter(SEMCONV.jobScheduleFailuresTotal)).toBe(0); + }); + + it('returns BEFORE the job service is probed — the missing-service warn is a different state', async () => { + // Otherwise a deployment that simply has not switched scheduled work + // on is told its job service is not registered, which is a composition + // defect with a composition remedy, and neither is true here. + const ctxNoJobService = { + ...ctx, + getService: vi.fn((name: string) => (name === 'job' ? undefined : undefined)), + } as unknown as PluginContext; + const plugin = new AppPlugin({ + id: 'com.test.jobs', + jobs: [defineJob({ name: 'a', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep' })], + functions: { sweep: vi.fn(async () => { /* noop */ }) }, + }); + + await plugin.start!(ctxNoJobService); + for (const cb of readyHooks) await cb(); + + expect( + vi.mocked(ctxNoJobService.logger.warn).mock.calls.map((c) => String(c[0])) + .filter((m) => m.includes('job service not registered')), + 'the job-service verdict must not be reported — it was never reached', + ).toEqual([]); + }); + }); + it('REVERT-PROOF: the raw authored schedule still breaks the adapter, exactly as #4567 reported', async () => { const job = defineJob({ name: 'health_sweep', diff --git a/packages/runtime/src/app-plugin.option-b-packages.test.ts b/packages/runtime/src/app-plugin.option-b-packages.test.ts index 6d63be8f61..6b4860c72d 100644 --- a/packages/runtime/src/app-plugin.option-b-packages.test.ts +++ b/packages/runtime/src/app-plugin.option-b-packages.test.ts @@ -29,6 +29,7 @@ import { collectBundleFunctionEntries, collectBundleHooks, } from './app-plugin.js'; +import { withScheduledWorkOn } from './scheduled-work.test-support.js'; const field = { name: 'name', type: 'text', label: 'Name' } as const; @@ -101,6 +102,13 @@ const additiveBundle = (extra: Record = {}) => { }; }; +// [#17396] `AppPlugin` schedules package-authored jobs only where the +// deployment runs package-authored scheduled work, and that switch is OFF by +// default in every posture. These suites measure the READER, not the +// deployment, so without this line every assertion below fails for a reason +// that has nothing to do with its subject. +withScheduledWorkOn(); + describe('#15005 — runtime collectors resolve `packages[]`', () => { it('reads actions — global AND object-embedded — out of `packages[]`', () => { const names = collectBundleActions(optionBBundle()).map((a) => `${a.object ?? 'global'}:${a.name}`); diff --git a/packages/runtime/src/scheduled-work.test-support.ts b/packages/runtime/src/scheduled-work.test-support.ts new file mode 100644 index 0000000000..d5a8c2bf86 --- /dev/null +++ b/packages/runtime/src/scheduled-work.test-support.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17396] Test-only helper for the deployment switch `AppPlugin` reads before + * scheduling a package-authored `defineJob`. + * + * ## Why the job suites need it, and why it is not a relaxation + * + * Ruling G (addendum, Q3) puts package-authored cron jobs under the same + * deployment switch as time-triggered flows, OFF by default in every posture. + * The suites below are not about the deployment: they measure whether the + * declarative-job READER sees the `jobs` collection, whether a handler gets + * data reach, whether a degraded outcome reaches `sys_job_run`. With the switch + * unset the loop returns before reading anything, so every one of those + * assertions goes red for a reason that has nothing to do with its subject. + * + * ⛔ Not a `setupFiles` entry. Arming it globally would make every suite in this + * package run in a non-default deployment while reading as if it ran in the + * default one, and the default is the interesting state. Each suite that needs + * it says so in one line. + * + * Outside the tsup entry (`src/index.ts`), so it is compiled by the test + * program and shipped by nothing. Restores the PREVIOUS value rather than + * deleting the key: "was unset" and "deleted" are the same end state only when + * the suite started from unset. + */ + +import { afterEach, beforeEach } from 'vitest'; +import { SCHEDULED_WORK_ENV } from '@objectstack/types'; + +/** Run this file's suites on a deployment that schedules package-authored jobs. */ +export function withScheduledWorkOn(): void { + let prior: string | undefined; + beforeEach(() => { + prior = process.env[SCHEDULED_WORK_ENV]; + process.env[SCHEDULED_WORK_ENV] = 'true'; + }); + afterEach(() => { + if (prior === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = prior; + }); +} From 59af6612569d75d7e83370dc91b2c9f97e2a2378 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 23:17:28 +0000 Subject: [PATCH 11/11] fix(automation): trim the undelivered Studio leg, pin the scheduled-work resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review rework, both items in the review's scope. 1. Ruling G item 6 named three surfaces that must carry a DISTINCT reason; only two are delivered. Studio's only status door is `GET /automation/_status`, whose rows are `FlowRuntimeState` — a shape with no reason field — so a policy-disabled flow reaches it as `bound: false` and nothing more. Six published carriers stated the Studio leg as fact: the ADR-0087 semantic entry 18 (and its generated registry mirror), the changeset that ships as CHANGELOG, two docs pages and the `SCHEDULED_WORK_DISABLED_REASON` docblock that reaches `dist/*.d.ts`. Each now names only the audit and the CLI startup summary; the flows page says plainly what Studio cannot show, so nobody re-adds the claim. 2. `resolveScheduledWorkEnabled` / `resolveScheduledWorkPolicy` were pinned nowhere in `@objectstack/types`. Added the sibling switches' template (defaults OFF / truthy case-insensitively / anything else off), the three-row policy table, the bogus-posture throw, and a control proving the opt-IN shape differs from `resolveMultiOrgEnabled`'s opt-OUT one. Also: five comments cited "PR #17476 refuses a second" for the one-organization contract under `single`. #17476 touched only `skills/objectstack-data/**`. The claim is true; the enforcement is plugin-auth's org-create posture gate (`auth-manager.ts` `beforeCreateOrganization` → 403, pinned in `org-create-posture-gate.test.ts`). Citation corrected, claim unchanged. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../scheduled-work-deployment-switch.md | 2 +- content/docs/automation/flows.mdx | 13 +- .../docs/deployment/environment-variables.mdx | 2 +- ...hedule-acting-organization.dogfood.test.ts | 5 +- ...e-sweep-organization-scope.dogfood.test.ts | 6 +- .../service-automation/src/engine.test.ts | 9 +- .../services/service-automation/src/engine.ts | 5 +- ...edule-flow-acting-organization-required.ts | 4 +- packages/spec/src/migrations/registry.ts | 4 +- .../trigger-schedule/src/schedule-trigger.ts | 18 +- .../src/time-relative-trigger.ts | 6 +- packages/types/src/env.test.ts | 175 ++++++++++++++++++ packages/types/src/env.ts | 12 +- 13 files changed, 229 insertions(+), 32 deletions(-) diff --git a/.changeset/scheduled-work-deployment-switch.md b/.changeset/scheduled-work-deployment-switch.md index af66504327..1d5e1f9d51 100644 --- a/.changeset/scheduled-work-deployment-switch.md +++ b/.changeset/scheduled-work-deployment-switch.md @@ -28,7 +28,7 @@ Maintainer ruling, 2026-09-12, verbatim, untranslated: **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. -1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()`, the `os dev` / `os start` startup summary and Studio with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. +1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 2ba9795851..62b9a0a155 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2109,10 +2109,15 @@ key for it. While the switch is off, such a flow is listed in the `os dev` / `os start` -startup summary, in `getTriggerBindingAudit()` and in Studio as **disabled by -deployment policy** — a distinct reason, never "binding failed". Nothing about -the flow needs fixing; the deployment has not asked for it. `os doctor` prints -the effective value. +startup summary and in `getTriggerBindingAudit()` as **disabled by deployment +policy** — a distinct reason, never "binding failed". Nothing about the flow +needs fixing; the deployment has not asked for it. `os doctor` prints the +effective value. + +Studio does **not** carry that reason today: `GET /automation/_status` reports +each flow as `bound` or not and has no field to say why, so a policy-disabled +flow is indistinguishable there from one whose trigger is missing. Read the +startup summary or the audit for the reason. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 6997a89640..286193e447 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -87,7 +87,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. | | `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. | | `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. | -| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()`, the CLI startup summary and Studio as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, a time-triggered flow under a walled posture (`group` / `isolated`) must declare `config.organization` on its start node or it is not armed; under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. | +| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, a time-triggered flow under a walled posture (`group` / `isolated`) must declare `config.organization` on its start node or it is not armed; under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. | | `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). | | `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. | | `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). | 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 847156a5c3..8c30c18683 100644 --- a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -364,8 +364,9 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { // ⭐ [#17396] The other half of pin (3), and the reason (3) had to gain the // words "under a wall". Ruling G leaves the 2026-09-08 refusal exactly as // it is where a wall exists, and removes it where one does not: under - // `single` the deployment holds exactly one organization by contract (PR - // #17476 refuses a second), so there is no cross-organization task to + // `single` the deployment holds exactly one organization by contract + // (plugin-auth's org-create posture gate refuses a second), so there is + // no cross-organization task to // forbid and nothing an author could usefully declare. // // ⛔ Not a relaxation pinned by its absence. The SAME flow object and the 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 index 6270e8b3dd..6b288bc5f0 100644 --- a/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts @@ -236,8 +236,8 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { // // With the switch OFF neither trigger arms anything, and every such flow is // listed in `getTriggerBindingAudit()` — the surface the automation - // plugin's `kernel:bootstrapped` warning, the CLI startup summary and - // Studio all read — with a DISTINCT reason: *disabled by deployment + // plugin's `kernel:bootstrapped` warning and the CLI startup summary read, + // its only two callers — with a DISTINCT reason: *disabled by deployment // policy*, ⛔ NEVER "binding failed". // // ⭐ That distinction is the whole of the ruled item, and it is not @@ -291,7 +291,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { expect(entry!.triggerType).toBe('time_relative'); expect( entry!.reason, - 'the reason must be the one sentence every surface shares, so the audit, the CLI summary and Studio cannot drift', + 'the reason must be the one sentence every surface shares, so the audit and the CLI summary cannot drift', ).toBe(SCHEDULED_WORK_DISABLED_REASON); expect(entry!.reason, 'and it must name the switch the operator has to set').toContain(SCHEDULED_WORK_ENV); // ⭐ The prohibition, pinned by absence because the branch it must not diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 073547774c..5bd5adad66 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -3429,10 +3429,11 @@ describe('AutomationEngine - the deployment switch (#17396)', () => { it('the audit reports what HAPPENED, not what the environment says when it is read', () => { // ⭐ REGRESSION PIN. The first spelling re-derived the reason inside // `getTriggerBindingAudit()` from a live `resolveScheduledWorkPolicy()` - // read. The audit is read long after the bind — `kernel:bootstrapped`, - // the CLI startup summary, every Studio poll — so an environment that - // moved in between made it report `binding failed — see earlier - // warnings` for a flow whose trigger was NEVER CALLED, pointing the + // read. The audit is read long after the bind — `kernel:bootstrapped` + // and the CLI startup summary, its only two callers — so an + // environment that moved in between made it report `binding failed — + // see earlier warnings` for a flow whose trigger was NEVER CALLED, + // pointing the // reader at warnings that do not exist. That is precisely the reading // ruled item 6 forbids, reached by a route the ruling's own words do // not describe. Caught by the dogfood sweep suite, pinned here. diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 44bafc71ef..317228b6fd 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -2229,8 +2229,9 @@ export class AutomationEngine implements IAutomationService { * ## Why a record and not a re-read * * `getTriggerBindingAudit()` runs long after the bind — at - * `kernel:bootstrapped`, in the CLI startup summary, on every Studio poll — - * and the switch is an ENVIRONMENT value that can move in between. Asking + * `kernel:bootstrapped` and in the CLI startup summary, its only two + * callers — and the switch is an ENVIRONMENT value that can move in + * between. Asking * the environment again at read time was the first spelling and it is * measurably wrong: a flow the engine never even offered to its trigger was * reported as `binding failed — see earlier warnings`, which is the one 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 index cbc29af02b..f63d6a9e4b 100644 --- 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 @@ -22,8 +22,8 @@ export const entry: SemanticMigration = { + 'PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` ' + 'arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global ' + 'default, in every posture and every kernel — arms neither, and every such flow is ' - + 'listed by `getTriggerBindingAudit()`, the CLI startup summary and Studio as DISABLED ' - + 'BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + + 'listed by `getTriggerBindingAudit()` and the CLI startup summary as DISABLED BY ' + + 'DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + '(approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership ' + 'backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on ' + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS WALLED, declare the ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 7413d207e0..b459ddd74e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10123,8 +10123,8 @@ const step18: MigrationStep = { + 'PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` ' + 'arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global ' + 'default, in every posture and every kernel — arms neither, and every such flow is ' - + 'listed by `getTriggerBindingAudit()`, the CLI startup summary and Studio as DISABLED ' - + 'BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + + 'listed by `getTriggerBindingAudit()` and the CLI startup summary as DISABLED BY ' + + 'DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + '(approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership ' + 'backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on ' + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS WALLED, declare the ' diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index f697036f9b..dbb994717c 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -348,10 +348,14 @@ export function refuseScheduledWorkDisabled( * where it applies — a flow declares its organization or it is not armed, no * fan-out, no organization is ever chosen for it — but it applies to the * postures that have a wall to be crossed. On a `single` deployment with the - * switch on there is exactly one organization (PR #17476 refuses a second), the - * run carries none, and every tenant-scoped insert beneath it resolves that one - * through the #8844 guard, so there is no cross-organization task to forbid and - * nothing for an author to declare. With the switch OFF this refusal is not + * switch on there is exactly one organization — plugin-auth's ORG-CREATE + * POSTURE GATE refuses a second: `auth-manager.ts`'s `beforeCreateOrganization` + * answers 403 "Creating additional organizations is disabled on this + * deployment." whenever `multiOrgPostureEffective()` is false, pinned in + * `org-create-posture-gate.test.ts`. So the run carries none, every + * tenant-scoped insert beneath it resolves that one organization through the + * #8844 guard, there is no cross-organization task to forbid and nothing for + * an author to declare. With the switch OFF this refusal is not * reached at all: {@link refuseScheduledWorkDisabled} answers first, because a * deployment that runs no scheduled work owes no authoring remedy. * @@ -700,9 +704,9 @@ export class ScheduleTrigger implements FlowTrigger { // ruling G an absent `tenantId` is a DECLARED state rather // than a forgotten one — the `single` posture with the // switch on, where the deployment holds exactly one - // organization (PR #17476 refuses a second) and the #8844 - // guard resolves it for every tenant-scoped insert beneath - // the run. It is reached only through that gate: under a + // organization (plugin-auth's org-create posture gate + // refuses a second) and the #8844 guard resolves it for + // every tenant-scoped insert beneath the run. It is reached only through that gate: under a // wall the bind above still refuses an undeclared flow, and // with the switch off nothing binds at all. ⛔ The key is // OMITTED rather than set to `undefined` — the ruling says diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 2b07f9db3a..13f9c49a66 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -324,7 +324,8 @@ export class TimeRelativeTrigger implements FlowTrigger { // [#17396] …under a WALL. With the switch on and posture `single` the // sweep runs unscoped and its runs carry no organization — which is not // the cross-organization task the ruling forbids, because there is only - // one organization there to cross to (PR #17476 refuses a second). The + // one organization there to cross to (plugin-auth's org-create posture + // gate refuses a second). The // elevation argument above is why the `single` case is still safe: an // unscoped `isSystem` read on a one-organization install selects that // organization's rows and the platform's NULL-tenant rows, which is @@ -492,7 +493,8 @@ export class TimeRelativeTrigger implements FlowTrigger { * * [#17396] `null` is now a REACHABLE and legal value, and exactly one * gate admits it: the switch on, tenancy posture `single`. There is no - * second organization to cross to there (PR #17476 refuses one), so an + * second organization to cross to there (plugin-auth's org-create + * posture gate refuses one), so an * unscoped sweep is not the cross-organization task the ruling forbids * — it is the shape a single-organization install had before #16659. * Under a wall `start()` still refuses an undeclared binding, and with diff --git a/packages/types/src/env.test.ts b/packages/types/src/env.test.ts index d8304da309..43b4277321 100644 --- a/packages/types/src/env.test.ts +++ b/packages/types/src/env.test.ts @@ -7,12 +7,17 @@ import { readEnvWithDeprecation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, + resolveMultiOrgEnabled, + resolveScheduledWorkEnabled, + resolveScheduledWorkPolicy, resolveSearchPinyinEnabled, resolveSandboxTimeoutMs, resolveOrgMembershipLimit, isMcpServerEnabled, resolveMcpStdioAutoStart, stampSearchPinyinEnabled, + SCHEDULED_WORK_DISABLED_REASON, + SCHEDULED_WORK_ENV, } from './env.js'; describe('readEnvWithDeprecation', () => { @@ -419,3 +424,173 @@ describe('resolveOrgMembershipLimit (OS_ORG_MEMBERSHIP_LIMIT)', () => { delete process.env.OS_ORG_LIMIT; }); }); + +/** + * [#17396] The deployment switch for PACKAGE-AUTHORED SCHEDULED WORK, pinned in + * the package that owns it. + * + * Its accept set is stated in three places an operator reads — the docblock on + * `resolveScheduledWorkEnabled`, `content/docs/deployment/environment-variables.mdx` + * and `content/docs/automation/flows.mdx` — and every consumer (both time + * triggers, the automation engine's binding audit, the AppPlugin job loop, + * `os doctor`) reads the deployment through these two functions. Pinned here so + * the vocabulary is a contract of THIS package rather than a side effect + * observable only from a trigger suite. + * + * Shape deliberately COPIED from the sibling switches above + * (`resolveAllowDegradedTenancy`, `resolveAllowDevPlugin`): defaults off / + * truthy case-insensitively / anything else off. A third dialect for a fourth + * switch is how the three drift apart. + */ +describe('resolveScheduledWorkEnabled (#17396, ruling G items 1-2)', () => { + const original = process.env[SCHEDULED_WORK_ENV]; + const originalMultiOrg = process.env.OS_MULTI_ORG_ENABLED; + afterEach(() => { + if (original === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = original; + if (originalMultiOrg === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = originalMultiOrg; + }); + + it('is the variable the docs and `os doctor` name', () => { + // The constant exists so one spelling reaches every surface; a test that + // only ever indexes `process.env` BY that constant would pass on a typo. + expect(SCHEDULED_WORK_ENV).toBe('OS_AUTOMATION_SCHEDULED_WORK_ENABLED'); + }); + + it('defaults OFF (unset -> no time trigger arms, no packaged job schedules)', () => { + delete process.env[SCHEDULED_WORK_ENV]; + expect(resolveScheduledWorkEnabled()).toBe(false); + }); + + it('accepts the documented opt-in vocabulary case-insensitively', () => { + for (const v of ['1', 'true', 'TRUE', 'True', 'on', 'ON', 'yes', 'Yes', ' true ', ' 1 ']) { + process.env[SCHEDULED_WORK_ENV] = v; + expect(resolveScheduledWorkEnabled(), `${JSON.stringify(v)} is documented as truthy`).toBe(true); + } + }); + + it('treats anything else as off, empty string included', () => { + for (const v of ['0', 'false', 'FALSE', 'off', 'no', '', ' ', 'maybe', 'enabled', 't', 'y']) { + process.env[SCHEDULED_WORK_ENV] = v; + expect( + resolveScheduledWorkEnabled(), + `${JSON.stringify(v)} is outside the opt-in vocabulary`, + ).toBe(false); + } + }); + + it('is opt-IN, where `resolveMultiOrgEnabled` is opt-OUT — a typo must not arm the workload', () => { + // The two shapes disagree on exactly this input, and the disagreement is + // the point: `!== 'false'` reads a typo as ON, which for this switch would + // arm the clock-driven load the operator meant to refuse. + process.env[SCHEDULED_WORK_ENV] = 'ture'; + process.env.OS_MULTI_ORG_ENABLED = 'ture'; + expect(resolveScheduledWorkEnabled()).toBe(false); + expect(resolveMultiOrgEnabled(), 'control: the opt-OUT sibling really does read this as on').toBe(true); + }); + + it('the OFF reason is a deployment-policy sentence, never a binding failure', () => { + // Ruled item 6 in the package that owns the sentence: it must name the + // switch the operator has to set, and must NOT read as "binding failed". + expect(SCHEDULED_WORK_DISABLED_REASON).toContain(SCHEDULED_WORK_ENV); + expect(SCHEDULED_WORK_DISABLED_REASON).not.toMatch(/binding failed/i); + }); +}); + +/** + * [#17396] The three bind states, as one reading. + * + * `resolveScheduledWorkPolicy` exists so both time triggers, the binding audit + * and the packaged-job loop cannot disagree about which of the three a + * deployment is in; this is the table its docblock states. + */ +describe('resolveScheduledWorkPolicy (#17396, ruling G — the three bind states)', () => { + const originalSwitch = process.env[SCHEDULED_WORK_ENV]; + const originalPosture = process.env.OS_TENANCY_POSTURE; + const originalMultiOrg = process.env.OS_MULTI_ORG_ENABLED; + afterEach(() => { + if (originalSwitch === undefined) delete process.env[SCHEDULED_WORK_ENV]; + else process.env[SCHEDULED_WORK_ENV] = originalSwitch; + if (originalPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = originalPosture; + if (originalMultiOrg === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = originalMultiOrg; + }); + + /** The default deployment: nothing set at all. */ + const clean = (): void => { + delete process.env[SCHEDULED_WORK_ENV]; + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + }; + + it('row 1 — OFF (the default): nothing binds, and no declaration is demanded either', () => { + clean(); + expect(resolveScheduledWorkPolicy()).toEqual({ + enabled: false, + posture: 'single', + requiresActingOrganization: false, + }); + }); + + it('row 1 holds under a WALL too — the OFF reason is the one to report, not an authoring remedy', () => { + clean(); + for (const posture of ['group', 'isolated'] as const) { + process.env.OS_TENANCY_POSTURE = posture; + expect(resolveScheduledWorkPolicy()).toEqual({ + enabled: false, + posture, + requiresActingOrganization: false, + }); + } + }); + + it('row 2 — ON under `single`: binds, and requires NO acting organization', () => { + clean(); + process.env[SCHEDULED_WORK_ENV] = 'true'; + process.env.OS_TENANCY_POSTURE = 'single'; + expect(resolveScheduledWorkPolicy()).toEqual({ + enabled: true, + posture: 'single', + requiresActingOrganization: false, + }); + }); + + it('row 3 — ON under a wall (`group` and `isolated` alike): the declaration is required', () => { + clean(); + process.env[SCHEDULED_WORK_ENV] = 'true'; + for (const posture of ['group', 'isolated'] as const) { + process.env.OS_TENANCY_POSTURE = posture; + expect(resolveScheduledWorkPolicy()).toEqual({ + enabled: true, + posture, + requiresActingOrganization: true, + }); + } + }); + + it('reports the REQUESTED posture, derived from the legacy boolean when unset', () => { + clean(); + process.env[SCHEDULED_WORK_ENV] = 'true'; + process.env.OS_MULTI_ORG_ENABLED = 'true'; + expect(resolveScheduledWorkPolicy()).toEqual({ + enabled: true, + posture: 'isolated', + requiresActingOrganization: true, + }); + }); + + it('throws on a bogus posture rather than resolving to `single` and dropping the requirement', () => { + // A typo'd posture that fell back to `single` would silently remove the + // declaration requirement with it — the deployment-layer form of the + // "declared but unenforced" defect. It throws in BOTH switch states, + // because the posture is resolved before the switch is consulted. + clean(); + process.env.OS_TENANCY_POSTURE = 'mutli'; + process.env[SCHEDULED_WORK_ENV] = 'true'; + expect(() => resolveScheduledWorkPolicy()).toThrow(/Invalid OS_TENANCY_POSTURE/); + delete process.env[SCHEDULED_WORK_ENV]; + expect(() => resolveScheduledWorkPolicy()).toThrow(/Invalid OS_TENANCY_POSTURE/); + }); +}); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 953addfece..d9ff5a554a 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -289,8 +289,16 @@ export function resolveScheduledWorkPolicy(): ScheduledWorkPolicy { /** * The one sentence a surface prints when package-authored scheduled work is - * OFF — so the bind refusal, the engine's binding audit, the CLI startup - * summary and Studio cannot drift about WHY a flow is not armed. + * OFF — so the bind refusal, the engine's binding audit and the CLI startup + * summary cannot drift about WHY a flow is not armed. + * + * ⚠️ Those are the surfaces that carry it, and Studio is NOT one of them. Its + * only status door is `GET /automation/_status`, whose rows are + * `FlowRuntimeState` (`@objectstack/spec` `contracts/automation-service.ts`) — + * a shape with no reason field at all — so a policy-disabled flow reaches + * Studio as `bound: false` and nothing more. ⛔ Do not write that Studio + * reports this reason until a reason reaches that wire shape: declared is not + * delivered. * * ⛔ It must never read as "binding failed". A binding failure is a defect with * an engineering remedy; this is a deployment POLICY with an operator remedy,