2929 * to be computed BEFORE the create node, which is what a registered function
3030 * invoked by a `script` node is for (#1870, pure per #4396).
3131 *
32+ * (Both readings above are history, kept for the reasoning: since #19938 a
33+ * `fields` value may be a CEL value envelope, which IS evaluated, and since
34+ * #19939 a value slot no longer reads the `{…}` template dialect at all — the
35+ * pre-fix `DATEADD({…})` shape is now refused at registration, before any
36+ * driver sees it. The script stays: a recurrence rule is a function's job.)
37+ *
3238 * The suite drives the app's REAL metadata (`allFlows`, the real `Task` object,
3339 * the real `todoFunctions` registry) through a real kernel over sqlite, so it
3440 * fails if any link is re-broken: the function name, the node wiring, the
35- * arithmetic, or the interpolation shape of the `due_date` slot.
41+ * arithmetic, or the shape of the `due_date` slot.
3642 */
3743
3844import { describe , it , expect , afterEach } from 'vitest' ;
3945import { ObjectKernel } from '@objectstack/core' ;
4046import { ObjectQLPlugin } from '@objectstack/objectql' ;
4147import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm' ;
4248import { AutomationServicePlugin , type AutomationEngine } from '@objectstack/service-automation' ;
49+ import { VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation' ;
4350import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change' ;
4451
4552import { allFlows , TaskCompletionFlow } from '../src/flows/index.js' ;
@@ -262,12 +269,12 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
262269 expect ( script . config . outputVariable ) . toBe ( 'nextDueDate' ) ;
263270 } ) ;
264271
265- it ( 'create_next_task reads the computed variable as a WHOLE-STRING token ' , ( ) => {
266- // A whole-string `{token}` is the one form `interpolateString` returns the
267- // raw value for. Any surrounding text turns the slot back into
268- // text-with-holes — which is precisely the shape that shipped `DATEADD`.
272+ it ( 'create_next_task reads the computed variable as a CEL path — the raw value, type kept ' , ( ) => {
273+ // [#19939] A value slot computes with a CEL value envelope; a bare path
274+ // hands the create the RAW value the script returned. Text around it
275+ // would be a concatenation — the shape that shipped `DATEADD`.
269276 const fields = node ( 'create_next_task' ) . config . fields ;
270- expect ( fields . due_date ) . toBe ( '{ nextDueDate}' ) ;
277+ expect ( fields . due_date ) . toEqual ( { dialect : 'cel' , source : ' nextDueDate' } ) ;
271278 } ) ;
272279
273280 it ( 'the recurring branch runs the computation BEFORE the create' , ( ) => {
@@ -291,10 +298,12 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
291298 } ) ;
292299
293300 it ( 'GUARD: no write node in any app flow puts function-call text in a field value' , ( ) => {
294- // The class, not the instance. `fields` values are interpolated , never
301+ // The class, not the instance. A STRING `fields` value is a literal , never
295302 // evaluated, so ANY `NAME(...)` text in one reaches the driver verbatim —
296- // whatever the invented function is called. This is the check that would
297- // have caught the original defect at authoring time.
303+ // whatever the invented function is called. (A computed value is a CEL
304+ // envelope, an object, which this guard leaves to the envelope's own CEL
305+ // check.) This is the check that would have caught the original defect
306+ // at authoring time.
298307 const callShaped = / ^ [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * \s * \( / ;
299308 for ( const flow of allFlows ) {
300309 for ( const n of ( flow . nodes ?? [ ] ) as any [ ] ) {
@@ -304,7 +313,7 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
304313 expect (
305314 callShaped . test ( value . trim ( ) ) ,
306315 `flow '${ flow . name } ' node '${ n . id } ' field '${ field } ' = ${ value } — ` +
307- `a create/update field value is template-interpolated , not evaluated` ,
316+ `a string create/update field value is a literal , not evaluated` ,
308317 ) . toBe ( false ) ;
309318 }
310319 }
@@ -369,19 +378,23 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
369378 expect ( ( Array . isArray ( all ) ? all : [ ] ) . length ) . toBe ( 1 ) ;
370379 } , 30000 ) ;
371380
372- it ( 'REVERSE: the pre-fix shape — a function call left in the field value — is refused by the driver ' , async ( ) => {
381+ it ( 'REVERSE: the pre-fix shape — a function call left in the field value — is refused at registration ' , async ( ) => {
373382 // The pre-#7037 defect, rebuilt from the REAL flow so it cannot drift: the
374383 // computation node removed, the recurring edge pointed straight at the
375384 // create, and `due_date` written as text-with-holes wrapping a call to a
376385 // function that does not exist.
377386 //
378387 // The invented name is deliberately NOT the historical one. What failed
379- // was never specific to `DATEADD`: a `create_record` field value is
380- // interpolated and passed through verbatim, so ANY function-call text
381- // reaches the driver as a literal string. Pinning the class keeps the
382- // dead name out of the repo entirely, which is the other half of this
383- // card — an invented function name in a shipped example is exactly the
384- // shape an AI author copies.
388+ // was never specific to `DATEADD`: ANY function-call text in a field
389+ // value was a literal. Pinning the class keeps the dead name out of the
390+ // repo entirely, which is the other half of this card — an invented
391+ // function name in a shipped example is exactly the shape an AI author
392+ // copies.
393+ //
394+ // [#19939] Before, this shape registered and the DRIVER refused the write
395+ // at run time (`Due Date must be a valid date`). The `{…}` tokens are the
396+ // retired template dialect in a value slot now, so it never registers —
397+ // and the refusal names the CEL spelling instead.
385398 const { automation, data } = await bootTodoKernel ( ) ;
386399 const ctx = { context : { userId : 'u_todo' } } ;
387400
@@ -393,52 +406,34 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
393406 'create_next_task' ;
394407 broken . nodes . find ( ( n : any ) => n . id === 'create_next_task' ) . config . fields . due_date =
395408 'SHIFT_DATE({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")' ;
396- // The fixture is a manual flow: it must not race the real one on the same
397- // record-change hook.
398409 delete broken . nodes . find ( ( n : any ) => n . type === 'start' ) . config . triggerType ;
399410 broken . type = 'autolaunched' ;
400- automation . registerFlow ( broken . name , broken ) ;
401-
402- // [#14147] Unlike the cases above, this fixture must START from an
403- // already-completed row — there is no transition to stamp on, and the
404- // object's `completed_date_required` rule refuses a completed task with a
405- // blank `completed_date`. Seeding a server-owned column at create time is
406- // a SYSTEM act (maintainer ruling, 2026-09-03: `context.isSystem`,
407- // `runAs: 'system'`, a system hook or a seed), so this one write declares
408- // itself trusted. Nothing else about the case changes: the subject is
409- // still what the BROKEN flow fixture does with an uncomputed date, and
410- // the assertions below are untouched.
411+ let refusal = '' ;
412+ try {
413+ automation . registerFlow ( broken . name , broken ) ;
414+ } catch ( err ) {
415+ refusal = ( err as Error ) . message ;
416+ }
417+ expect ( refusal , 'the pre-fix shape must not register' ) . toContain ( VALUE_SLOT_TEMPLATE_REFUSAL ) ;
418+ expect ( refusal ) . toContain ( "node 'create_next_task' (create_record) create_record field value at config.fields.due_date" ) ;
419+ expect ( refusal ) . not . toMatch ( / n o f u n c t i o n n a m e d / ) ;
420+
421+ // [#14147] The real flow needs an already-completed row to start from —
422+ // seeding a server-owned column at create time is a SYSTEM act
423+ // (maintainer ruling, 2026-09-03), so this one write declares itself
424+ // trusted.
411425 const created = await data . insert ( 'todo_task' , {
412426 subject : 'Reverse fixture task' , status : 'completed' , priority : 'normal' , owner : 'u_todo' ,
413427 due_date : '2026-08-10' , is_recurring : true , recurrence_type : 'daily' , recurrence_interval : 1 ,
414428 completed_date : '2026-08-09T10:00:00.000Z' ,
415429 } , { context : { userId : 'u_todo' , isSystem : true } } ) ;
416430 const id = Array . isArray ( created ) ? created [ 0 ] . id : created . id ;
417-
418- // Driven directly rather than through the record-change hook, so the
419- // context has to supply what the hook would: the triggering record AND the
420- // `previous` row the start predicate transitions from. Without `previous`
421- // the CEL gate aborts with `No such key: status` — the same totality trap
422- // #6882 documented on the insert leg — and the run would fail for a reason
423- // that has nothing to do with this card.
424431 const record = { ...( Array . isArray ( created ) ? created [ 0 ] : created ) , id } ;
425432 const trigger = { record, previous : { ...record , status : 'in_progress' } , userId : 'u_todo' } ;
426433
427- const result : any = await automation . execute ( broken . name , trigger ) ;
428-
429- // The direction that matters: the run FAILS, and it fails inside the
430- // create with the field's own date refusal — NOT with "no function named
431- // 'SHIFT_DATE' is registered", because nothing ever tried to CALL one.
432- // The value was never a call; it was always just text.
433- expect ( result ?. success , `unexpected result: ${ JSON . stringify ( result ) } ` ) . toBe ( false ) ;
434- const text = String ( result ?. error ?? '' ) + JSON . stringify ( result ?. output ?? null ) ;
435- expect ( text ) . toContain ( 'create_next_task' ) ;
436- expect ( text ) . toMatch ( / v a l i d d a t e | I S O - 8 6 0 1 | i n v a l i d _ d a t e / ) ;
437- expect ( text ) . not . toMatch ( / n o f u n c t i o n n a m e d / ) ;
438-
439434 // Non-vacuous: the REAL flow, on the very same kernel and record, computes
440- // the date and completes. So the failure above is the missing computation,
441- // not a broken fixture or an unusable harness.
435+ // the date and completes. So the refusal above is the retired dialect, not
436+ // a broken fixture or an unusable harness.
442437 const good : any = await automation . execute ( 'task_completion' , trigger ) ;
443438 expect ( good ?. success , `unexpected result: ${ JSON . stringify ( good ) } ` ) . toBe ( true ) ;
444439 const spawned = ( await data . find ( 'todo_task' , { where : { subject : 'Reverse fixture task' } , ...ctx } ) )
0 commit comments