11// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
33import { describe , it , expect } from 'vitest' ;
4- import { TimeRelativeTriggerSchema , LoopConfigSchema } from '@objectstack/spec/automation' ;
4+ import { TimeRelativeTriggerSchema , LoopConfigSchema , FlowSchema } from '@objectstack/spec/automation' ;
5+ import { AUTHORING_RULES } from './authoring-rules.js' ;
56import {
67 lintFlowPatterns ,
78 FLOW_TIME_RELATIVE_ANTIPATTERN ,
@@ -339,7 +340,8 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
339340 expect ( fnds ) . toHaveLength ( 1 ) ;
340341 expect ( fnds [ 0 ] . rule ) . toBe ( FLOW_RUNAS_UNSCOPED ) ;
341342 expect ( fnds [ 0 ] . where ) . toContain ( 'nightly_sweep' ) ;
342- expect ( fnds [ 0 ] . message ) . toMatch ( / d e f a u l t .* r u n A s : ' u s e r ' / ) ;
343+ // #5693 — one wording for both authoring inputs; see the dedicated block below.
344+ expect ( fnds [ 0 ] . message ) . toMatch ( / r u n A s : ' u s e r ' ` \( t h e d e f a u l t w h e n n o n e i s d e c l a r e d \) / ) ;
343345 expect ( fnds [ 0 ] . message ) . toMatch ( / R E F U S E D / ) ;
344346 expect ( fnds [ 0 ] . hint ) . toMatch ( / r u n A s : ' s y s t e m ' / ) ;
345347 } ) ;
@@ -552,8 +554,11 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
552554 } ) ;
553555
554556 // The A/B twin. Same flow, same node, moved out of the body — this was
555- // already flagged before #5633, and its message must not have moved a byte.
556- it ( 'leaves the TOP-LEVEL twin byte-identical (no region clause, no regression)' , ( ) => {
557+ // already flagged before #5633, and its message must carry no region clause.
558+ // (The sentence itself was re-worded once since, by #5693 — see the block at
559+ // the end of this file for why the authored-vs-defaulted branch it used to
560+ // carry could not survive.)
561+ it ( 'leaves the TOP-LEVEL twin without a region clause (no regression)' , ( ) => {
557562 const fnds = lintFlowPatterns ( {
558563 flows : [ {
559564 name : 'nightly_sweep' ,
@@ -568,8 +573,8 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
568573 expect ( fnds ) . toHaveLength ( 1 ) ;
569574 expect ( fnds [ 0 ] . where ) . toBe ( "flow 'nightly_sweep' · runAs" ) ;
570575 expect ( fnds [ 0 ] . message ) . toBe (
571- "schedule-triggered flow runs as the default `runAs:'user'`, but a schedule run has no trigger " +
572- "user — so its data node 'touch' (update_record) has no identity to scope to and " +
576+ "schedule-triggered flow runs under `runAs:'user'` (the default when none is declared), but a " +
577+ "schedule run has no trigger user — so its data node 'touch' (update_record) has no identity to scope to and " +
573578 "will be REFUSED at run time." ,
574579 ) ;
575580 expect ( fnds [ 0 ] . message ) . not . toContain ( 'in loop' ) ;
@@ -673,6 +678,104 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
673678 } ) ;
674679 } ) ;
675680 } ) ;
681+
682+ /**
683+ * #5693 — ONE wording, true whether the author declared `runAs:'user'` or
684+ * declared nothing.
685+ *
686+ * The message used to branch: `` `runAs:'user'` `` when `flow.runAs` was a
687+ * string, `the default …` when it was absent. The distinction is real and
688+ * useful — "you wrote something incoherent" is not "you inherited a default
689+ * that does not fit a user-less trigger" — but *this rule cannot observe it*,
690+ * and which arm an author got depended on the SURFACE rather than on their
691+ * file:
692+ *
693+ * - **CLI** — always the explicit arm. `FlowSchema.runAs` carries
694+ * `.default('user')` and the registry wires this rule `input: 'parsed'`, so
695+ * `flow.runAs` is the string `'user'` either way. `os lint` does not parse,
696+ * but `defineStack`/`defineFlow` parse at *definition* time, so even it
697+ * receives the default already materialized. Measured on `app-todo` with the
698+ * `runAs` line deleted: `os validate` AND `os lint` both told an author who
699+ * had declared nothing that their flow "runs as `runAs:'user'`".
700+ * - **Runtime publish gate (#4463)** — both arms, because it judges the
701+ * verbatim authored body (`saveMetaItem` keeps `request.item` past the
702+ * schema check).
703+ *
704+ * So the branch was not merely dead: it made one flow get two different
705+ * sentences from two shipped surfaces, and on the surface authors meet first it
706+ * produced the one that reads as an accusation. #5693 removed it in favour of a
707+ * sentence that is true of both inputs on every surface.
708+ *
709+ * These two cases split the work deliberately, and only the second has teeth
710+ * against a re-introduction — say so rather than let the pair read as one
711+ * assertion made twice:
712+ *
713+ * - the PARSED case pins *why* the branch was pointless (both inputs arrive as
714+ * the same object). A re-introduced branch would still pass it — that is the
715+ * point: the CLI cannot tell these apart, which is the whole defect.
716+ * - the UNPARSED case is the regression guard. It is the one input shape where
717+ * the two authoring choices are still distinguishable (and the shape the
718+ * runtime gate really passes), so any future `typeof flow.runAs === 'string'`
719+ * branch in the message fails it immediately.
720+ */
721+ describe ( "#5693 — one wording for authored `runAs:'user'` and for none at all" , ( ) => {
722+ /**
723+ * Authorable on purpose: `FlowSchema` requires `label` on the flow and on
724+ * every node, so the raw literals the rest of this file feeds would fail the
725+ * parse — and a fixture that cannot be parsed cannot demonstrate anything
726+ * about the parsed tier. This one is the same sweep, declared in full.
727+ */
728+ const sweep = ( runAs ?: 'user' ) => ( {
729+ name : 'nightly_sweep' ,
730+ label : 'Nightly Sweep' ,
731+ type : 'schedule' ,
732+ ...( runAs ? { runAs } : { } ) ,
733+ nodes : [
734+ { id : 'start' , type : 'start' , label : 'Start' , config : { triggerType : 'schedule' , cron : '0 8 * * *' } } ,
735+ { id : 'op' , type : 'update_record' , label : 'Touch' , config : { objectName : 'thing' , fields : { a : 1 } } } ,
736+ ] ,
737+ edges : [ { id : 'e1' , source : 'start' , target : 'op' } ] ,
738+ } ) ;
739+
740+ const EXPECTED =
741+ "schedule-triggered flow runs under `runAs:'user'` (the default when none is declared), but a " +
742+ "schedule run has no trigger user — so its data node 'op' (update_record) has no identity to scope to and " +
743+ "will be REFUSED at run time." ;
744+
745+ const messagesFor = ( flow : unknown ) =>
746+ lintFlowPatterns ( { flows : [ flow ] } )
747+ . filter ( ( f ) => f . rule === FLOW_RUNAS_UNSCOPED )
748+ . map ( ( f ) => f . message ) ;
749+
750+ it ( 'is wired to the tier where the default has already been filled in' , ( ) => {
751+ const wiring = AUTHORING_RULES . find ( ( r ) => r . name === 'lintFlowPatterns' ) ;
752+ expect ( wiring ?. input ) . toBe ( 'parsed' ) ;
753+ } ) ;
754+
755+ it ( 'PARSED input: the two authoring choices are literally the same object here' , ( ) => {
756+ const authored = FlowSchema . parse ( sweep ( 'user' ) ) ;
757+ const defaulted = FlowSchema . parse ( sweep ( ) ) ;
758+ // The premise, asserted rather than assumed: the parse materializes the
759+ // default, so `flow.runAs` carries no trace of what was authored.
760+ expect ( defaulted . runAs ) . toBe ( 'user' ) ;
761+ expect ( authored . runAs ) . toBe ( 'user' ) ;
762+
763+ expect ( messagesFor ( defaulted ) ) . toEqual ( [ EXPECTED ] ) ;
764+ expect ( messagesFor ( authored ) ) . toEqual ( [ EXPECTED ] ) ;
765+ } ) ;
766+
767+ it ( 'UNPARSED input: an absent key and an explicit one still get the same sentence' , ( ) => {
768+ // The runtime publish gate's shape — the only surface where the omission
769+ // survives to the rule. Both must read the same, or the surfaces disagree
770+ // about one flow again.
771+ expect ( messagesFor ( sweep ( ) ) ) . toEqual ( [ EXPECTED ] ) ;
772+ expect ( messagesFor ( sweep ( 'user' ) ) ) . toEqual ( [ EXPECTED ] ) ;
773+ } ) ;
774+
775+ it ( 'says the same thing to both surfaces about the same flow' , ( ) => {
776+ expect ( messagesFor ( FlowSchema . parse ( sweep ( ) ) ) ) . toEqual ( messagesFor ( sweep ( ) ) ) ;
777+ } ) ;
778+ } ) ;
676779} ) ;
677780
678781/**
0 commit comments