@dforge-core/dforge-mcp 0.2.14 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,89 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
- ## 0.2.6
7
+ ## 0.2.15
8
+
9
+ `canExecute` became a real server-side gate and `isTransacted` began opening a real
10
+ transaction (platform change `5398a48b0`). Both had been documented here as the
11
+ opposite — "a UI hint" and "does not open a transaction" — so this release is a
12
+ correction pass across the authoring guidance, with no tool or schema changes.
13
+
14
+ ### Changed — `canExecute` is enforced, with a fail-open subset
15
+
16
+ `action.execute` now evaluates the stored `can_execute_parsed` AST over every targeted
17
+ record before any write and refuses the call with `ACTION_EXECUTION_FAIL`. So
18
+ `canExecute: false` is a genuine "only automation may fire this" — the scheduler and
19
+ triggers invoke the script engine directly and still bypass it.
20
+
21
+ The server evaluator is a scalar walker, not the formula engine, and the client has its
22
+ own separate gaps, so `references/action-dsl.md` and `dsl-reference.md` now carry a tier
23
+ table instead of a single list:
24
+
25
+ | tier | construct | client | server |
26
+ |---|---|---|---|
27
+ | 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
28
+ | 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
29
+ | 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
30
+ | — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
31
+
32
+ Four things in there are easy to get wrong and are called out explicitly:
33
+
34
+ - **A bare boolean field is not enforced.** The walker evaluates conditions, not values —
35
+ a field resolves only as an operand of a comparison — so `canExecute: [is_active]` and
36
+ `NOT [is_blocked]` fail open. Write `[is_active] = true` and `NOT ([is_blocked] = true)`.
37
+ - **A misspelled function ships silently.** A `canExecute` is never validated against the
38
+ function set at install (`FindUnknownFunctions` is wired only into the formula-column
39
+ path), so `TOADY()` installs and enables the button for every record.
40
+ - **Tier 3 mixed into a predicate is worse than useless**, because the two sides disagree
41
+ about short-circuiting: the server's `AND` / `OR` short-circuit, the client's evaluator
42
+ does not. `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record throws
43
+ in the browser, is caught, and **enables** the button — then the server refuses the call.
44
+ An error where a greyed-out button was wanted. Count with a Generated (`G`) column and
45
+ compare the scalar. Mixing tier 1 with tier 2 is fine and is the normal shape.
46
+ - **Settings do not work in `canExecute` at all** — the client resolver is stubbed to
47
+ `null` and the server has none, so the guard is unenforced server-side and tends to jam
48
+ the button *shut* client-side. Read them in `execute:`.
49
+
50
+ `references/jobs.md` also stops promising that `canExecute: false` hides the button: the
51
+ action stays in the toolbar, permanently disabled.
52
+
53
+ ### Changed — `isTransacted` opens a transaction, and defaults to `true`
54
+
55
+ The *Don't assume your action is atomic* section is replaced by *Atomicity*. `true` now
56
+ makes the whole run one transaction in both execution modes, covering the script's own
57
+ `insert()` / `update()` / `query()` calls as well as the flush of `[field] = …`
58
+ assignments, and it no longer depends on the entity carrying `auditHistory` — advice to
59
+ reach for that trait as an atomicity switch is gone.
60
+
61
+ `false` is documented per mode, because "failures are isolated" only ever made sense where
62
+ there is a loop: `single` / `each` report the failure and continue with the next record,
63
+ while a `batch` script is one invocation, so a failure just ends the run with its earlier
64
+ writes committed and its `[field]` assignments never flushed.
65
+
66
+ The background path is the exception, and it splits by mode too: the worker opens no
67
+ transaction, so nothing is ever undone; its `single` / `each` loop still reads the flag for
68
+ control flow, while its `batch` path never reads it at all — a queued `batch` action gets
69
+ nothing from `isTransacted` in either direction.
70
+
71
+ ### Fixed — `isAsync` does not force background execution, and there is no Hangfire
72
+
73
+ `isAsync: true` only *permits* queuing. The server branches on the `async` argument the
74
+ request carries, so a parameterless action is queued directly while one with parameters is
75
+ offered to the user as both "Run" (inline, transactional) and "Run in Background". Whether
76
+ an action is atomic is therefore not answerable from the manifest alone.
77
+
78
+ Two claims that dForge runs background actions "via Hangfire" are removed — the platform
79
+ uses a hosted `BackgroundService` polling `background_action`, and `FileCleanupService`
80
+ says so in as many words.
81
+
82
+ ### Changed — validation checklist
83
+
84
+ Two new items under actions: that a `canExecute` meant to *gate* is written in the
85
+ server-enforced subset, and that no bare boolean field is used as a whole condition. The
86
+ `references/formulas.md` entry for `canExecute:` blocks now summarises the same rules and
87
+ points at `action-dsl.md`.
88
+
89
+ ## 0.2.14
8
90
 
9
91
  Record reports — a report attached to an entity so it opens **from a record**, with the
10
92
  record's values feeding its parameters — plus a report-level home for report parameters,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
@@ -14,7 +14,8 @@ params:
14
14
 
15
15
  canExecute:
16
16
  # Single-expression formula returning boolean
17
- # Determines whether the action button is enabled for current record(s)
17
+ # Enables/disables the button AND is re-checked server-side on execute
18
+ # (see "canExecute is enforced, with a fail-open subset")
18
19
 
19
20
  onBeforeStart:
20
21
  # Pre-queue initialization (async / background actions only)
@@ -396,6 +397,62 @@ For "who did this", prefer the platform audit columns (`created_by` /
396
397
  predicates in `canExecute:` or a formula column, use `CURRENT_USER_ID()`, which
397
398
  the server translates to an exact `::bigint` SQL literal.
398
399
 
400
+ > **The table above is about `execute:` (JavaScript).** The *formula* engine —
401
+ > `canExecute:`, formula columns, filters, reports — keeps large integer literals
402
+ > exact: the parser holds them as 64-bit integers and serializes anything past
403
+ > 2^53 as text so the browser cannot round it. `canExecute: [owner_id] = 215117312238288897`
404
+ > compares against the id you wrote. `CURRENT_USER_ID()` is still the better way to
405
+ > say "mine" — it does not hard-code an id — but a literal is no longer a
406
+ > correctness trap on that side.
407
+
408
+ ## canExecute is enforced, with a fail-open subset
409
+
410
+ `canExecute:` is evaluated **twice**: by the browser to decide whether the button
411
+ is **enabled** (the action stays in the toolbar, greyed out — it is not hidden),
412
+ and by `action.execute` over every targeted record before any write. A
413
+ record in the wrong state is refused with `ACTION_EXECUTION_FAIL`, so the predicate
414
+ is a gate rather than a UI hint, and `canExecute: false` is the way to make an
415
+ action fireable only by a trigger or a scheduled job (both invoke the script engine
416
+ directly and bypass the check).
417
+
418
+ **Both evaluators have gaps, and both fail open**, but not the same gaps. The
419
+ server is a scalar walker and lets anything richer through. The client implements
420
+ the whole documented formula function set, so ordinary functions evaluate there
421
+ normally; it throws — and its `catch` **enables** the button — on ref navigation,
422
+ on the set aggregates `SUM` / `COUNT` / `AVG` (deliberately not formula functions;
423
+ counting belongs in a Generated `G` column), and on any name it does not know, a
424
+ typo included — a `canExecute:` is never validated against the function set at
425
+ install, so `TOADY()` ships and enables the button for every record.
426
+ `$[setting]` references are a separate case again: they do not throw, the client
427
+ resolver is stubbed to `null`, and the server has no settings resolver — so a
428
+ settings-dependent guard is unenforced server-side and resolves unpredictably in
429
+ the browser. Read settings in `execute:` instead.
430
+
431
+ | tier | construct | client | server |
432
+ |---|---|---|---|
433
+ | 1 | **comparisons** (`=` `==` `!=` `<>` `>` `>=` `<` `<=`) between `[field]`s and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
434
+ | 2 | arithmetic, `IN`, `BETWEEN`, any formula function (`TODAY`, `IF`, `DATEADD`, `REGEX_MATCH`, `CURRENT_USER_ID`, …) | evaluates | fail-open |
435
+ | 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
436
+ | — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
437
+
438
+ **A bare boolean column is not tier 1.** The walker evaluates conditions, not
439
+ values — a `[field]` resolves only as an operand of a comparison — so
440
+ `canExecute: [is_active]` and `canExecute: NOT [is_blocked]` fail open. Write
441
+ `[is_active] = true` and `NOT ([is_blocked] = true)`. A bare `false` is fine: it is
442
+ a complete condition, which is what makes the `canExecute: false` idiom work.
443
+
444
+ Tier 1 + tier 2 is the normal shape and behaves correctly: the client evaluates the
445
+ whole predicate so the button state is right, and the server short-circuits `AND` /
446
+ `OR` so the tier-1 half is still enforced.
447
+
448
+ **Tier 3 is what breaks.** The server short-circuits, the client does not, so
449
+ `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record still
450
+ evaluates `COUNT` in the browser, throws, and the button is **enabled** — then the
451
+ server refuses the call. A `canExecute:` is not validated against the function set
452
+ at install, so that spelling ships silently. Count with a `G` column and compare the
453
+ scalar; denormalise instead of navigating; put the rest in `execute:` behind
454
+ `error()`.
455
+
399
456
  ### Messaging & notifications
