@dforge-core/dforge-mcp 0.2.5 → 0.2.14

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,108 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
+ ## 0.2.6
8
+
9
+ Record reports — a report attached to an entity so it opens **from a record**, with the
10
+ record's values feeding its parameters — plus a report-level home for report parameters,
11
+ and the removal of two parameter spellings the installer never read.
12
+
13
+ Requires `@dforge-core/metadata` ≥ 0.0.15 (bumped): the vendored
14
+ `resources/schemas/reports.schema.json` served as `dforge://schema/reports` comes from
15
+ there.
16
+
17
+ ### Added — record-report attachments
18
+
19
+ A report in `ui/reports.json` gains an `entities` array. Each entry attaches the report to
20
+ one entity, mapping report parameters to source columns on it:
21
+
22
+ ```jsonc
23
+ "credit_check": {
24
+ "entities": [
25
+ { "entityCd": "parties.party", "params": { "customer_id": "party_id" }, "orderNum": 45 },
26
+ { "entityCd": "crm.quote", "params": { "customer_id": "customer_id" }, "orderNum": 45 }
27
+ ]
28
+ }
29
+ ```
30
+
31
+ The report then appears on that record's toolbar and opens on a record-scoped route with
32
+ the mapped params resolved server-side and hidden. `dforge_report_add` accepts and
33
+ documents the block; `references/reports.md` gains a **Record reports** section with the
34
+ rules (source-column allowlist, type compatibility, one attachment per (entity, report)
35
+ pair, the `"metadata": ">=1.5.0"` gate, and the cross-module dependency bound).
36
+
37
+ Note the two different `params` keys, which the tool description now calls out: on an
38
+ attachment it is the *mapping* (param code → column); on the report or a dataset it is the
39
+ *declaration*.
40
+
41
+ ### Added — `dforge_module_validate` checks reports
42
+
43
+ Mirrors the server's new pack-time `ReportAttachmentValidator`, so these fail offline
44
+ instead of at install against a live tenant:
45
+
46
+ - a mapped param code neither the report-level `parameters` block nor any dataset declares;
47
+ - a source column the entity doesn't have, or one that's a set / formula / free-text column;
48
+ - two attachments to the same entity in one report (the second overwrites the first);
49
+ - an attachment to a cross-module entity whose module isn't a declared dependency;
50
+ - `isRequired`, a top-level `link`, or `fieldTypeCd` + `domain` together on a param;
51
+ - a warning when a module ships attachments with no `metadata` dependency.
52
+
53
+ ### Added — report-level `parameters`
54
+
55
+ Report parameters are **report-scoped**: the declaration is stored once per report
56
+ (`report.param_set_id` → `param_set`), the authoring API is `report.params.save`, and
57
+ `report.get` flattens per-dataset defaults report-wide before use. The installer had only
58
+ ever read the per-dataset shorthand, so a report-level block was silently dropped and the
59
+ report installed with no parameters at all — which is what made
60
+ `crm-fin.customer_statement` and `wms-fin.vendor_statement` ship parameterless.
61
+
62
+ Both declaration sites are now legal and mean the same thing; the installer merges them,
63
+ **report level winning** on a code collision:
64
+
65
+ ```jsonc
66
+ "customer_statement": {
67
+ "parameters": {
68
+ "customer_id": { "label": "Customer", "fieldTypeCd": "lookup",
69
+ "params": { "link": { "entity": "parties.party" } }, "required": true }
70
+ },
71
+ "datasets": { "statement_invoices": { }, "statement_payments": { } }
72
+ }
73
+ ```
74
+
75
+ Declare at report level when several datasets use the parameter — `customer_statement` is
76
+ exactly that case, and there is no meaningful dataset to attribute it to.
77
+ `datasets.<cd>.params` stays the right shorthand for a single-dataset parameter, and every
78
+ module written before this keeps working.
79
+
80
+ `dforge_module_validate` merges both sites when resolving a record-report mapping, so a
81
+ mapping onto a report-level parameter resolves exactly as install will resolve it.
82
+
83
+ ### Fixed — two report-param spellings that are silently ignored
84
+
85
+ Unlike `parameters`, these are simply misspellings of keys that already exist, so they are
86
+ gone from the schema rather than implemented — and the validator flags them at either
87
+ declaration site:
88
+
89
+ - **`isRequired`** — the key is `required`; the parameter installed as optional.
90
+ - **top-level `link`** on a lookup parameter — it belongs under `params`; the parameter
91
+ installed with no autocomplete.
92
+
93
+ `references/reports.md` also corrects the rights-key example from `report.<code>` to the
94
+ colon form `report:<code>`, and the lookup parameter config from `params.entityCd` to
95
+ `params.link.entity`.
96
+
97
+ ### Changed — steer calculation toward reports, not the DSL
98
+
99
+ `action-dsl.md`'s *When an action is the wrong tool* now names the record report as the
100
+ direct replacement for a `check_…` action — same toolbar entry point, but the user sees
101
+ the open invoices and the ageing rather than just "FAILED" — and adds the general rule:
102
+ **do not compute in the DSL what a report dataset already aggregates.** Sums, ratios,
103
+ ageing buckets and exposure get formatting, drill-through, row caps and live re-reads for
104
+ free from a report; DSL arithmetic over `select()` reproduces that badly, once, somewhere
105
+ nothing else can reuse. Write DSL when the number must be *written down* or must *stop*
106
+ something. The same steer is in `dsl-reference.md`, Phase 2a and Phase 3c of the
107
+ `dforge-module-build` skill, and the validation checklist.
108
+
7
109
  ## 0.2.5
8
110
 
9
111
  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.14",
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
  },
@@ -401,7 +401,7 @@ the server translates to an exact `::bigint` SQL literal.
401
401
  #### `info(message, opts?)`, `warn(message, opts?)`
402
402
  Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
403
403
 
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*.
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 **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
405
 
406
406
  ```dsl
407
407
  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. |
@@ -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)
@@ -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