@dforge-core/dforge-mcp 0.2.5 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,190 @@ 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.15
8
+
9
+ `canExecute` became a real server-side gate and `isTransacted` began opening a real
10
+ transaction (platform change `5398a48b0`). Both had been documented here as the
11
+ opposite — "a UI hint" and "does not open a transaction" — so this release is a
12
+ correction pass across the authoring guidance, with no tool or schema changes.
13
+
14
+ ### Changed — `canExecute` is enforced, with a fail-open subset
15
+
16
+ `action.execute` now evaluates the stored `can_execute_parsed` AST over every targeted
17
+ record before any write and refuses the call with `ACTION_EXECUTION_FAIL`. So
18
+ `canExecute: false` is a genuine "only automation may fire this" — the scheduler and
19
+ triggers invoke the script engine directly and still bypass it.
20
+
21
+ The server evaluator is a scalar walker, not the formula engine, and the client has its
22
+ own separate gaps, so `references/action-dsl.md` and `dsl-reference.md` now carry a tier
23
+ table instead of a single list:
24
+
25
+ | tier | construct | client | server |
26
+ |---|---|---|---|
27
+ | 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
28
+ | 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
29
+ | 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
30
+ | — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
31
+
32
+ Four things in there are easy to get wrong and are called out explicitly:
33
+
34
+ - **A bare boolean field is not enforced.** The walker evaluates conditions, not values —
35
+ a field resolves only as an operand of a comparison — so `canExecute: [is_active]` and
36
+ `NOT [is_blocked]` fail open. Write `[is_active] = true` and `NOT ([is_blocked] = true)`.
37
+ - **A misspelled function ships silently.** A `canExecute` is never validated against the
38
+ function set at install (`FindUnknownFunctions` is wired only into the formula-column
39
+ path), so `TOADY()` installs and enables the button for every record.
40
+ - **Tier 3 mixed into a predicate is worse than useless**, because the two sides disagree
41
+ about short-circuiting: the server's `AND` / `OR` short-circuit, the client's evaluator
42
+ does not. `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record throws
43
+ in the browser, is caught, and **enables** the button — then the server refuses the call.
44
+ An error where a greyed-out button was wanted. Count with a Generated (`G`) column and
45
+ compare the scalar. Mixing tier 1 with tier 2 is fine and is the normal shape.
46
+ - **Settings do not work in `canExecute` at all** — the client resolver is stubbed to
47
+ `null` and the server has none, so the guard is unenforced server-side and tends to jam
48
+ the button *shut* client-side. Read them in `execute:`.
49
+
50
+ `references/jobs.md` also stops promising that `canExecute: false` hides the button: the
51
+ action stays in the toolbar, permanently disabled.
52
+
53
+ ### Changed — `isTransacted` opens a transaction, and defaults to `true`
54
+
55
+ The *Don't assume your action is atomic* section is replaced by *Atomicity*. `true` now
56
+ makes the whole run one transaction in both execution modes, covering the script's own
57
+ `insert()` / `update()` / `query()` calls as well as the flush of `[field] = …`
58
+ assignments, and it no longer depends on the entity carrying `auditHistory` — advice to
59
+ reach for that trait as an atomicity switch is gone.
60
+
61
+ `false` is documented per mode, because "failures are isolated" only ever made sense where
62
+ there is a loop: `single` / `each` report the failure and continue with the next record,
63
+ while a `batch` script is one invocation, so a failure just ends the run with its earlier
64
+ writes committed and its `[field]` assignments never flushed.
65
+
66
+ The background path is the exception, and it splits by mode too: the worker opens no
67
+ transaction, so nothing is ever undone; its `single` / `each` loop still reads the flag for
68
+ control flow, while its `batch` path never reads it at all — a queued `batch` action gets
69
+ nothing from `isTransacted` in either direction.
70
+
71
+ ### Fixed — `isAsync` does not force background execution, and there is no Hangfire
72
+
73
+ `isAsync: true` only *permits* queuing. The server branches on the `async` argument the
74
+ request carries, so a parameterless action is queued directly while one with parameters is
75
+ offered to the user as both "Run" (inline, transactional) and "Run in Background". Whether
76
+ an action is atomic is therefore not answerable from the manifest alone.
77
+
78
+ Two claims that dForge runs background actions "via Hangfire" are removed — the platform
79
+ uses a hosted `BackgroundService` polling `background_action`, and `FileCleanupService`
80
+ says so in as many words.
81
+
82
+ ### Changed — validation checklist
83
+
84
+ Two new items under actions: that a `canExecute` meant to *gate* is written in the
85
+ server-enforced subset, and that no bare boolean field is used as a whole condition. The
86
+ `references/formulas.md` entry for `canExecute:` blocks now summarises the same rules and
87
+ points at `action-dsl.md`.
88
+
89
+ ## 0.2.14
90
+
91
+ Record reports — a report attached to an entity so it opens **from a record**, with the
92
+ record's values feeding its parameters — plus a report-level home for report parameters,
93
+ and the removal of two parameter spellings the installer never read.
94
+
95
+ Requires `@dforge-core/metadata` ≥ 0.0.15 (bumped): the vendored
96
+ `resources/schemas/reports.schema.json` served as `dforge://schema/reports` comes from
97
+ there.
98
+
99
+ ### Added — record-report attachments
100
+
101
+ A report in `ui/reports.json` gains an `entities` array. Each entry attaches the report to
102
+ one entity, mapping report parameters to source columns on it:
103
+
104
+ ```jsonc
105
+ "credit_check": {
106
+ "entities": [
107
+ { "entityCd": "parties.party", "params": { "customer_id": "party_id" }, "orderNum": 45 },
108
+ { "entityCd": "crm.quote", "params": { "customer_id": "customer_id" }, "orderNum": 45 }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ The report then appears on that record's toolbar and opens on a record-scoped route with
114
+ the mapped params resolved server-side and hidden. `dforge_report_add` accepts and
115
+ documents the block; `references/reports.md` gains a **Record reports** section with the
116
+ rules (source-column allowlist, type compatibility, one attachment per (entity, report)
117
+ pair, the `"metadata": ">=1.5.0"` gate, and the cross-module dependency bound).
118
+
119
+ Note the two different `params` keys, which the tool description now calls out: on an
120
+ attachment it is the *mapping* (param code → column); on the report or a dataset it is the
121
+ *declaration*.
122
+
123
+ ### Added — `dforge_module_validate` checks reports
124
+
125
+ Mirrors the server's new pack-time `ReportAttachmentValidator`, so these fail offline
126
+ instead of at install against a live tenant:
127
+
128
+ - a mapped param code neither the report-level `parameters` block nor any dataset declares;
129
+ - a source column the entity doesn't have, or one that's a set / formula / free-text column;
130
+ - two attachments to the same entity in one report (the second overwrites the first);
131
+ - an attachment to a cross-module entity whose module isn't a declared dependency;
132
+ - `isRequired`, a top-level `link`, or `fieldTypeCd` + `domain` together on a param;
133
+ - a warning when a module ships attachments with no `metadata` dependency.
134
+
135
+ ### Added — report-level `parameters`
136
+
137
+ Report parameters are **report-scoped**: the declaration is stored once per report
138
+ (`report.param_set_id` → `param_set`), the authoring API is `report.params.save`, and
139
+ `report.get` flattens per-dataset defaults report-wide before use. The installer had only
140
+ ever read the per-dataset shorthand, so a report-level block was silently dropped and the
141
+ report installed with no parameters at all — which is what made
142
+ `crm-fin.customer_statement` and `wms-fin.vendor_statement` ship parameterless.
143
+
144
+ Both declaration sites are now legal and mean the same thing; the installer merges them,
145
+ **report level winning** on a code collision:
146
+
147
+ ```jsonc
148
+ "customer_statement": {
149
+ "parameters": {
150
+ "customer_id": { "label": "Customer", "fieldTypeCd": "lookup",
151
+ "params": { "link": { "entity": "parties.party" } }, "required": true }
152
+ },
153
+ "datasets": { "statement_invoices": { }, "statement_payments": { } }
154
+ }
155
+ ```
156
+
157
+ Declare at report level when several datasets use the parameter — `customer_statement` is
158
+ exactly that case, and there is no meaningful dataset to attribute it to.
159
+ `datasets.<cd>.params` stays the right shorthand for a single-dataset parameter, and every
160
+ module written before this keeps working.
161
+
162
+ `dforge_module_validate` merges both sites when resolving a record-report mapping, so a
163
+ mapping onto a report-level parameter resolves exactly as install will resolve it.
164
+
165
+ ### Fixed — two report-param spellings that are silently ignored
166
+
167
+ Unlike `parameters`, these are simply misspellings of keys that already exist, so they are
168
+ gone from the schema rather than implemented — and the validator flags them at either
169
+ declaration site:
170
+
171
+ - **`isRequired`** — the key is `required`; the parameter installed as optional.
172
+ - **top-level `link`** on a lookup parameter — it belongs under `params`; the parameter
173
+ installed with no autocomplete.
174
+
175
+ `references/reports.md` also corrects the rights-key example from `report.<code>` to the
176
+ colon form `report:<code>`, and the lookup parameter config from `params.entityCd` to
177
+ `params.link.entity`.
178
+
179
+ ### Changed — steer calculation toward reports, not the DSL
180
+
181
+ `action-dsl.md`'s *When an action is the wrong tool* now names the record report as the
182
+ direct replacement for a `check_…` action — same toolbar entry point, but the user sees
183
+ the open invoices and the ageing rather than just "FAILED" — and adds the general rule:
184
+ **do not compute in the DSL what a report dataset already aggregates.** Sums, ratios,
185
+ ageing buckets and exposure get formatting, drill-through, row caps and live re-reads for
186
+ free from a report; DSL arithmetic over `select()` reproduces that badly, once, somewhere
187
+ nothing else can reuse. Write DSL when the number must be *written down* or must *stop*
188
+ something. The same steer is in `dsl-reference.md`, Phase 2a and Phase 3c of the
189
+ `dforge-module-build` skill, and the validation checklist.
190
+
7
191
  ## 0.2.5
8
192
 
9
193
  Catches up with the platform's parameter-options work (dForge-core 1.20.0). A dropdown
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.13/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.14/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 = {
@@ -31214,7 +31214,7 @@ function buildZedSettings() {
31214
31214
  var fs = __toESM(require("fs"));
31215
31215
  var path = __toESM(require("path"));
31216
31216
 
31217
- // node_modules/.pnpm/@dforge-core+metadata@0.0.14/node_modules/@dforge-core/metadata/dist/index.js
31217
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.15/node_modules/@dforge-core/metadata/dist/index.js
31218
31218
  var AggType = {
31219
31219
  Sum: "sum",
31220
31220
  Count: "count",
@@ -35146,9 +35146,20 @@ var reportAddSchema = {
35146
35146
  description: external_exports.string(),
35147
35147
  layout: external_exports.record(external_exports.string(), external_exports.unknown()),
35148
35148
  datasets: external_exports.record(external_exports.string(), external_exports.unknown()),
35149
- parameters: external_exports.record(external_exports.string(), external_exports.unknown()).optional()
35149
+ parameters: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
35150
+ "Report-level parameter declarations \u2014 the canonical home for a param, and the only one that fits a param several datasets use. Merged with each dataset's `params` into the report's one param set; this block wins on a code collision."
35151
+ ),
35152
+ entities: external_exports.array(
35153
+ external_exports.object({
35154
+ entityCd: external_exports.string(),
35155
+ params: external_exports.record(external_exports.string(), external_exports.string()).optional(),
35156
+ orderNum: external_exports.number().int().optional()
35157
+ }).passthrough()
35158
+ ).optional().describe(
35159
+ "RECORD-REPORT attachments \u2014 entities this report can be opened FROM, so it appears on the record's toolbar with the record's values feeding its params. Each entry: { entityCd, params: { <reportParamCd>: <sourceColumnOnThatEntity> }, orderNum? }. NOTE `params` here is the MAPPING (param \u2192 column), not a param declaration. Qualify a cross-module entityCd ('parties.party') and declare that module as a dependency. Source columns: the PK, a reference ('R') column, or a bounded scalar \u2014 never text/json/file. Requires 'metadata': '>=1.5.0' in the manifest."
35160
+ )
35150
35161
  }).passthrough().describe(
35151
- "Full report spec per reports.schema.json: { description, layout: { panels: [...] }, datasets: { code: {...} }, parameters? }."
35162
+ "Full report spec per reports.schema.json: { description, layout: { panels: [...] }, datasets: { code: {...} }, parameters?, entities?, reloadInterval? }. PARAMS ARE REPORT-SCOPED: declare them in the top-level `parameters` block, or as shorthand under `datasets.<cd>.params` when exactly one dataset uses the param \u2014 the installer merges both into the report's single param set, report level winning on a collision. A param several datasets use belongs at report level; there is no meaningful dataset to attribute it to. Within a param, use `required` (not `isRequired`) and nest a lookup's binding as `params: { link: { entity } }` (not a top-level `link`)."
35152
35163
  )
35153
35164
  };
35154
35165
  function reportAdd(args) {
@@ -35919,6 +35930,119 @@ function moduleValidate(args) {
35919
35930
  `folder code '${code2}' is used ${dupPaths.length} times (${dupPaths.join(", ")}). Codes must be unique across the whole tree: role rights say 'folder:${code2}' with no path, and translations key on 'folders.${code2}.label', so duplicates are ambiguous and silently collide.`
35920
35931
  );
35921
35932
  }
35933
+ for (const [rcode, rawReport] of Object.entries(reports)) {
35934
+ const report = rawReport ?? {};
35935
+ const where = `ui/reports.json \u2192 ${rcode}`;
35936
+ const declaredParams = /* @__PURE__ */ new Set();
35937
+ const checkParamDef = (loc, pcode, pdef) => {
35938
+ const p = pdef ?? {};
35939
+ if ("isRequired" in p) {
35940
+ err(
35941
+ `${where} \u2192 ${loc}.${pcode}`,
35942
+ "uses 'isRequired', which the installer does not read \u2014 the param installs as OPTIONAL. Rename it to 'required'."
35943
+ );
35944
+ }
35945
+ if ("link" in p) {
35946
+ err(
35947
+ `${where} \u2192 ${loc}.${pcode}`,
35948
+ 'puts \'link\' at the top level, where the installer does not read it \u2014 a lookup param with no `params.link` has no autocomplete. Nest it: "params": { "link": { "entity": "\u2026" } }.'
35949
+ );
35950
+ }
35951
+ if ("fieldTypeCd" in p && "domain" in p) {
35952
+ err(
35953
+ `${where} \u2192 ${loc}.${pcode}`,
35954
+ "declares both 'fieldTypeCd' and 'domain' \u2014 install rejects the pair rather than picking a winner. A domain supplies the control; drop the fieldTypeCd."
35955
+ );
35956
+ }
35957
+ };
35958
+ const reportParams = report.parameters ?? {};
35959
+ for (const [pcode, pdef] of Object.entries(reportParams)) {
35960
+ declaredParams.add(pcode);
35961
+ checkParamDef("parameters", pcode, pdef);
35962
+ }
35963
+ const datasets = report.datasets ?? {};
35964
+ for (const [dcode, ds] of Object.entries(datasets)) {
35965
+ const dsParams = ds?.params ?? {};
35966
+ for (const [pcode, pdef] of Object.entries(dsParams)) {
35967
+ declaredParams.add(pcode);
35968
+ checkParamDef(`datasets.${dcode}.params`, pcode, pdef);
35969
+ }
35970
+ }
35971
+ const attachments = report.entities;
35972
+ if (attachments === void 0) continue;
35973
+ if (!Array.isArray(attachments)) {
35974
+ err(where, "'entities' must be an array of record-report attachments.");
35975
+ continue;
35976
+ }
35977
+ const seen = /* @__PURE__ */ new Set();
35978
+ for (const raw of attachments) {
35979
+ const att = raw ?? {};
35980
+ const entityCd = att.entityCd;
35981
+ if (typeof entityCd !== "string" || entityCd.trim() === "") {
35982
+ err(where, "an entry in 'entities' is missing 'entityCd'.");
35983
+ continue;
35984
+ }
35985
+ const bare = entityCd.startsWith(`${manifest.code}.`) ? entityCd.slice(manifest.code.length + 1) : entityCd;
35986
+ if (seen.has(bare)) {
35987
+ err(
35988
+ where,
35989
+ `attaches to '${entityCd}' twice. One attachment per (entity, report) pair \u2014 merge the two 'params' maps into a single entry.`
35990
+ );
35991
+ continue;
35992
+ }
35993
+ seen.add(bare);
35994
+ if (!isKnownEntity(entityCd)) {
35995
+ err(
35996
+ where,
35997
+ `attaches to '${entityCd}', which is not a known entity (same-module, system, or a declared cross-module dependency). Qualify a foreign entity as 'module.entity' and add that module to the manifest's dependencies.`
35998
+ );
35999
+ }
36000
+ const paramMap = att.params ?? {};
36001
+ for (const [paramCd, source] of Object.entries(paramMap)) {
36002
+ if (!declaredParams.has(paramCd)) {
36003
+ err(
36004
+ where,
36005
+ `maps '${paramCd}' from '${entityCd}.${String(source)}', but '${paramCd}' is not a declared parameter of this report. Declare it in the report's 'parameters' block, or under the 'params' of the dataset that consumes it.`
36006
+ );
36007
+ continue;
36008
+ }
36009
+ if (typeof source !== "string" || source.trim() === "") {
36010
+ err(where, `maps parameter '${paramCd}' from an empty source column on '${entityCd}'.`);
36011
+ continue;
36012
+ }
36013
+ const fields = fieldDefsOf[bare];
36014
+ if (!fields) continue;
36015
+ const col = fields[source];
36016
+ if (!col) {
36017
+ err(
36018
+ where,
36019
+ `maps parameter '${paramCd}' from '${entityCd}.${source}', which is not a column of that entity.`
36020
+ );
36021
+ continue;
36022
+ }
36023
+ if (col.columnType === "S" || col.columnType === "F") {
36024
+ err(
36025
+ where,
36026
+ `maps parameter '${paramCd}' from '${entityCd}.${source}', a ${col.columnType === "S" ? "set ('S')" : "formula ('F')"} column. Map the PK, a reference ('R') column, or a bounded scalar.`
36027
+ );
36028
+ continue;
36029
+ }
36030
+ const db = typeof col.dbDatatype === "string" ? col.dbDatatype.toLowerCase() : "";
36031
+ if (col.columnType !== "R" && col.isPk !== true && /^(text|json|jsonb|bytea|uuid)$/.test(db)) {
36032
+ err(
36033
+ where,
36034
+ `maps parameter '${paramCd}' from '${entityCd}.${source}' (dbDatatype '${db}'), which is not a valid record-report parameter source. Allowed: the entity PK, a reference column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code).`
36035
+ );
36036
+ }
36037
+ }
36038
+ }
36039
+ if (seen.size > 0 && !deps.has("metadata")) {
36040
+ warn(
36041
+ where,
36042
+ `declares record-report attachments but the manifest has no 'metadata' dependency. The 'entity_report' table ships with the metadata system module \u2014 add "metadata": ">=1.5.0" to dependencies so install fails loudly on a platform without it, rather than the attachment quietly doing nothing.`
36043
+ );
36044
+ }
36045
+ }
35922
36046
  const roleCodes = Object.keys(roles);
