@dforge-core/dforge-mcp 0.2.4 → 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/package.json CHANGED
@@ -1,48 +1,49 @@
1
1
  {
2
- "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.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
- "license": "MIT",
6
- "homepage": "https://github.com/iash44/dForge-core",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/dforge-core/dforge-mcp.git"
10
- },
11
- "bin": {
12
- "dforge-mcp": "dist/server.js",
13
- "dforge-install-skills": "scripts/install-skills.mjs"
14
- },
15
- "files": [
16
- "dist/",
17
- "docs/",
18
- "resources/",
19
- "skills/",
20
- "scripts/install-skills.mjs",
21
- "README.md",
22
- "CHANGELOG.md"
23
- ],
24
- "main": "dist/server.js",
25
- "engines": {
26
- "node": ">=18"
27
- },
28
- "scripts": {
29
- "build": "tsup",
30
- "sync-schemas": "node scripts/vendor-schemas.cjs",
31
- "prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
32
- "typecheck": "tsc --noEmit",
33
- "test": "vitest run",
34
- "install-skills": "node scripts/install-skills.mjs"
35
- },
36
- "dependencies": {
37
- "@dforge-core/dforge-cli": "^0.2.12",
38
- "@dforge-core/metadata": "^0.0.13",
39
- "@modelcontextprotocol/sdk": "^1.30.0",
40
- "zod": "^4.4.3"
41
- },
42
- "devDependencies": {
43
- "@types/node": "^22.20.1",
44
- "tsup": "^8.5.1",
45
- "typescript": "^6.0.3",
46
- "vitest": "^3.2.7"
47
- }
2
+ "name": "@dforge-core/dforge-mcp",
3
+ "version": "0.2.14",
4
+ "description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/iash44/dForge-core",
7
+ "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dforge-core/dforge-mcp.git"
11
+ },
12
+ "bin": {
13
+ "dforge-mcp": "dist/server.js",
14
+ "dforge-install-skills": "scripts/install-skills.mjs"
15
+ },
16
+ "files": [
17
+ "dist/",
18
+ "docs/",
19
+ "resources/",
20
+ "skills/",
21
+ "scripts/install-skills.mjs",
22
+ "README.md",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "main": "dist/server.js",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup",
31
+ "sync-schemas": "node scripts/vendor-schemas.cjs",
32
+ "prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "vitest run",
35
+ "install-skills": "node scripts/install-skills.mjs"
36
+ },
37
+ "dependencies": {
38
+ "@dforge-core/dforge-cli": "^0.2.14",
39
+ "@dforge-core/metadata": "^0.0.15",
40
+ "@modelcontextprotocol/sdk": "^1.30.0",
41
+ "zod": "^4.4.3"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^26.2.0",
45
+ "tsup": "^8.5.1",
46
+ "typescript": "^6.0.3",
47
+ "vitest": "^4.1.10"
48
+ }
48
49
  }
@@ -869,6 +869,10 @@ For `dropdown` fields, define available options in `params.options`:
869
869
 
870
870
  Both formats can be mixed in the same array, but this is not recommended.
871
871
 
872
+ **Action / report parameters carry the same option list.** A `dropdown` param declares its choices in the DSL (`method: dropdown required "Method" options=[bank_transfer:Bank transfer, cash:Cash]`) or, for report params, under `params.options` in JSON — the identical `{value, label, icon, color}` shape. Bare codes (`options=bank_transfer,cash`) render as raw codes in **every** locale, English included, so label them. Per-locale labels go under `actions.<cd>.params.<param_cd>.options` / `reports.<cd>.params.<param_cd>.options`.
873
+
874
+ **Prefer a shared column domain over restating a list.** When the choices are the same set a column uses — the usual case, since the param's value normally ends up written to that column — bind the param to the domain instead: `method: domain payment_method required "Method"` in the DSL, `{"domain": "fin.payment_method"}` for a report param. The options and their translations then live in exactly one place for the column and every parameter on it. Keep inline `options=` for a list that belongs to one parameter and matches no column. See the `column-domains.md` and `translations.md` skill references.
875
+
872
876
  ---
