@dforge-core/dforge-mcp 0.2.36 → 0.2.37

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,6 +4,22 @@ 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.37 — 2026-10-12
8
+
9
+ **Author references brought in line with the hand-authoring copy in dForge-core.** Content that
10
+ had only reached one of the two now reaches both:
11
+
12
+ - **Data views:** a grid view needs a renderable scalar column, from `dataSources[].columns` or
13
+ from the entity's `V` flags; the `columns` array is authoritative. The old text calling it
14
+ layout-only was wrong.
15
+ - **Formulas:** the full `NUMBER()` / `BOOLEAN()` rules, where the client and server engines
16
+ agree, and multi-hop navigation at SQL time with its depth cap.
17
+ - **Security:** don't grant a right on a reference's target just to make its name show up.
18
+ - **Columns and traits:** how the reference picker behaves on a large target; how to show audit
19
+ columns from a trait; corrected params for the `code`, `tags` and `percent` field types.
20
+ - **Jobs:** `paused` seeds the initial value only; an upgrade never overwrites it.
21
+ - **Flags:** a view column's `notes` row in the example.
22
+
7
23
  ## 0.2.36 — 2026-10-12
8
24
 
9
25
  **`settings.json` gains `$constraints`**: check constraints across a module's settings, SQL over
package/dist/server.js CHANGED
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
31002
31002
  var fs2 = __toESM(require("fs"));
31003
31003
  var path2 = __toESM(require("path"));
31004
31004
 
