@dforge-core/dforge-mcp 0.2.14 → 0.2.16

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +239 -1
  2. package/dist/server.js +155 -7
  3. package/package.json +2 -2
  4. package/resources/docs/conventions.md +74 -1
  5. package/resources/docs/dsl-reference.md +58 -1
  6. package/resources/schemas/entity.schema.json +80 -2
  7. package/resources/schemas/folders.schema.json +1 -1
  8. package/skills/dforge-mcp-author/examples/column-security/README.md +67 -0
  9. package/skills/dforge-mcp-author/examples/column-security/entities/product.json +79 -0
  10. package/skills/dforge-mcp-author/examples/column-security/manifest.json +23 -0
  11. package/skills/dforge-mcp-author/examples/column-security/security/roles.json +16 -0
  12. package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +35 -0
  13. package/skills/dforge-mcp-author/examples/column-security/ui/data_views.json +20 -0
  14. package/skills/dforge-mcp-author/examples/column-security/ui/folders.json +23 -0
  15. package/skills/dforge-mcp-author/examples/column-security/ui/menus.json +21 -0
  16. package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +8 -12
  17. package/skills/dforge-mcp-author/references/action-dsl.md +113 -29
  18. package/skills/dforge-mcp-author/references/column-types.md +3 -2
  19. package/skills/dforge-mcp-author/references/data-views.md +2 -0
  20. package/skills/dforge-mcp-author/references/flags.md +41 -9
  21. package/skills/dforge-mcp-author/references/formulas.md +13 -1
  22. package/skills/dforge-mcp-author/references/jobs.md +4 -1
  23. package/skills/dforge-mcp-author/references/security.md +77 -1
  24. package/skills/dforge-mcp-author/references/validation-checklist.md +12 -0
  25. package/skills/dforge-module-build/SKILL.md +13 -2
@@ -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
 
@@ -36,8 +36,9 @@ Virtual N:1 lookup. No physical column. Always **paired with a hidden FK column*
36
36
  The example below is a **required** relation, so both halves carry `M`. For an optional
37
37
  one, drop it from both: FK `"E"`, Reference `"VE"`. `M` resolves to `isNullable: false`
38
38
  at install, so it is what makes the FK column `NOT NULL` — and while `M` is inert on the
39
- virtual Reference half, leaving it there over an optional FK reads as a required field to
40
- the next author. `dforge_entity_reference_add({ required })` emits both halves in step.
39
+ virtual Reference half (at the entity level; a per-view override behaves differently, see
40
+ `flags.md`), leaving it there over an optional FK reads as a required field to the next
41
+ author. `dforge_entity_reference_add({ required })` emits both halves in step.
41
42
 
42
43
  ```json
43
44
  // Hidden FK column — physical, hidden. "E" instead of "EM" if the relation is optional.
@@ -4,6 +4,8 @@ Data views define how users see and interact with entity data. One entity can ha
4
4
 
5
5
  Lives in: `ui/data_views.json`
6
6
 
7
+ > **Not to be confused with entity views.** An *entity view* (`views` on an entity file) is column-level **security** — which columns a folder may see at all — and is what `folders.json`'s `viewName` binds. A *data view* is a presentation of an entity, referenced by a menu's `dataViewCode`. A third thing, `isView` + `viewSql` on an entity, means the entity is backed by a SQL view. See `security.md` → Column-level security.
8
+
7
9
  ## Structure
8
10
 
9
11
  ```json
@@ -2,19 +2,21 @@
2
2
 
3
3
  Column flags are single-letter codes concatenated into a string. Order doesn't matter — `"VEM"` and `"EMV"` are equivalent.
4
4
 
5
- Valid flags regex: `^[VIEMH]*$`
5
+ Valid flags regex: `^[VIEM]*$`
6
6
 
7
7
  ## Flag letters
8
8
 
9
9
  | Letter | Name | Meaning |
10
10
  |---|---|---|
11
11
  | `V` | Visible | Column appears in grid / form / card views. Omit to hide from standard UI. |