35923
36047
  const localeFiles = fs11.existsSync(paths.translationsDir) ? fs11.readdirSync(paths.translationsDir).filter((f) => f.toLowerCase().endsWith(".json")).sort() : [];
35924
36048
  for (const raw of supportedLocales) {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.15",
4
4
  "description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
7
- "packageManager": "pnpm@11.21.0",
7
+ "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/dforge-core/dforge-mcp.git"
@@ -35,8 +35,8 @@
35
35
  "install-skills": "node scripts/install-skills.mjs"
36
36
  },
37
37
  "dependencies": {
38
- "@dforge-core/dforge-cli": "^0.2.13",
39
- "@dforge-core/metadata": "^0.0.14",
38
+ "@dforge-core/dforge-cli": "^0.2.14",
39
+ "@dforge-core/metadata": "^0.0.15",
40
40
  "@modelcontextprotocol/sdk": "^1.30.0",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -14,7 +14,8 @@ params:
14
14
 
15
15
  canExecute:
16
16
  # Single-expression formula returning boolean
17
- # Determines whether the action button is enabled for current record(s)
17
+ # Enables/disables the button AND is re-checked server-side on execute
18
+ # (see "canExecute is enforced, with a fail-open subset")
18
19
 
19
20
  onBeforeStart:
20
21
  # Pre-queue initialization (async / background actions only)
@@ -396,12 +397,68 @@ For "who did this", prefer the platform audit columns (`created_by` /
396
397
  predicates in `canExecute:` or a formula column, use `CURRENT_USER_ID()`, which
397
398
  the server translates to an exact `::bigint` SQL literal.
398
399
 
400
+ > **The table above is about `execute:` (JavaScript).** The *formula* engine —
401
+ > `canExecute:`, formula columns, filters, reports — keeps large integer literals
402
+ > exact: the parser holds them as 64-bit integers and serializes anything past
403
+ > 2^53 as text so the browser cannot round it. `canExecute: [owner_id] = 215117312238288897`
404
+ > compares against the id you wrote. `CURRENT_USER_ID()` is still the better way to
405
+ > say "mine" — it does not hard-code an id — but a literal is no longer a
406
+ > correctness trap on that side.
407
+
408
+ ## canExecute is enforced, with a fail-open subset
409
+
410
+ `canExecute:` is evaluated **twice**: by the browser to decide whether the button
411
+ is **enabled** (the action stays in the toolbar, greyed out — it is not hidden),
412
+ and by `action.execute` over every targeted record before any write. A
413
+ record in the wrong state is refused with `ACTION_EXECUTION_FAIL`, so the predicate
414
+ is a gate rather than a UI hint, and `canExecute: false` is the way to make an
415
+ action fireable only by a trigger or a scheduled job (both invoke the script engine
416
+ directly and bypass the check).
417
+
418
+ **Both evaluators have gaps, and both fail open**, but not the same gaps. The
419
+ server is a scalar walker and lets anything richer through. The client implements
420
+ the whole documented formula function set, so ordinary functions evaluate there
421
+ normally; it throws — and its `catch` **enables** the button — on ref navigation,
422
+ on the set aggregates `SUM` / `COUNT` / `AVG` (deliberately not formula functions;
423
+ counting belongs in a Generated `G` column), and on any name it does not know, a
424
+ typo included — a `canExecute:` is never validated against the function set at
425
+ install, so `TOADY()` ships and enables the button for every record.
426
+ `$[setting]` references are a separate case again: they do not throw, the client
427
+ resolver is stubbed to `null`, and the server has no settings resolver — so a
428
+ settings-dependent guard is unenforced server-side and resolves unpredictably in
429
+ the browser. Read settings in `execute:` instead.
430
+
431
+ | tier | construct | client | server |
432
+ |---|---|---|---|
433
+ | 1 | **comparisons** (`=` `==` `!=` `<>` `>` `>=` `<` `<=`) between `[field]`s and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
434
+ | 2 | arithmetic, `IN`, `BETWEEN`, any formula function (`TODAY`, `IF`, `DATEADD`, `REGEX_MATCH`, `CURRENT_USER_ID`, …) | evaluates | fail-open |
435
+ | 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
436
+ | — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
437
+
438
+ **A bare boolean column is not tier 1.** The walker evaluates conditions, not
439
+ values — a `[field]` resolves only as an operand of a comparison — so
440
+ `canExecute: [is_active]` and `canExecute: NOT [is_blocked]` fail open. Write
441
+ `[is_active] = true` and `NOT ([is_blocked] = true)`. A bare `false` is fine: it is
442
+ a complete condition, which is what makes the `canExecute: false` idiom work.
443
+
444
+ Tier 1 + tier 2 is the normal shape and behaves correctly: the client evaluates the
445
+ whole predicate so the button state is right, and the server short-circuits `AND` /
446
+ `OR` so the tier-1 half is still enforced.
447
+
448
+ **Tier 3 is what breaks.** The server short-circuits, the client does not, so
449
+ `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record still
450
+ evaluates `COUNT` in the browser, throws, and the button is **enabled** — then the
451
+ server refuses the call. A `canExecute:` is not validated against the function set
452
+ at install, so that spelling ships silently. Count with a `G` column and compare the
453
+ scalar; denormalise instead of navigating; put the rest in `execute:` behind
454
+ `error()`.
455
+
399
456
  ### Messaging & notifications
400
457
 
401
458
  #### `info(message, opts?)`, `warn(message, opts?)`
402
459
  Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
403
460
 
404
- 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*.
461
+ 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 **record report** (a report with an `entities` attachment: it lands on the same record toolbar, but shows the numbers *and* the rows behind them, re-read live on every open), a formula column, or an action that writes a result record instead. Do not compute in DSL what a report dataset already aggregates. See `dforge://reference/action-dsl`, *When an action is the wrong tool*, and `dforge://reference/reports`.
405
462
 
406
463
  ```dsl
407
464
  info('Created PO ' + po.po_number, {
@@ -247,11 +247,51 @@
247
247
  "additionalProperties": false
248
248
  },
249
249
 
250
+ "calculatedMember": {
251
+ "type": "object",
252
+ "description": "A synthetic, read-only member of a pivot-like view — a row (default) or column derived by a formula over other members at each cross-axis position. Platform-generic: any pivot-like surface consumes the same shape, so a module declares the metric in config with no platform code change. Pure presentation — no storage, evaluated client-side by the stock formula engine.",
253
+ "required": ["key", "label", "refs", "formula"],
254
+ "properties": {
255
+ "key": { "type": "string", "description": "Stable render key. Must not collide with a real member key." },
256
+ "axis": { "type": "string", "enum": ["row", "column"], "default": "row", "description": "Which axis the member is added to. A calculated ROW is a formula over other rows evaluated once per column; a calculated COLUMN is the transpose." },
257
+ "label": { "type": "string", "description": "Member label shown in the row/column head." },
258
+ "field": { "type": "string", "description": "Value field the refs read. Defaults to the surface's first value field." },
259
+ "refs": {
260
+ "type": "object",
261
+ "description": "Formula bracket-alias → referenced member identity on the same axis.",
262
+ "additionalProperties": { "type": "string" }
263
+ },
264
+ "formula": { "type": "string", "description": "Formula over the aliases, e.g. `[gp] / [rev] * 100`. Same `[name]` syntax as column formulas. A non-finite result (÷0, missing denominator) renders empty." },
265
+ "position": {
266
+ "description": "Placement on the axis. Defaults to the end.",
267
+ "oneOf": [
268
+ { "type": "object", "required": ["after"], "properties": { "after": { "type": "string", "description": "Key of the member to place this one after." } }, "additionalProperties": false },
269
+ { "type": "object", "required": ["at"], "properties": { "at": { "type": "string", "enum": ["end"] } }, "additionalProperties": false }
270
+ ]
271
+ },
272
+ "depth": { "type": "integer", "minimum": 0, "description": "Indent depth (row members only; 0 = top level)." },
273
+ "format": {
274
+ "type": "object",
275
+ "description": "Display format for the result.",
276
+ "properties": {
277
+ "style": { "type": "string", "enum": ["number", "percent"], "default": "number" },
278
+ "decimals": { "type": "integer", "minimum": 0, "default": 1 }
279
+ },
280
+ "additionalProperties": false
281
+ }
282
+ },
283
+ "additionalProperties": false
284
+ },
250
285
  "matrixViewConfig": {
251
286
  "type": "object",
252
287
  "description": "Matrix (pivot) view: rowAxis × colAxis grid with a value-source cell entity holding one record per (rowKey, colKey).",
253
288
  "required": ["rowAxis", "colAxis", "cell"],
254
289
  "properties": {
290
+ "calculatedMembers": {
291
+ "type": "array",
292
+ "description": "Synthetic read-only members derived by formula over OTHER members' values (e.g. a Gross Margin % row). Spliced into the rendered axis; never loaded or saved.",
293
+ "items": { "$ref": "#/$defs/calculatedMember" }
294
+ },
255
295
  "rowAxis": {
256
296
  "description": "Row axis values: an entity's records ('dataset') or a column's dropdown options ('dropdown').",
257
297
  "oneOf": [
@@ -133,6 +133,12 @@
133
133
  "items": { "type": "string", "pattern": "^[a-z]{2,3}(-[A-Z]{2})?$" },
134
134
  "description": "Non-English locales the package promises to translate. Each entry MUST match a translations/<locale>.json file, and every translatable resource (entity/field labels, folders, views, menus + items, actions + params, reports + datasets + params, settings) MUST have a label in that file. English is the default — don't list 'en' or 'en-US'."
135
135
  },
136
+ "features": {
137
+ "type": "array",
138
+ "items": { "type": "string", "enum": ["report-security"] },
139
+ "uniqueItems": true,
140
+ "description": "Opt-in platform behaviours this package was authored for. Absent or empty = the pre-feature behaviour for all of them, so a module written before a feature existed keeps working until its author declares it and republishes. Unknown names are REJECTED at package load, never ignored — a misspelled feature would silently leave the old behaviour in place while the manifest claims otherwise. Values: 'report-security' = this module's reports require 'E' on their sec_object; declare the matching \"report:<code>\": \"E\" grants in security/roles.json first, or the reports go dark for every role."
141
+ },
136
142
  "created": {
137
143
  "type": "string",
138
144
  "format": "date",
@@ -24,10 +24,42 @@
24
24
  "description": "Map of dataset code → dataset definition. Panels reference datasets by code via `datasetCd`.",
25
25
  "additionalProperties": { "$ref": "#/$defs/dataset" }
26
26
  },
27
+ "reloadInterval": {
28
+ "type": "integer",
29
+ "minimum": 0,
30
+ "description": "Auto-refresh interval in seconds. Omit (or 0) for no auto-refresh."
31
+ },
27
32
  "parameters": {
28
33
  "type": "object",
29
- "description": "Report-level parameters (alternative to per-dataset `params`). Map of param code → param definition.",
34
+ "description": "Report-level parameter declarations — the canonical home for a parameter, and the only one that fits a parameter several datasets use. Parameters are report-scoped throughout the platform: one `param_set` per report, `report.params.save` in the authoring API, and `report.get` flattens per-dataset defaults report-wide before use. A parameter declared under a dataset's `params` is shorthand for declaring it here; the installer merges both into one set, with this block winning on a code collision.",
30
35
  "additionalProperties": { "$ref": "#/$defs/paramDef" }
36
+ },
37
+ "entities": {
38
+ "type": "array",
39
+ "description": "Record-report attachments: entities this report can be opened from, with the mapping from report parameter to source column on that entity. Each entry becomes one `dForge.entity_report` row. See docs/business-logic/record-reports.md.",
40
+ "items": { "$ref": "#/$defs/entityAttachment" }
41
+ }
42
+ },
43
+ "additionalProperties": false
44
+ },
45
+
46
+ "entityAttachment": {
47
+ "type": "object",
48
+ "description": "One (entity, report) attachment. The binding is per-attachment, not per-report — the same report can attach to several entities with different parameter mappings.",
49
+ "required": ["entityCd"],
50
+ "properties": {
51
+ "entityCd": {
52
+ "type": "string",
53
+ "description": "Entity the report attaches to, qualified `module.entity` for anything outside the declaring module. The owning module must be this module or a declared dependency."
54
+ },
55
+ "params": {
56
+ "type": "object",
57
+ "description": "Parameter mapping: report param code → source column on the entity. Stored as `entity_report.param_map` and resolved server-side from the record's PK. Source columns are limited to the PK, reference (`R`) columns, and bounded scalars (number, date/datetime, bool, dropdown/radio/flags code); source and target must be type-compatible. Params left unmapped keep their normal behaviour (default value, or prompt the user).",
58
+ "additionalProperties": { "type": "string" }
59
+ },
60
+ "orderNum": {
61
+ "type": "integer",
62
+ "description": "Sort position in the record's combined action + report menu."
31
63
  }
32
64
  },
33
65
  "additionalProperties": false
@@ -228,7 +260,7 @@
228
260
  },
229
261
  "params": {
230
262
  "type": "object",
231
- "description": "Dataset-level parameters (alternative to top-level `parameters`)",
263
+ "description": "Parameters declared on this dataset — shorthand for the report-level `parameters` block, convenient when exactly one dataset uses the parameter. Either way the parameter is REPORT-scoped: `ReportRegistrar` merges the report-level block and every dataset's `params` into the report's one `param_set`, so a parameter declared here serves the whole report (the param bar, saved param sets, `@param` filters in any dataset, and record-report `entities[].params` mappings). On a code collision the report-level block wins. Declare a parameter used by several datasets at report level — there is no meaningful dataset to attribute it to.",
232
264
  "additionalProperties": { "$ref": "#/$defs/paramDef" }
233
265
  }
234
266
  },
@@ -330,30 +362,22 @@
330
362
  "type": "integer",
331
363
  "description": "Display order of the parameter in the report's parameter form. Read by ReportRegistrar (paramDef.OrderNum); falls back to declaration order when omitted."
332
364
  },
365
+ "columnType": {
366
+ "type": "string",
367
+ "enum": ["R"],
368
+ "description": "Declares a REFERENCE parameter: its submitted value is the whole picked record rather than the bare key. The JSON counterpart of the action DSL's `paramCd: ref <entityCd>`, stored in the same `param.column_type`. Optional — omitting it (a plain `lookup`) submits the key, which is what a filter comparing against a reference column needs, since the server destructures a key object across every key column and ignores non-key fields. Declare \"R\" when the report wants the record itself, not just its identity."
369
+ },
333
370
  "required": {
334
371
  "type": "boolean",
335
372
  "default": false,
336
- "description": "Whether the user must provide a value (older modules also use `isRequired`)"
337
- },
338
- "isRequired": {
339
- "type": "boolean",
340
- "description": "Alternate spelling of `required` used by some modules. Prefer `required` for new code."
373
+ "description": "Whether the user must provide a value. Stored inverted as `param.is_nullable`. There is no `isRequired` spelling — `ReportParamDef` reads this key only."
341
374
  },
342
375
  "default": {
343
376
  "description": "Default value applied if the user doesn't provide one"
344
377
  },
345
- "link": {
346
- "type": "object",
347
- "description": "Entity link for lookup-type params (alternative to nesting under `params.link`). Shape: { entity: '<code>', otherKey?: '<column>' }.",
348
- "properties": {
349
- "entity": { "type": "string" },
350
- "otherKey": { "type": "string" }
351
- },
352
- "additionalProperties": false
353
- },
354
378
  "params": {
355
379
  "type": "object",
356
- "description": "Field-type-specific params (e.g. lookup → { link: { entity, otherKey } })"
380
+ "description": "Field-type-specific params, nested under this key — there is no top-level `link`. A `lookup` param needs `{ \"link\": { \"entity\": \"<code>\", \"otherKey\": \"<column>\" } }`; `otherKey` auto-fills from the target's PK when omitted. Without `params.link` a lookup param installs, warns, and has no autocomplete."
357
381
  }
358
382
  },
359
383
  "additionalProperties": false
@@ -11,11 +11,14 @@ An action exists to **change something**. Its message is a receipt for work that
11
11
 
12
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
13
 
14
+ **Do not compute in the DSL what a report can compute.** Sums, ratios, ageing buckets, exposure, "how much is outstanding" — a report dataset already does aggregation, formatting, drill-through and row caps, and it re-reads live data every time it opens. DSL arithmetic over `select()` results reproduces that badly, once, in a place nothing else can reuse. Reach for the DSL when the number has to be **written down** or has to **stop** something; reach for a report when it has to be **looked at**.
15
+
14
16
  If you are about to write a `check_…`, `calculate_…` or `show_…` action, build one of these instead:
15
17
 
16
18
  | What's actually needed | Build this |
17
19
  |---|---|
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. |
20
+ | The user needs to **see** current numbers **for the record they're on** | A **record report** — a report with an `entities` attachment, which puts it on that record's toolbar and feeds its params from the record. This is the direct replacement for a `check_…` action: same entry point, but the user sees the open invoices and the ageing, not just "FAILED". See `references/reports.md`. |
21
+ | The user needs to **see** current numbers generally | 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
22
  | 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
23
  | 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
24
  | The check must **block** something | A **trigger** with `async: false`, or a `canExecute:` guard on the action being gated — enforcement, not narration. |
@@ -95,7 +98,68 @@ preference:
95
98
 
96
99
  ### `canExecute:` — availability formula
97
100
 
98
- 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.
99
163
 
100
164
  ```
101
165
  canExecute:
@@ -114,7 +178,7 @@ canExecute:
114
178
  [quantity] >= 0
115
179
  ```
116
180
 
117
- 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.
118
182
 
119
183
  **Note**: `canExecute` uses single `=` for equality (formula syntax), NOT `==` (JS syntax).
120
184
 
@@ -532,7 +596,9 @@ In `batch` mode, access individual records with `__records.get(i)`, the count wi
532
596
 
533
597
  ## Async actions
534
598
 
535
- 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.
536
602
 
537
603
  ## Registering the action
538
604
 
@@ -573,37 +639,58 @@ In `ui/actions.json`:
573
639
  | `entityCode` | Yes | Which entity this action applies to |
574
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` |
575
641
  | `script` | Yes | DSL script name (without path or extension — matches filename in `logic/actions/`) |
576
- | `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 |
577
643
  | `orderNum` | Recommended | Display order in the action menu |
578
- | `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`.
579
653
 
580
- ## 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:
581
656
 
582
- An action is **not** reliably one transaction, and `isTransacted: true` does not
583
- make it one — it only decides whether the first failing record aborts the run.
584
- 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.
585
664
 
586
- | path | mode | writes before the failure |
665
+ | path | `isTransacted: true` | `isTransacted: false` |
587
666
  |---|---|---|
588
- | Sync execute, entity **with** `auditHistory` | `single` | rolled back |
589
- | Sync execute, entity **with** `auditHistory` | `batch` | **stay committed** |
590
- | Sync execute, entity without it | either | **stay committed** |
591
- | Scheduled job | — | rolled back |
592
- | Background (`isAsync: true`) action | either | **stay committed** |
593
-
594
- Both "rolled back" rows are a side effect of the audit machinery opening a
595
- transaction the action then inherits — and in `batch` mode it opens only after the
596
- script has finished, so it doesn't cover the script at all. Adding or removing
597
- `auditHistory`, or switching between `single` and `batch`, changes whether a failed
598
- action leaves partial writes behind, with no change to the action itself.
599
-
600
- **This is a platform defect, not a contract** — actions are intended to be atomic,
601
- and this table is what happens until that lands. So don't design around either
602
- outcome, and don't reach for `auditHistory` as an atomicity switch.
603
-
604
- **Write actions that don't depend on it:** do all validation up front and call
605
- `error()` before any `insert()` / `update()`, so there is nothing to unwind either
606
- 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.
607
694
 
608
695
  ## SQL in query() — important notes
609
696
 
@@ -3,7 +3,12 @@
3
3
  Formulas are used in:
4
4
 
5
5
  - **Formula columns** (`columnType: "F"`) — computed values
6
- - **Action `canExecute:` blocks** — availability checks
6
+ - **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
7
+ and **enforced** by `action.execute`, but only over a narrow subset: comparisons
8
+ between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
9
+ arithmetic, ref navigation, `$[setting]` reads and a bare boolean field all fail
10
+ open server-side, and some of them throw client-side. See the `canExecute:`
11
+ section in [action-dsl.md](action-dsl.md).
7
12
  - **Default values** — a setting's `formula` (e.g. `"formula": "TODAY()"`) or a formula (`F`) column. Note: entity *data* columns have **no** `default`/`defaultValue` key — model a default with an `F` column or set it in action/trigger logic.
8
13
  - **Filter expressions** (partially)
9
14
  - **Validation expressions** (partially)
@@ -74,7 +74,10 @@ execute:
74
74
  }