31005
- // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.28/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.29/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31006
31006
  function buildManifest(opts, moduleId) {
31007
31007
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
31008
31008
  const manifest = {
@@ -31290,7 +31290,7 @@ function buildZedTasks() {
31290
31290
  var fs = __toESM(require("fs"));
31291
31291
  var path = __toESM(require("path"));
31292
31292
 
31293
- // node_modules/.pnpm/@dforge-core+metadata@0.0.35/node_modules/@dforge-core/metadata/dist/index.js
31293
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.37/node_modules/@dforge-core/metadata/dist/index.js
31294
31294
  var AggType = {
31295
31295
  Sum: "sum",
31296
31296
  Count: "count",
@@ -31530,6 +31530,26 @@ function deriveDbDatatype(fieldTypeCd, options = {}) {
31530
31530
  }
31531
31531
  return base;
31532
31532
  }
31533
+ var INFO_PROPERTIES = ["period", "recorder", "recordChangesOnly"];
31534
+ var ACCOUNTING_PROPERTIES = [
31535
+ "chart",
31536
+ "composition",
31537
+ "analyticFields",
31538
+ "lineMode",
31539
+ "account",
31540
+ "debitAccount",
31541
+ "creditAccount",
31542
+ "debitAnalyticFields",
31543
+ "creditAnalyticFields",
31544
+ "sideDimensions"
31545
+ ];
31546
+ var BALANCE_PROPERTIES = ["allowNegative", "autoCreate"];
31547
+ var REFUSED = {
31548
+ balance: [...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES],
31549
+ turnover: ["entity", ...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES, ...BALANCE_PROPERTIES],
31550
+ info: ["journal", "journalDirection", "journalDate", ...ACCOUNTING_PROPERTIES, "totals", ...BALANCE_PROPERTIES],
31551
+ accounting: ["entity", ...INFO_PROPERTIES, "journalDirection", ...BALANCE_PROPERTIES]
31552
+ };
31533
31553
 
31534
31554
  // src/tools/_helpers.ts
31535
31555
  function modulePaths(moduleDir) {
@@ -34602,7 +34622,7 @@ var path10 = __toESM(require("path"));
34602
34622
  var fs7 = __toESM(require("fs"));
34603
34623
  var path9 = __toESM(require("path"));
34604
34624
 
34605
- // node_modules/.pnpm/@dforge-core+metadata@0.0.35/node_modules/@dforge-core/metadata/dist/dsl/index.js
34625
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.37/node_modules/@dforge-core/metadata/dist/dsl/index.js
34606
34626
  var PUNCT3 = ["===", "!=="];
34607
34627
  var PUNCT2 = [
34608
34628
  "==",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.36",
3
+ "version": "0.2.37",
4
4
  "description": "MCP server for dForge module authoring. Exposes 35 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",
@@ -18,12 +18,24 @@
18
18
  },
19
19
  "entities": {
20
20
  "type": "object",
21
- "description": "Consumed provider entities, keyed by entity code (e.g. 'party'). At least one, except against a system module (admin, metadata, workspace, sys-billing), where {} declares a pure platform-version gate for a feature that introduced no entity.",
21
+ "description": "Consumed provider entities, keyed by entity code (e.g. 'party'). At least one, except against a system module (admin, metadata, workspace, sys-billing), where {} declares a pure platform-version gate for a feature that introduced no entity, or when 'functions' imports at least one function.",
22
22
  "additionalProperties": { "$ref": "#/definitions/entityContract" }
23
+ },
24
+ "functions": {
25
+ "type": "array",
26
+ "description": "The provider's DSL library functions (logic/functions/*.dsl) the consumer's actions call, as <provider code with '-' replaced by '_'>.<name>(...). Only listed names are reachable, and install checks the installed provider declares each. See docs/business-logic/dsl-functions.md.",
27
+ "items": { "type": "string", "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$" },
28
+ "uniqueItems": true,
29
+ "minItems": 1
23
30
  }
24
31
  },
25
32
  "if": {
26
- "not": { "properties": { "module": { "enum": ["admin", "metadata", "workspace", "sys-billing"] } }, "required": ["module"] }
33
+ "not": {
34
+ "anyOf": [
35
+ { "properties": { "module": { "enum": ["admin", "metadata", "workspace", "sys-billing"] } }, "required": ["module"] },
36
+ { "required": ["functions"] }
37
+ ]
38
+ }
27
39
  },
28
40
  "then": {
29
41
  "properties": { "entities": { "minProperties": 1 } }
@@ -4,10 +4,41 @@
4
4
  "title": "dForge Module Settings",
5
5
  "description": "Module-level settings definitions: a flat map of setting code → setting definition. Settings are folder-scoped at runtime (values inherit through the folder tree) and surface in the Module Settings UI.",
6
6
  "type": "object",
7
+ "propertyNames": {
8
+ "pattern": "^(\\$constraints|[^$].*)$",
9
+ "description": "A setting code cannot start with '$'; $constraints is the only reserved key."
10
+ },
11
+ "properties": {
12
+ "$constraints": {
13
+ "type": "object",
14
+ "description": "Checks across this module's settings, keyed by constraint name. The settings are the columns of one virtual row, so each is an entity check constraint: SQL over setting codes. settings.set and settings.setMany run every rule naming a written setting, at the folder written and at each folder below it that overrides a setting the rule names; install refuses defaults that fail one. NULL passes, as in a CHECK.",
15
+ "additionalProperties": {
16
+ "$ref": "#/$defs/constraint"
17
+ }
18
+ }
19
+ },
7
20
  "additionalProperties": {
8
21
  "$ref": "#/$defs/setting"
9
22
  },
10
23
  "$defs": {
24
+ "constraint": {
25
+ "type": "object",
26
+ "required": ["type", "expression"],
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "type": {
30
+ "const": "check"
31
+ },
32
+ "expression": {
33
+ "type": "string",
34
+ "description": "Boolean over this module's settings in SQL CHECK syntax, e.g. \"default_discount_pct <= max_discount_pct\": comparisons, IS [NOT] NULL, [NOT] IN, [NOT] BETWEEN, regex matches (~ ~* !~ !~*) against a string literal, AND/OR/NOT, + - * /, literals and setting codes (bare or \"quoted\"). No functions, casts or [field] brackets; IS NULL rather than = null. The settings it names are found in it: writing any of them re-checks the rule."
35
+ },
36
+ "message": {
37
+ "type": "string",
38
+ "description": "Violation message. Localizable via translations/<locale>.json under settings.$constraints.<name>.message."
39
+ }
40
+ }
41
+ },
11
42
  "setting": {
12
43
  "type": "object",
13
44
  "properties": {
@@ -46,7 +46,19 @@
46
46
  },
47
47
  "settings": {
48
48
  "type": "object",
49
- "description": "Setting code (settings.json) → label and description.",
49
+ "description": "Setting code (settings.json) → label and description; $constraints → constraint name → message.",
50
+ "properties": {
51
+ "$constraints": {
52
+ "type": "object",
53
+ "additionalProperties": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "properties": {
57
+ "message": { "type": "string" }
58
+ }
59
+ }
60
+ }
61
+ },
50
62
  "additionalProperties": {
51
63
  "$ref": "#/$defs/labelDesc"
52
64
  }
@@ -101,6 +101,17 @@ A reference entry may declare `onDelete` and/or `onUpdate` to emit an `ON DELETE
101
101
 
102
102
  **Why two columns?** The FK stores the actual value. The Reference column configures how the value is rendered in the UI (as a typeahead picker showing the target's `toString`). They must be kept in sync — column order in JSON doesn't matter but `orderNum` determines UI ordering.
103
103
 
104
+ ### How the picker behaves on a large target
105
+
106
+ Opening the picker loads the **first 200 rows** of the target entity — enough to browse, cheap on a table of any size. Typing does *not* filter those 200 rows: the query goes to the server as a case-insensitive match over the columns named in the target's `toString`, so a target with 200 000 rows is as pickable as one with 20. Every whitespace-separated term must match at least one of those columns, which is what lets a `toString` of `"{first_name} {last_name}"` answer "John Smith".
107
+
108
+ Two consequences for the entity you point at:
109
+
110
+ - **Put the columns people search by in `toString`.** They are the only ones the picker searches. A target whose `toString` is `"{code}"` cannot be found by name, however prominent the name column is elsewhere.
111
+ - **A `toString` built only from formula columns can't be searched server-side.** Neither formula nor set columns translate to a `WHERE` clause, so the picker falls back to narrowing the 200 rows it already loaded — rows past that point stay unreachable. Name at least one stored column in the template.
112
+
113
+ `refFilter` and folder row filters still apply: they are AND-ed onto the search, so a filtered-out row does not surface just because the user typed its name.
114
+
104
115
  ## Set columns (`columnType: "S"`)
105
116
 
106
117
  Virtual 1:N backwards reference. Shows a grid of related records on the parent's detail view.
@@ -60,26 +60,58 @@ Lives in: `ui/data_views.json`
60
60
  }
61
61
  ```
62
62
 
63
- ## Critical rule — the entity needs a visible column
63
+ ## Critical rule — the view needs something to draw
64
64
 
65
- A grid-style view can only render an entity that has at least one **visible scalar column** — a field whose `flags` include `V` and whose `columnType` is **not** a set (`S`). Column visibility is entity-driven (the field's flags), **not** view-driven: the `columns` array inside `dataSources[]` is layout-only (reorder/pin/width among already-visible fields) — it can't make a non-`V` field appear.
65
+ A grid-style view resolves its columns one of two ways, and **one of them has to produce a renderable scalar column** — anything that isn't a set (`columnType: "S"`, a 1:N collection with no per-row value):
66
66
 
67
- If none of an entity's fields are visible (or only its set/child-collection columns are), the view renders the runtime empty state *"No visible columns configured for this entity."* — a broken screen the user only hits when they open the menu item. `dforge_module_validate` and `dforge_module_pack` now **reject** this before install.
67
+ 1. **The source names its own columns.** `dataSources[].columns` wins outright — see [the section below](#the-columns-array-is-authoritative). Flags don't enter into it, so this route works even when every field on the entity is internal.
68
+ 2. **It doesn't, and the entity's `V` flags decide.** With no `columns` array the view falls back to the entity's **visible scalar columns** — fields whose `flags` include `V` and whose `columnType` isn't `S`.
69
+
70
+ With neither, the view renders the runtime empty state *"No visible columns configured for this entity."* — a broken screen the user only hits when they open the menu item. `dforge_module_validate` / `dforge_module_pack` **reject** that before install.
68
71
 
69
72
  ```jsonc
70
- // WRONG — grid over an entity whose fields are all hidden (no "V")
73
+ // WRONG — grid falls back to flags, and no field has "V"
71
74
  "fields": {
72
- "code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M=required, but no V
73
- "lines": { "columnType": "S", "flags": "VE" } // visible, but a SET — doesn't count for a grid
75
+ "code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M=… but no V
76
+ "lines": { "columnType": "S", "flags": "VEM" } // visible, but a SET — doesn't count for a grid
74
77
  }
75
- // RIGHT — at least one visible scalar field
78
+ // RIGHT (route 2) — at least one visible scalar field
76
79
  "fields": {
77
80
  "code": { "fieldTypeCd": "text", "flags": "VEM" } // V present, not a set → satisfies the grid
78
81
  }
82
+ // ALSO RIGHT (route 1) — the entity's fields stay internal, the view says what it shows
83
+ "dataSources": [
84
+ { "entityCode": "audit_log", "columns": ["created_date", "created_by_user"] }
85
+ ]
79
86
  ```
80
87
 
88
+ Practical guidance: reach for route 2. Flags are what every other surface reads — the card, the Fields popover, quick-add — so an entity whose real business fields are invisible is usually a mistake, even when one view papers over it. Route 1 is for columns that are *deliberately* internal, audit columns above all.
89
+
81
90
  This applies to every column-rendering view type (grid, list, kanban, calendar, gallery, tree-grid, master-detail, and the default). The column-agnostic types — `diagram`, `matrix`, `library` — draw from `viewConfig`/relationships instead, so they're **exempt** from this rule.
82
91
 
92
+ ## The `columns` array is authoritative
93
+
94
+ A column you name in `dataSources[].columns` renders **even if its flags omit `V`**. The list is your statement of what this view shows, so it wins over the entity's default visibility — that's how the internal (`flags: "I"`) columns the `audit` / `audit-full` traits contribute get onto a grid:
95
+
96
+ ```jsonc
97
+ {
98
+ "entityCode": "trip",
99
+ "columns": ["trip_number", "status", "created_by_user"] // renders Created By, read-only
100
+ }
101
+ ```
102
+
103
+ A promoted column stays **read-only** unless its flags also carry `E`, and it shows up in the Fields popover like any other column.
104
+
105
+ What `columns` can't do — each fails `dforge_module_validate` / `dforge_module_pack`, instead of dropping silently:
106
+
107
+ | Entry | Why it's rejected |
108
+ |-------|-------------------|
109
+ | A code the entity doesn't define | Nothing to render — usually a typo (`avatar` for `avatar_image`) |
110
+ | A set column (`columnType: "S"`) | A 1:N collection has no per-row value for a column |
111
+ | A nav path (`account.company_name`) | The list takes plain column codes — add a Reference (`R`) or Formula (`F`) column to the entity and list that |
112
+
113
+ The `matrix` / `diagram` / `library` types are exempt here too: their `columns` entry feeds axes and config, not a header row.
114
+
83
115
  See [flags.md](flags.md) for the flag string, [column-types.md](column-types.md) for `columnType` (`R`/`S`/`F`).
84
116
 
85
117
  ## View types
@@ -24,10 +24,10 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
24
24
  | `textarea` | `string` | Multi-line plain text. | `text` | `defaultLines`, `maxLines` |
25
25
  | `markdown` | `string` | Markdown editor (Write/Preview tabs), rendered sanitized. | `text` | — |
26
26
  | `richtext` | `string` | Rich text WYSIWYG editor, stores sanitized HTML. | `text` | — |
27
- | `code` | `string` | Code editor with syntax highlighting. | `text` | `language` |
27
+ | `code` | `string` | Code editor (CodeMirror, dForge DSL grammar). | `text` | `defaultLines` |
28
28
  | `dropdown` | `string` | Fixed list of options. | `varchar` | `options: ["a","b",…]` or `[{value,label,icon,color}]` |
29
29
  | `flags` | `string` | Multiple checkboxes with letter flags. | `varchar` | `optionSets`, `style: "buttons"` |
30
- | `tags` | `string` | Free-form tag list. | `varchar` or `text` | — |
30
+ | `tags` | `string` | Free-form comma-separated tag list. | `varchar` or `text` | `options` (optional suggestions) |
31
31
  | `color` | `string` | Color picker. | `varchar(20)` | — |
32
32
  | `hidden` | `string` | Internal, never shown in UI. | `varchar` | — |
33
33
 
@@ -37,7 +37,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
37
37
  |---|---|---|---|---|
38
38
  | `number` | `number` | Numeric value. | `int`, `bigint`, `numeric` | `min`, `max`, `scale` |
39
39
  | `currency` | `number` | Monetary value. Precision 2. | `numeric(18,2)` | `currency: "USD"`, `min`, `max` |
40
- | `percent` | `number` | Percentage 0–100. Precision 2. | `numeric(5,2)` | `min`, `max` (default 0–100) |
40
+ | `percent` | `number` | Percentage. Precision 2. | `numeric(5,2)` | `min`, `max` |
41
41
 
42
42
  ## Boolean-backed
43
43
 
@@ -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 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`). |
75
+ | `entitylink` | `json` | `D` | Polymorphic link to any entity. Stores `{entityId, entity, pk: {col: "val"}, description?}` — `pk` is a per-column map (values as strings), there is no `id` key. 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
 
@@ -57,7 +57,8 @@ Because a view writes no DDL, the requiredness `M` produces there is runtime-onl
57
57
  "party_id": {},
58
58
  "party_name": { "flags": "V" },
59
59
  "vat_no": { "flags": "VEM" },
60
- "salary": { "flags": "" }
60
+ "salary": { "flags": "" },
61
+ "notes": { "flags": "VE", "isNullable": true }
61
62
  }
62
63
  }
63
64
  }
@@ -129,8 +129,12 @@ The whole date family evaluates identically **client-side and SQL-time** (report
129
129
  ### Conversion and context functions
130
130
 
131
131
  - `TEXT(v)` — as text (null becomes `''`)
132
- - `NUMBER(v)` — as a number, or null when the value isn't numeric (including a blank string)
133
- - `BOOLEAN(v)` — null, `''` and `0` are false; everything else is true
132
+ - `NUMBER(v)` — as a number, or null when the text isn't one. "Is a number" means an optional
133
+ sign, digits and an optional fraction/exponent — a blank string, `'0x10'` and `'Infinity'`
134
+ are all null, because SQL would reject them too
135
+ - `BOOLEAN(v)` — null is false; a number is false only at **zero**; text is false only when
136
+ **empty**; anything else is true. Note that text and numbers differ: the string `'0'` is
137
+ true (it isn't empty) while the number `0` is false
134
138
  - `JSON_GET(json, 'key' [, type])` — read a key out of a JSON column. The optional type is a
135
139
  string literal: `'text'` (default), `'number'`/`'numeric'`, `'int'`/`'integer'`/`'bigint'`,
136
140
  `'bool'`/`'boolean'`, `'date'`, `'datetime'`/`'timestamp'`/`'timestamptz'`
@@ -143,21 +147,44 @@ report selects it, or when it is used in a filter or sort. **Every function list
143
147
  implemented in both**, so a column reads the same in a grid and in a report; a test executes
144
148
  each one against PostgreSQL and diffs the result against the client runtime.
145
149
 
146
- Two deliberate points of agreement, because the engines disagree by nature:
150
+ Four deliberate points of agreement, because the engines disagree by nature:
147
151
 
148
- - **Out-of-domain math is null, not an error.** `SQRT(-1)`, `LOG(0)`, `ASIN(2)`, `EXP(1000)`,
149
- `POW(0, -1)`, `MOD(x, 0)` — JavaScript would answer NaN or Infinity and PostgreSQL would
150
- raise (failing the whole report). Both return null, so the cell renders blank.
152
+ - **Comparison is null-safe and two-valued.** `null = null` is true and `[x] != null` is true
153
+ for any non-null `x` — there is no SQL "unknown" third answer that an enclosing `IF`/`AND`
154
+ would then read as false. `IF([completed_date] != null AND …, 100, 0)` therefore scores the
155
+ same in a grid and in a report. (At SQL time `=`/`!=` become `IS NOT DISTINCT FROM` /
156
+ `IS DISTINCT FROM`.) `ISNULL([x])` remains the clearer spelling of an explicit null check.
157
+
158
+ - **Out-of-domain math is null, not an error.** `SQRT(-1)`, `LOG(0)`, `ASIN(2)`, `ATANH(1)`,
159
+ `EXP(1000)`, `POW(0, -1)`, `POW(-8, 0.5)`, `POW(10, 1000)`, `MOD(x, 0)` — JavaScript would
160
+ answer NaN or Infinity and PostgreSQL would raise (failing the whole report). Both return
161
+ null, so the cell renders blank. A result too *small* to represent is the one exception:
162
+ `POW(10, -1000)` is **0** on both sides, because zero is the answer, not "no answer".
151
163
  - **String indexing is JavaScript-flavoured, not SQL's.** `SUBSTRING(s, start, end)` and
152
164
  `MID(s, start, len)` take a **0-based** start, and `SUBSTRING`'s third argument is an **end
153
- offset**, not a length. `INDEX_OF` / `LAST_INDEX_OF` return a 0-based index, or `-1`.
154
-
155
- Known remaining difference: `CONCAT` on a numeric column renders PostgreSQL's declared scale
156
- (`-3.0`) where the client renders the JavaScript number (`-3`). Use `FORMAT`/`TEXT` when the
157
- exact rendering matters.
158
-
159
- `CURRENT_USER_ID()` needs the requesting user, so it resolves in grids, reports, filters and
160
- `select()`, but not in a context with no user (an unattended job).
165
+ offset**, not a length. A negative offset clamps to 0, and reversed bounds **swap** —
166
+ `SUBSTRING(s, 4, 1)` is the same as `SUBSTRING(s, 1, 4)`. `INDEX_OF` / `LAST_INDEX_OF`
167
+ return a 0-based index, or `-1`.
168
+ - **Comparison never invents a number.** `[a] = [b]` reads both sides as numbers only when
169
+ both genuinely are (a number, or text SQL would also accept as one). A blank string is not
170
+ zero, so `NULLIF([note], '')` leaves a note of `'0'` alone, and `[qty] = ''` is false. The
171
+ same comparator backs `=`, `!=`, `<`, `>`, `<=`, `>=` and `BETWEEN`, so they can't disagree
172
+ with each other.
173
+
174
+ Three known remaining differences:
175
+
176
+ - `CONCAT` on a numeric column renders PostgreSQL's declared scale (`-3.0`) where the client
177
+ renders the JavaScript number (`-3`). Use `FORMAT`/`TEXT` when the exact rendering matters.
178
+ - `MIN`/`MAX` are **number** functions. Handed a non-numeric argument the client silently
179
+ drops it, while a report raises on the type mismatch. Wrap the argument in `NUMBER()` if a
180
+ column might not be numeric.
181
+ - `NUMBER('1e309')` is null client-side — the magnitude exceeds what a JavaScript number can
182
+ hold, and every non-finite result in the client is null. PostgreSQL's `numeric` has a far
183
+ wider range, so a report keeps the value.
184
+
185
+ `CURRENT_USER_ID()` needs the requesting user. It resolves in grids, cards, reports, filters,
186
+ sorts and `select()` — anywhere a request has one — but not in a context with no user, such as
187
+ a scheduled job.
161
188
 
162
189
  ### Unknown functions
163
190
 
@@ -37,7 +37,7 @@ Lives in: `logic/jobs.json` at the module root. Schema: [`docs/schemas/jobs.sche
37
37
  | `timeZone` | No | tenant tz | IANA name. Falls back to `auth.tenant.time_zone`. |
38
38
  | `params` | No | `{}` | Static parameters passed to the action as `__params`. Manifest-fixed — an admin cannot change them. For tenant-configurable values use module settings, resolved from the job's folder (below). |
39
39
  | `enabled` | No | `true` | `false` fully disables — no scheduler, no manual trigger. |
40
- | `paused` | No | `false` | Soft pause. Scheduler skips, but "Run now" still works. |
40
+ | `paused` | No | `false` | Soft pause. Scheduler skips, but "Run now" still works. Seeds the initial value only — upgrades never overwrite it. |
41
41
  | `description` | No | | Human-readable description. |
42
42
 
43
43
  **Hard cap: 50 jobs per module.**
@@ -152,7 +152,7 @@ Full source: [`modules/chore/`](../../../modules/chore/).
152
152
  - **Sub-minute cron.** The scheduler ticks every 60 s. Anything finer is dishonest — declare a minute cron.
153
153
  - **Renaming `code` between versions.** Treats it as a new job and leaves the old `scheduled_job` row orphaned until uninstall (actually reaped on upgrade — but you lose `job_run` history). Keep `code` stable.
154
154
  - **"Schedule this 5 minutes from now" pattern.** There is no per-call `schedule()`. Use a cron-scan job that watches a queue table — see [memory: cron-scan pattern](../../../docs/business-logic/jobs.md#when-to-use-a-job-vs-a-trigger).
155
- - **Confusing `enabled` and `paused`.** `enabled: false` → "Run now" no-ops. `paused: true` → cron skips but "Run now" works. Use `paused` for incident response; use `enabled: false` when you mean "turn this off entirely".
155
+ - **Confusing `enabled` and `paused`.** `enabled: false` → "Run now" no-ops. `paused: true` → cron skips but "Run now" works. Use `paused` for incident response; use `enabled: false` when you mean "turn this off entirely". Only `enabled` is rewritten on upgrade — `paused` is operator state, so shipping `paused: true` in a new version does nothing to tenants that already have the job.
156
156
 
157
157
  ## Reference
158
158
 
@@ -145,6 +145,13 @@ installer resolves those within this module only, so a dotted one silently grant
145
145
  }
146
146
  ```
147
147
  8. **Folder access** is granted separately — roles can reference folder codes, but folder definitions themselves live in `ui/folders.json`.
148
+ 9. **Don't grant a right on a reference's target just to make the name show up.** A reference column
149
+ resolves its display value for anyone who can read the record carrying it — a read-only driver role
150
+ with `S` on `trip` sees each trip's Vehicle name without any grant on `vehicle`. Grant `S` on the
151
+ target only when the role should genuinely *browse* that entity, open its records, or when a view
152
+ or formula navigates **through** the reference to another of its columns (`vehicle.plate_no`) —
153
+ that still requires the right. Granting it for the name alone unlocks the entity as a full catalog
154
+ everywhere, which is strictly more than intended.
148
155
 
149
156
  ## Folders (`ui/folders.json`)
150
157
 
@@ -42,6 +42,8 @@ Adds **two timestamp columns** (no user tracking):
42
42
 
43
43
  Both columns have `flags: "I"` (internal — not shown in UI by default, managed by the platform).
44
44
 
45
+ To show one, name it in a data view's `dataSources[].columns` — an explicitly listed column renders (read-only) regardless of its flags. See [Showing audit columns](#showing-audit-columns) below.
46
+
45
47
  **Important**: `audit` does NOT add `created_by` / `updated_by` user columns. For user tracking, use `audit-full`.
46
48
 
47
49
  ### `audit-full`
@@ -59,6 +61,34 @@ Use `audit-full` when you need to know **who** created/modified a record, not ju
59
61
 
60
62
  **You don't need to declare both `audit` and `audit-full`** — `audit-full` inherits from `audit` automatically.
61
63
 
64
+ ### Showing audit columns
65
+
66
+ Trait columns are internal by default, not off-limits. Two ways to surface them:
67
+
68
+ 1. **Per view (usual case)** — list the column in `ui/data_views.json`. A column named in `dataSources[].columns` renders whatever its flags say, so this is all it takes:
69
+
70
+ ```json
71
+ {
72
+ "entityCode": "trip",
73
+ "columns": ["trip_number", "status", "created_by_user", "last_updated_by_user"]
74
+ }
75
+ ```
76
+
77
+ It renders **read-only** (the trait flags carry no `'E'`) and shows up in the Fields popover so users can toggle it off. Use the Reference columns (`created_by_user`) for a readable name — `created_by` is the raw cuid FK.
78
+
79
+ 2. **Everywhere, or editable** — redeclare the field on the entity with your own flags. An explicit field definition wins over the trait's, as long as the type matches:
80
+
81
+ ```json
82
+ "created_by_user": {
83
+ "columnType": "R",
84
+ "fieldTypeCd": "lookup",
85
+ "flags": "V",
86
+ "orderNum": 9904,
87
+ "description": "Created By",
88
+ "link": { "entity": "user", "thisKey": "created_by", "otherKey": "user_id" }
89
+ }
90
+ ```
91
+
62
92
  > **⚠️ `audit-full` + seed data needs handling.** `created_by` and `last_updated_by` are
63
93
  > **required** (NOT NULL) cuid FKs to `user`, with **no default formula**. At runtime the platform
64
94
  > fills them from the current user, but at **seed** time there is no current user, so a seeded