873
877
 
874
878
  ## Version Management
@@ -59,10 +59,56 @@ Common field types + their extra keys:
59
59
  | `date` | (none) | `due_date: date required "Due"` |
60
60
  | `dateTime` | (none) | `scheduled_at: dateTime required "Scheduled at"` |
61
61
  | `boolean` | (none) | `urgent: boolean optional "Urgent?"` |
62
- | `dropdown` | `options` | `priority: dropdown required "Priority" options=low,medium,high` |
62
+ | `dropdown` | `options` | `priority: dropdown required "Priority" options=[low:Low, medium:Medium, high:High]` |
63
63
  | `lookup` | (use ref form instead — see below) | |
64
64
  | `file` | `accept` | `attachment: file optional "Attachment" accept=.pdf,.png` |
65
65
 
66
+ ### Dropdown options — label them, or prefer a domain
67
+
68
+ `options=low,medium,high` stores bare codes, and the dialog then shows `low` / `medium` /
69
+ `high` in **every** locale, English included. Give each choice a label:
70
+
71
+ ```dsl
72
+ params:
73
+ // value:Label — wrap the list in [ ] (or " ") when a label contains a space,
74
+ // because the trailing key=value scan is whitespace-delimited.
75
+ method: dropdown required "Method" options=[bank_transfer:Bank transfer, cash:Cash]
76
+
77
+ // JSON form — for values with commas, or to set icon/color.
78
+ priority: dropdown required "Priority" options=[{"value":"high","label":"High","color":"#c00"},"normal"]
79
+ ```
80
+
81
+ An option object must carry a non-empty string `value` — that's the stored code and the key
82
+ translations match on. Per-locale labels go under
83
+ `actions.<cd>.params.<param_cd>.options` (see `translations.md`).
84
+
85
+ ### Domain form (borrow a shared option list)
86
+
87
+ ```
88
+ paramName: domain domainCd [required|optional] ["description"]
89
+ ```
90
+
91
+ ```dsl
92
+ params:
93
+ payment_method: domain payment_method required "Payment method for this batch"
94
+ status: domain fin.doc_status optional "Status" // module-qualified
95
+ ```
96
+
97
+ **Prefer this whenever the choices are (or could be) the same list a column uses** — which
98
+ is the usual case, since the param's value normally ends up written to that column. The
99
+ param takes its control and options from the `column_domain`, so the list and its
100
+ translations are authored **once** instead of restated in the DSL and drifting from the
101
+ column's.
102
+
103
+ - The param keeps its **own caption**; only the choices come from the domain.
104
+ - Nothing may follow the description: `options=`, `min=` and friends are a compile error
105
+ here — the domain owns them.
106
+ - An unknown domain code fails the install; it never degrades to an empty dropdown.
107
+ - Report params take the same binding as JSON: `{"domain": "fin.payment_method", "label": "…"}`.
108
+
109
+ Use inline `options=` only when the list belongs to that one parameter and matches no
110
+ column (a run mode, an export format).
111
+
66
112
  ### Reference form (FK to another entity)
67
113
 
68
114
  ```
@@ -355,7 +401,7 @@ the server translates to an exact `::bigint` SQL literal.
355
401
  #### `info(message, opts?)`, `warn(message, opts?)`
356
402
  Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
357
403
 
358
- 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`.
359
405
 