12
- | `I` | Internal | Auto-managed by the platform. Used on PK columns, audit timestamps, system fields. Not shown in UI, not directly editable by users. |
12
+ | `I` | Identity | Auto-managed by the platform. Used on PK columns, audit timestamps, system fields. Not shown in UI, not directly editable by users, excluded from required validation. |
13
13
  | `E` | Editable | User can modify the value in the UI. Omit for read-only. |
14
14
  | `M` | Mandatory | Required. Resolved into `isNullable: false` at install — see below. |
15
- | `H` | Hidden | Permanently hidden — never shown, not even in admin metadata views. Stronger than omitting `V`. |
16
15
 
17
- **Only these 5 letters are valid.** The platform rejects any other characters.
16
+ **Only these 4 letters are valid.** The platform rejects any other characters.
17
+ > **There is no `H` flag.** Earlier drafts listed one, described as "stronger than omitting `V`", and nothing ever read it — a column with `flags: "H"` is hidden because the string contains no `V`, not because it contains `H`. To hide a column, omit `V`. To hide it from specific folders (and make it unqueryable there), use an entity view. To render it as a hidden input, use `fieldTypeCd: "hidden"`.
18
+
19
+ > **The same letters override per entity view.** `views.<v>.columns.<cd>.flags` re-flags a column for the folders bound to that view, while the entity keeps its own flags everywhere else. Same `^[VIEM]*$` vocabulary — see "Per-view overrides" below. See `security.md` → Column-level security.
18
20
 
19
21
  ## `M` is the platform's one notion of "required"
20
22
 
@@ -31,7 +33,8 @@ Four consequences worth knowing before you type a flag:
31
33
  entity and every offending field. It's a contradiction only you can resolve: drop
32
34
  `M` if the column is optional, or drop `isNullable` if it is required.
33
35
  - **`M` is inert on virtual (`R`/`S`/`F`) and identity columns** — nullability is
34
- meaningless where there is no physical column. It won't fail, but `VEM` on a
36
+ meaningless where there is no physical column. (At the *entity* level; a per-view
37
+ override behaves differently — see "Per-view overrides".) It won't fail, but `VEM` on a
35
38
  Reference over an *optional* FK reads as a required field to the next author, which
36
39
  is exactly how these get out of step. Match the Reference to its FK.
37
40
  - **Adding `M` to a shipped field aborts the upgrade** on any tenant whose existing
@@ -41,6 +44,33 @@ Four consequences worth knowing before you type a flag:
41
44
 
42
45
  Omitting both `M` and `isNullable` leaves the column nullable — that is the default.
43
46
 
