@@ -39,6 +39,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify';
3939import { MessagingServicePlugin , INBOX_OBJECT , NOTIFICATION_EVENT_OBJECT } from '@objectstack/service-messaging' ;
4040import { TimeRelativeTrigger , type JobServiceSurface , type TriggerLogger } from '@objectstack/trigger-schedule' ;
4141import type { JobHandler , JobSchedule } from '@objectstack/spec/contracts' ;
42+ import { SCHEDULED_WORK_ENV , SCHEDULED_WORK_DISABLED_REASON } from '@objectstack/types' ;
4243import {
4344 scheduleOrganizationStack ,
4445 declaringTimeRelativeFlow ,
@@ -119,6 +120,9 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) {
119120 let orgB : string ;
120121 let rowA : string ;
121122 let rowsB : string [ ] ;
123+ let recipientId : string ;
124+ let priorSwitch : string | undefined ;
125+ let priorPosture : string | undefined ;
122126
123127 beforeAll ( async ( ) => {
124128 stack = await bootStack ( scheduleOrganizationStack as never , {
@@ -143,7 +147,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) {
143147 expect ( orgA ) . not . toBe ( orgB ) ;
144148
145149 const admin = await ql . findOne ( 'sys_user' , { where : { email : 'admin@objectos.ai' } , ...SYS } ) ;
146- const recipientId = String ( admin ?. id ?? 'usr_system' ) ;
150+ recipientId = String ( admin ?. id ?? 'usr_system' ) ;
147151
148152 // ── the differential fixture ──────────────────────────────────────
149153 // 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) {
165169 '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' ,
166170 ) . toEqual ( [ orgA , orgB , orgB ] . sort ( ) ) ;
167171
172+ // ── [#17396] The DEPLOYMENT this suite is about ───────────────────
173+ //
174+ // Ruling G put two environment facts in front of every bind, and both
175+ // are set HERE, around the bind, rather than at boot:
176+ //
177+ // 1. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` — package-authored
178+ // scheduled work is OFF by default in every posture, so without it
179+ // NOTHING arms and the `precondition: the sweep BOUND` case below
180+ // fails, taking every assertion built on it with it. ⛔ It is a
181+ // PRECONDITION of this file's subject, not a convenience: what these
182+ // pins measure is which rows an ARMED sweep selects, and an unarmed
183+ // sweep selects nothing for a reason that has nothing to do with
184+ // tenancy.
185+ // 2. `OS_TENANCY_POSTURE=isolated` — the acting-organization
186+ // declaration this sweep carries is REQUIRED only behind a wall.
187+ // Under `single` the same flow arms while declaring nothing and
188+ // sweeps unscoped, which is a different subject with a different
189+ // correct answer.
190+ //
191+ // ⚠️ Set around the BIND, not around `bootStack`: both triggers read
192+ // these live at `start()`, while booting the STACK under a wall would
193+ // demand the enterprise organizations plugin this suite deliberately
194+ // does not install (ADR-0093 D5 refuses to boot a wall it cannot
195+ // enforce). Nothing the pins measure moves: which rows the sweep selects
196+ // is decided by the two `sys_organization` rows and the declaration.
197+ priorSwitch = process . env [ SCHEDULED_WORK_ENV ] ;
198+ priorPosture = process . env . OS_TENANCY_POSTURE ;
199+ process . env [ SCHEDULED_WORK_ENV ] = 'true' ;
200+ process . env . OS_TENANCY_POSTURE = 'isolated' ;
201+
168202 automation . registerFlow ( SWEEP_FLOW , declaringTimeRelativeFlow ( orgA , recipientId ) ) ;
169203
170204 job = fakeJobService ( ) ;
@@ -174,6 +208,12 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) {
174208 } , 120_000 ) ;
175209
176210 afterAll ( async ( ) => {
211+ // [#17396] Restore the PREVIOUS values rather than deleting the keys — a
212+ // CI box that exported either one must be left exactly as it was found.
213+ if ( priorSwitch === undefined ) delete process . env [ SCHEDULED_WORK_ENV ] ;
214+ else process . env [ SCHEDULED_WORK_ENV ] = priorSwitch ;
215+ if ( priorPosture === undefined ) delete process . env . OS_TENANCY_POSTURE ;
216+ else process . env . OS_TENANCY_POSTURE = priorPosture ;
177217 await stack ?. stop ( ) ;
178218 } ) ;
179219
@@ -184,8 +224,94 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) {
184224 it ( 'precondition: the sweep BOUND' , ( ) => {
185225 expect (
186226 job . has ( SWEEP_JOB ) ,
187- `the sweep did not bind — registered jobs: ${ job . names ( ) . join ( ', ' ) || '(none)' } ` ,
227+ `the sweep did not bind — registered jobs: ${ job . names ( ) . join ( ', ' ) || '(none)' } `
228+ + ` (⚠️ #17396: this is also the case that fails when ${ SCHEDULED_WORK_ENV } is not set —`
229+ + ' package-authored scheduled work is off by default in every posture, and an unarmed'
230+ + ' sweep selects nothing for a reason that has nothing to do with tenancy)' ,
231+ ) . toBe ( true ) ;
232+ } ) ;
233+
234+ // ── [#17396] The OTHER deployment state, which ruling G item 6 requires
235+ // and nothing measured before this card ────────────────────────────
236+ //
237+ // With the switch OFF neither trigger arms anything, and every such flow is
238+ // listed in `getTriggerBindingAudit()` — the surface the automation
239+ // plugin's `kernel:bootstrapped` warning, the CLI startup summary and
240+ // Studio all read — with a DISTINCT reason: *disabled by deployment
241+ // policy*, ⛔ NEVER "binding failed".
242+ //
243+ // ⭐ That distinction is the whole of the ruled item, and it is not
244+ // cosmetic: a binding failure is a defect with an engineering remedy, while
245+ // this is a deployment policy with an operator remedy, and the two send
246+ // whoever reads the boot summary to different places. It is pinned HERE,
247+ // on the real engine with a real registered trigger, because the engine's
248+ // own catch — the one that writes "binding failed" — is the thing that must
249+ // NOT be reached.
250+ it ( '[#17396] switch OFF: the sweep does not arm, and the audit says disabled by deployment policy' , async ( ) => {
251+ const OFF_FLOW = `${ SWEEP_FLOW } _policy_off` ;
252+ const OFF_JOB = `flow-time-relative:${ OFF_FLOW } ` ;
253+ const restore = process . env [ SCHEDULED_WORK_ENV ] ;
254+ try {
255+ delete process . env [ SCHEDULED_WORK_ENV ] ;
256+ automation . registerFlow ( OFF_FLOW , declaringTimeRelativeFlow ( orgA , recipientId ) ) ;
257+ await new Promise < void > ( ( r ) => setTimeout ( r , 0 ) ) ;
258+ } finally {
259+ if ( restore === undefined ) delete process . env [ SCHEDULED_WORK_ENV ] ;
260+ else process . env [ SCHEDULED_WORK_ENV ] = restore ;
261+ }
262+
263+ // ⛔ The flow is well-formed and DECLARES its organization — the same
264+ // fixture the armed sweep above uses. Nothing about it is wrong; the
265+ // deployment simply has not asked for scheduled work.
266+ expect (
267+ job . has ( OFF_JOB ) ,
268+ `a policy-disabled flow must have no job at all — registered: ${ job . names ( ) . join ( ', ' ) || '(none)' } ` ,
269+ ) . toBe ( false ) ;
270+
271+ const states = automation . getFlowRuntimeStates ( ) as Array < { name : string ; bound : boolean } > ;
272+ expect (
273+ states . find ( ( st : { name : string } ) => st . name === OFF_FLOW ) ?. bound ,
274+ "Studio's status badge must not report this flow as armed" ,
275+ ) . toBe ( false ) ;
276+ expect (
277+ states . find ( ( st : { name : string } ) => st . name === SWEEP_FLOW ) ?. bound ,
278+ 'control: the sweep armed while the switch was ON must still read as bound, or this pin would pass with everything broken' ,
188279 ) . toBe ( true ) ;
280+
281+ const audit = automation . getTriggerBindingAudit ( ) as Array < {
282+ flowName : string ;
283+ triggerType : string ;
284+ reason : string ;
285+ } > ;
286+ const entry = audit . find ( ( a : { flowName : string } ) => a . flowName === OFF_FLOW ) ;
287+ expect (
288+ entry ,
289+ `ruled item 6: the flow must be LISTED, so the boot summary names it; audit: ${ JSON . stringify ( audit ) } ` ,
290+ ) . toBeTruthy ( ) ;
291+ expect ( entry ! . triggerType ) . toBe ( 'time_relative' ) ;
292+ expect (
293+ entry ! . reason ,
294+ 'the reason must be the one sentence every surface shares, so the audit, the CLI summary and Studio cannot drift' ,
295+ ) . toBe ( SCHEDULED_WORK_DISABLED_REASON ) ;
296+ expect ( entry ! . reason , 'and it must name the switch the operator has to set' ) . toContain ( SCHEDULED_WORK_ENV ) ;
297+ // ⭐ The prohibition, pinned by absence because the branch it must not
298+ // take produces exactly this phrase.
299+ expect (
300+ entry ! . reason ,
301+ 'ruled item 6: a policy-disabled flow is ⛔ NEVER reported as a binding failure' ,
302+ ) . not . toMatch ( / b i n d i n g f a i l e d / ) ;
303+ expect (
304+ audit . map ( ( a : { flowName : string } ) => a . flowName ) ,
305+ 'control: the armed sweep must not be listed as a silent miss' ,
306+ ) . not . toContain ( SWEEP_FLOW ) ;
307+
308+ // And the trigger was never asked: with the switch off the engine does
309+ // not call `start()` at all, so nothing threw and nothing was logged as
310+ // a failure.
311+ expect (
312+ log . errors . filter ( ( l ) => l . includes ( OFF_FLOW ) ) ,
313+ 'a deployment running the configuration it asked for must not print an error' ,
314+ ) . toEqual ( [ ] ) ;
189315 } ) ;
190316
191317 if ( databaseDriver === 'memory' ) {
0 commit comments