@dforge-core/dforge-mcp 0.2.1 → 0.2.2

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/package.json CHANGED
@@ -1,48 +1,48 @@
1
1
  {
2
- "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.1",
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
- "license": "MIT",
6
- "homepage": "https://github.com/iash44/dForge-core",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/dforge-core/dforge-mcp.git"
10
- },
11
- "bin": {
12
- "dforge-mcp": "dist/server.js",
13
- "dforge-install-skills": "scripts/install-skills.mjs"
14
- },
15
- "files": [
16
- "dist/",
17
- "docs/",
18
- "resources/",
19
- "skills/",
20
- "scripts/install-skills.mjs",
21
- "README.md",
22
- "CHANGELOG.md"
23
- ],
24
- "main": "dist/server.js",
25
- "engines": {
26
- "node": ">=18"
27
- },
28
- "scripts": {
29
- "build": "tsup",
30
- "sync-schemas": "node scripts/vendor-schemas.cjs",
31
- "prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
32
- "typecheck": "tsc --noEmit",
33
- "test": "vitest run",
34
- "install-skills": "node scripts/install-skills.mjs"
35
- },
36
- "dependencies": {
37
- "@dforge-core/dforge-cli": "^0.2.7",
38
- "@dforge-core/metadata": "^0.0.10",
39
- "@modelcontextprotocol/sdk": "^1.29.0",
40
- "zod": "^4.4.3"
41
- },
42
- "devDependencies": {
43
- "@types/node": "^22.20.1",
44
- "tsup": "^8.5.1",
45
- "typescript": "^6.0.3",
46
- "vitest": "^3.2.7"
47
- }
2
+ "name": "@dforge-core/dforge-mcp",
3
+ "version": "0.2.2",
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
+ "license": "MIT",
6
+ "homepage": "https://github.com/iash44/dForge-core",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dforge-core/dforge-mcp.git"
10
+ },
11
+ "bin": {
12
+ "dforge-mcp": "dist/server.js",
13
+ "dforge-install-skills": "scripts/install-skills.mjs"
14
+ },
15
+ "files": [
16
+ "dist/",
17
+ "docs/",
18
+ "resources/",
19
+ "skills/",
20
+ "scripts/install-skills.mjs",
21
+ "README.md",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "main": "dist/server.js",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "sync-schemas": "node scripts/vendor-schemas.cjs",
31
+ "prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "vitest run",
34
+ "install-skills": "node scripts/install-skills.mjs"
35
+ },
36
+ "dependencies": {
37
+ "@dforge-core/dforge-cli": "^0.2.10",
38
+ "@dforge-core/metadata": "^0.0.12",
39
+ "@modelcontextprotocol/sdk": "^1.29.0",
40
+ "zod": "^4.4.3"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.20.1",
44
+ "tsup": "^8.5.1",
45
+ "typescript": "^6.0.3",
46
+ "vitest": "^3.2.7"
47
+ }
48
48
  }
@@ -135,15 +135,14 @@ The entity file uses the same `fields` schema as regular entities, plus an `exte
135
135
  "fields": {
136
136
  "customer_id": {
137
137
  "dbDatatype": "int8",
138
- "isNullable": true,
139
- "flags": "EM",
138
+ "flags": "E",
140
139
  "orderNum": 34,
141
140
  "description": "Customer FK"
142
141
  },
143
142
  "customer": {
144
143
  "columnType": "R",
145
144
  "fieldTypeCd": "lookup",
146
- "flags": "VEM",
145
+ "flags": "VE",
147
146
  "orderNum": 35,
148
147
  "description": "Customer",
149
148
  "link": {
@@ -641,7 +640,6 @@ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/a
641
640
  },
642
641
  "department_id": {
643
642
  "dbDatatype": "cuid",
644
- "fieldTypeCd": "hidden",
645
643
  "flags": "EM",
646
644
  "orderNum": 30,
647
645
  "description": "Department ID"
@@ -715,8 +713,7 @@ For every foreign key relationship, create TWO columns:
715
713
  ```json
716
714
  "department_id": {
717
715
  "dbDatatype": "cuid",
718
- "fieldTypeCd": "hidden",
719
- "flags": "EM",
716
+ "flags": "E",
720
717
  "orderNum": 30,
721
718
  "description": "Department ID"
722
719
  }
