@dforge-core/dforge-mcp 0.2.15 → 0.2.17

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.
Files changed (26) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/dist/server.js +157 -9
  3. package/package.json +3 -3
  4. package/resources/docs/conventions.md +74 -1
  5. package/resources/schemas/deps.schema.json +1 -1
  6. package/resources/schemas/entity.schema.json +80 -2
  7. package/resources/schemas/folders.schema.json +1 -1
  8. package/resources/schemas/manifest.schema.json +1 -1
  9. package/resources/schemas/menus.schema.json +1 -1
  10. package/resources/schemas/triggers.schema.json +1 -1
  11. package/skills/dforge-mcp-author/examples/column-security/README.md +67 -0
  12. package/skills/dforge-mcp-author/examples/column-security/entities/product.json +79 -0
  13. package/skills/dforge-mcp-author/examples/column-security/manifest.json +23 -0
  14. package/skills/dforge-mcp-author/examples/column-security/security/roles.json +16 -0
  15. package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +35 -0
  16. package/skills/dforge-mcp-author/examples/column-security/ui/data_views.json +20 -0
  17. package/skills/dforge-mcp-author/examples/column-security/ui/folders.json +23 -0
  18. package/skills/dforge-mcp-author/examples/column-security/ui/menus.json +21 -0
  19. package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +8 -12
  20. package/skills/dforge-mcp-author/references/column-types.md +3 -2
  21. package/skills/dforge-mcp-author/references/data-views.md +2 -0
  22. package/skills/dforge-mcp-author/references/flags.md +41 -9
  23. package/skills/dforge-mcp-author/references/formulas.md +7 -0
  24. package/skills/dforge-mcp-author/references/security.md +77 -1
  25. package/skills/dforge-mcp-author/references/validation-checklist.md +2 -0
  26. package/skills/dforge-module-build/SKILL.md +20 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,198 @@ 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.17
