@dforge-core/dforge-mcp 0.2.15 → 0.2.16

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,162 @@ 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.16
8
+
9
+ Entity views — the platform's **column-level security** — became reachable from a
10
+ module package (platform change `e6e29b604`). A view is a named subset of an
11
+ entity's columns declared under `views` on the entity file; a folder binds one per
12
+ entity with `entities.<code>.viewName`, and users working there see only the
13
+ columns that view lists. The schema had gained the property while nothing here
14
+ knew about it, so authoring one meant an editor error on valid metadata and a
15
+ refactor that could quietly break the module.
16
+
17
+ ### Added — entity views are validated offline
18
+
19
+ `dforge_module_validate` now mirrors what the platform installer rejects, all of
20
+ which is silent at runtime if it slips through:
21
+
22
+ | Check | Why it matters |
23
+ |---|---|
24
+ | Column exists on the entity | A stale name is simply not in the view |
25
+ | View lists the primary key | Records are addressed by it — the client cannot open a row without it |
26
+ | View has at least one column | A view is the COMPLETE visible set, so an empty one would hide every field |
27
+ | No two views differing only by case | A folder binds case-insensitively; only one could ever be reached |
28
+ | No column listed twice under different casing | Both name one column and the later would silently win |
29
+ | `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` |
30
+ | `folders.json` `viewName` resolves to a declared view | **Fails the install** — it does not fall back, because falling back shows every column |
31
+
32
+ `"default"` is exempt from the last check and means "no view": every shipped module
33
+ writes it, and the platform auto-creates a column-less row under that name for any
34
+ entity reached without a folder binding.
35
+
36
+ ### Fixed — refactors reach into views
37
+
38
+ `dforge_entity_field_rename` and `dforge_entity_field_remove` rewrote fields, links,
39
+ formulas, data views and seed data but never touched `views.<v>.columns.<cd>`. Since
40
+ the platform now rejects a view naming a column the entity does not have, a rename
41
+ handed back a module that no longer installs. Both cascade into entity views now,
42
+ including per-view `formula` overrides, preserving the view's column order (which is
43
+ its display order). Removal warns when a view is left with no columns, or when a
44
+ per-view formula still names a removed field — the same judgement-call warnings the
45
+ tool already emitted for entity formulas.
46
+
47
+ ### Fixed — `viewName` was documented as a data view code
48
+
49
+ It is not, and the mistake ran from `schemas/folders.schema.json` through
50
+ `references/security.md` to `dforge-module-build`'s Phase 3, which claimed
51
+ `"default"` was "a fallback alias the platform resolves to the entity's first
52
+ declared view". It resolves to *no view* (the full column set), and any other
53
+ undeclared name now fails the install — so the wrong mental model produced a hard
54
+ error rather than a shrug. Data view codes are what a menu's `dataViewCode`
55
+ references.
56
+
57
+ ### Changed
58
+
59
+ - `@dforge-core/metadata` → `0.0.18`; `resources/schemas/` re-vendored, so
60
+ `entity.schema.json` carries `views` (with the per-column `formula` override), the
61
+ retired `H` flag and the per-view `M` fold below, and the editor stops flagging a
62
+ valid `views` block as an unknown property. The
63
+ `minimumReleaseAgeExclude` pin in `pnpm-workspace.yaml` moves with it — a caret on
64
+ a `0.0.x` version pins the patch, so the dep does not float onto a new metadata
65
+ release by itself.
66
+ - `references/security.md` gains a **Column-level security** section — the layer the
67
+ 3-layer model named but never documented — with the rules, the empty-override trap
68
+ (`COALESCE` skips NULL only, so `"flags": ""` means "no flags", not "inherit"), and
69
+ a table disambiguating the platform's three "views": entity views, data views, and
70
+ `isView`/`viewSql` SQL-backed entities.
71
+ - Cross-references in `references/flags.md` (the same letters override per view),
72
+ `references/formulas.md` (the per-view override and its `"F"`-only rule) and
73
+ `references/data-views.md` (the name collision); two new lines in
74
+ `references/validation-checklist.md`; `dforge-module-build` Phase 5 gains guidance
75
+ on when column-level security is the right answer instead of more roles.
76
+ - `dforge_module_inspect` reports `entityViews` per entity (name → column count).
77
+
78
+ ### Removed — the `H` flag
79
+
80
+ `H` was in the flag vocabulary from the start, documented as "permanently hidden —
81
+ stronger than omitting `V`", and **nothing ever read it**. A column carrying it was
82
+ hidden because its flags string contained no `V`. The platform retired it
83
+ (`abbedb009`), so `field-rules` now rejects `H` with a message pointing at the fix
84
+ rather than at the generic "U/S/P are not flag letters" hint: omit `V` to hide a
85
+ column, use an entity view to hide it from specific folders, `fieldTypeCd: "hidden"`
86
+ to render it as a hidden input, and `I` to mark it platform-managed.
87
+
88
+ `references/flags.md` and the `dforge://reference/flags` blurb drop it too, and both
89
+ `^[VIEMH]+$` checks in `field-rules` are now `^[VIEM]+$`.
90
+
91
+ ### Added — `M` in a per-view override
92
+
93
+ `views.<v>.columns.<cd>.flags` previously accepted `M` and did nothing with it: the
94
+ platform's `MandatoryFlagNormalizer` folded `M` into `isNullable` for entity fields
95
+ only. The fold now covers view overrides, so the shorthand means the same thing at
96
+ both levels — and because a view writes no DDL, the requiredness it produces is
97
+ runtime-only. That makes **"nullable in the database, mandatory in this view"**
98
+ expressible for the first time:
99
+
100
+ ```json
101
+ "vat_no": { "flags": "VEM" } // required in this view
102
+ "vat_no": { "flags": "VE", "isNullable": false } // the same thing, spelled out
103
+ ```
104
+
105
+ `references/flags.md` gains a **Per-view overrides** section covering it, plus the
106
+ two traps that come with it: the letters **replace** rather than merge (so
107
+ `{ "flags": "V" }` is read-only however the entity flagged the column), and the `M`
108
+ fold is **one-directional** — dropping `M` from a view does not make a column
109
+ optional, since `isNullable` still inherits. `references/security.md` picks up the
110
+ same note.
111
+
112
+ ### Fixed — smaller documentation corrections
113
+
114
+ - **`I` was called "Internal" throughout `references/flags.md`.** The runtime reads
115
+ it as **Identity** (`EntityColumn.IsIdentity => HasFlag('I')`); the letter is
116
+ unchanged, the name was wrong in the letter table, two rules and the role-rights
117
+ disambiguation note.
118
+ - **`"flags": null` was implied to mean "inherit".** It does not validate — the
119
+ property is typed `string`, so only OMITTING the key inherits. (To inherit
120
+ everything, set the whole column override to `{}` or `null`.)
121
+
122
+ ### Added — a worked example module
123
+
124
+ `examples/column-security/` (served as `dforge://example/column-security/…`): one
125
+ `product` entity carrying both stock and pricing, two folders showing two different
126
+ column sets of it. It is the first bundled example with a `ui/folders.json` at all,
127
+ so the *binding* half of column-level security had no example anywhere before.
128
+
129
+ Its `accountant` view demonstrates both override kinds — `{ "flags": "V" }` making a
130
+ column read-only in that view only, and a per-view `formula` computing margin as a
131
+ percentage where the entity computes an amount — while `storekeeper` shows the plain
132
+ case where the price columns are simply absent. The README lists the five ways a view
133
+ fails the install, and a test pins the module at zero errors AND zero warnings, since
134
+ a warning copied out of an example propagates into every module built from it.
135
+
136
+ Note its manifest declares no `dataViews`/`menus`/`security` pointers: those files are
137
+ found by convention and the manifest schema is `additionalProperties: false`.
138
+
139
+ ### Fixed — `simple-todo`'s manifest was not schema-valid
140
+
141
+ It declared `dataViews`, `menus`, `actions`, `security` and `seedData` pointers. None
142
+ of those are manifest properties, the schema is `additionalProperties: false`, and no
143
+ shipped module declares them — every path is resolved by convention from the module
144
+ root (`ui/data_views.json`, `security/roles.json`, …), here and in the platform alike.
145
+ Removed. This matters more than a stray key usually would: the examples are served as
146
+ resources and described to agents as mandatory structures to copy, so an invalid shape
147
+ in one propagates into every module built from it.
148
+
149
+ ### Fixed — the view-column flag vocabulary
150
+
151
+ `entity.schema.json` described a view column's `flags` as `I/E/V/O/G/S/F/X/W`, with the
152
+ example `'VO'` "to expose it read-only". No such letters: the canonical set is
153
+ `V/I/E/M/H` (`flagDefs`), the client `Column` honours `V`/`E`/`I`, and the installer
154
+ resolves `M` into `isNullable`. Nothing reads `O`, `G`, `S`, `F`, `X` or `W`. Read-only
155
+ is plain `"V"` — visible, not editable.
156
+
157
+ `references/flags.md` here was already right, and both the new guidance and the
158
+ `column-security` example use only `V`/`VE`. Fixed upstream and picked up with
159
+ `@dforge-core/metadata` **0.0.17**, so `resources/schemas/entity.schema.json` now says
160
+ the same thing — which matters because that is the copy the editor `$schema` binding
161
+ validates against.
162
+
7
163
  ## 0.2.15