400
457
 
401
458
  #### `info(message, opts?)`, `warn(message, opts?)`
@@ -98,7 +98,68 @@ preference:
98
98
 
99
99
  ### `canExecute:` — availability formula
100
100
 
101
- A formula expression (same grammar as formula columns) that evaluates to true/false. When false, the action button is hidden or disabled in the UI.
101
+ A formula expression (same grammar as formula columns) that evaluates to true/false.
102
+
103
+ **It is enforced on both sides.** The client evaluates it to decide whether the
104
+ button is **enabled** — the action stays in the toolbar either way, greyed out
105
+ rather than hidden. `action.execute` evaluates it again server-side over every
106
+ targeted record, before any write, and refuses the call with
107
+ `ACTION_EXECUTION_FAIL` when a record is in the wrong state. So it is a real gate,
108
+ not a UI hint — a caller hitting the API directly cannot approve an
109
+ already-rejected record.
110
+
111
+ Two consequences worth designing around:
112
+
113
+ - **`canExecute: false` is the idiom for an action only automation may fire.** The
114
+ server refuses it through `action.execute`; triggers and scheduled jobs invoke
115
+ the script engine directly and are unaffected.
116
+ - **Both evaluators have gaps, and both fail open.** The server is a scalar walker
117
+ — comparisons between fields and literals, combined with `AND` / `OR` / `NOT`,
118
+ nothing else — and lets anything richer through. The client implements the full documented formula
119
+ function set, so ordinary functions are fine there; it throws (and its `catch`
120
+ **enables** the button) on **ref navigation** (`[ref].target`), on the set
121
+ aggregates **`SUM` / `COUNT` / `AVG`** (deliberately not formula functions —
122
+ counting is a Generated `G` column's job), and on **any name it does not know, a
123
+ typo included** — a `canExecute` is never validated against the function set at
124
+ install, so `TOADY()` ships and enables the button for every record.
125
+ - **`$[setting]` references are a separate case.** They do not throw: the client's
126
+ resolver is stubbed to `null`, so the predicate resolves however that operator
127
+ treats null (an ordered comparison against null is `false`, so it tends to
128
+ *disable* the button), while the server has no settings resolver and fails open.
129
+ Keep settings out of `canExecute` and read them in `execute:` instead.
130
+
131
+ | tier | construct | client | server |
132
+ |---|---|---|---|
133
+ | 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
134
+ | 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
135
+ | 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
136
+ | — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
137
+
138
+ **A bare boolean column is not tier 1.** The walker evaluates conditions, not
139
+ values — a field is only resolvable as an operand of a comparison — so
140
+ `canExecute: [is_active]` and `canExecute: NOT [is_blocked]` fail open. Write
141
+ `[is_active] = true` and `NOT ([is_blocked] = true)`. (`canExecute: false` is fine:
142
+ a bare literal is a complete condition.)
143
+
144
+ Mixing tier 1 and tier 2 is the normal, healthy shape: the client evaluates the
145
+ whole thing so the button state is right, and the server short-circuits `AND` /
146
+ `OR` so the tier-1 half is still enforced.
147
+
148
+ **Tier 3 is what breaks.** The server short-circuits, the client does not — so
149
+ `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record still
150
+ evaluates `COUNT` in the browser, throws, and the button is **enabled**, then the
151
+ server refuses the call: an error where a greyed-out button was wanted. And a
152
+ `canExecute` is not validated against the function set at install, so that spelling
153
+ ships silently. Count with a `G` column and compare the scalar
154
+ (`[detail_count] > 0`); denormalise instead of navigating; put anything genuinely
155
+ unexpressible in `execute:` behind `error()`.
156
+
157
+ > Never make `canExecute` the *only* guard for something that must hold. Between
158
+ > the fail-open rules on both sides, the enqueue-only check on background runs, and
159
+ > the fact that the client evaluates over the grid row (which does not carry
160
+ > extension columns) while the server evaluates over a full record load, the
161
+ > authoritative check belongs in `execute:` — validate and `error()` before the
162
+ > first write.
102
163
 
103
164
  ```
104
165
  canExecute:
@@ -117,7 +178,7 @@ canExecute:
117
178
  [quantity] >= 0
118
179
  ```
119
180
 
120
- Uses formula syntax: `[field]` for field access, `=` / `!=` / `<` / `>` / `AND` / `OR` operators, `TODAY()`, etc.
181
+ Uses formula syntax: `[field]` for field access, `=` / `!=` / `<` / `>` / `AND` / `OR` operators, `TODAY()`, etc. — but see the fail-open note above: only the scalar comparison subset is enforced server-side.
121
182
 
122
183
  **Note**: `canExecute` uses single `=` for equality (formula syntax), NOT `==` (JS syntax).
123
184
 
@@ -535,7 +596,9 @@ In `batch` mode, access individual records with `__records.get(i)`, the count wi
535
596
 
536
597
  ## Async actions
537
598
 
538
- For long-running actions, set `"isAsync": true` in `ui/actions.json`. The action runs in the background via Hangfire, and the user gets a completion notification via SSE. The DSL syntax is the same.
599
+ For long-running actions, set `"isAsync": true` in `ui/actions.json`. That **permits** background execution: the call is queued as a `background_action` row and picked up by `BackgroundActionService` (a hosted `BackgroundService` polling every ~10s — there is no Hangfire in dForge), and the user gets a completion notification via SSE. The DSL syntax is the same.
600
+
601
+ `isAsync` does not force it. `action.execute` carries its own `async` argument and the server branches on that, so a parameterless action is queued directly while one with parameters is offered to the user as both "Run" (inline) and "Run in Background". Note that a queued run has **no wrapping transaction** — see *Atomicity* below.
539
602
 
540
603
  ## Registering the action
541
604
 
@@ -576,37 +639,58 @@ In `ui/actions.json`:
576
639
  | `entityCode` | Yes | Which entity this action applies to |
577
640
  | `executionMode` | Yes | `"single"` (default) or `"each"` — runs per record, DSL uses `[field]`; or `"batch"` — runs once, DSL uses `for x in records { ... }` with `__records` |
578
641
  | `script` | Yes | DSL script name (without path or extension — matches filename in `logic/actions/`) |
579
- | `isTransacted` | Recommended | `true` = the first record that fails aborts the run; `false` = the failure is reported and the loop moves to the next record. It does **not** open a transaction — see *Don't assume your action is atomic* below |
642
+ | `isTransacted` | Recommended | `true` = the whole run is **one transaction**: the first failure aborts it *and* rolls back everything already written. `false` = no transaction, and what happens next depends on the mode — `single`/`each` report the failure and carry on with the next record, keeping what succeeded; a `batch` script is one invocation, so a failure just ends the run there with its earlier writes committed and its `[field]` assignments never flushed. **Defaults to `true`** when omitted — see *Atomicity* below |
580
643
  | `orderNum` | Recommended | Display order in the action menu |
581
- | `isAsync` | Optional | `true` = runs in background via Hangfire, user gets completion notification |
644
+ | `isAsync` | Optional | `true` **permits** background execution — it does not force it. A parameterless action is queued directly; one with params is offered to the user as both "Run" and "Run in Background". Queued runs get a completion notification |
645
+
646
+ ## Atomicity
647
+
648
+ `isTransacted: true` makes the run **one transaction**, in both execution modes.
649
+ The first failing record aborts the run and every write the run made — including
650
+ those for records that already succeeded — is rolled back. It covers the script's
651
+ own `insert()` / `update()` / `query()` calls as well as the flush of `[field] = …`
652
+ assignments. It does not depend on the entity carrying `auditHistory`.
582
653
 
583
- ## Don't assume your action is atomic
654
+ `isTransacted: false` is the opposite contract — no transaction — and what it means
655
+ depends on the mode:
584
656
 
585
- An action is **not** reliably one transaction, and `isTransacted: true` does not
586
- make it one — it only decides whether the first failing record aborts the run.
587
- Whether the writes made before a failure survive depends on the execution path:
657
+ - **`single` / `each`**: each record stands alone. The failure is reported, the loop
658
+ carries on with the next record, and what the earlier records wrote is kept.
659
+ - **`batch`**: there is one script invocation and no loop to continue, so a failure
660
+ simply ends the run. Whatever the script had already written with `insert()` /
661
+ `update()` is committed, and the pending `[field] = …` assignments are never
662
+ flushed. There is nothing "independent" about it — which is why `batch` +
663
+ `isTransacted: false` is a shape to avoid.
588
664
 
589
- | path | mode | writes before the failure |
665
+ | path | `isTransacted: true` | `isTransacted: false` |
590
666
  |---|---|---|
591
- | Sync execute, entity **with** `auditHistory` | `single` | rolled back |
592
- | Sync execute, entity **with** `auditHistory` | `batch` | **stay committed** |
593
- | Sync execute, entity without it | either | **stay committed** |
594
- | Scheduled job | — | rolled back |
595
- | Background (`isAsync: true`) action | either | **stay committed** |
596
-
597
- Both "rolled back" rows are a side effect of the audit machinery opening a
598
- transaction the action then inherits — and in `batch` mode it opens only after the
599
- script has finished, so it doesn't cover the script at all. Adding or removing
600
- `auditHistory`, or switching between `single` and `batch`, changes whether a failed
601
- action leaves partial writes behind, with no change to the action itself.
602
-
603
- **This is a platform defect, not a contract** — actions are intended to be atomic,
604
- and this table is what happens until that lands. So don't design around either
605
- outcome, and don't reach for `auditHistory` as an atomicity switch.
606
-
607
- **Write actions that don't depend on it:** do all validation up front and call
608
- `error()` before any `insert()` / `update()`, so there is nothing to unwind either
609
- way.
667
+ | Sync execute, `single` | whole selection rolled back | writes stay committed |
668
+ | Sync execute, `batch` | whole run rolled back | writes stay committed |
669
+ | Scheduled job | rolled back | rolled back (the dispatcher owns the transaction) |
670
+ | **Queued to background**, `single` / `each` | **stays committed** (run still aborts at the first failure) | **stays committed** (run continues) |
671
+ | **Queued to background**, `batch` | **stays committed** (flag not read at all) | **stays committed** (same) |
672
+
673
+ **The background row is the exception and it is a known platform gap.** The
674
+ background worker opens no transaction, so an action that is atomic when run
675
+ inline is not when the same call is queued. The rollback half is lost outright;
676
+ what survives depends on the mode. The worker's `single` / `each` loop still reads
677
+ `isTransacted` to decide whether to abort after the first failed record or carry
678
+ on, so there `true` means "stop here", not "undo". Its `batch` path never reads the
679
+ flag — one invocation, ending on failure either way — so a queued `batch` action
680
+ gets nothing from `isTransacted` at all. And which one happens is decided by
681
+ the *request*, not by the manifest: `isAsync: true` only permits queuing, and an
682
+ action with parameters is offered to the user as both "Run" (inline, transactional)
683
+ and "Run in Background" (queued, not). So an `isAsync` action cannot be assumed to
684
+ be either.
685
+
686
+ **Two things that follow for how you write actions:**
687
+
688
+ 1. **Declare `isTransacted: true`** — which is also the default — for anything that
689
+ writes more than one row. Do *not* reach for `auditHistory` as an atomicity
690
+ switch; that was a workaround for a defect that no longer exists.
691
+ 2. **Still validate up front.** Call `error()` before the first `insert()` /
692
+ `update()` where you can, so a background-queued run has nothing to unwind
693
+ either. This is the only guard that holds on every path.
610
694
 
611
695
  ## SQL in query() — important notes
612
696
 
@@ -3,7 +3,12 @@
3
3
  Formulas are used in:
4
4
 
5
5
  - **Formula columns** (`columnType: "F"`) — computed values
6
- - **Action `canExecute:` blocks** — availability checks
6
+ - **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
7
+ and **enforced** by `action.execute`, but only over a narrow subset: comparisons
8
+ between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
9
+ arithmetic, ref navigation, `$[setting]` reads and a bare boolean field all fail
10
+ open server-side, and some of them throw client-side. See the `canExecute:`
11
+ section in [action-dsl.md](action-dsl.md).
7
12
  - **Default values** — a setting's `formula` (e.g. `"formula": "TODAY()"`) or a formula (`F`) column. Note: entity *data* columns have **no** `default`/`defaultValue` key — model a default with an `F` column or set it in action/trigger logic.
8
13
  - **Filter expressions** (partially)
9
14
  - **Validation expressions** (partially)
@@ -74,7 +74,10 @@ execute:
74
74
  }
75
75
  ```
76
76
 
77
- Hide the action from the UI with `canExecute: false` — the scheduler bypasses the check:
77
+ Gate the action to automation only with `canExecute: false`. The button is left in
78
+ the toolbar but permanently **disabled**, and `action.execute` refuses the call
79
+ server-side, while the scheduler — which invokes the script engine directly — is
80
+ unaffected:
78
81
 
79
82
  ```
80
83
  canExecute:
@@ -119,6 +119,16 @@ For each action:
119
119
  - [ ] DSL file exists at `logic/actions/<script>.dsl`, where `<script>` is the bare filename in the action's `script` field (no path, no `.dsl` extension)
120
120
  - [ ] DSL file has `params:`, `canExecute:`, and `execute:` blocks
121
121
  - [ ] `canExecute:` is a valid formula expression
122
+ - [ ] If `canExecute:` is meant to **gate** and not just grey out a button, its
123
+ guard is expressed in the server-enforced subset — **comparisons** between
124
+ fields and literals, combined with `AND` / `OR` / `NOT`. Function calls
125
+ (`TODAY()`, `COUNT()`, `CURRENT_USER_ID()`), arithmetic, ref navigation and
126
+ `$[setting]` reads are fail-open server-side and are advisory only
127
+ - [ ] No **bare boolean field** as a whole condition — `[is_active]` and
128
+ `NOT [is_blocked]` are not enforced (a field is only an operand). Write
129
+ `[is_active] = true` / `NOT ([is_blocked] = true)`
130
+ - [ ] Anything that MUST hold is also checked in `execute:` with `error()` — a
131
+ `canExecute` alone is never the authoritative guard
122
132
  - [ ] `execute:` uses only documented built-in functions
123
133
  - [ ] Referenced entities exist
124
134
  - [ ] Referenced parameters are declared in `params:`