@@ -727,7 +724,7 @@ For every foreign key relationship, create TWO columns:
727
724
  "department": {
728
725
  "columnType": "R",
729
726
  "fieldTypeCd": "lookup",
730
- "flags": "VEM",
727
+ "flags": "VE",
731
728
  "orderNum": 35,
732
729
  "description": "Department",
733
730
  "link": {
@@ -752,8 +749,14 @@ For every foreign key relationship, create TWO columns:
752
749
  - Reference columns use `"link"` (not `"params"`) for entity binding
753
750
  - `link.entity` = target entity code, `link.thisKey` = local FK field, `link.otherKey` = remote PK field
754
751
  - `params` is used for other purposes (e.g., dropdown `options`)
755
- - Hidden FK column: `flags: "EM"` (no `V` = hidden from UI)
756
- - Visible reference column: `flags: "VEM"`, `columnType: "R"`
752
+ - Hidden FK column: `flags: "E"` (no `V` = hidden from UI), `flags: "EM"` when the
753
+ relationship is genuinely required. `M` resolves to `isNullable: false` at install, so it
754
+ makes the FK `NOT NULL`; omitting both leaves the column nullable, which is the default.
755
+ Declaring `M` alongside `"isNullable": true` fails at pack time.
756
+ - Visible reference column: `columnType: "R"`, `flags: "VE"` — or `"VEM"` when the FK is
757
+ required. **Keep the two in step.** `M` is inert on a virtual column (a Reference is
758
+ required exactly when its hidden FK is), so `VEM` over an optional FK changes nothing but
759
+ reads as a required field to the next author.
757
760
  - The FK column's `dbDatatype` **MUST match the referenced PK's type** — use `cuid` for `identity`-trait PKs (`cuid` is physically `int8`, **not** a UUID). A mismatch (e.g. FK `uuid` → PK `cuid`) fails install with *"foreign key constraint … cannot be implemented"*.
758
761
 
759
762
  ---
@@ -149,7 +149,10 @@ var open = query(
149
149
  { s: 'open', cid: [customer_id] }
150
150
  )
151
151
  if (open.length == 0) { exit('Nothing open', 'info') }
152
- info('Open invoices: ' + open.length + ', total $' + open[0].total)
152
+ for (var i = 0; i < open.length; i++) {
153
+ update('fin.invoice', open[i].invoice_id, { status: 'reminded' })
154
+ }
155
+ info('Reminded ' + open.length + ' open invoice(s)')
153
156
  ```
154
157
 
155
158
  > **Pooled connection.** The connection is handed to another request once your
@@ -228,7 +231,8 @@ The result is a **read-only** snapshot — writing to it (`rec.set(...)`) throws
228
231
 
229
232
  ```dsl
230
233
  var rec = getRecord('crm.customer', [customer_id])
231
- info('Customer: ' + rec.name + ' — credit limit $' + rec.credit_limit)
234
+ if (rec.credit_limit == null) { error('Customer has no credit limit set') }
235
+ [customer_name] = rec.name
232
236
  ```
233
237
 
234
238
  #### `getRecordOrNull(entityCd, key) → record | null`
@@ -245,7 +249,7 @@ Call a stored procedure with named args. Returns the proc's result row (or rows
245
249
 
246
250
  ```dsl
247
251
  var summary = callProc('fin.compute_balance', { account_id: [account_id], as_of: now() })
248
- info('Balance: ' + summary.balance)
252
+ [closing_balance] = summary.balance
249
253
  ```
250
254
 
251
255
  #### `preloadRef(fkField)`
@@ -289,6 +293,8 @@ Fractional-unit arithmetic.
289
293
  #### `info(message, opts?)`, `warn(message, opts?)`
290
294
  Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
291
295
 
296
+ A message is a **receipt for work the action did**, not a way to publish a computed value. An action whose only output is `info('<some computed number>')` stores nothing — it can't be audited, re-read or reported on, and the value arrives unformatted. Build a report, a formula column, or an action that writes a result record instead. See `dforge://reference/action-dsl`, *When an action is the wrong tool*.
297
+
292
298
  ```dsl
293
299
  info('Created PO ' + po.po_number, {
294
300
  links: [{ entity: 'purchase_order', id: po.purchase_order_id }]
@@ -458,12 +464,21 @@ if (det != null) { var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url')
458
464
  #### `entityLink(entityCd, record, description?) → link value`
459
465
  Build a clickable link to any entity record for storing in an `entitylink` (jsonb)
460
466
  column. `record` is a row object (e.g. from `insert()` / `getRecord()`); its PK
461
- columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact. Optional
462
- `description` sets the display text.
467
+ columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact.
468
+
469
+ The target entity is resolved at build time and stored **by id**, not by code:
470
+ `entity_cd` is only unique per module, so a bare `'invoice'` would re-resolve at click
471
+ time against the *reader's* folder module and could land on another module's entity.
472
+ Always qualify the code you pass (`'fin.invoice'`).
473
+
474
+ `description` is optional — omit it and the link is captioned with the entity's
475
+ `toString` evaluated against `record`, the way records are captioned everywhere else.
476
+ An explicit `description` still wins.
463
477
 
464
478
  ```dsl
465
479
  var task = insert('workspace.task', { title: 'Follow up' })
466
- insert('workspace.task_note', { link: entityLink('workspace.task', task, task.title) })
480
+ # Captioned by workspace.task's toString — no third argument needed.
481
+ insert('workspace.task_note', { link: entityLink('workspace.task', task) })
467
482
  ```
468
483
 
469
484
  #### `flush()`
@@ -130,7 +130,7 @@
130
130
  "flags": {
131
131
  "type": "string",
132
132
  "pattern": "^[VIEMH]*$",
133
- "description": "Display flags: V=Visible, I=Identity, E=Editable, M=Mandatory, H=Hidden"
133
+ "description": "Display flags: V=Visible, I=Identity, E=Editable, M=Mandatory, H=Hidden. M is resolved into isNullable:false at install, so it makes the column NOT NULL; it is inert on virtual (R/S/F) and identity columns. Declaring M together with isNullable:true is rejected at pack time."
134
134
  },
135
135
  "isPk": {
136
136
  "type": "boolean",
@@ -138,7 +138,7 @@
138
138
  },
139
139
  "isNullable": {
140
140
  "type": "boolean",
141
- "description": "Whether the column allows NULL values"
141
+ "description": "Whether the column allows NULL values. Omitted means true (nullable) — only an explicit false, or the M flag which resolves to it, makes the column NOT NULL. Adding it to a column that existing tenant rows leave empty aborts the upgrade."
142
142
  },
143
143
  "isIdentity": {
144
144
  "type": "boolean",
@@ -33,7 +33,7 @@
33
33
  "event": {
34
34
  "type": "string",
35
35
  "enum": ["insert", "update", "delete", "status_change", "any"],
36
- "description": "DB event that fires this trigger. 'status_change' only fires when the entity's status field actually changes value. 'any' fires for all insert/update/delete."
36
+ "description": "DB event that fires this trigger. 'status_change' only fires when the entity's status field actually changes value. 'any' fires for all insert/update/delete. Events are raised by the row change itself, whichever path wrote it \u2014 a user edit and an action's `[field] = value` both count \u2014 except for the DSL's insert()/update()/delete()/query() builtins writing OTHER records, which raise nothing."
37
37
  },
38
38
  "condition": {
39
39
  "type": "string",
@@ -23,6 +23,7 @@
23
23
  "dbDatatype": "varchar",
24
24
  "fieldTypeCd": "dropdown",
25
25
  "flags": "VEM",
26
+ "formula": "'medium'",
26
27
  "maxLen": 20,
27
28
  "orderNum": 30,
28
29
  "description": "Priority",
@@ -5,6 +5,26 @@ Actions are dForge's business logic. They are scripts with three declarative blo
5
5
  Lives in: `logic/actions/<action_name>.dsl`
6
6
  Registered in: `ui/actions.json`
7
7
 
8
+ ## When an action is the wrong tool
9
+
10
+ An action exists to **change something**. Its message is a receipt for work that was done — not the output itself.
11
+
12
+ Do **not** write an action whose `execute:` block only reads data, computes a value and reports it with `info()`. The result is a line of text that is never stored: it can't be audited, referenced, re-read or reported on, and every reader has to re-run the action to see it again. The value also reaches the user unformatted — a raw `query()` result carries no column metadata, so money renders as `1000.0099999999999` instead of `1,000.01`.
13
+
14
+ If you are about to write a `check_…`, `calculate_…` or `show_…` action, build one of these instead:
15
+
16
+ | What's actually needed | Build this |
17
+ |---|---|
18
+ | The user needs to **see** current numbers | A **report** or a data view — both are metadata-driven, so values format correctly, and the user can return to them any time without re-running anything. |
19
+ | A derived value **belongs to the record** | A **formula column** (`columnType: "F"`) or a roll-up — computed on read, visible on the card and in grids, filterable and sortable. |
20
+ | The **verdict itself matters** (approvals, credit checks, compliance) | An action that **writes a result record** — e.g. `credit_check(customer, checked_at, limit, outstanding, projected, verdict, checked_by)`. Now it's auditable and reportable, and `info()` becomes a legitimate receipt with a link to the new row. |
21
+ | The check must **block** something | A **trigger** with `async: false`, or a `canExecute:` guard on the action being gated — enforcement, not narration. |
22
+
23
+ Messages stay correct for exactly two things, and both are short by nature:
24
+
25
+ - a **receipt** for a mutation that just happened — `info('Created PO ' + po.po_number, { links: [...] })`
26
+ - a **refusal** that prevented one — `error('Period is closed')`
27
+
8
28
  ## Structure
9
29
 
10
30
  A DSL file has up to three blocks. All are optional (but `execute:` is needed to actually do something).
@@ -126,6 +146,17 @@ var note = params[note] // no quotes around param name in this DSL
126
146
  > `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
127
147
  > loop and mutate per id instead.
128
148
 
149
+ > **These writes raise no events.** `insert()`, `update()`, `delete()` and `query()` are the raw
150
+ > write layer: by design they skip folder row-filters, closed-period checks and registry state, and
151
+ > they raise no trigger or webhook events either. Only the **acted-on record's own**
152
+ > `[field] = value` raises them — that one is indistinguishable from a user editing the same field,
153
+ > and fires the entity's `update` / `status_change` triggers exactly as the grid does.
154
+ >
155
+ > So a trigger on `b` will not see `update('b', …)` from an action bound to `a`. If `b` must react,
156
+ > put the trigger on `a` — the entity the action is bound to — or make the change through `b`'s own
157
+ > action. The symptom when you get this wrong is a trigger that simply never fires, with nothing in
158
+ > the log to say why.
159
+
129
160
  ### select() — structured multi-row reads
130
161
 
131
162
  The read counterpart to `insert()`/`update()`/`delete()`. Entity codes are metadata-validated,
@@ -174,7 +205,7 @@ var queued = select('comm.message', {
174
205
  |---|---|
175
206
  | `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
176
207
  | `warn('message')` | Show a warning but continue execution. |
177
- | `info('message')` | Show an informational message. |
208
+ | `info('message')` | Show an informational message. A **receipt for work the action did** — not a way to publish a computed value; see *When an action is the wrong tool*. |
178
209
  | `exit('message'?, 'level'?)` | **Stop the script early without error** — changes made so far are kept. Level `'info'` (default) or `'warn'` styles the message. This is the DSL's early-return: a bare `return` is not valid at the top level of `execute:`. |
179
210
  | `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
180
211
 
@@ -203,7 +234,7 @@ var queued = select('comm.message', {
203
234
  | `addDays(date, n)` | Date | Add `n` days to a date, e.g. `addDays(now(), 30)`. |
204
235
  | `nextNumber('entity')` | String | Generate next value from a number sequence. Usually not needed — platform auto-fills on `insert()`. Use for pre-generating numbers. |
205
236
  | `callProc('proc_name', { args })` | Result | Call a stored procedure. Args are passed as named parameters. |
206
- | `entityLink('entityCd', record, description?)` | Link value | Build a clickable link to any entity record, for storing in an `entitylink` (jsonb) column. `record` is a row object (e.g. the result of `insert()` or `getRecord()`); its PK columns are read and stored as **strings**, so snowflake/cuid ids (> 2^53) stay exact. Optional `description` sets the link's display text. |
237
+ | `entityLink('entityCd', record, description?)` | Link value | Build a clickable link to any entity record, for storing in an `entitylink` (jsonb) column. `record` is a row object (e.g. the result of `insert()` or `getRecord()`); its PK columns are read and stored as **strings**, so snowflake/cuid ids (> 2^53) stay exact. The entity is resolved at build time and stored **by id** — `entity_cd` is only unique per module, so always pass a qualified code (`'fin.invoice'`). Omit `description` and the link is captioned with the entity's `toString` against `record`; an explicit `description` wins. |
207
238
 
208
239
  > **Dates: `now()` in `execute:`, `TODAY()`/`NOW()` in formulas.** The `execute:` block runs as
209
240
  > JavaScript (Jint) and exposes **lowercase `now()`** only — `TODAY()` and `NOW()` (uppercase) are
@@ -514,6 +545,7 @@ way.
514
545
 
515
546
  ## Common mistakes
516
547
 
548
+ - Writing an action that only reads, computes and prints (`info('Balance: ' + total)`) — **wrong shape**, not just wrong formatting. Nothing is stored, so nobody can re-read the answer. Use a report, a formula column, or write a result record. → *When an action is the wrong tool* (top of this file)
517
549
  - Writing `params.note` instead of `params[note]` — **wrong**. Use bracket syntax for params.
518
550
  - Writing `params["note"]` — **also wrong in DSL syntax**. Use `params[note]` without quotes.
519
551
  - Writing `update [status] = 'Draft'` (declarative style) — **wrong**. Use `[status] = 'Draft'` (assignment).
@@ -33,8 +33,14 @@ Most columns. They map 1:1 to a physical SQL column.
33
33
 
34
34
  Virtual N:1 lookup. No physical column. Always **paired with a hidden FK column** that does hold the physical data. Together they form the **FK+Reference pattern**.
35
35
 
36
+ The example below is a **required** relation, so both halves carry `M`. For an optional
37
+ one, drop it from both: FK `"E"`, Reference `"VE"`. `M` resolves to `isNullable: false`
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.
41
+
36
42
  ```json
37
- // Hidden FK column — physical, hidden
43
+ // Hidden FK column — physical, hidden. "E" instead of "EM" if the relation is optional.
38
44
  "account_id": {
39
45
  "dbDatatype": "cuid",
40
46
  "flags": "EM",
@@ -175,9 +181,10 @@ inside one aggregate must point at the **same** set column, and `COUNT(*)` is re
175
181
 
176
182
  > ⛔ **Do not use a Formula (`F`) column for set aggregation.** `SUM([set].[field])` in an `F`
177
183
  > column is **not supported** by the engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and
178
- > nav resolution only walks single-hop N:1 references, never a 1:N set. The column silently renders
179
- > **empty** on the form, grid, and reports (no error). `F` is for same-row expressions
180
- > (`[qty] * [price]`) and single-hop reference navigation (`[account].[name]`) only.
184
+ > nav resolution walks N:1 references only, never a 1:N set (however many hops it is given). The
185
+ > column silently renders **empty** on the form, grid, and reports (no error). `F` is for same-row
186
+ > expressions (`[qty] * [price]`) and N:1 reference navigation (`[account].[name]`, and multi-hop
187
+ > chains — see `dforge://reference/formulas`).
181
188
 
182
189
  > ⛔ **The aggregated child column must be physical.** A `G` trigger reads the child's `OLD`/`NEW`
183
190
  > physical values, so aggregating a **virtual** child column (`F`/`R`/`S`) fails at install with
@@ -64,8 +64,10 @@ Only include what your module actually uses — not every module needs `print_te
64
64
 
65
65
  **Every reference to another entity is two columns**:
66
66
 
67
- 1. Hidden FK column with `flags: "EM"` and `dbDatatype` matching the target PK type
68
- 2. Visible Reference column with `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags: "VEM"`, and a `link` object pointing at the target
67
+ 1. Hidden FK column with `flags: "E"` — `"EM"` when the relation is genuinely required — and `dbDatatype` matching the target PK type
68
+ 2. Visible Reference column with `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags: "VE"` / `"VEM"` **matching the FK**, and a `link` object pointing at the target
69
+
70
+ `M` resolves to `isNullable: false` at install, so it is what makes the FK column `NOT NULL` — keep the two halves in step. `dforge_entity_reference_add({ required })` emits both correctly.
69
71
 
70
72
  Plus the actual FK constraint declared in the entity's `references` block.
71
73
 
@@ -67,8 +67,8 @@ If none of an entity's fields are visible (or only its set/child-collection colu
67
67
  ```jsonc
68
68
  // WRONG — grid over an entity whose fields are all hidden (no "V")
69
69
  "fields": {
70
- "code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M=… but no V
71
- "lines": { "columnType": "S", "flags": "VEM" } // visible, but a SET — doesn't count for a grid
70
+ "code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M=required, but no V
71
+ "lines": { "columnType": "S", "flags": "VE" } // visible, but a SET — doesn't count for a grid
72
72
  }
73
73
  // RIGHT — at least one visible scalar field
74
74
  "fields": {
@@ -72,7 +72,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
72
72
  | `user` | `cuid` | `D` | User picker. Writes a user ID directly, no paired FK needed. |
73
73
  | `grid` | `set` | `S` | Detail grid of related records (1:N backwards reference). Used with `link` declaring the relation. |
74
74
  | `list` | `set` | `S` | Detail list — same 1:N set as `grid`, rendered as a card list instead of a table. |
75
- | `entitylink` | `json` | `D` | Polymorphic link to any entity (stores `{entity, id}` pair). Populate from action DSL with the `entityLink('entityCd', record, description?)` built-in (see `action-dsl.md`). |
75
+ | `entitylink` | `json` | `D` | Polymorphic link to any entity — stores the **resolved entity id** plus the record's PK (as strings) and a display caption. Populate from action DSL with the `entityLink('entityCd', record, description?)` built-in; pass a qualified entity code (see `action-dsl.md`). |
76
76
 
77
77
  ## JSON
78
78
 
@@ -11,26 +11,54 @@ Valid flags regex: `^[VIEMH]*$`
11
11
  | `V` | Visible | Column appears in grid / form / card views. Omit to hide from standard UI. |
12
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. |
13
13
  | `E` | Editable | User can modify the value in the UI. Omit for read-only. |
14
- | `M` | Mandatory | Required (NOT NULL + UI enforcement). |
14
+ | `M` | Mandatory | Required. Resolved into `isNullable: false` at install — see below. |
15
15
  | `H` | Hidden | Permanently hidden — never shown, not even in admin metadata views. Stronger than omitting `V`. |
16
16
 
17
17
  **Only these 5 letters are valid.** The platform rejects any other characters.
18
18
 
19
+ ## `M` is the platform's one notion of "required"
20
+
21
+ At install, `MandatoryFlagNormalizer` folds `M` into `isNullable: false` before DDL
22
+ generation. Everything downstream reads only `isNullable`: the `NOT NULL` in the
23
+ generated DDL, `data.insert`'s required-column check, and the red asterisk on the
24
+ client. There is no second signal.
25
+
26
+ Four consequences worth knowing before you type a flag:
27
+
28
+ - **`M` makes the column `NOT NULL`.** It is not decoration and not a UI hint. Put it
29
+ on a column only when a row is genuinely meaningless without the value.
30
+ - **`M` together with `"isNullable": true` is a hard error at pack time**, naming the
31
+ entity and every offending field. It's a contradiction only you can resolve: drop
32
+ `M` if the column is optional, or drop `isNullable` if it is required.
33
+ - **`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
35
+ Reference over an *optional* FK reads as a required field to the next author, which
36
+ is exactly how these get out of step. Match the Reference to its FK.
37
+ - **Adding `M` to a shipped field aborts the upgrade** on any tenant whose existing
38
+ rows leave it empty — rolled back, naming the column and its NULL row count. Give
39
+ the column a `params.serverDefault` if the install should backfill it. Removing `M`
40
+ is always safe.
41
+
42
+ Omitting both `M` and `isNullable` leaves the column nullable — that is the default.
43
+
19
44
  ## Common column flag combinations
20
45
 
21
46
  | Flags | Meaning | Use case |
22
47
  |---|---|---|
23
- | `"VEM"` | Visible, Editable, Mandatory | Required field shown in UI (most business fields) |
24
- | `"VE"` | Visible, Editable, optional | Nullable field |
48
+ | `"VEM"` | Visible, Editable, Mandatory | Required field shown in UI |
49
+ | `"VE"` | Visible, Editable, optional | Nullable field — the common case for most business fields |
25
50
  | `"V"` | Visible, read-only | Display-only, like formula columns or computed values |
26
- | `"EM"` | Editable, Mandatory, **not visible** | Hidden FK columns in the FK+Reference pattern |
51
+ | `"E"` | Editable, **not visible** | Hidden FK column of an *optional* relation |
52
+ | `"EM"` | Editable, Mandatory, **not visible** | Hidden FK column of a *required* relation |
27
53
  | `"I"` | Internal | Trait-provided columns (PK, audit timestamps). Platform-managed. |
28
54
  | `""` (empty) | No flags | Rare — column exists but is invisible and not editable |
29
55
 
30
56
  ## Rules
31
57
 
32
- 1. **Hidden FK columns**: always `"EM"`. They must be writeable (E) and required (M), but never shown (no V).
33
- 2. **Visible Reference columns**: always `"VEM"`. The user sees and edits the lookup picker.
58
+ 1. **Hidden FK columns**: `"E"`, or `"EM"` when the relationship is genuinely required.
59
+ Writeable (E), never shown (no V). `M` here is what makes the FK `NOT NULL`.
60
+ 2. **Visible Reference columns**: `"VE"`, or `"VEM"` when the FK is required — **keep the
61
+ two halves in step**. `dforge_entity_reference_add({ required })` sets both for you.
34
62
  3. **Formula columns**: usually `"V"` (visible, read-only). The user never edits them.
35
63
  4. **Trait columns** (PK, audit): use `"I"` (internal). These are set automatically by `traits: ["identity", "audit"]`.
36
64
  5. **Don't use `I` on your own columns** — it's reserved for trait-provided and platform-managed columns.
@@ -68,6 +96,9 @@ These are **NOT** flag letters. They're separate properties on the column defini
68
96
  - Using `"U"` for unique — **not a valid flag**. Use `"isUnique": true` or indexes.
69
97
  - Using `"S"` for searchable — **not a valid flag**.
70
98
  - Using `"P"` for primary key — **not a valid flag**. Use `"isPk": true` or the `identity` trait.
99
+ - Declaring `M` **and** `"isNullable": true` on the same field — a contradiction that fails the pack.
100
+ - Reaching for `"VEM"` by default — `M` now means `NOT NULL`. Most business fields are `"VE"`.
101
+ - Putting `M` on a Reference whose hidden FK is optional — inert, but it misreports the field as required.
71
102
  - Using `"VEM"` on trait-provided columns (PK, audit) — **don't**. Traits set their own flags (`"I"`).
72
103
  - Using `"I"` on business columns — **don't**. `I` means "platform-managed"; regular business columns use `V`/`E`/`M`.
73
104
  - Using long names like `"Visible,Editable"` — **wrong**. Single letters concatenated: `"VE"`.
@@ -26,6 +26,12 @@ The same grammar applies everywhere.
26
26
  `CONTAINS` / `STARTS_WITH` / `ENDS_WITH` are infix operators, not functions — write
27
27
  `[note] CONTAINS 'urgent'`, never `CONTAINS([note], 'urgent')`.
28
28
 
29
+ **Comparison is null-safe and two-valued in every context.** `null = null` is true, and
30
+ `[x] != null` is true for any non-null `x`. There is no SQL-style third "unknown" answer,
31
+ so `IF([completed_date] != null, 100, 0)` gives the same result on the record card and in
32
+ a report. At SQL time `=` / `!=` translate to `IS NOT DISTINCT FROM` / `IS DISTINCT FROM`
33
+ to preserve that.
34
+
29
35
  ## Examples
30
36
 
31
37
  ```
@@ -156,8 +162,9 @@ like any other boolean. A bare `date` column is treated as a **local calendar da
156
162
  - `AVG`, `MIN`, `MAX` similarly
157
163
 
158
164
  > ⚠️ **Set aggregation is NOT a Formula (`F`) feature.** `SUM([set].[field])` in an `F` column is
159
- > unsupported by the engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and nav resolution
160
- > only walks single-hop N:1 references, never a 1:N set. It silently renders **empty** (no error).
165
+ > unsupported by **either** engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and nav
166
+ > resolution walks N:1 references only, never a 1:N set, however many hops it is given. It
167
+ > silently renders **empty** (no error).
161
168
 
162
169
  Put set aggregations in a **Generated (`G`) column** instead. The installer detects the set
163
170
  navigation and maintains the value with a database trigger on the child table. The aggregated
@@ -186,9 +193,20 @@ and the JSON shape: `column-types.md` → "Roll-up totals over child rows — us
186
193
  [account].[primary_contact].[phone] -- chained
187
194
  ```
188
195
 
189
- Navigation works through `columnType: "R"` columns (reference columns). Chains of length 1 are **synchronous** and resolved instantly. Chains of length ≥ 2 are **asynchronous** — the formula engine resolves them after the initial data load.
196
+ Navigation works through `columnType: "R"` columns (reference columns) — every step except the last must be a Reference, and the last reads a field on the final referenced record. Chains of length 1 are **synchronous** and resolved instantly. Chains of length ≥ 2 are **asynchronous** — the formula engine resolves them after the initial data load.
197
+
198
+ **SQL-time (reports, filters, sorts).** Multi-hop navigation works here too: QueryBuilder pre-scans the formula and registers one `LEFT JOIN` per hop, then the translator resolves the path against the final alias. A nav path may also land on a *formula* column of the referenced entity — that formula is translated against the referenced entity's alias, and its own hops are joined off that alias. The same nav map drives formula columns in filters and ORDER BY, so filtering and sorting a report by a nav-formula column works.
199
+
200
+ Hops are counted from the query's **root** entity and capped at `MaxNavigationDepth`, whether they come from a dotted column, a formula's own navigation, or the combination — so the hops spent reaching a formula reduce the budget left for that formula's own navigation.
201
+
202
+ Still unavailable at SQL time — the column comes back NULL and the **response carries a warning** naming the column and the reason:
203
+
204
+ - Settings references `$[SettingName]`
205
+ - Navigation past the hop cap
206
+ - **Extension-table columns** — a column an extension module added lives in a separate 1:1 ext table needing its own JOIN, and every alias the translator emits is the entity's own base alias. Applies to the hidden FK and to the Reference built on it, on the query's own entity as well as a referenced one
207
+ - **Misauthored nav paths** — a final segment that isn't a column of the referenced entity, or a dot after something that isn't a Reference (`account.account_name.foo`)
190
208
 
191
- **SQL-time (reports, filters, sorts):** single-hop navigation (`[ref].[field]`) is translated to a `LEFT JOIN` and computes in reports and query datasets. Multi-hop chains and `$[Setting]` references are **not** available at SQL time — the column returns NULL there and the response carries a warning naming the column. Keep report-bound formulas to single-hop navigation.
209
+ Warning wording follows the clause, because the same unresolvable path costs a different thing in each: a SELECT slot goes NULL (*"could not be computed and will be empty"*), an ORDER BY term is dropped so rows come back in a different order (*"Sorting by … was skipped"*), and a WHERE condition is dropped so **more** rows come back than were asked for (*"Filter on … was ignored"*). Untranslatable formula filters are never dropped silently.
192
210
 
193
211
  ## Sync vs async formulas
194
212
 
@@ -79,8 +79,10 @@ If the source uses non-standard audit column names (e.g. `date_created`, `modifi
79
79
 
80
80
  For each FK, create **two columns** following the FK+Reference pattern:
81
81
 
82
- 1. Hidden FK column: `flags: "EM"`, matching `dbDatatype`
83
- 2. Visible Reference column: `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags: "VEM"`, with `link`
82
+ 1. Hidden FK column: `flags: "E"` — `"EM"` only when the source column is `NOT NULL` — matching `dbDatatype`
83
+ 2. Visible Reference column: `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags` **matching the FK** (`"VE"` / `"VEM"`), with `link`
84
+
85
+ `M` resolves to `isNullable: false` at install, so carrying it onto an FK the source declares nullable would import the column as `NOT NULL`. `dforge_dbml_import` / `dforge_module_import` take `M` from the source's own nullability.
84
86
 
85
87
  Plus declare the constraint in the entity's `references` block.
86
88
 
@@ -42,8 +42,9 @@ For each entity:
42
42
  ### FK+Reference pattern
43
43
 
44
44
  - [ ] Every reference column (`columnType: "R"`) has a paired hidden FK column
45
- - [ ] The hidden FK column has `flags: "EM"` (not `V`, not `I`)
46
- - [ ] The reference column has `flags: "VEM"`, `fieldTypeCd: "lookup"`, and a `link` object
45
+ - [ ] The hidden FK column has `flags: "E"` — `"EM"` only if the relation is required (not `V`, not `I`)
46
+ - [ ] The reference column has `flags` **matching the FK** (`"VE"` / `"VEM"`), `fieldTypeCd: "lookup"`, and a `link` object
47
+ - [ ] No field declares `M` together with `"isNullable": true` — that combination fails the pack
47
48
  - [ ] `link.entity` points to an entity that exists (in this module or a dependency)
48
49
  - [ ] `link.thisKey` matches the FK column name on this entity
49
50
  - [ ] `link.otherKey` matches the PK column name on the target entity
@@ -57,8 +57,10 @@ Enough to author inline; load the linked reference for the full detail.
57
57
  - **A relation is TWO columns + a references entry.** Use
58
58
  `dforge_entity_reference_add` — it emits all three parts. → `column-types`
59
59
  - **Flags** are letters from `V I E M H` only (never `U`/`S`/`P`): `VEM`
60
- required+visible, `VE` optional+visible, `V` read-only, `EM` hidden FK, `I`
61
- trait-provided. → `flags`
60
+ required+visible, `VE` optional+visible, `V` read-only, `E`/`EM` hidden FK
61
+ (optional/required), `I` trait-provided. `M` means required — it resolves to
62
+ `isNullable: false`, so it makes the column `NOT NULL`; don't spend it as
63
+ decoration, and never pair it with `"isNullable": true`. → `flags`
62
64
  - **`fieldTypeCd` = UI control; `dbDatatype` = SQL type.** Omit `dbDatatype` on
63
65
  plain data columns — it's derived. Set it only for a hidden FK or to override
64
66
  size/precision. Common fixes: `number` not `integer`/`float`; `phone` not
@@ -168,9 +170,14 @@ to fill a sub-step** — a pure CRUD module skips Phase 2 entirely (record it wi
168
170
  | 2c Jobs | a cron timer fires | `dforge_job_add` |
169
171
  | 2d Webhooks | a DB event → outbound POST | `dforge_webhook_add` |
170
172
 
171
- **2a Actions.** Load all four resources in the action row — the wrong-field-
172
- access / wrong-batch-flag / wrong-`ui/actions.json`-property mistakes only
173
- surface when you cross-check them. Draft the DSL, run
173
+ **2a Actions.** First check the action is an action at all: it must **change**
174
+ something. If the `execute:` block would only read, compute and `info()` the
175
+ answer, build a report, a formula column, or an action that writes a result
176
+ record instead — a printed number is never stored and can't be re-read
177
+ (`dforge://reference/action-dsl`, *When an action is the wrong tool*). Then load
178
+ all four resources in the action row — the wrong-field-access /
179
+ wrong-batch-flag / wrong-`ui/actions.json`-property mistakes only surface when
180
+ you cross-check them. Draft the DSL, run
174
181
  **`dforge_action_check({ dslBody, executionMode })`** to catch install failures
175
182
  before committing, then `dforge_action_add`. One action per turn.
176
183