8
164
 
9
165
  `canExecute` became a real server-side gate and `isTransacted` began opening a real
package/dist/server.js CHANGED
@@ -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.18/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}`;
@@ -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.16",
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",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@dforge-core/dforge-cli": "^0.2.14",
39
- "@dforge-core/metadata": "^0.0.15",
39
+ "@dforge-core/metadata": "^0.0.18",
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`)
@@ -95,10 +95,88 @@
95
95
  }
96
96
  },
97
97
  "additionalProperties": true
98
+ },
99
+ "views": {
100
+ "type": "object",
101
+ "description": "Entity views keyed by view name — column-level security. Unrelated to 'isView'/'viewSql' (a SQL-view-backed entity) and to ui/data_views.json (grids, kanban, calendars). A folder binds one view per entity via ui/folders.json (entities.<code>.viewName); users working in that folder see ONLY the columns the view lists. Entities without views (or folders naming a view that isn't declared here) show the entity's full column set.",
102
+ "propertyNames": {
103
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]*$",
104
+ "maxLength": 100
105
+ },
106
+ "additionalProperties": {
107
+ "$ref": "#/$defs/entityView"
108
+ }
98
109
  }
99
110
  },
100
111
  "additionalProperties": false,
101
112
  "$defs": {
113
+ "entityView": {
114
+ "type": "object",
115
+ "description": "One entity view: the columns it exposes, plus optional per-view overrides on each.",
116
+ "required": ["columns"],
117
+ "properties": {
118
+ "columns": {
119
+ "type": "object",
120
+ "description": "Column code -> per-view overrides. Columns absent from this map are hidden in a folder bound to this view. Use {} (or null) to expose a column with no overrides. Must include the entity's primary key column(s) — records are addressed by them; hide the key from the UI by omitting 'V' from its flags instead.",
121
+ "minProperties": 1,
122
+ "propertyNames": {
123
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
124
+ },
125
+ "additionalProperties": {
126
+ "$ref": "#/$defs/entityViewColumn"
127
+ }
128
+ },
129
+ "params": {
130
+ "type": "object",
131
+ "description": "Free-form view configuration, stored on entity_view.params.",
132
+ "additionalProperties": true
133
+ }
134
+ },
135
+ "additionalProperties": false
136
+ },
137
+ "entityViewColumn": {
138
+ "description": "Per-view overrides for one column. Every property is optional; anything omitted falls back to the entity-level value on the column itself.",
139
+ "type": ["object", "null"],
140
+ "properties": {
141
+ "flags": {
142
+ "type": "string",
143
+ "maxLength": 10,
144
+ "pattern": "^[VIEM]*$",
145
+ "description": "Flag letters for this column in this view — the same V/I/E/M vocabulary as a field's 'flags'. One rule, three cases: OMIT the key to inherit the entity-level flags (omission is the only inherit spelling — this field is a string, so a literal null does not validate; to inherit everything, set the whole column override to null or {}); any string, empty included, OVERRIDES them, because the runtime merges view over entity with COALESCE, which skips a missing value only; and a non-empty string REPLACES the entity letters rather than merging with them. So 'V' exposes the column read-only (visible, not editable) however the entity flagged it, and '' means 'no flags here' — neither visible nor editable. M works here as it does on a field: the installer folds it into this override's isNullable, making the column required IN THIS VIEW even when it is nullable in the database (a view writes no DDL, so the requiredness is runtime-only). Declaring M together with \"isNullable\": true is rejected at pack time. Note the fold is one-directional — DROPPING M from a view's flags does not make a column optional, because isNullable still COALESCEs to the entity value; write \"isNullable\": true to loosen it."
146
+ },
147
+ "orderNum": {
148
+ "type": "integer",
149
+ "description": "Display order within this view."
150
+ },
151
+ "isNullable": {
152
+ "type": "boolean",
153
+ "description": "Nullability override for this view."
154
+ },
155
+ "editMask": {
156
+ "type": "string",
157
+ "maxLength": 100
158
+ },
159
+ "displayFmt": {
160
+ "type": "string",
161
+ "maxLength": 100
162
+ },
163
+ "refFilter": {
164
+ "type": "object",
165
+ "description": "Lookup filter override for a reference column in this view.",
166
+ "additionalProperties": true
167
+ },
168
+ "formula": {
169
+ "type": "string",
170
+ "description": "Formula override for this column in this view, in the same DSL as a field's 'formula'. Allowed only on a column the entity declares as columnType 'F'; on any other column the install FAILS (it does not silently ignore the override), because there that field holds the SQL default and a view cannot change a physical column's DEFAULT. There is no per-view default value for a normal column today. The runtime prefers it over the entity-level formula, so one view can compute a column differently from the entity. The parsed AST is regenerated at install; a formula that does not parse, calls an unimplemented function, or aggregates over a 1:N set fails the install (each renders empty at runtime)."
171
+ },
172
+ "params": {
173
+ "type": "object",
174
+ "description": "Control-specific configuration override for this view.",
175
+ "additionalProperties": true
176
+ }
177
+ },
178
+ "additionalProperties": false
179
+ },
102
180
  "field": {
103
181
  "type": "object",
104
182
  "description": "Entity column/field definition",
@@ -129,8 +207,8 @@
129
207
  },
130
208
  "flags": {
131
209
  "type": "string",
132
- "pattern": "^[VIEMH]*$",
133
- "description": "Display flags: V=Visible, I=Identity, E=Editable, M=Mandatory, H=Hidden. M is resolved into isNullable:false at install, so it makes the column NOT NULL; it is inert on virtual (R/S/F) and identity columns. Declaring M together with isNullable:true is rejected at pack time."
210
+ "pattern": "^[VIEM]*$",
211
+ "description": "Column flags, in any order. V=Visible and E=Editable are display/interaction; I=Identity marks a platform-managed column (never user-editable, excluded from required validation); M=Mandatory is not a runtime flag at all — it is resolved into isNullable:false at install, so it makes the column NOT NULL, is inert on virtual (R/S/F) and identity columns, and declaring it together with isNullable:true is rejected at pack time. Any other letter is rejected at pack time; there is no H flag — a column is hidden by omitting V."
134
212
  },
135
213
  "isPk": {
136
214
  "type": "boolean",
@@ -58,7 +58,7 @@
58
58
  "properties": {
59
59
  "viewName": {
60
60
  "type": "string",
61
- "description": "Entity view name (data view code) to use in this folder. Common values include 'default'."
61
+ "description": "Entity view to bind for this entity in this folder — the name of a key under the entity definition's 'views' (column-level security), NOT a data view code from ui/data_views.json. Users in this folder then see only the columns that view lists. The conventional 'default' declares nothing and means 'no view' (full column set); any other name that the entity does not declare fails the install."
62
62
  },
63
63
  "quickAdd": {
64
64
  "type": "boolean",
@@ -0,0 +1,67 @@
1
+ # column-security — entity views example
2
+
3
+ A minimal module whose only job is to demonstrate **entity views**: dForge's
4
+ column-level security. One `product` entity holds both stock and pricing, and two
5
+ folders show two different column sets of it.
6
+
7
+ ## The shape
8
+
9
+ Column-level security is always **two halves**, and both are required:
10
+
11
+ | Piece | File | Effect |
12
+ |---|---|---|
13
+ | The column set | `entities/product.json` → `views` | Declares `storekeeper` and `accountant` — each a named subset of the entity's columns |
14
+ | The binding | `ui/folders.json` → `entities.product.viewName` | The Warehouse folder binds `storekeeper`, the Finance folder binds `accountant` |
15
+
16
+ ```
17
+ product columns: name sku quantity cost_price sale_price margin
18
+
19
+ Warehouse folder → view "storekeeper": name sku quantity
20
+ Finance folder → view "accountant": name sku quantity(read-only) cost_price sale_price margin
21
+ ```
22
+
23
+ A user working in Warehouse cannot see `cost_price` **at all** — not on screen, not
24
+ through a filter, not in an export. The columns a view omits are invisible *and*
25
+ unqueryable there. That is the mechanism: omission is the restriction.
26
+
27
+ ## What each view demonstrates
28
+
29
+ - **`storekeeper`** — the plain case: list the columns this folder may see, and no
30
+ others. Note it still lists `product_id`; a view must include the primary key
31
+ because records are addressed by it.
32
+ - **`accountant`** — the override cases:
33
+ - `"quantity": { "flags": "V" }` — visible but **not** editable here, while the
34
+ entity itself declares `VEM`. A per-column `flags` override re-flags the column
35
+ for the folders bound to this view only.
36
+ - `"margin": { "formula": "…", "displayFmt": "N1" }` — a per-view **formula**
37
+ override: the entity computes margin as an absolute amount, this view computes it
38
+ as a percentage. Allowed only because `margin` is a `columnType: "F"` column — on
39
+ any other column that field holds the SQL default, so the override would be inert
40
+ and the install rejects it.
41
+
42
+ ## Things that would fail the install
43
+
44
+ Worth knowing, because each one is silent at runtime if it slips through:
45
+
46
+ - Dropping `product_id` from a view — records the client cannot open or save.
47
+ - A view with **no** columns, or a column code the entity doesn't have.
48
+ - Two views whose names differ only by case — a folder binds case-insensitively, so
49
+ only one could ever be reached.
50
+ - A `viewName` in `folders.json` that no view declares. This does **not** fall back:
51
+ the fallback would be the entity's full column set, so a typo would quietly
52
+ *unrestrict* the folder. `"default"` is the one exempt name and means "no view".
53
+
54
+ `dforge_module_validate` checks all of these offline, before packing.
55
+
56
+ ## What this example deliberately leaves out
57
+
58
+ The `product_grid` data view lists **every** column, including the prices. That is
59
+ correct: a data view describes the presentation, and the folder's entity view decides
60
+ what a user in that folder is allowed to see of it. The grid renders whichever
61
+ columns survive the view — you do not maintain one data view per role.
62
+
63
+ Entity views are also unrelated to the two other things called "view" here: a *data
64
+ view* (`ui/data_views.json`) is a grid/kanban/calendar, and `isView` + `viewSql` on an
65
+ entity means it is backed by a SQL view.
66
+
67
+ See `dforge://reference/security` → Column-level security for the full rules.
@@ -0,0 +1,79 @@
1
+ {
2
+ "description": "Products, with stock and pricing on one record",
3
+ "dbObject": "product",
4
+ "toString": "{name}",
5
+ "traits": ["identity", "audit"],
6
+ "fields": {
7
+ "name": {
8
+ "dbDatatype": "varchar",
9
+ "fieldTypeCd": "text",
10
+ "flags": "VEM",
11
+ "maxLen": 200,
12
+ "orderNum": 10,
13
+ "description": "Product Name"
14
+ },
15
+ "sku": {
16
+ "dbDatatype": "varchar",
17
+ "fieldTypeCd": "text",
18
+ "flags": "VEM",
19
+ "maxLen": 40,
20
+ "orderNum": 20,
21
+ "description": "SKU"
22
+ },
23
+ "quantity": {
24
+ "dbDatatype": "numeric(18,2)",
25
+ "fieldTypeCd": "number",
26
+ "flags": "VEM",
27
+ "precision": 2,
28
+ "orderNum": 30,
29
+ "description": "Quantity On Hand"
30
+ },
31
+ "cost_price": {
32
+ "dbDatatype": "numeric(18,2)",
33
+ "fieldTypeCd": "currency",
34
+ "flags": "VEM",
35
+ "precision": 2,
36
+ "orderNum": 40,
37
+ "description": "Cost Price"
38
+ },
39
+ "sale_price": {
40
+ "dbDatatype": "numeric(18,2)",
41
+ "fieldTypeCd": "currency",
42
+ "flags": "VEM",
43
+ "precision": 2,
44
+ "orderNum": 50,
45
+ "description": "Sale Price"
46
+ },
47
+ "margin": {
48
+ "columnType": "F",
49
+ "fieldTypeCd": "currency",
50
+ "baseDatatypeCd": "number",
51
+ "flags": "V",
52
+ "precision": 2,
53
+ "formula": "[sale_price] - [cost_price]",
54
+ "orderNum": 60,
55
+ "description": "Margin"
56
+ }
57
+ },
58
+ "views": {
59
+ "storekeeper": {
60
+ "columns": {
61
+ "product_id": {},
62
+ "name": {},
63
+ "sku": {},
64
+ "quantity": {}
65
+ }
66
+ },
67
+ "accountant": {
68
+ "columns": {
69
+ "product_id": {},
70
+ "name": {},
71
+ "sku": {},
72
+ "quantity": { "flags": "V" },
73
+ "cost_price": {},
74
+ "sale_price": {},
75
+ "margin": { "formula": "([sale_price] - [cost_price]) / [sale_price] * 100", "displayFmt": "N1" }
76
+ }
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "packageFormat": 1,
3
+ "moduleId": "20000000-0000-0000-0000-000000000003",
4
+ "code": "column_security",
5
+ "version": "0.1.0",
6
+ "dbSchemaVersion": "0.0.1",
7
+ "displayName": "Column Security",
8
+ "description": "Example module demonstrating entity views — column-level security",
9
+ "author": { "name": "dForge Examples" },
10
+ "license": "MIT",
11
+ "category": "Examples",
12
+ "tags": ["example", "security", "entity-views"],
13
+ "icon": "bi-eye-slash",
14
+ "dependencies": {
15
+ "admin": ">=0.0.1"
16
+ },
17
+ "supportedLocales": ["en-US"],
18
+ "created": "2026-08-19",
19
+ "updated": "2026-08-19",
20
+ "entities": {
21
+ "product": "./entities/product.json"
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "column_security.storekeeper": {
3
+ "description": "Counts stock; never sees cost or price",
4
+ "rights": {
5
+ "product": "SIU",
6
+ "folder:warehouse": "E"
7
+ }
8
+ },
9
+ "column_security.accountant": {
10
+ "description": "Owns costing and pricing",
11
+ "rights": {
12
+ "product": "SIU",
13
+ "folder:finance": "E"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "entities": {
3
+ "product": {
4
+ "label": "Product",
5
+ "fields": {
6
+ "name": { "label": "Product Name" },
7
+ "sku": { "label": "SKU" },
8
+ "quantity": { "label": "Quantity On Hand" },
9
+ "cost_price": { "label": "Cost Price" },
10
+ "sale_price": { "label": "Sale Price" },
11
+ "margin": { "label": "Margin" }
12
+ }
13
+ }
14
+ },
15
+ "folders": {
16
+ "warehouse": { "label": "Warehouse" },
17
+ "finance": { "label": "Finance" }
18
+ },
19
+ "roles": {
20
+ "column_security.storekeeper": { "label": "Storekeeper" },
21
+ "column_security.accountant": { "label": "Accountant" }
22
+ },
23
+ "views": {
24
+ "product_grid": { "label": "Products" }
25
+ },
26
+ "menus": {
27
+ "product_menu": {
28
+ "label": "Products",
29
+ "items": {
30
+ "catalog": { "label": "Catalog" },
31
+ "all_products": { "label": "Products" }
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "product_grid": {
3
+ "label": "Products",
4
+ "description": "One grid, two column sets — whichever the folder's entity view allows",
5
+ "viewType": "grid",
6
+ "icon": "bi-box-seam",
7
+ "dataSources": [{
8
+ "entityCode": "product",
9
+ "columns": [
10
+ { "column_cd": "name", "width": 240 },
11
+ { "column_cd": "sku", "width": 120 },
12
+ { "column_cd": "quantity", "width": 120 },
13
+ { "column_cd": "cost_price", "width": 120 },
14
+ { "column_cd": "sale_price", "width": 120 },
15
+ { "column_cd": "margin", "width": 120 }
16
+ ],
17
+ "order": ["name"]
18
+ }]
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "label": "Products",
3
+ "description": "Product master data",
4
+ "icon": "bi-box-seam",
5
+ "children": {
6
+ "warehouse": {
7
+ "label": "Warehouse",
8
+ "description": "Stock keeping — no pricing",
9
+ "color": "#0ea5e9",
10
+ "entities": {
11
+ "product": { "viewName": "storekeeper", "quickAdd": true }
12
+ }
13
+ },
14
+ "finance": {
15
+ "label": "Finance",
16
+ "description": "Costing and pricing",
17
+ "color": "#f59e0b",
18
+ "entities": {
19
+ "product": { "viewName": "accountant", "quickAdd": false }
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "product_menu": {
3
+ "label": "Products",
4
+ "items": {
5
+ "catalog": {
6
+ "orderNum": 1,
7
+ "label": "Catalog",
8
+ "icon": "box-seam",
9
+ "children": {
10
+ "all_products": {
11
+ "itemType": "V",
12
+ "label": "Products",
13
+ "icon": "box-seam",
14
+ "dataViewCode": "product_grid",
15
+ "orderNum": 1
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+ }
@@ -6,10 +6,15 @@
6
6
  "dbSchemaVersion": "0.0.1",
7
7
  "displayName": "Simple Todo",
8
8
  "description": "Minimal example module demonstrating the dForge package format",
9
- "author": { "name": "dForge Examples" },
9
+ "author": {
10
+ "name": "dForge Examples"
11
+ },
10
12
  "license": "MIT",
11
13
  "category": "Examples",
12
- "tags": ["example", "todo"],
14
+ "tags": [
15
+ "example",
16
+ "todo"
17
+ ],
13
18
  "icon": "bi-check-square",
14
19
  "dependencies": {
15
20
  "admin": ">=0.0.1"
@@ -19,14 +24,5 @@
19
24
  "entities": {
20
25
  "todo_list": "./entities/todo_list.json",
21
26
  "todo_item": "./entities/todo_item.json"
22
- },
23
- "dataViews": "./ui/data_views.json",
24
- "menus": "./ui/menus.json",
25
- "actions": "./ui/actions.json",
26
- "security": {
27
- "roles": "./security/roles.json"
28
- },
29
- "seedData": [
30
- "./seed-data/01-lists.json"
31
- ]
27
+ }
32
28
  }
@@ -36,8 +36,9 @@ Virtual N:1 lookup. No physical column. Always **paired with a hidden FK column*
36
36
  The example below is a **required** relation, so both halves carry `M`. For an optional
37
37
  one, drop it from both: FK `"E"`, Reference `"VE"`. `M` resolves to `isNullable: false`
38
38
  at install, so it is what makes the FK column `NOT NULL` — and while `M` is inert on the
39
- virtual Reference half, leaving it there over an optional FK reads as a required field to
40
- the next author. `dforge_entity_reference_add({ required })` emits both halves in step.
39
+ virtual Reference half (at the entity level; a per-view override behaves differently, see
40
+ `flags.md`), leaving it there over an optional FK reads as a required field to the next
41
+ author. `dforge_entity_reference_add({ required })` emits both halves in step.
41
42
 
42
43
  ```json
43
44
  // Hidden FK column — physical, hidden. "E" instead of "EM" if the relation is optional.
@@ -4,6 +4,8 @@ Data views define how users see and interact with entity data. One entity can ha
4
4
 
5
5
  Lives in: `ui/data_views.json`
6
6
 
7
+ > **Not to be confused with entity views.** An *entity view* (`views` on an entity file) is column-level **security** — which columns a folder may see at all — and is what `folders.json`'s `viewName` binds. A *data view* is a presentation of an entity, referenced by a menu's `dataViewCode`. A third thing, `isView` + `viewSql` on an entity, means the entity is backed by a SQL view. See `security.md` → Column-level security.
8
+
7
9
  ## Structure
8
10
 
9
11
  ```json
@@ -2,19 +2,21 @@
2
2
 
3
3
  Column flags are single-letter codes concatenated into a string. Order doesn't matter — `"VEM"` and `"EMV"` are equivalent.
4
4
 
5
- Valid flags regex: `^[VIEMH]*$`
5
+ Valid flags regex: `^[VIEM]*$`
6
6
 
7
7
  ## Flag letters
8
8
 
9
9
  | Letter | Name | Meaning |
10
10
  |---|---|---|
11
11
  | `V` | Visible | Column appears in grid / form / card views. Omit to hide from standard UI. |
12
- | `I` | Internal | Auto-managed by the platform. Used on PK columns, audit timestamps, system fields. Not shown in UI, not directly editable by users. |
12
+ | `I` | Identity | Auto-managed by the platform. Used on PK columns, audit timestamps, system fields. Not shown in UI, not directly editable by users, excluded from required validation. |
13
13
  | `E` | Editable | User can modify the value in the UI. Omit for read-only. |
14
14
  | `M` | Mandatory | Required. Resolved into `isNullable: false` at install — see below. |
15
- | `H` | Hidden | Permanently hidden — never shown, not even in admin metadata views. Stronger than omitting `V`. |
16
15
 
17
- **Only these 5 letters are valid.** The platform rejects any other characters.
16
+ **Only these 4 letters are valid.** The platform rejects any other characters.
17
+ > **There is no `H` flag.** Earlier drafts listed one, described as "stronger than omitting `V`", and nothing ever read it — a column with `flags: "H"` is hidden because the string contains no `V`, not because it contains `H`. To hide a column, omit `V`. To hide it from specific folders (and make it unqueryable there), use an entity view. To render it as a hidden input, use `fieldTypeCd: "hidden"`.
18
+
19
+ > **The same letters override per entity view.** `views.<v>.columns.<cd>.flags` re-flags a column for the folders bound to that view, while the entity keeps its own flags everywhere else. Same `^[VIEM]*$` vocabulary — see "Per-view overrides" below. See `security.md` → Column-level security.
18
20
 
19
21
  ## `M` is the platform's one notion of "required"
20
22
 
@@ -31,7 +33,8 @@ Four consequences worth knowing before you type a flag:
31
33
  entity and every offending field. It's a contradiction only you can resolve: drop
32
34
  `M` if the column is optional, or drop `isNullable` if it is required.
33
35
  - **`M` is inert on virtual (`R`/`S`/`F`) and identity columns** — nullability is
34
- meaningless where there is no physical column. It won't fail, but `VEM` on a
36
+ meaningless where there is no physical column. (At the *entity* level; a per-view
37
+ override behaves differently — see "Per-view overrides".) It won't fail, but `VEM` on a
35
38
  Reference over an *optional* FK reads as a required field to the next author, which
36
39
  is exactly how these get out of step. Match the Reference to its FK.
37
40
  - **Adding `M` to a shipped field aborts the upgrade** on any tenant whose existing
@@ -41,6 +44,33 @@ Four consequences worth knowing before you type a flag:
41
44
 
42
45
  Omitting both `M` and `isNullable` leaves the column nullable — that is the default.
43
46
 
47
+ ## Per-view overrides
48
+
49
+ `views.<v>.columns.<cd>.flags` re-flags one column for the folders bound to that view. The letters are the same four, and `M` means the same thing — the installer folds it into **that override's** `isNullable`.
50
+
51
+ Because a view writes no DDL, the requiredness `M` produces there is runtime-only. That is how you express **"nullable in the database, mandatory in this view"**, which no entity-level flag can say:
52
+
53
+ ```json
54
+ "views": {
55
+ "compliance": {
56
+ "columns": {
57
+ "party_id": {},
58
+ "party_name": { "flags": "V" },
59
+ "vat_no": { "flags": "VEM" },
60
+ "salary": { "flags": "" }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Three rules to keep straight:
67
+
68
+ - **The letters replace, they don't merge.** The cascade is `COALESCE(view, entity)`. A non-empty string wins outright, so `{ "flags": "V" }` is read-only there however the entity flagged the column.
69
+ - **`""` is a real value, omission is not.** An empty string means "no flags here" — neither visible nor editable. To inherit, leave the key out; `"flags": null` fails schema validation, since the property is typed `string`. (To inherit *everything*, set the whole column override to `{}` or `null`.)
70
+ - **The `M` fold is one-directional.** Dropping `M` from a view's flags does NOT make the column optional — `isNullable` still `COALESCE`s to the entity value. Write `"isNullable": true` to loosen it, and only where the database actually allows null, or the save trades a clean validation error for a raw constraint violation.
71
+
72
+ Unlike the entity level, `M` here is *not* inert on virtual or identity columns: the fold only reaches the runtime required check, so a view can mark a Reference required. (Identity columns stay excluded — they are never asked of the user.)
73
+
44
74
  ## Common column flag combinations
45
75
 
46
76
  | Flags | Meaning | Use case |
@@ -50,7 +80,7 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
50
80
  | `"V"` | Visible, read-only | Display-only, like formula columns or computed values |
51
81
  | `"E"` | Editable, **not visible** | Hidden FK column of an *optional* relation |
52
82
  | `"EM"` | Editable, Mandatory, **not visible** | Hidden FK column of a *required* relation |
53
- | `"I"` | Internal | Trait-provided columns (PK, audit timestamps). Platform-managed. |
83
+ | `"I"` | Identity | Trait-provided columns (PK, audit timestamps). Platform-managed. |
54
84
  | `""` (empty) | No flags | Rare — column exists but is invisible and not editable |
55
85
 
56
86
  ## Rules
@@ -60,9 +90,9 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
60
90
  2. **Visible Reference columns**: `"VE"`, or `"VEM"` when the FK is required — **keep the
61
91
  two halves in step**. `dforge_entity_reference_add({ required })` sets both for you.
62
92
  3. **Formula columns**: usually `"V"` (visible, read-only). The user never edits them.
63
- 4. **Trait columns** (PK, audit): use `"I"` (internal). These are set automatically by `traits: ["identity", "audit"]`.
93
+ 4. **Trait columns** (PK, audit): use `"I"` (identity). These are set automatically by `traits: ["identity", "audit"]`.
64
94
  5. **Don't use `I` on your own columns** — it's reserved for trait-provided and platform-managed columns.
65
- 6. **Don't mix `I` with `V` or `E`** — internal columns are not user-facing.
95
+ 6. **Don't mix `I` with `V` or `E`** — identity columns are not user-facing.
66
96
 
67
97
  ## Entity rights flags (different namespace — roles only)
68
98
 
@@ -79,7 +109,7 @@ These letters appear in role `rights` declarations, **NOT** on column flags. Don
79
109
 
80
110
  So `"SIUD"` on a role grants full CRUD; `"SI"` grants read + create only.
81
111
 
82
- **Note**: the `I` in column flags (Internal) and the `I` in role rights (Insert) are different concepts using the same letter. Context makes them unambiguous — column flags go on entity fields, role rights go on `security/roles.json`.
112
+ **Note**: the `I` in column flags (Identity) and the `I` in role rights (Insert) are different concepts using the same letter. Context makes them unambiguous — column flags go on entity fields, role rights go on `security/roles.json`.
83
113
 
84
114
  ## How uniqueness, PKs, and search work
85
115
 
@@ -101,4 +131,6 @@ These are **NOT** flag letters. They're separate properties on the column defini
101
131
  - Putting `M` on a Reference whose hidden FK is optional — inert, but it misreports the field as required.
102
132
  - Using `"VEM"` on trait-provided columns (PK, audit) — **don't**. Traits set their own flags (`"I"`).
103
133
  - Using `"I"` on business columns — **don't**. `I` means "platform-managed"; regular business columns use `V`/`E`/`M`.
134
+ - Expecting a per-view `{ "flags": "VE" }` to make a column **optional** because it drops `M` — it doesn't. `isNullable` still inherits; write `"isNullable": true`.
135
+ - Writing `"flags": null` in a view override to inherit — fails validation. Omit the key.
104
136
  - Using long names like `"Visible,Editable"` — **wrong**. Single letters concatenated: `"VE"`.
@@ -3,6 +3,13 @@
3
3
  Formulas are used in:
4
4
 
5
5
  - **Formula columns** (`columnType: "F"`) — computed values
6
+ - **Per-view formula overrides** (`views.<v>.columns.<cd>.formula`) — one entity view
7
+ computing a column differently from the entity. Allowed **only** on a column the
8
+ entity declares as `columnType: "F"`; on any other column the install FAILS, because
9
+ there that field holds the SQL default and a view cannot change a physical column's
10
+ `DEFAULT`. (There is no per-view default value for a normal column.) Same rules as an
11
+ `F` column otherwise: it must parse, call only implemented functions, and not
12
+ aggregate over a 1:N set. See `security.md` → Column-level security.
6
13
  - **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
7
14
  and **enforced** by `action.execute`, but only over a narrow subset: comparisons
8
15
  between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
@@ -213,7 +213,7 @@ Lives in: `ui/folders.json`
213
213
 
214
214
  | Property | Type | Description |
215
215
  |---|---|---|
216
- | `viewName` | string | Default data view (usually `"default"`) |
216
+ | `viewName` | string | Entity view to bind — a key under the entity's `views` (see [Column-level security](#column-level-security-entity-views)), **not** a `ui/data_views.json` code. `"default"` means "no view" (full column set). |
217
217
  | `quickAdd` | boolean | Show "+" quick-add button (`true` for main entities, `false` for detail/line items) |
218
218
  | `rowFilter` | object | Row-level filter scoping which records are visible in this folder |
219
219
 
@@ -226,6 +226,82 @@ Row filters use a compact filter object with:
226
226
 
227
227
  This is how **row-level security** works in the module package — subfolders with `rowFilter` restrict which rows are visible. Users assigned to "Central Warehouse" only see stock where `warehouse_id = 2001`.
228
228
 
229
+ ## Column-level security (entity views)
230
+
231
+ > Worked example: `dforge://example/column-security/` — one entity, two folders, two
232
+ > column sets. Its README walks the overrides and the ways a view fails the install.
233
+
234
+ Layer 2 of the model. A **entity view** is a named subset of an entity's columns, declared on the entity itself; a folder binds one per entity. Users working in that folder see **only** the columns the view lists — the rest are neither visible nor queryable there.
235
+
236
+ Two halves, both required:
237
+
238
+ | File | Key | Effect |
239
+ |---|---|---|
240
+ | `entities/<entity>.json` | `views.<viewName>.columns` | Declares the view and its column set |
241
+ | `ui/folders.json` | `entities.<code>.viewName` | Binds that view for this entity in this folder |
242
+
243
+ ```json
244
+ {
245
+ "fields": { "product_id": {}, "name": {}, "price": {}, "cost": {}, "quantity": {} },
246
+ "views": {
247
+ "accountant": {
248
+ "columns": {
249
+ "product_id": {},
250
+ "name": { "flags": "V" },
251
+ "price": { "flags": "VE" },
252
+ "cost": { "flags": "V" }
253
+ }
254
+ },
255
+ "storekeeper": {
256
+ "columns": {
257
+ "product_id": {},
258
+ "name": { "flags": "V" },
259
+ "quantity": { "flags": "VE" }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ ```json
267
+ "entities": { "product": { "viewName": "accountant", "quickAdd": true } }
268
+ ```
269
+
270
+ The storekeeper folder cannot see `price` or `cost` at all — not on screen, not through a filter, not in an export.
271
+
272
+ **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".
273
+
274
+ > **An empty override is a value, not an absence.** The runtime merges view over entity with `COALESCE`, 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 — `"flags": null` fails schema validation. See `flags.md` for the flag letters.
275
+
276
+ > **`M` works in a view override too**, folding into that override's `isNullable`. Since a view writes no DDL, the requiredness is runtime-only — which is how a column nullable in the database is made mandatory inside one view: `{ "flags": "VEM" }`. The fold is one-directional: dropping `M` does not loosen the column, because `isNullable` still inherits. See `flags.md` → Per-view overrides.
277
+
278
+ `formula` lets one view compute a column differently from the entity — same DSL as a field's `formula`, and **only on a column the entity declares as `columnType: "F"`**; elsewhere the install fails, because there that field holds the SQL default and a view cannot change a physical column's `DEFAULT`. There is no per-view default value for a normal column. See `formulas.md`.
279
+
280
+ ### Rules the installer enforces
281
+
282
+ Each of these is an install-time error, and each is silent at runtime if it slips through — which is why `dforge_module_validate` checks them offline:
283
+
284
+ - A column absent from `columns` is **hidden**. That is the mechanism, not an oversight.
285
+ - A view must list the entity's **primary key** — records are addressed by it. To keep it off screen, drop `V` from its flags instead of omitting it.
286
+ - A column code that doesn't exist on the entity, a view with **no columns**, the same column listed twice under different casing, or two views on one entity whose names differ only by case (a folder binds case-insensitively, so only one could ever be reached).
287
+ - Views must be declared on the entity's **own** module. An `extends` extension declaring `views` is rejected — a view enumerates the complete column set, which an extension does not own.
288
+ - A `viewName` that no view declares, or one naming a view with no columns, **fails the install**. It does not fall back: the fallback is the entity's full column set, so a typo would quietly *unrestrict* the folder.
289
+ - Columns removed from a view are reaped on upgrade — that is the security change the author asked for. Whole views are not: `entity_view` also holds tenant views from the card-layout editor, and the installer cannot tell them apart.
290
+
291
+ ### `"default"` is the one exempt name
292
+
293
+ `"viewName": "default"` means **no view** (full column set) and declares nothing. It is what every shipped module writes, and what the runtime auto-creates for an entity reached without a folder binding — so it is exempt from the checks above. A module that really declares a view named `default` still gets it bound.
294
+
295
+ ### Don't confuse the three "views"
296
+
297
+ | Term | Where | What it is |
298
+ |---|---|---|
299
+ | **Entity view** | `entities/*.json` → `views` | Column-level security (this section) |
300
+ | **Data view** | `ui/data_views.json` | A grid / kanban / calendar over an entity (`data-views.md`) |
301
+ | **SQL view** | `entities/*.json` → `isView` + `viewSql` | An entity backed by a SQL view instead of a table |
302
+
303
+ `viewName` in `folders.json` binds the first. Menus' `dataViewCode` references the second.
304
+
229
305
  ### When to use subfolders
230
306
 
231
307
  - **Multi-location modules** (warehouses, offices, branches) — one subfolder per location, filtered by location FK
@@ -105,6 +105,8 @@ For each entity:
105
105
  ## Security
106
106
 
107
107
  - [ ] `security/roles.json` exists (at least one role)
108
+ - [ ] Every entity view (`views.<v>.columns` on an entity) lists the entity's **primary key**, names only real columns, and has at least one column — each of these fails the install
109
+ - [ ] Every `viewName` in `ui/folders.json` is either `"default"` (= no view) or a view that entity actually declares — an unresolved name fails the install rather than falling back
108
110
  - [ ] Every role has `description` and `rights` (both required; no `label` field in `roles.json` — the `roles.schema.json` sets `additionalProperties: false`, so any extra field including `label` is rejected here). `description` is the English display-name fallback; **localized role labels live in the translation files** (`roles.<code>.label`) and are completeness-enforced — see Translations below.
109
111
  - [ ] The field name is `rights`, NOT `entityRights`
110
112
  - [ ] Rights strings use only valid letters: `SIUDC` for entities, `E` for actions/reports/folders
@@ -31,6 +31,7 @@ flags, or column patterns.
31
31
  | Add a grid / list view | `dforge://reference/data-views`, `dforge://example/ui/data_views.json` | — |
32
32
  | Add a matrix (pivot) view | `dforge://reference/data-views` (§Matrix), `dforge://example/matrix-budget/ui/data_views.json`, `dforge://example/matrix-budget/entities/budget_line.json` | — |
33
33
  | Add kanban / calendar / tree-grid / master-detail | `dforge://reference/data-views` | `dforge://schema/data-views` |
34
+ | Hide columns from a user type (column-level security) | `dforge://reference/security` (§Column-level security), `dforge://example/column-security/entities/product.json`, `dforge://example/column-security/ui/folders.json` | — |
34
35
  | Add a menu | `dforge://reference/menus`, `dforge://example/ui/menus.json` | — |
35
36
  | Add an action | `dforge://reference/action-dsl`, `dforge://example/logic/actions/mark_done.dsl`, `dforge://example/ui/actions.json` | `dforge://docs/dsl` |
36
37
  | Add a trigger | — | `dforge://schema/triggers`; condition syntax in `dforge://docs/dsl` |
@@ -204,8 +205,11 @@ per entity, so you'll often `view_modify` rather than `view_add`.
204
205
 
205
206
  View codes are semantic — the entity name (`feedback_item`), a plural
206
207
  (`invoices`), or descriptive (`invoices_kanban`). **Never use the literal code
207
- `default`**; `ui/folders.json`'s `viewName: "default"` is a fallback alias the
208
- platform resolves to the entity's first declared view.
208
+ `default`.** Note that `ui/folders.json`'s `viewName` is a different axis
209
+ entirely: it binds an *entity view* (column-level security, declared under
210
+ `views` on the entity file), not a data view code. `"default"` there means "no
211
+ entity view — show the full column set", and any other name the entity does not
212
+ declare **fails the install**. See `references/security.md`.
209
213
 
210
214
  Then wire menus with `dforge_menu_add` — a section node per group, leaves
211
215
  pointing at the views.
@@ -273,6 +277,13 @@ stated trigger; every report's params sit on a dataset.
273
277
  4. **Show the rights matrix as a table** (rows = entities/actions/reports,
274
278
  columns = roles, cells = rights strings), each cell explained by the verb it
275
279
  maps to. Get sign-off.
280
+ 5. **Column-level security only when the intake asks for it.** Roles grant rights
281
+ per entity, not per field. When a user type must not see specific *columns*
282
+ (salaries, cost prices, personal data), that is an **entity view**: declare
283
+ `views.<name>.columns` on the entity and bind it with `viewName` on the folder
284
+ that user type works in. Don't reach for it otherwise — a view is the complete
285
+ column list, so it is one more list to keep in step with the fields. Rules and
286
+ the worked example: `references/security.md` → Column-level security.
276
287
  5. `dforge_role_add` for new roles; `dforge_role_right_set` per grant when
277
288
  amending an existing one.
278
289