75
75
  ```
76
76
 
77
- Hide the action from the UI with `canExecute: false` — the scheduler bypasses the check:
77
+ Gate the action to automation only with `canExecute: false`. The button is left in
78
+ the toolbar but permanently **disabled**, and `action.execute` refuses the call
79
+ server-side, while the scheduler — which invokes the script engine directly — is
80
+ unaffected:
78
81
 
79
82
  ```
80
83
  canExecute:
@@ -71,6 +71,35 @@ The manifest lists every file in the package by logical key. The installer reads
71
71
  | `supportedLocales` | string[] | IETF locale tags that the module ships translations for, e.g. `["de-DE", "uk-UA"]`. Files are **auto-discovered** at `./translations/{locale}.json` — there is no per-file manifest entry. English (`en`/`en-*`) is the default and must not be listed. |
72
72
  | `printTemplates` | object | `{ "template_code": "./print_templates/template.scriban" }` |
73
73
  | `webhooks` | string | path to `./webhooks.json` |
74
+ | `features` | string[] | Opt-in platform behaviours this package was authored for, e.g. `["report-security"]`. Absent/empty = the pre-feature behaviour. See **Feature opt-ins** below. |
75
+
76
+ ## Feature opt-ins
77
+
78
+ `dependencies` answers "does the platform have this?". `features` answers "should this behaviour apply to **my** module?".
79
+
80
+ They exist for opposite failure modes. A dependency is a hard gate — the module will not install without it. A feature is the reverse: the platform already has the behaviour, but switching it on for every installed module at once would break every package written before it existed. So it stays off per module until the author asks for it.
81
+
82
+ ```json
83
+ "features": ["report-security"]
84
+ ```
85
+
86
+ | Feature | Effect |
87
+ |---|---|
88
+ | `report-security` | This module's reports require the `E` right on their security object — checked when running or opening a report and when reading or changing its saved presets and panel layouts. Without it, a report is reachable by anyone who can read the underlying data. |
89
+
90
+ **Adopting one is two releases, in order:**
91
+
92
+ 1. Add `"report:<report_code>": "E"` to every role in `security/roles.json` that should reach each report. Bump `version`, install.
93
+ 2. Add `"features": ["report-security"]`. Bump `version` again, install.
94
+
95
+ Do it in the other order and every report in the module denies for every role until the grants land. Both steps need a version bump — a same-version install is skipped, so the tenant would keep the old manifest.
96
+
97
+ **Rules:**
98
+
99
+ - Unknown names are **rejected** at package load, not ignored. A misspelled `report-securty` would otherwise leave the old behaviour in place while the manifest claims the protection is on.
100
+ - Casing and surrounding whitespace are normalised; the canonical spelling is lowercase.
101
+ - Removing a feature genuinely removes it on the next install — an opt-in is a migration window, not a one-way door.
102
+ - Installing against an older platform that predates the feature is safe: unknown manifest keys are ignored there, so the module runs the old behaviour.
74
103
 