360
406
  ```dsl
361
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
  },
@@ -314,41 +346,38 @@
314
346
 
315
347
  "paramDef": {
316
348
  "type": "object",
317
- "required": ["fieldTypeCd"],
349
+ "not": { "required": ["fieldTypeCd", "domain"] },
318
350
  "properties": {
319
351
  "fieldTypeCd": {
320
352
  "type": "string",
321
- "description": "Field type code (see settings.schema.json for the common list)"
353
+ "description": "Field type code (see settings.schema.json for the common list). Omit when `domain` is set — the domain supplies the control. May also be omitted entirely: a param declaring neither key installs as `text` (ReportRegistrar.DefaultParamFieldTypeCd). The one rejected combination is declaring both."
354
+ },
355
+ "domain": {
356
+ "type": "string",
357
+ "description": "Column domain (`domain_cd` or `module_cd.domain_cd`) the parameter borrows its control and option list from. The options and their translations stay on the domain, so a list shared with an entity column is authored once. The DSL equivalent for action params is `paramCd: domain <domainCd>`.",
358
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)?$"
322
359
  },
323
360
  "label": { "type": "string" },
324
361
  "orderNum": {
325
362
  "type": "integer",
326
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."
327
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
+ },
328
370
  "required": {
329
371
  "type": "boolean",
330
372
  "default": false,
331
- "description": "Whether the user must provide a value (older modules also use `isRequired`)"
332
- },
333
- "isRequired": {
334
- "type": "boolean",
335
- "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."
336
374
  },
337
375
  "default": {
338
376
  "description": "Default value applied if the user doesn't provide one"
339
377
  },
340
- "link": {
341
- "type": "object",
342
- "description": "Entity link for lookup-type params (alternative to nesting under `params.link`). Shape: { entity: '<code>', otherKey?: '<column>' }.",
343
- "properties": {
344
- "entity": { "type": "string" },
345
- "otherKey": { "type": "string" }
346
- },
347
- "additionalProperties": false
348
- },
349
378
  "params": {
350
379
  "type": "object",
351
- "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."
352
381
  }
353
382
  },
354
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. |
@@ -48,7 +51,7 @@ Each param is one line: `name: type [required] ["Label"]`
48
51
 
49
52
  ```
50
53
  params:
51
- new_stage: dropdown required "New Stage"
54
+ new_stage: dropdown required "New Stage" options=[open:Open, won:Won, lost:Lost]
52
55
  note: textarea "Follow-up Note"
53
56
  adjustment_qty: number required "Adjustment Quantity"
54
57
  reason: text required "Reason"
@@ -58,6 +61,41 @@ Types are a subset of column field types: `text`, `textarea`, `number`, `currenc
58
61
 
59
62
  Access in execute block: `params[param_name]` (bracket syntax, NOT dot syntax).
60
63
 
64
+ #### Dropdown params: label the options, or borrow a domain
65
+
66
+ A bare `options=open,won,lost` stores codes with no labels, and the dialog shows `open` /
67
+ `won` / `lost` in **every** locale, English included. Two ways to do better, in order of
68
+ preference:
69
+
70
+ 1. **`domain <domainCd>`** — the param borrows a shared `column_domain`:
71
+
72
+ ```
73
+ params:
74
+ payment_method: domain payment_method required "Payment method for this batch"
75
+ status: domain fin.doc_status optional "Status" // module-qualified
76
+ ```
77
+
78
+ Use this whenever the choices are the same list a column uses — the usual case, since
79
+ the param's value normally ends up written to that column. Control and options come off
80
+ the domain, so the list and its translations live in **one** place instead of being
81
+ restated here and drifting from the column's. The param keeps its own caption. Nothing
82
+ may follow the description (`options=`, `min=` … are a compile error — the domain owns
83
+ them), and an unknown domain code fails the install.
84
+
85
+ 2. **Inline labels** — for a list that belongs to this parameter alone (a run mode, an
86
+ export format) and matches no column:
87
+
88
+ ```
89
+ params:
90
+ method: dropdown required "Method" options=[bank_transfer:Bank transfer, cash:Cash]
91
+ priority: dropdown required "Priority" options=[{"value":"high","label":"High","color":"#c00"},"normal"]
92
+ ```
93
+
94
+ `value:Label` splits on the first colon; wrap the list in `[ ]` (or `" "`) when a label
95
+ contains a space, since the trailing `key=value` scan is whitespace-delimited. An option
96
+ object needs a non-empty string `value` — the stored code, and the key translations match
97
+ on. Per-locale labels: `actions.<cd>.params.<param_cd>.options` (`translations.md`).
98
+
61
99
  ### `canExecute:` — availability formula
62
100
 
63
101
  A formula expression (same grammar as formula columns) that evaluates to true/false. When false, the action button is hidden or disabled in the UI.
@@ -8,7 +8,7 @@ Schema: `dforge://schema/domains`.
8
8
 