8
+
9
+ A bridge module's `async: false` trigger **can** roll back its host module's write,
10
+ and that is intended (platform issue #999). It is the same coupling a mandatory
11
+ extension column already creates — mark one `M` and the host's own `data.insert`
12
+ is refused with *"Missing required columns: …"* for a field the host knows nothing
13
+ about. A bridge that can add a required field can add a required *condition*, and
14
+ cross-module invariants are what bridge modules exist to enforce.
15
+
16
+ What changed platform-side is that the veto now says who issued it, so the guidance
17
+ here says what to point it at.
18
+
19
+ ### Changed — `async: false` is a refusal mechanism, not a reaction
20
+
21
+ `dforge_trigger_add`'s `async` description and `dforge-module-build` Phase 2b both
22
+ said only that a failure "rolls back the original change" — true, and read by an
23
+ authoring agent as a footnote about transactions rather than as the decision it is.
24
+ Both now state that the rollback reaches **another module's** entity, and the rule
25
+ that follows: pick `async: false` to enforce an invariant, not merely to react,
26
+ because a bug in the action blocks the host's workflow exactly as effectively as a
27
+ real violation. Keep such an action to the check and put the rest behind
28
+ `try`/`catch` or in an `async: true` trigger.
29
+
30
+ ### Changed — a blocked write names the module that blocked it
31
+
32
+ Trigger failures now report error code **`TRIGGER_ACTION_FAIL`** (the other action
33
+ phases keep `ACTION_EXECUTION_FAIL`), rendered as *Blocked by "crm-fin" (automation
34
+ "on_invoice_posted"): Credit limit exceeded.* in the caller's locale, with the
35
+ action's own `error()` text passed through. The module named is the one that
36
+ **declared the trigger**, not the one owning the action — a bridge routinely
37
+ subscribes its own trigger to an action living elsewhere, and what refused the write
38
+ is the subscription. Nothing to change in a module for this; it is what an author
39
+ should expect to see when their trigger fires.
40
+
41
+ Nothing is rejected that was accepted before — `dforge_module_validate` is unchanged.
42
+
43
+ ## 0.2.16
44
+
45
+ Entity views — the platform's **column-level security** — became reachable from a
46
+ module package (platform change `e6e29b604`). A view is a named subset of an
47
+ entity's columns declared under `views` on the entity file; a folder binds one per
48
+ entity with `entities.<code>.viewName`, and users working there see only the
49
+ columns that view lists. The schema had gained the property while nothing here
50
+ knew about it, so authoring one meant an editor error on valid metadata and a
51
+ refactor that could quietly break the module.
52
+
53
+ ### Added — entity views are validated offline
54
+
55
+ `dforge_module_validate` now mirrors what the platform installer rejects, all of
56
+ which is silent at runtime if it slips through:
57
+
58
+ | Check | Why it matters |
59
+ |---|---|
60
+ | Column exists on the entity | A stale name is simply not in the view |
61
+ | View lists the primary key | Records are addressed by it — the client cannot open a row without it |
62
+ | View has at least one column | A view is the COMPLETE visible set, so an empty one would hide every field |
63
+ | No two views differing only by case | A folder binds case-insensitively; only one could ever be reached |
64
+ | No column listed twice under different casing | Both name one column and the later would silently win |
65
+ | `formula` override only on a `columnType: "F"` column | Elsewhere that field is the SQL default and the install FAILS — a view cannot change a physical column's `DEFAULT` |
66
+ | `folders.json` `viewName` resolves to a declared view | **Fails the install** — it does not fall back, because falling back shows every column |
67
+
68
+ `"default"` is exempt from the last check and means "no view": every shipped module
69
+ writes it, and the platform auto-creates a column-less row under that name for any
70
+ entity reached without a folder binding.
71
+
72
+ ### Fixed — refactors reach into views
73
+
74
+ `dforge_entity_field_rename` and `dforge_entity_field_remove` rewrote fields, links,
75
+ formulas, data views and seed data but never touched `views.<v>.columns.<cd>`. Since
76
+ the platform now rejects a view naming a column the entity does not have, a rename
77
+ handed back a module that no longer installs. Both cascade into entity views now,
78
+ including per-view `formula` overrides, preserving the view's column order (which is
79
+ its display order). Removal warns when a view is left with no columns, or when a
80
+ per-view formula still names a removed field — the same judgement-call warnings the
81
+ tool already emitted for entity formulas.
82
+
83
+ ### Fixed — `viewName` was documented as a data view code
84
+
85
+ It is not, and the mistake ran from `schemas/folders.schema.json` through
86
+ `references/security.md` to `dforge-module-build`'s Phase 3, which claimed
87
+ `"default"` was "a fallback alias the platform resolves to the entity's first
88
+ declared view". It resolves to *no view* (the full column set), and any other
89
+ undeclared name now fails the install — so the wrong mental model produced a hard
90
+ error rather than a shrug. Data view codes are what a menu's `dataViewCode`
91
+ references.
92
+
93
+ ### Changed
94
+
95
+ - `@dforge-core/metadata` → `0.0.18`; `resources/schemas/` re-vendored, so
96
+ `entity.schema.json` carries `views` (with the per-column `formula` override), the
97
+ retired `H` flag and the per-view `M` fold below, and the editor stops flagging a
98
+ valid `views` block as an unknown property. The
99
+ `minimumReleaseAgeExclude` pin in `pnpm-workspace.yaml` moves with it — a caret on
100
+ a `0.0.x` version pins the patch, so the dep does not float onto a new metadata
101
+ release by itself.
102
+ - `references/security.md` gains a **Column-level security** section — the layer the
103
+ 3-layer model named but never documented — with the rules, the empty-override trap
104
+ (`COALESCE` skips NULL only, so `"flags": ""` means "no flags", not "inherit"), and
105
+ a table disambiguating the platform's three "views": entity views, data views, and
106
+ `isView`/`viewSql` SQL-backed entities.
107
+ - Cross-references in `references/flags.md` (the same letters override per view),
108
+ `references/formulas.md` (the per-view override and its `"F"`-only rule) and
109
+ `references/data-views.md` (the name collision); two new lines in
110
+ `references/validation-checklist.md`; `dforge-module-build` Phase 5 gains guidance
111
+ on when column-level security is the right answer instead of more roles.
112
+ - `dforge_module_inspect` reports `entityViews` per entity (name → column count).
113
+
114
+ ### Removed — the `H` flag
115
+
116
+ `H` was in the flag vocabulary from the start, documented as "permanently hidden —
117
+ stronger than omitting `V`", and **nothing ever read it**. A column carrying it was
118
+ hidden because its flags string contained no `V`. The platform retired it
119
+ (`abbedb009`), so `field-rules` now rejects `H` with a message pointing at the fix
120
+ rather than at the generic "U/S/P are not flag letters" hint: omit `V` to hide a
121
+ column, use an entity view to hide it from specific folders, `fieldTypeCd: "hidden"`
122
+ to render it as a hidden input, and `I` to mark it platform-managed.
123
+
124
+ `references/flags.md` and the `dforge://reference/flags` blurb drop it too, and both
125
+ `^[VIEMH]+$` checks in `field-rules` are now `^[VIEM]+$`.
126
+
127
+ ### Added — `M` in a per-view override
128
+
129
+ `views.<v>.columns.<cd>.flags` previously accepted `M` and did nothing with it: the
130
+ platform's `MandatoryFlagNormalizer` folded `M` into `isNullable` for entity fields
131
+ only. The fold now covers view overrides, so the shorthand means the same thing at
132
+ both levels — and because a view writes no DDL, the requiredness it produces is
133
+ runtime-only. That makes **"nullable in the database, mandatory in this view"**
134
+ expressible for the first time:
135
+
136
+ ```json
137
+ "vat_no": { "flags": "VEM" } // required in this view
138
+ "vat_no": { "flags": "VE", "isNullable": false } // the same thing, spelled out
139
+ ```
140
+
141
+ `references/flags.md` gains a **Per-view overrides** section covering it, plus the
142
+ two traps that come with it: the letters **replace** rather than merge (so
143
+ `{ "flags": "V" }` is read-only however the entity flagged the column), and the `M`
144
+ fold is **one-directional** — dropping `M` from a view does not make a column
145
+ optional, since `isNullable` still inherits. `references/security.md` picks up the
146
+ same note.
147
+
148
+ ### Fixed — smaller documentation corrections
149
+
150
+ - **`I` was called "Internal" throughout `references/flags.md`.** The runtime reads
151
+ it as **Identity** (`EntityColumn.IsIdentity => HasFlag('I')`); the letter is
152
+ unchanged, the name was wrong in the letter table, two rules and the role-rights
153
+ disambiguation note.
154
+ - **`"flags": null` was implied to mean "inherit".** It does not validate — the
155
+ property is typed `string`, so only OMITTING the key inherits. (To inherit
156
+ everything, set the whole column override to `{}` or `null`.)
157
+
158
+ ### Added — a worked example module
159
+
160
+ `examples/column-security/` (served as `dforge://example/column-security/…`): one
161
+ `product` entity carrying both stock and pricing, two folders showing two different
162
+ column sets of it. It is the first bundled example with a `ui/folders.json` at all,
163
+ so the *binding* half of column-level security had no example anywhere before.
164
+
165
+ Its `accountant` view demonstrates both override kinds — `{ "flags": "V" }` making a
166
+ column read-only in that view only, and a per-view `formula` computing margin as a
167
+ percentage where the entity computes an amount — while `storekeeper` shows the plain
168
+ case where the price columns are simply absent. The README lists the five ways a view
169
+ fails the install, and a test pins the module at zero errors AND zero warnings, since
170
+ a warning copied out of an example propagates into every module built from it.
171
+
172
+ Note its manifest declares no `dataViews`/`menus`/`security` pointers: those files are
173
+ found by convention and the manifest schema is `additionalProperties: false`.
174
+
175
+ ### Fixed — `simple-todo`'s manifest was not schema-valid
176
+
177
+ It declared `dataViews`, `menus`, `actions`, `security` and `seedData` pointers. None
178
+ of those are manifest properties, the schema is `additionalProperties: false`, and no
179
+ shipped module declares them — every path is resolved by convention from the module
180
+ root (`ui/data_views.json`, `security/roles.json`, …), here and in the platform alike.
181
+ Removed. This matters more than a stray key usually would: the examples are served as
182
+ resources and described to agents as mandatory structures to copy, so an invalid shape
183
+ in one propagates into every module built from it.
184
+
185
+ ### Fixed — the view-column flag vocabulary
186
+
187
+ `entity.schema.json` described a view column's `flags` as `I/E/V/O/G/S/F/X/W`, with the
188
+ example `'VO'` "to expose it read-only". No such letters: the canonical set is
189
+ `V/I/E/M/H` (`flagDefs`), the client `Column` honours `V`/`E`/`I`, and the installer
190
+ resolves `M` into `isNullable`. Nothing reads `O`, `G`, `S`, `F`, `X` or `W`. Read-only
191
+ is plain `"V"` — visible, not editable.
192
+
193
+ `references/flags.md` here was already right, and both the new guidance and the
194
+ `column-security` example use only `V`/`VE`. Fixed upstream and picked up with
195
+ `@dforge-core/metadata` **0.0.17**, so `resources/schemas/entity.schema.json` now says
196
+ the same thing — which matters because that is the copy the editor `$schema` binding
197
+ validates against.
198
+
7
199
  ## 0.2.15