75
104
  ## Dependencies
76
105
 
@@ -7,7 +7,7 @@ SP files: `logic/reports/rpt_*.sql`
7
7
 
8
8
  ## Structure
9
9
 
10
- A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...] }`), and optional `parameters`. Panels reference datasets by code via `datasetCd`.
10
+ A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...] }`), optional per-dataset `params`, and an optional `entities` array (record-report attachments). Panels reference datasets by code via `datasetCd`.
11
11
 
12
12
  ```json
13
13
  {
@@ -17,12 +17,18 @@ A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...
17
17
  "pipeline": {
18
18
  "caption": "Pipeline Data",
19
19
  "datasetType": "Q",
20
+ "params": {
21
+ "min_amount": { "fieldTypeCd": "number", "label": "Min Amount", "required": false, "default": 0 }
22
+ },
20
23
  "query": {
21
24
  "entityCd": "opportunity",
22
25
  "columns": ["stage", "amount", "lead_source", "customer.name"],
23
26
  "filter": {
24
27
  "g": "and",
25
- "i": [ { "c": "stage", "o": "nIn", "v": ["Closed Won", "Closed Lost"] } ]
28
+ "i": [
29
+ { "c": "stage", "o": "nIn", "v": ["Closed Won", "Closed Lost"] },
30
+ { "c": "amount", "o": "grEq", "v": "@min_amount" }
31
+ ]
26
32
  },
27
33
  "sort": [ { "c": "amount", "d": "desc" } ]
28
34
  }
@@ -38,9 +44,6 @@ A report has `description`, a `datasets` map, a `layout` object (`{ panels: [...
38
44
  },
39
45
  { "vizType": "table", "datasetCd": "pipeline", "title": "Pipeline Detail" }
40
46
  ]