47
+ ## Per-view overrides
48
+
49
+ `views.<v>.columns.<cd>.flags` re-flags one column for the folders bound to that view. The letters are the same four, and `M` means the same thing — the installer folds it into **that override's** `isNullable`.
50
+
51
+ Because a view writes no DDL, the requiredness `M` produces there is runtime-only. That is how you express **"nullable in the database, mandatory in this view"**, which no entity-level flag can say:
52
+
53
+ ```json
54
+ "views": {
55
+ "compliance": {
56
+ "columns": {
57
+ "party_id": {},
58
+ "party_name": { "flags": "V" },
59
+ "vat_no": { "flags": "VEM" },
60
+ "salary": { "flags": "" }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Three rules to keep straight:
67
+
68
+ - **The letters replace, they don't merge.** The cascade is `COALESCE(view, entity)`. A non-empty string wins outright, so `{ "flags": "V" }` is read-only there however the entity flagged the column.
69
+ - **`""` is a real value, omission is not.** An empty string means "no flags here" — neither visible nor editable. To inherit, leave the key out; `"flags": null` fails schema validation, since the property is typed `string`. (To inherit *everything*, set the whole column override to `{}` or `null`.)
70
+ - **The `M` fold is one-directional.** Dropping `M` from a view's flags does NOT make the column optional — `isNullable` still `COALESCE`s to the entity value. Write `"isNullable": true` to loosen it, and only where the database actually allows null, or the save trades a clean validation error for a raw constraint violation.
71
+
72
+ Unlike the entity level, `M` here is *not* inert on virtual or identity columns: the fold only reaches the runtime required check, so a view can mark a Reference required. (Identity columns stay excluded — they are never asked of the user.)
73
+
44
74
  ## Common column flag combinations
45
75
 
46
76
  | Flags | Meaning | Use case |
@@ -50,7 +80,7 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
50
80
  | `"V"` | Visible, read-only | Display-only, like formula columns or computed values |
51
81
  | `"E"` | Editable, **not visible** | Hidden FK column of an *optional* relation |
52
82
  | `"EM"` | Editable, Mandatory, **not visible** | Hidden FK column of a *required* relation |
53
- | `"I"` | Internal | Trait-provided columns (PK, audit timestamps). Platform-managed. |
83
+ | `"I"` | Identity | Trait-provided columns (PK, audit timestamps). Platform-managed. |
54
84
  | `""` (empty) | No flags | Rare — column exists but is invisible and not editable |
55
85
 
56
86
  ## Rules
@@ -60,9 +90,9 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
60
90
  2. **Visible Reference columns**: `"VE"`, or `"VEM"` when the FK is required — **keep the
61
91
  two halves in step**. `dforge_entity_reference_add({ required })` sets both for you.
62
92
  3. **Formula columns**: usually `"V"` (visible, read-only). The user never edits them.
63
- 4. **Trait columns** (PK, audit): use `"I"` (internal). These are set automatically by `traits: ["identity", "audit"]`.
93
+ 4. **Trait columns** (PK, audit): use `"I"` (identity). These are set automatically by `traits: ["identity", "audit"]`.
64
94
  5. **Don't use `I` on your own columns** — it's reserved for trait-provided and platform-managed columns.
65
- 6. **Don't mix `I` with `V` or `E`** — internal columns are not user-facing.
95
+ 6. **Don't mix `I` with `V` or `E`** — identity columns are not user-facing.
66
96
 
67
97
  ## Entity rights flags (different namespace — roles only)
68
98
 
@@ -79,7 +109,7 @@ These letters appear in role `rights` declarations, **NOT** on column flags. Don
79
109
 
80
110
  So `"SIUD"` on a role grants full CRUD; `"SI"` grants read + create only.
81
111
 
82
- **Note**: the `I` in column flags (Internal) and the `I` in role rights (Insert) are different concepts using the same letter. Context makes them unambiguous — column flags go on entity fields, role rights go on `security/roles.json`.
112
+ **Note**: the `I` in column flags (Identity) and the `I` in role rights (Insert) are different concepts using the same letter. Context makes them unambiguous — column flags go on entity fields, role rights go on `security/roles.json`.
83
113
 
84
114
  ## How uniqueness, PKs, and search work
85
115
 
@@ -101,4 +131,6 @@ These are **NOT** flag letters. They're separate properties on the column defini
101
131
  - Putting `M` on a Reference whose hidden FK is optional — inert, but it misreports the field as required.
102
132
  - Using `"VEM"` on trait-provided columns (PK, audit) — **don't**. Traits set their own flags (`"I"`).
103
133
  - Using `"I"` on business columns — **don't**. `I` means "platform-managed"; regular business columns use `V`/`E`/`M`.
134
+ - Expecting a per-view `{ "flags": "VE" }` to make a column **optional** because it drops `M` — it doesn't. `isNullable` still inherits; write `"isNullable": true`.
135
+ - Writing `"flags": null` in a view override to inherit — fails validation. Omit the key.
104
136
  - Using long names like `"Visible,Editable"` — **wrong**. Single letters concatenated: `"VE"`.
@@ -3,7 +3,19 @@
3
3
  Formulas are used in:
4
4
 
5
5
  - **Formula columns** (`columnType: "F"`) — computed values
6
- - **Action `canExecute:` blocks** — availability checks
6
+ - **Per-view formula overrides** (`views.<v>.columns.<cd>.formula`) — one entity view
7
+ computing a column differently from the entity. Allowed **only** on a column the
8
+ entity declares as `columnType: "F"`; on any other column the install FAILS, because
9
+ there that field holds the SQL default and a view cannot change a physical column's
10
+ `DEFAULT`. (There is no per-view default value for a normal column.) Same rules as an
11
+ `F` column otherwise: it must parse, call only implemented functions, and not
12
+ aggregate over a 1:N set. See `security.md` → Column-level security.
13
+ - **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
14
+ and **enforced** by `action.execute`, but only over a narrow subset: comparisons
15
+ between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
16
+ arithmetic, ref navigation, `$[setting]` reads and a bare boolean field all fail
17
+ open server-side, and some of them throw client-side. See the `canExecute:`
18
+ section in [action-dsl.md](action-dsl.md).
7
19
  - **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
20
  - **Filter expressions** (partially)
9
21
  - **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:
@@ -213,7 +213,7 @@ Lives in: `ui/folders.json`
213
213
 
214
214
  | Property | Type | Description |
215
215
  |---|---|---|
216
- | `viewName` | string | Default data view (usually `"default"`) |
216
+ | `viewName` | string | Entity view to bind — a key under the entity's `views` (see [Column-level security](#column-level-security-entity-views)), **not** a `ui/data_views.json` code. `"default"` means "no view" (full column set). |
217
217
  | `quickAdd` | boolean | Show "+" quick-add button (`true` for main entities, `false` for detail/line items) |
218
218
  | `rowFilter` | object | Row-level filter scoping which records are visible in this folder |
219
219
 
@@ -226,6 +226,82 @@ Row filters use a compact filter object with:
226
226
 
227
227
  This is how **row-level security** works in the module package — subfolders with `rowFilter` restrict which rows are visible. Users assigned to "Central Warehouse" only see stock where `warehouse_id = 2001`.
228
228
 
229
+ ## Column-level security (entity views)
230
+
231
+ > Worked example: `dforge://example/column-security/` — one entity, two folders, two
232
+ > column sets. Its README walks the overrides and the ways a view fails the install.
233
+
234
+ Layer 2 of the model. A **entity view** is a named subset of an entity's columns, declared on the entity itself; a folder binds one per entity. Users working in that folder see **only** the columns the view lists — the rest are neither visible nor queryable there.
235
+
236
+ Two halves, both required:
237
+
238
+ | File | Key | Effect |
239
+ |---|---|---|
240
+ | `entities/<entity>.json` | `views.<viewName>.columns` | Declares the view and its column set |
241
+ | `ui/folders.json` | `entities.<code>.viewName` | Binds that view for this entity in this folder |
242
+
243
+ ```json
244
+ {
245
+ "fields": { "product_id": {}, "name": {}, "price": {}, "cost": {}, "quantity": {} },
246
+ "views": {
247
+ "accountant": {
248
+ "columns": {
249
+ "product_id": {},
250
+ "name": { "flags": "V" },
251
+ "price": { "flags": "VE" },
252
+ "cost": { "flags": "V" }
253
+ }
254
+ },
255
+ "storekeeper": {
256
+ "columns": {
257
+ "product_id": {},
258
+ "name": { "flags": "V" },
259
+ "quantity": { "flags": "VE" }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ ```json
267
+ "entities": { "product": { "viewName": "accountant", "quickAdd": true } }
268
+ ```
269
+
270
+ The storekeeper folder cannot see `price` or `cost` at all — not on screen, not through a filter, not in an export.
271
+
272
+ **Per-column overrides** (all optional, each falls back to the entity-level value): `flags`, `orderNum`, `isNullable`, `editMask`, `displayFmt`, `refFilter`, `params`, `formula`. `{}` means "expose this column, override nothing".
273
+
274
+ > **An empty override is a value, not an absence.** The runtime merges view over entity with `COALESCE`, which skips NULL only — so `"flags": ""` means "no flags in this view" (a column neither visible nor editable) and `"params": {}` suppresses the entity-level params rather than inheriting them. Omit the key entirely to inherit — `"flags": null` fails schema validation. See `flags.md` for the flag letters.
275
+
276
+ > **`M` works in a view override too**, folding into that override's `isNullable`. Since a view writes no DDL, the requiredness is runtime-only — which is how a column nullable in the database is made mandatory inside one view: `{ "flags": "VEM" }`. The fold is one-directional: dropping `M` does not loosen the column, because `isNullable` still inherits. See `flags.md` → Per-view overrides.
277
+
278
+ `formula` lets one view compute a column differently from the entity — same DSL as a field's `formula`, and **only on a column the entity declares as `columnType: "F"`**; elsewhere the install fails, because there that field holds the SQL default and a view cannot change a physical column's `DEFAULT`. There is no per-view default value for a normal column. See `formulas.md`.
279
+
280
+ ### Rules the installer enforces
281
+
282
+ Each of these is an install-time error, and each is silent at runtime if it slips through — which is why `dforge_module_validate` checks them offline:
283
+
284
+ - A column absent from `columns` is **hidden**. That is the mechanism, not an oversight.
285
+ - A view must list the entity's **primary key** — records are addressed by it. To keep it off screen, drop `V` from its flags instead of omitting it.
286
+ - A column code that doesn't exist on the entity, a view with **no columns**, the same column listed twice under different casing, or two views on one entity whose names differ only by case (a folder binds case-insensitively, so only one could ever be reached).
287
+ - Views must be declared on the entity's **own** module. An `extends` extension declaring `views` is rejected — a view enumerates the complete column set, which an extension does not own.
288
+ - A `viewName` that no view declares, or one naming a view with no columns, **fails the install**. It does not fall back: the fallback is the entity's full column set, so a typo would quietly *unrestrict* the folder.
289
+ - Columns removed from a view are reaped on upgrade — that is the security change the author asked for. Whole views are not: `entity_view` also holds tenant views from the card-layout editor, and the installer cannot tell them apart.
290
+
291
+ ### `"default"` is the one exempt name
292
+
293
+ `"viewName": "default"` means **no view** (full column set) and declares nothing. It is what every shipped module writes, and what the runtime auto-creates for an entity reached without a folder binding — so it is exempt from the checks above. A module that really declares a view named `default` still gets it bound.
294
+
295
+ ### Don't confuse the three "views"
296
+
297
+ | Term | Where | What it is |
298
+ |---|---|---|
299
+ | **Entity view** | `entities/*.json` → `views` | Column-level security (this section) |
300
+ | **Data view** | `ui/data_views.json` | A grid / kanban / calendar over an entity (`data-views.md`) |
301
+ | **SQL view** | `entities/*.json` → `isView` + `viewSql` | An entity backed by a SQL view instead of a table |
302
+
303
+ `viewName` in `folders.json` binds the first. Menus' `dataViewCode` references the second.
304
+
229
305
  ### When to use subfolders
230
306
 
231
307
  - **Multi-location modules** (warehouses, offices, branches) — one subfolder per location, filtered by location FK
@@ -105,6 +105,8 @@ For each entity:
105
105
  ## Security
106
106
 
107
107
  - [ ] `security/roles.json` exists (at least one role)
108
+ - [ ] Every entity view (`views.<v>.columns` on an entity) lists the entity's **primary key**, names only real columns, and has at least one column — each of these fails the install
109
+ - [ ] Every `viewName` in `ui/folders.json` is either `"default"` (= no view) or a view that entity actually declares — an unresolved name fails the install rather than falling back
108
110
  - [ ] Every role has `description` and `rights` (both required; no `label` field in `roles.json` — the `roles.schema.json` sets `additionalProperties: false`, so any extra field including `label` is rejected here). `description` is the English display-name fallback; **localized role labels live in the translation files** (`roles.<code>.label`) and are completeness-enforced — see Translations below.
109
111
  - [ ] The field name is `rights`, NOT `entityRights`
110
112
  - [ ] Rights strings use only valid letters: `SIUDC` for entities, `E` for actions/reports/folders
@@ -119,6 +121,16 @@ For each action:
119
121
  - [ ] 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
122
  - [ ] DSL file has `params:`, `canExecute:`, and `execute:` blocks
121
123
  - [ ] `canExecute:` is a valid formula expression
124
+ - [ ] If `canExecute:` is meant to **gate** and not just grey out a button, its
125
+ guard is expressed in the server-enforced subset — **comparisons** between
126
+ fields and literals, combined with `AND` / `OR` / `NOT`. Function calls
127
+ (`TODAY()`, `COUNT()`, `CURRENT_USER_ID()`), arithmetic, ref navigation and
128
+ `$[setting]` reads are fail-open server-side and are advisory only
129
+ - [ ] No **bare boolean field** as a whole condition — `[is_active]` and
130
+ `NOT [is_blocked]` are not enforced (a field is only an operand). Write
131
+ `[is_active] = true` / `NOT ([is_blocked] = true)`
132
+ - [ ] Anything that MUST hold is also checked in `execute:` with `error()` — a
133
+ `canExecute` alone is never the authoritative guard
122
134
  - [ ] `execute:` uses only documented built-in functions
123
135
  - [ ] Referenced entities exist
124
136
  - [ ] Referenced parameters are declared in `params:`
@@ -31,6 +31,7 @@ flags, or column patterns.
31
31
  | Add a grid / list view | `dforge://reference/data-views`, `dforge://example/ui/data_views.json` | — |
32
32
  | Add a matrix (pivot) view | `dforge://reference/data-views` (§Matrix), `dforge://example/matrix-budget/ui/data_views.json`, `dforge://example/matrix-budget/entities/budget_line.json` | — |
33
33
  | Add kanban / calendar / tree-grid / master-detail | `dforge://reference/data-views` | `dforge://schema/data-views` |
34
+ | Hide columns from a user type (column-level security) | `dforge://reference/security` (§Column-level security), `dforge://example/column-security/entities/product.json`, `dforge://example/column-security/ui/folders.json` | — |
34
35
  | Add a menu | `dforge://reference/menus`, `dforge://example/ui/menus.json` | — |
35
36
  | Add an action | `dforge://reference/action-dsl`, `dforge://example/logic/actions/mark_done.dsl`, `dforge://example/ui/actions.json` | `dforge://docs/dsl` |
36
37
  | Add a trigger | — | `dforge://schema/triggers`; condition syntax in `dforge://docs/dsl` |
@@ -204,8 +205,11 @@ per entity, so you'll often `view_modify` rather than `view_add`.
204
205
 
205
206
  View codes are semantic — the entity name (`feedback_item`), a plural
206
207
  (`invoices`), or descriptive (`invoices_kanban`). **Never use the literal code
207
- `default`**; `ui/folders.json`'s `viewName: "default"` is a fallback alias the
208
- platform resolves to the entity's first declared view.
208
+ `default`.** Note that `ui/folders.json`'s `viewName` is a different axis
209
+ entirely: it binds an *entity view* (column-level security, declared under
210
+ `views` on the entity file), not a data view code. `"default"` there means "no
211
+ entity view — show the full column set", and any other name the entity does not
212
+ declare **fails the install**. See `references/security.md`.
209
213
 
210
214
  Then wire menus with `dforge_menu_add` — a section node per group, leaves
211
215
  pointing at the views.
@@ -273,6 +277,13 @@ stated trigger; every report's params sit on a dataset.
273
277
  4. **Show the rights matrix as a table** (rows = entities/actions/reports,
274
278
  columns = roles, cells = rights strings), each cell explained by the verb it
275
279
  maps to. Get sign-off.
280
+ 5. **Column-level security only when the intake asks for it.** Roles grant rights
281
+ per entity, not per field. When a user type must not see specific *columns*
282
+ (salaries, cost prices, personal data), that is an **entity view**: declare
283
+ `views.<name>.columns` on the entity and bind it with `viewName` on the folder
284
+ that user type works in. Don't reach for it otherwise — a view is the complete
285
+ column list, so it is one more list to keep in step with the fields. Rules and
286
+ the worked example: `references/security.md` → Column-level security.
276
287
  5. `dforge_role_add` for new roles; `dforge_role_right_set` per grant when
277
288
  amending an existing one.
278
289