8
200
 
9
201
  `canExecute` became a real server-side gate and `isTransacted` began opening a real
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.14/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.15/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.15/node_modules/@dforge-core/metadata/dist/index.js
31217
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.20/node_modules/@dforge-core/metadata/dist/index.js
31218
31218
  var AggType = {
31219
31219
  Sum: "sum",
31220
31220
  Count: "count",
@@ -33137,9 +33137,10 @@ function checkFieldSpec(where, field) {
33137
33137
  'dropdown options go under params.options, not at the field root, e.g. "params": { "options": [{ "value": "a", "label": "A" }] }.'
33138
33138
  );
33139
33139
  }
33140
- if (typeof v.flags === "string" && v.flags.length > 0 && !/^[VIEMH]+$/.test(v.flags)) {
33140
+ if (typeof v.flags === "string" && v.flags.length > 0 && !/^[VIEM]+$/.test(v.flags)) {
33141
+ const hint = v.flags.includes("H") ? " There is no 'H' flag \u2014 a column is hidden by omitting 'V'." : " U/S/P are not flag letters.";
33141
33142
  err(
33142
- `flags '${v.flags}' contains invalid letters \u2014 use only V/I/E/M/H (e.g. VEM, VE, V, E). U/S/P are not flag letters.`
33143
+ `flags '${v.flags}' contains invalid letters \u2014 use only V/I/E/M (e.g. VEM, VE, V, E).${hint}`
33143
33144
  );
33144
33145
  }
33145
33146
  if (typeof v.flags === "string" && v.flags.includes("M") && v.isNullable === true) {
@@ -33186,7 +33187,7 @@ function checkFieldSpec(where, field) {
33186
33187
  if (typeof v.formula !== "string" || v.formula.trim() === "") {
33187
33188
  err("formula column (columnType 'F') requires a non-empty 'formula' expression.");
33188
33189
  }
33189
- if (typeof v.flags === "string" && v.flags !== "V" && /^[VIEMH]+$/.test(v.flags)) {
33190
+ if (typeof v.flags === "string" && v.flags !== "V" && /^[VIEM]+$/.test(v.flags)) {
33190
33191
  warn(
33191
33192
  `formula column has flags '${v.flags}' \u2014 a computed column is read-only, expected flags 'V'.`
33192
33193
  );
@@ -33717,6 +33718,23 @@ function entityFieldRename(args) {
33717
33718
  changes.push(`references.${rname}.from.field`);
33718
33719
  }
33719
33720
  }
33721
+ const entityViews = entity.views ?? {};
33722
+ for (const [vname, v] of Object.entries(entityViews)) {
33723
+ const cols = v.columns;
33724
+ if (!cols) continue;
33725
+ if (oldName in cols) {
33726
+ v.columns = renameKey(cols, oldName, newName);
33727
+ changes.push(`views.${vname}.columns.${oldName}`);
33728
+ }
33729
+ for (const [cn, ov] of Object.entries(
33730
+ v.columns
33731
+ )) {
33732
+ if (ov && typeof ov.formula === "string" && ov.formula.includes(oldToken)) {
33733
+ ov.formula = ov.formula.split(oldToken).join(newToken);
33734
+ changes.push(`views.${vname}.${cn}.formula`);
33735
+ }
33736
+ }
33737
+ }
33720
33738
  files[rel(paths.root, entityPath)] = jsonText(entity);
33721
33739
  for (const [oname, orel] of Object.entries(manifest.entities ?? {})) {
33722
33740
  if (oname === entityName || oname.includes(".")) continue;
@@ -33840,6 +33858,31 @@ function entityFieldRemove(args) {
33840
33858
  changes.push(`references.${rn}`);
33841
33859
  }
33842
33860
  }
33861
+ const entityViews = entity.views ?? {};
33862
+ for (const [vname, v] of Object.entries(entityViews)) {
33863
+ const cols = v.columns;
33864
+ if (!cols) continue;
33865
+ const kept = {};
33866
+ for (const [k, val] of Object.entries(cols)) if (!removed.has(k)) kept[k] = val;
33867
+ if (Object.keys(kept).length !== Object.keys(cols).length) {
33868
+ v.columns = kept;
33869
+ changes.push(`views.${vname}.columns`);
33870
+ }
33871
+ if (Object.keys(kept).length === 0) {
33872
+ warnings.push(
33873
+ `view '${vname}' has no columns left \u2014 delete the view, or give it columns (an empty view fails the install)`
33874
+ );
33875
+ }
33876
+ for (const [cn, ov] of Object.entries(kept)) {
33877
+ if (ov && typeof ov.formula === "string") {
33878
+ for (const r of removed) {
33879
+ if (ov.formula.includes(`[${r}]`)) {
33880
+ warnings.push(`formula in view '${vname}'.${cn} still references removed '[${r}]'`);
33881
+ }
33882
+ }
33883
+ }
33884
+ }
33885
+ }
33843
33886
  files[rel(paths.root, entityPath)] = jsonText(entity);
33844
33887
  for (const [fn, f] of Object.entries(entity.fields)) {
33845
33888
  if (typeof f.formula !== "string") continue;
@@ -35372,6 +35415,11 @@ function moduleInspect(args) {
35372
35415
  fieldCount: Object.keys(fields).length,
35373
35416
  fields: Object.keys(fields),
35374
35417
  hasNumberSequence: Boolean(e.numberSequence),
35418
+ entityViews: Object.fromEntries(
35419
+ Object.entries(e.views ?? {}).map(
35420
+ ([vname, v]) => [vname, Object.keys(v?.columns ?? {}).length]
35421
+ )
35422
+ ),
35375
35423
  // Read as an OWN property — `toString` is inherited from
35376
35424
  // Object.prototype, so a plain `e.toString` reports the built-in
35377
35425
  // function (which JSON.stringify then silently drops) for every
@@ -35615,6 +35663,67 @@ function moduleValidate(args) {
35615
35663
  }
35616
35664
  }
35617
35665
  }
35666
+ for (const [name, e] of Object.entries(entities)) {
35667
+ const entityViews = e.views ?? {};
35668
+ if (Object.keys(entityViews).length === 0) continue;
35669
+ const fields = fieldDefsOf[name] ?? {};
35670
+ const cols = columnsOf[name];
35671
+ const pk = pkOf2(name);
35672
+ const byLower = /* @__PURE__ */ new Map();
35673
+ for (const vname of Object.keys(entityViews)) {
35674
+ const list = byLower.get(vname.trim().toLowerCase()) ?? [];
35675
+ list.push(vname);
35676
+ byLower.set(vname.trim().toLowerCase(), list);
35677
+ }
35678
+ for (const [, spellings] of byLower) {
35679
+ if (spellings.length > 1) {
35680
+ err(
35681
+ `entities/${name}.json \u2192 views`,
35682
+ `views ${spellings.map((v) => `'${v}'`).join(" and ")} differ only by case or surrounding whitespace. A folder binds a view case-insensitively, so only one could ever be reached.`
35683
+ );
35684
+ }
35685
+ }
35686
+ for (const [vname, view] of Object.entries(entityViews)) {
35687
+ const where = `entities/${name}.json \u2192 views.${vname}`;
35688
+ const vcols = view?.columns ?? void 0;
35689
+ if (!vcols || Object.keys(vcols).length === 0) {
35690
+ err(
35691
+ where,
35692
+ "lists no columns. A view is the COMPLETE set of columns visible in a folder bound to it, so an empty one would hide every field \u2014 the installer rejects it."
35693
+ );
35694
+ continue;
35695
+ }
35696
+ const declared = Object.keys(vcols);
35697
+ for (const cd of declared) {
35698
+ if (cols && !cols.has(cd)) {
35699
+ err(where, `column '${cd}' is not a field on entity '${name}'`);
35700
+ }
35701
+ }
35702
+ const dupes = declared.filter(
35703
+ (c, i) => declared.findIndex((o) => o.toLowerCase() === c.toLowerCase()) !== i
35704
+ );
35705
+ for (const d of dupes) {
35706
+ err(where, `lists column '${d}' more than once (differing only by case)`);
35707
+ }
35708
+ if (pk && !declared.some((c) => c.toLowerCase() === pk.toLowerCase())) {
35709
+ err(
35710
+ where,
35711
+ `omits the primary key '${pk}'. A view must list it \u2014 records are addressed by it. To keep it off the screen, drop 'V' from its flags instead of omitting it.`
35712
+ );
35713
+ }
35714
+ for (const [cd, ovRaw] of Object.entries(vcols)) {
35715
+ const ov = ovRaw ?? {};
35716
+ if (typeof ov.formula !== "string" || ov.formula.trim() === "") continue;
35717
+ const colType = fields[cd]?.columnType;
35718
+ if (colType !== "F") {
35719
+ err(
35720
+ `${where}.columns.${cd}`,
35721
+ `sets a formula on a column of type '${colType ?? "D"}'. A view formula is only evaluated on a Formula ("F") column \u2014 on any other column that field holds the SQL default, and the override would do nothing.`
35722
+ );
35723
+ }
35724
+ }
35725
+ }
35726
+ }
35618
35727
  const views = readJsonOrDefault(paths.dataViews, {});
35619
35728
  const viewCodes = new Set(Object.keys(views));
35620
35729
  for (const [vcode, v] of Object.entries(views)) {
@@ -35930,6 +36039,30 @@ function moduleValidate(args) {
35930
36039
  `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.`
35931
36040
  );
35932
36041
  }
36042
+ const folderNodes = [
36043
+ { path: "(root)", node: folderRoot },
36044
+ ...walkFolders(folderRoot).map((f) => ({ path: f.path, node: f.node }))
36045
+ ];
36046
+ for (const { path: fpath, node } of folderNodes) {
36047
+ const bindings = node.entities ?? {};
36048
+ for (const [entityCd, binding] of Object.entries(bindings)) {
36049
+ const viewName = (binding ?? {})?.viewName;
36050
+ if (typeof viewName !== "string" || viewName.trim() === "") continue;
36051
+ if (viewName.trim().toLowerCase() === "default") continue;
36052
+ if (entityCd.includes(".")) continue;
36053
+ const e = entities[entityCd];
36054
+ if (!e) continue;
36055
+ const declared = Object.keys(
36056
+ e.views ?? {}
36057
+ ).map((v) => v.trim().toLowerCase());
36058
+ if (!declared.includes(viewName.trim().toLowerCase())) {
36059
+ err(
36060
+ `ui/folders.json \u2192 ${fpath} \u2192 entities.${entityCd}`,
36061
+ `viewName '${viewName}' is not declared under 'views' in entities/${entityCd}.json. This fails the install: an unresolved view would show every column of the entity, which is the opposite of what naming one asks for. Declare the view, or drop 'viewName' (or use "default", which means no view).`
36062
+ );
36063
+ }
36064
+ }
36065
+ }
35933
36066
  for (const [rcode, rawReport] of Object.entries(reports)) {
35934
36067
  const report = rawReport ?? {};
35935
36068
  const where = `ui/reports.json \u2192 ${rcode}`;
@@ -36115,7 +36248,7 @@ var triggerAddSchema = {
36115
36248
  "STATIC params only \u2014 the literal values given here. Nothing is injected at runtime; there is no record_id param. The affected record arrives as record context ([field]), its pre-change values as old[field]."
36116
36249
  ),
36117
36250
  async: external_exports.boolean().default(true).describe(
36118
- "When true (the platform default), the action runs in background \u2014 recommended for slow actions. When false, it runs in the caller's transaction, so a failing action ROLLS BACK the user's original write."
36251
+ "When true (the platform default), the action runs in background \u2014 recommended for slow actions. When false, it runs in the caller's transaction, so a failing action ROLLS BACK the user's original write. That applies to a trigger on ANOTHER module's entity too: a bridge module CAN veto its host's write, the same way a mandatory extension column already refuses one. So async:false is a refusal mechanism \u2014 pick it to enforce an invariant, not merely to react, because a bug in the action blocks the host's workflow just as effectively as a real violation. The rollback is reported to the user as TRIGGER_ACTION_FAIL, naming the blocking module and trigger."
36119
36252
  )
36120
36253
  };
36121
36254
  function triggerAdd(args) {
@@ -36350,6 +36483,15 @@ function matrixExample(relPath, description) {
36350
36483
  read: () => readSkill(`examples/matrix-budget/${relPath}`)
36351
36484
  };
36352
36485
  }
36486
+ function columnSecurityExample(relPath, description) {
36487
+ return {
36488
+ uri: `dforge://example/column-security/${relPath}`,
36489
+ name: `Example: column-security/${relPath}`,
36490
+ description,
36491
+ mimeType: relPath.endsWith(".json") ? "application/json" : "text/plain",
36492
+ read: () => readSkill(`examples/column-security/${relPath}`)
36493
+ };
36494
+ }
36353
36495
  var resources = [
36354
36496
  schema(
36355
36497
  "manifest",
@@ -36359,7 +36501,7 @@ var resources = [
36359
36501
  schema(
36360
36502
  "entity",
36361
36503
  "Entity JSON schema",
36362
- "JSON Schema for entity files under entities/*.json (description, dbObject, toString, traits, fields)."
36504
+ "JSON Schema for entity files under entities/*.json (description, dbObject, toString, traits, fields, views)."
36363
36505
  ),
36364
36506
  schema(
36365
36507
  "domains",
@@ -36442,7 +36584,7 @@ var resources = [
36442
36584
  },
36443
36585
  // ── Per-element authoring references (skills/.../references/*.md) ──────
36444
36586
  reference("field-types", "Field types \u2014 fieldTypeCd (UI control) vs dbDatatype (SQL type), and the correct value for each. Load before adding any field."),
36445
- reference("flags", "Column flags \u2014 only V/I/E/M/H; valid combos (VEM/VE/V/EM/I) and why 'VEMHI' is invalid. Load before setting any flags."),
36587
+ reference("flags", "Column flags \u2014 only V/I/E/M (there is no H); valid combos (VEM/VE/V/EM/I), how M folds into isNullable, and how the same letters override per entity view. Load before setting any flags."),
36446
36588
  reference("column-types", "The FK + Reference two-column pattern (the #1 source of broken modules) and Set columns. Load before any relation."),
36447
36589
  reference("column-domains", "Column domains: a reusable field type (datatype + control + shared option list) referenced via a single `domain` key. Use when the same enum/type repeats across columns. Load before authoring domains.json or a domain-backed column."),
36448
36590
  reference("formulas", "Formula columns (columnType 'F'): baseDatatypeCd, no dbDatatype, flags 'V', and the formula expression grammar."),
@@ -36488,7 +36630,13 @@ var resources = [
36488
36630
  matrixExample("entities/budget_category.json", "Matrix row-axis (dataset) entity."),
36489
36631
  matrixExample("manifest.json", "Matrix example manifest (entities + dataViews + roles + seed)."),
36490
36632
  matrixExample("security/roles.json", "Matrix example roles: S on the axis entity, SIUD on the editable cell entity."),
36491
- matrixExample("seed-data/01-categories.json", "Matrix example seed: row-axis category records.")
36633
+ matrixExample("seed-data/01-categories.json", "Matrix example seed: row-axis category records."),
36634
+ columnSecurityExample("entities/product.json", `Canonical ENTITY VIEWS (column-level security): one entity, two views \u2014 'storekeeper' (a plain column subset, prices absent entirely) and 'accountant' (with a per-column flags override making a column read-only in that view only, and a per-view formula override on an "F" column). Both list the primary key, which a view must.`),
36635
+ columnSecurityExample("ui/folders.json", "The BINDING half: two folders over one entity, each with a different entities.product.viewName. Without this the views exist and nothing uses them; with a name no view declares, the install fails rather than falling back to the full column set."),
36636
+ columnSecurityExample("security/roles.json", "Roles for the two folders. Note rights are per ENTITY, not per column \u2014 the column dimension is the entity view, not the role."),
36637
+ columnSecurityExample("ui/data_views.json", "The data view lists EVERY column including prices \u2014 correct: presentation is one axis, the folder's entity view decides what a user may see of it. You do not maintain one data view per role."),
36638
+ columnSecurityExample("manifest.json", "Column-security example manifest. Note it declares no dataViews/menus/security pointers \u2014 those files are found by convention, and the manifest schema is additionalProperties:false."),
36639
+ columnSecurityExample("README.md", "Why entity views exist, what each of the two views demonstrates, and the five ways a view fails the install.")
36492
36640
  ];
36493
36641
  function readVendored(rel2) {
36494
36642
  const p = path16.join(RESOURCES_DIR, rel2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
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",
@@ -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.14",
39
- "@dforge-core/metadata": "^0.0.15",
38
+ "@dforge-core/dforge-cli": "^0.2.15",
39
+ "@dforge-core/metadata": "^0.0.20",
40
40
  "@modelcontextprotocol/sdk": "^1.30.0",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -426,7 +426,7 @@ slashes.
426
426
  - **The whole file is the root folder.** Top-level fields (`label`, `description`, `color`, `entities`) belong to the root.
427
427
  - **Subfolders nest under `children`** (a dictionary keyed by path segment).
428
428
  - **Each child key is a single path segment** — lowercase letters, digits, dashes, and underscores only, no slashes. Pattern: `^[a-z0-9][a-z0-9_-]*$`. Slashes are forbidden in keys because deeper trees use `children` recursively, not slashes in keys.
429
- - **`entities` is a dictionary** mapping entity codes to settings objects. Each entity has `viewName` (string or null), `quickAdd` (boolean), and an optional `rowFilter`.
429
+ - **`entities` is a dictionary** mapping entity codes to settings objects. Each entity has `viewName` (an **entity view** name — see below — or null), `quickAdd` (boolean), and an optional `rowFilter`.
430
430
  - **`inheritSecurity`** is allowed only on child folders. The root has no parent to inherit from, so the field is rejected at the root level with a clear error.
431
431
  - **Children can have their own children**, recursively, for arbitrary tree depth.
432
432
 
@@ -464,6 +464,78 @@ The DB still has a `UNIQUE INDEX` on `(module_id, folder_path)` as
464
464
  defense-in-depth, and `FolderTreeFlattener` validates the file format
465
465
  before the install pipeline touches the database.
466
466
 
467
+ ### Entity views — column-level security
468
+
469
+ An **entity view** is a named subset of an entity's columns, declared on the entity file and bound per folder. Users working in that folder see **only** the columns the view lists — the rest are neither visible nor queryable there. This is the column-level layer of the security model; roles grant rights per *entity*, never per field.
470
+
471
+ Two halves, both required:
472
+
473
+ | File | Key | Effect |
474
+ |------|-----|--------|
475
+ | `entities/<entity>.json` | `views.<viewName>.columns` | Declares the view and its column set |
476
+ | `ui/folders.json` | `entities.<code>.viewName` | Binds that view for this entity in this folder |
477
+
478
+ ```jsonc
479
+ "views": {
480
+ "storekeeper": {
481
+ "columns": {
482
+ "product_id": {},
483
+ "name": {},
484
+ "quantity": {}
485
+ }
486
+ },
487
+ "accountant": {
488
+ "columns": {
489
+ "product_id": {},
490
+ "name": {},
491
+ "quantity": { "flags": "V" },
492
+ "cost_price": {},
493
+ "sale_price": {}
494
+ }
495
+ }
496
+ }
497
+ ```
498
+
499
+ **Per-column overrides** (all optional, each falls back to the entity-level value): `flags`, `orderNum`, `isNullable`, `editMask`, `displayFmt`, `refFilter`, `params`, `formula`. `{}` means "expose this column, override nothing".
500
+
501
+ **An empty override is a value, not an absence.** The runtime merges view over entity with `COALESCE(view, entity)`, which skips NULL only — so `"flags": ""` means "no flags in this view" (a column neither visible nor editable) and `"params": {}` suppresses the entity-level params rather than inheriting them. Omit the key entirely to inherit.
502
+
503
+ `formula` lets one view compute a column differently from the entity, and only on a column the entity declares as `columnType: "F"` — elsewhere that field holds the SQL default, so an override there would be inert.
504
+
505
+ **Rules the installer enforces** (each is silent at runtime, which is why they fail the install instead):
506
+
507
+ - A column absent from `columns` is **hidden**. That is the mechanism, not an oversight.
508
+ - A view must list the **primary key** — records are addressed by it. Keep it off screen by dropping `V` from its flags instead of omitting it.
509
+ - A column code that doesn't exist, a view with **no** columns, one column listed twice under different casing, or two views whose names differ only by case (a folder binds case-insensitively, so only one could ever be reached).
510
+ - Views belong to the entity's **own** module; an `extends` extension declaring `views` is rejected, since a view enumerates the complete column set.
511
+ - A `viewName` that no view declares, or one naming a column-less view, **fails the install**. It does not fall back: the fallback is the full column set, so a typo would quietly *unrestrict* the folder.
512
+
513
+ **`"default"` is the one exempt name** and means "no view" (full column set). It is what the ~100 shipped folder entries write, what `FolderExporter` writes back for a NULL `view_name`, and what the runtime auto-creates (column-less) for an entity reached without a folder binding — so rejecting it would fail every install. A module that really declares a view named `default` still gets it bound.
514
+
515
+ Don't confuse the three things called "view": an **entity view** (`views`, this section) is column security; a **data view** (`ui/data_views.json`, referenced by a menu's `dataViewCode`) is a grid/kanban/calendar; `isView` + `viewSql` on an entity means it is backed by a SQL view. `folders.json`'s `viewName` binds the first.
516
+
517
+ ## Column Domains (`domains.json`)
518
+
519
+ A **domain** is a reusable, named semantic type: a base datatype, a control, sizing, and a shared option list under one code. Columns reference it instead of restating the definition.
520
+
521
+ ```json
522
+ {
523
+ "doc_status": {
524
+ "description": "Document Status",
525
+ "baseDatatypeCd": "string",
526
+ "dbDatatype": "varchar",
527
+ "maxLen": 20,
528
+ "fieldTypeCd": "dropdown",
529
+ "params": {
530
+ "options": [
531
+ { "value": "draft", "label": "Draft" },
532
+ { "value": "posted", "label": "Posted", "color": "#0a0" }
533
+ ]
534
+ }
535
+ }
536
+ }
537
+ ```
538
+
467
539
  ### 5. Security Roles (`security/roles.json`)
468
540
 
469
541
  ```json
@@ -939,6 +1011,7 @@ When creating a module, ensure:
939
1011
  - [ ] `menus.json` uses nested dictionaries with `children` (not arrays)
940
1012
  - [ ] `menus.json` leaf items use `dataViewCode` (not `viewCode`)
941
1013
  - [ ] `folders.json` uses entity dictionary with `{ viewName, quickAdd }` objects
1014
+ - [ ] Every `viewName` is `"default"` (= no view) or a view the entity declares under `views`; each declared view lists the primary key and at least one column
942
1015
  - [ ] `roles.json` uses `"rights"` property (not `"entityRights"`)
943
1016
  - [ ] Seed data files are numbered for FK dependency order (01-, 02-, etc.)
944
1017
  - [ ] Seed data includes explicit numeric (int8) PKs for cross-entity references — NOT UUID strings (`cuid` is `int8`)
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "columns": {
42
42
  "type": "object",
43
- "description": "Provider columns the consumer actually reads, writes, or joins on. Omit entirely for extension-only targets that touch no existing provider column.",
43
+ "description": "Provider columns the consumer actually reads, writes, or joins on. Omit entirely for extension-only targets that touch no existing provider column. A column your OWN 'extends' file adds to the provider needs no entry either — the platform exempts those and validates them separately — and a VIRTUAL extension field (columnType R/S/F: the lookup beside an FK, a reverse set, a formula) must never be listed at all: it is metadata, never a provider storage column, so no storage class can ever match and the install-time satisfaction check rejects the declaration. Declare the FK you add (e.g. trip_id), never the lookup beside it (trip).",
44
44
  "additionalProperties": { "$ref": "#/definitions/columnContract" }
45
45
  }
46
46
  }