41
- },
42
- "parameters": {
43
- "min_amount": { "fieldTypeCd": "number", "label": "Min Amount", "required": false, "default": 0 }
44
47
  }
45
48
  }
46
49
  }
@@ -85,7 +88,7 @@ For SQL the query builder can't express — window functions, CTEs, cross-schema
85
88
  "spCd": "rpt_ar_aging",
86
89
  "params": {
87
90
  "as_of_date": { "fieldTypeCd": "date", "label": "As of Date", "required": true, "default": "=NOW()" },
88
- "customer_id": { "fieldTypeCd": "lookup", "label": "Customer", "required": false, "params": { "entityCd": "account" } }
91
+ "customer_id": { "fieldTypeCd": "lookup", "label": "Customer", "required": false, "params": { "link": { "entity": "account" } } }
89
92
  },
90
93
  "columnsDef": {
91
94
  "customer_name": { "label": "Customer", "fieldTypeCd": "text", "baseDatatypeCd": "string", "width": 200 },
@@ -99,7 +102,7 @@ Key differences from entity datasets:
99
102
  - `datasetType: "S"`.
100
103
  - `spCd` — the stored-procedure code (the function name **without** the schema prefix; resolved to `sp_id` at install). (**Not** `sp` or `procedureName`.)
101
104
  - `columnsDef` — **required** — the platform can't infer columns from a function.
102
- - Params can be per-dataset or per-report.
105
+ - Params work exactly as they do for an entity dataset, and `datasetType` makes no difference: declare them at report level (`parameters`) or on the dataset (`params`). Report level wins on a code collision.
103
106
 
104
107
  **Multi-result-set SPs** map extra datasets to the same function via `parentDatasetCd` (the dataset that owns the SP call) + `parentRef` (the named refcursor):
105
108
 
@@ -139,16 +142,33 @@ $$;
139
142
 
140
143
  ## Parameters
141
144
 
142
- Parameters prompt the user before running the report. Declare them per-report (`parameters`) or per-dataset (`params`).
145
+ Parameters prompt the user before running the report.
146
+
147
+ **Parameters are report-scoped**, so there are two legal declaration sites and they mean the same thing:
148
+
149
+ - **`parameters`** at report level — the canonical home, and the only one that fits a param several datasets use.
150
+ - **`datasets.<cd>.params`** — shorthand, fine when exactly one dataset uses it. Every module written before this existed uses it, and it stays fully supported.
151
+
152
+ The installer merges both into the report's single param set (**report level wins** on a code collision), so either way the param serves the whole report: the param bar, saved param sets, `@param` filters in *any* dataset, and record-report mappings.
153
+
154
+ > Why report-scoped: the declaration is stored once per report (`report.param_set_id` → `param_set`), the authoring API is `report.params.save`, and `report.get` flattens per-dataset defaults report-wide before use. There is no dataset-scoped parameter behaviour to choose between.
143
155
 
144
156
  ```json
145
157
  "parameters": {
146
- "start_date": { "fieldTypeCd": "date", "label": "Start Date", "required": true, "default": "=STARTMONTH()" },
147
- "status": { "domain": "fin.doc_status", "label": "Status" },
148
- "region": { "fieldTypeCd": "dropdown", "label": "Region", "params": { "options": [
149
- { "value": "north", "label": "North" }, { "value": "south", "label": "South" }
150
- ] } },
151
- "customer": { "fieldTypeCd": "lookup", "label": "Customer", "params": { "entityCd": "account" } }
158
+ "customer": { "fieldTypeCd": "lookup", "label": "Customer", "params": { "link": { "entity": "account" } } }
159
+ },
160
+ "datasets": {
161
+ "sales": {
162
+ "datasetType": "Q",
163
+ "params": {
164
+ "start_date": { "fieldTypeCd": "date", "label": "Start Date", "required": true, "default": "=STARTMONTH()" },
165
+ "status": { "domain": "fin.doc_status", "label": "Status" },
166
+ "region": { "fieldTypeCd": "dropdown", "label": "Region", "params": { "options": [
167
+ { "value": "north", "label": "North" }, { "value": "south", "label": "South" }
168
+ ] } }
169
+ },
170
+ "query": { "entityCd": "invoice", "columns": ["invoice_number", "total"] }
171
+ }
152
172
  }
153
173
  ```
154
174
 
@@ -159,9 +179,11 @@ Parameters prompt the user before running the report. Declare them per-report (`
159
179
  | `label` | Display label in the parameter dialog |
160
180
  | `required` | Whether the user must fill this before running |
161
181
  | `default` | Plain value or `=`-prefixed formula (`"=NOW()"`, `"=STARTMONTH()"`, `"=TODAY()"`) |
162
- | `params` | Extra config — `options` for dropdowns, `entityCd` for lookups |
182
+ | `params` | Extra config — `options` for dropdowns, `link: { entity, otherKey? }` for lookups |
163
183
  | `orderNum` | Display order in the parameter form (falls back to declaration order) |
164
184
 
185
+ Two spellings that look right and are silently ignored: **`isRequired`** (the key is `required`, so the param installs as optional) and a **top-level `link`** (it must be nested under `params`, so the lookup installs with no autocomplete). Both are validator errors.
186
+
165
187
  **Dropdown params: prefer `domain`, and never ship bare codes.** A list shared with a column belongs on a `column_domain` — bind the param to it and the options plus their translations are authored once (`column-domains.md`). For a list that exists only for this report, write the rich option form (`{ "value": "north", "label": "North" }`); a bare `"options": ["north", "south"]` renders the raw codes in **every** locale, English included. Per-locale labels go under `reports.<cd>.params.<param_cd>.options` (`translations.md`).
166
188
 
167
189
  Reference a parameter with `@param_code` inside a query filter value:
@@ -260,14 +282,72 @@ Dashboard KPI/chart *tiles* get the same support via a `sources` map in the tile
260
282
 
261
283
  `config.chartSize` ∈ `sm | m | l | xl` controls the default chart height (`m` when omitted).
262
284
 
285
+ ## Record reports (`entities`)
286
+
287
+ A report can be **attached to an entity** so it opens from a record — the way a print template does — with the record's values feeding its parameters. Add an `entities` array to the report:
288
+
289
+ ```json
290
+ "credit_check": {
291
+ "description": "Customer Credit Check — limit against outstanding AR and open quotes",
292
+ "entities": [
293
+ { "entityCd": "parties.party", "params": { "customer_id": "party_id" }, "orderNum": 45 },
294
+ { "entityCd": "crm.quote", "params": { "customer_id": "customer_id" }, "orderNum": 45 }
295
+ ],
296
+ "parameters": {
297
+ "customer_id": {
298
+ "label": "Customer",
299
+ "fieldTypeCd": "lookup",
300
+ "params": { "link": { "entity": "parties.party" } },
301
+ "required": true
302
+ }
303
+ },
304
+ "layout": { "panels": [ { "vizType": "table", "datasetCd": "open_invoices" } ] },
305
+ "datasets": {
306
+ "open_invoices": {
307
+ "caption": "Open Invoices",
308
+ "datasetType": "Q",
309
+ "query": {
310
+ "entityCd": "invoice",
311
+ "columns": ["invoice_number", "due_date", "amount_due"],
312
+ "filter": { "g": "and", "i": [ { "c": "customer_id", "o": "eq", "v": "@customer_id" } ] }
313
+ }
314
+ }
315
+ }
316
+ }
317
+ ```
318
+
319
+ Each entry becomes one attachment row, and the report gains a toolbar entry on that entity's record. Note the two different `params` keys:
320
+
321
+ - `entities[].params` — the **mapping**: report param code → **source column on the attached entity**.
322
+ - `parameters` / `datasets.<cd>.params` — the param **declaration**. The mapping can only name codes declared in one of those.
323
+
324
+ | Rule | Detail |
325
+ |---|---|
326
+ | `entityCd` | Qualified `module.entity` for anything outside this module, and that module must be a **declared dependency**. |
327
+ | Source columns | The entity PK, a reference (`R`) column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code). `text`, `json`, `file`/`image` are rejected. |
328
+ | Types | Source and target must be compatible; only `lookup`↔`number` and `date`↔`datetime` widen. A names-only match like `{ "customer_id": "created_date" }` is an error, not a runtime surprise. |
329
+ | One per pair | One attachment per (entity, report). Two entries for the same entity overwrite each other. |
330
+ | Unmapped params | Keep their normal behaviour — default value, or prompt the user. |
331
+ | Mapped params | Are **hidden** on the record-report page. The URL is about this record; pivoting means going back to a record. |
332
+ | Dependency | Declare `"metadata": ">=1.5.0"` — the attachment table ships with the metadata system module, so this is the gate that fails loudly on an older platform instead of the attachment quietly doing nothing. This is metadata's **`version`**; dependency ranges are never checked against `dbSchemaVersion`, so do not use the `1.4.0` of the migration that creates the table. |
333
+ | Rights | Report `E` is still required. A bridge role must grant `E` on the report **and** `S` on the dataset entities. |
334
+
335
+ There is no `canExecute` on an attachment: if the record exists the report is meaningful for it, and an empty report is a legitimate answer.
336
+
337
+ > **Prefer a record report over an action that calculates.** See *When an action is the wrong tool* in `references/action-dsl.md`. If the `execute:` block only reads, computes and `info()`s a number, it is not an action — a printed number is never stored, can't be re-read, and shows the verdict without the working. Attach a report instead: it gets you KPI tiles, charts, the underlying rows, drill-through, saved layouts and row caps for free, and the DSL you don't write is DSL that can't drift from the data.
338
+ >
339
+ > Keep it an action when it **changes** something — writing the result to a column, creating a record, or *blocking* a transition. "Explain the number" is a report; "refuse the over-limit quote" is an action or trigger.
340
+
263
341
  ## Grants
264
342
 
265
- Grant report access with the `E` right:
343
+ Grant report access with the `E` right — a **colon** prefix, never a dot:
266
344
 
267
345
  ```json
268
- "rights": { "report.sales_pipeline": "E" }
346
+ "rights": { "report:sales_pipeline": "E" }
269
347
  ```
270
348
 
349
+ A dependency's report is granted with the qualified form, `"report:fin.ar_aging": "E"` — bridge modules need this to grant on a report they attach but do not own.
350
+
271
351
  ## Common mistakes
272
352
 
273
353
  - Using a bare `"layout": [ ... ]` array — `layout` is an **object**: `"layout": { "panels": [ ... ] }`.
@@ -276,5 +356,11 @@ Grant report access with the `E` right:
276
356
  - Using `sp` / `procedureName` for an SP dataset — the field is `spCd`.
277
357
  - Forgetting `columnsDef` on an SP dataset — **required**.
278
358
  - Forgetting to grant `E` on the report in at least one role — it becomes invisible.
359
+ - Writing the rights key as `report.<code>` — actions/reports/folders take a **colon**: `report:<code>`.
360
+ - Writing `isRequired` instead of `required`, or a top-level `link` instead of `params.link` — both are ignored at install.
361
+ - Declaring both `fieldTypeCd` and `domain` on one param — install rejects the pair rather than picking a winner.
362
+ - Mapping a record-report attachment onto a param no dataset declares, or from a `text`/`json` column — both are rejected.
363
+ - Attaching a report to another module's entity without declaring that module as a dependency.
279
364
  - Forgetting `p_folder_uid` / `p_user_id` as the first two SP function params — the call fails.
280
365
  - Referencing a parameter as `$param` — use `@param_code` in filters.
366
+ - Writing an action that computes a number and `info()`s it, where a record report would show the working.
@@ -73,6 +73,9 @@ For each entity:
73
73
  - [ ] Each source has `entityCode` and `columns`
74
74
  - [ ] `entityCode` points to an entity that exists
75
75
  - [ ] Column codes in `columns` all exist on the entity
76
+ - [ ] No `columns` entry is a **set** (`columnType: "S"`) — a 1:N collection has no per-row value to put in a column; show it on the record card instead. **Rejected at validate/pack**
77
+ - [ ] No `columns` entry is a **dotted navigation path** (`owner.email`, `account.company_name`) — a view's column list is matched against the entity's own columns, so a dotted entry resolves to nothing. Add a Reference (`R`) or Formula (`F`) column to the entity and list that. **Rejected at validate/pack**
78
+ - [ ] A listed column does **not** need `V` in its flags — the renderer promotes anything the list names (that is how trait-contributed `created_by_user`, flagged `I`, appears). Listing a non-`V` column is valid intent, not a mistake
76
79
  - [ ] The rendered entity has **at least one visible scalar column** (a field with `V` in `flags`, `columnType` not `S`) — a grid/list/kanban/etc. view over an entity whose fields are all hidden (or only sets) is **rejected** at validate/pack and would render "No visible columns configured for this entity." `diagram`/`matrix`/`library` are exempt
77
80
  - [ ] If set, `viewType` is from the supported list (`grid`, `list`, `kanban`, `calendar`, `gallery`, `tree-grid`, `diagram`, `master-detail`, `library`, `matrix`)
78
81
  - [ ] A `matrix` view has a `viewConfig` with `rowAxis`, `colAxis`, and `cell` (cell `entity` matches the primary `dataSources` entity; `rowKey`/`colKey` are real cell columns)
@@ -89,6 +92,16 @@ For each entity:
89
92
  - [ ] Every `reportCode` points to a report that exists
90
93
  - [ ] `orderNum` is set on each node for deterministic ordering
91
94
 
95
+ ## Reports (`ui/reports.json`)
96
+
97
+ - [ ] Params are declared in the report-level `parameters` block, or under `datasets.<cd>.params` — a param several datasets use belongs at report level (the installer merges both; report level wins on a collision)
98
+ - [ ] Params use `required` (not `isRequired`), and a lookup nests its binding as `params: { link: { entity } }` (not a top-level `link`)
99
+ - [ ] No param declares both `fieldTypeCd` and `domain`
100
+ - [ ] `layout` is an object (`{ "panels": [...] }`), and the chart kind is `config.chartType` with `vizType: "chart"`
101
+ - [ ] Record-report attachments (`entities`): every mapped param code is declared at report level or on a dataset; every source column is the PK, a reference (`R`) column, or a bounded scalar; cross-module `entityCd` is qualified and its module is a declared dependency
102
+ - [ ] A module with attachments declares `"metadata": ">=1.5.0"`
103
+ - [ ] No `check_…` / `calculate_…` action exists that a record report should be
104
+
92
105
  ## Security
93
106
 
94
107
  - [ ] `security/roles.json` exists (at least one role)
@@ -106,6 +119,16 @@ For each action:
106
119
  - [ ] DSL file exists at `logic/actions/<script>.dsl`, where `<script>` is the bare filename in the action's `script` field (no path, no `.dsl` extension)
107
120
  - [ ] DSL file has `params:`, `canExecute:`, and `execute:` blocks
108
121
  - [ ] `canExecute:` is a valid formula expression
122
+ - [ ] If `canExecute:` is meant to **gate** and not just grey out a button, its
123
+ guard is expressed in the server-enforced subset — **comparisons** between
124
+ fields and literals, combined with `AND` / `OR` / `NOT`. Function calls
125
+ (`TODAY()`, `COUNT()`, `CURRENT_USER_ID()`), arithmetic, ref navigation and
126
+ `$[setting]` reads are fail-open server-side and are advisory only
127
+ - [ ] No **bare boolean field** as a whole condition — `[is_active]` and
128
+ `NOT [is_blocked]` are not enforced (a field is only an operand). Write
129
+ `[is_active] = true` / `NOT ([is_blocked] = true)`
130
+ - [ ] Anything that MUST hold is also checked in `execute:` with `error()` — a
131
+ `canExecute` alone is never the authoritative guard
109
132
  - [ ] `execute:` uses only documented built-in functions
110
133
  - [ ] Referenced entities exist
111
134
  - [ ] Referenced parameters are declared in `params:`
@@ -172,9 +172,12 @@ to fill a sub-step** — a pure CRUD module skips Phase 2 entirely (record it wi
172
172
 
173
173
  **2a Actions.** First check the action is an action at all: it must **change**
174
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
175
+ answer, build a **record report** (a report with an `entities` attachment — same
176
+ toolbar entry point, but it shows the working and re-reads live data), a formula
177
+ column, or an action that writes a result record instead — a printed number is
178
+ never stored and can't be re-read (`dforge://reference/action-dsl`, *When an
179
+ action is the wrong tool*). Do not compute in DSL what a report dataset already
180
+ aggregates. Then load
178
181
  all four resources in the action row — the wrong-field-access /
179
182
  wrong-batch-flag / wrong-`ui/actions.json`-property mistakes only surface when
180
183
  you cross-check them. Draft the DSL, run
@@ -219,10 +222,31 @@ pointing at the views.
219
222
 
220
223
  If none fire, skip specialized views for that entity.
221
224
 
222
- **3c. Reports** only when aggregation/grouping isn't covered by views.
225
+ **3c. Reports** only when aggregation/grouping isn't covered by views — or when
226
+ Phase 2a sent you here because the "action" only wanted to show a number.
227
+
228
+ Params are **report-scoped**: declare them in the report-level `parameters`
229
+ block, or as shorthand under `datasets.<cd>.params` when exactly one dataset uses
230
+ the param. The installer merges both into the report's one param set, report level
231
+ winning on a collision — so a param several datasets share belongs at report level.
232
+ Grant with a **colon** key: `"report:<code>": "E"`.
233
+
234
+ A report that answers a question *about one record* takes an `entities`
235
+ attachment, which puts it on that record's toolbar with the record's values
236
+ feeding its params:
237
+
238
+ ```json
239
+ "entities": [ { "entityCd": "parties.party", "params": { "customer_id": "party_id" }, "orderNum": 45 } ]
240
+ ```
241
+
242
+ `entities[].params` is the **mapping** (report param → source column), not a
243
+ param declaration. Qualify a cross-module `entityCd` and declare that module as a
244
+ dependency; add `"metadata": ">=1.5.0"` too. Source columns are limited to the
245
+ PK, a reference column, or a bounded scalar. Full rules:
246
+ `dforge://reference/reports`.
223
247
 
224
248
  **Exit:** every entity has a grid and a menu entry; every specialized view has a
225
- stated trigger.
249
+ stated trigger; every report's params sit on a dataset.
226
250
 
227
251
  ## Phase 4 — Polish (mostly optional)
228
252