9
9
  ## Why
10
10
 
11
- Without domains, every "status" column restates the same `fieldTypeCd: "dropdown"` + the full `params.options` list — and, if you localize, the same option translations, once per column. A domain declares that once; every consuming column inherits the type, the control, the options, **and** their translations. Localize `fin.doc_status` a single time and all its uses update together.
11
+ Without domains, every "status" column — and every action param that writes to one — restates the same `fieldTypeCd: "dropdown"` + the full `params.options` list, and, if you localize, the same option translations, once per consumer. A domain declares that once; every consuming column **and parameter** inherits the type, the control, the options, **and** their translations. Localize `fin.doc_status` a single time and all its uses update together.
12
12
 
13
13
  ## `domains.json`
14
14
 
@@ -54,6 +54,24 @@ A column swaps its whole type block for a single `domain` key — qualified as `
54
54
 
55
55
  The column keeps what is genuinely its own — `description` (its label), `flags`, `orderNum`, `isNullable`, `isPk`, `columnGroupCd`. The domain supplies the rest.
56
56
 
57
+ ## Using a domain in an action / report param
58
+
59
+ Parameters consume the same domain, so an action that writes to a domain-backed column offers exactly that column's choices instead of restating them:
60
+
61
+ ```
62
+ params:
63
+ status: domain fin.doc_status required "New status"
64
+ ```
65
+
66
+ ```json
67
+ "status": { "domain": "fin.doc_status", "label": "New status", "required": true }
68
+ ```
69
+
70
+ - **Prefer this over inline `options=`** whenever the param's value ends up on a domain-backed column — otherwise the same enum is authored twice, translated twice, and free to drift (a param option whose code isn't on the column is a value the grid can't label).
71
+ - Install materializes the domain's `fieldTypeCd` onto the param; its datatype and sizing don't apply, because a parameter has no storage. A domain with no `fieldTypeCd` is rejected — the param would have no control to render.
72
+ - The param keeps its **own caption** (`"New status"` above) and translates it under `actions.<cd>.params.<param_cd>.label` as usual. Only the choices come from the domain, options translations included.
73
+ - Nothing may follow the description in the DSL form — `options=`, `min=` and friends are a compile error there, same authority rule columns get. A report param declaring both `domain` and `fieldTypeCd` is rejected for the same reason.
74
+
57
75
  ## Rules (these fail the install)
58
76
 
59
77
  1. **Don't restate what the domain owns.** Declaring `dbDatatype`, `fieldTypeCd`, `maxLen`, `precision`, `baseDatatypeCd` or `params` alongside `domain` is rejected, naming the conflicting key — it is never silently overridden. Domains are authoritative.
@@ -78,7 +96,7 @@ An option override is a partial merged over the base — a bare string is shorth
78
96
  ## How it resolves (mental model)
79
97
 
80
98
  - **Install** materializes the domain's structural fields (datatype/control/sizing) onto each consuming column, because DDL generation needs them locally.
81
- - **Runtime** resolves `params` (the options) — and their per-culture overrides — from the domain, not the column. That is what lets a shared list be authored and translated once.
99
+ - **Runtime** resolves `params` (the options) — and their per-culture overrides — from the domain, not the column *or the param*. That is what lets a shared list be authored and translated once.
82
100
 
83
101
  You don't act on this split; just know that the option list stays on the domain, so **don't** paste `params.options` onto a domain-backed column (the install rejects it).
84
102
 
@@ -90,5 +108,6 @@ You don't act on this split; just know that the option list stays on the domain,
90
108
  ## Common mistakes
91
109
 
92
110
  - Putting `params.options` on the column **and** naming a `domain` → install error. Options belong on the domain.
111
+ - Restating a domain's list inline on an action param (`options=…`) instead of binding the param to the domain → compiles, but you now maintain and translate the same enum twice.
93
112
  - Using a cross-module domain without declaring the dependency → "unknown column domain" at install.
94
113
  - Expecting per-column option overrides — the domain is authoritative in v1; narrow with `optionSets` (which filters by `value`) if you need a conditional subset.
@@ -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