@dforge-core/dforge-mcp 0.2.18 → 0.2.19

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,78 @@ 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.19
8
+
9
+ A module could declare a dependency but not ship the contract the platform
10
+ requires for it, and nothing here said the contract existed. The manifest
11
+ reference's own minimal example carried a dependency on `admin` that made every
12
+ module copying it unpackable.
13
+
14
+ ### Fixed — `dforge_dependency_add` writes the contract, not just the manifest
15
+
16
+ Every entry in a manifest's `dependencies` needs a matching `deps/<module>.json`
17
+ contract declaring what the module consumes from that dependency; without it the
18
+ package fails `dforge_module_validate`, `dforge_module_pack` and install alike.
19
+ The tool wrote only the manifest half, so the one tool meant to author this exact
20
+ manifest key left the module in a state it could not be packed from
21
+ (dForge-core#1090).
22
+
23
+ It now writes both files, with the version mirrored verbatim — the validator
24
+ compares the two ranges and a drift is a hard error.
25
+
26
+ `entities` is now **required**, and that is the point rather than a side effect: a
27
+ contract must name at least one consumed entity, and a dependency you cannot name
28
+ one for is a dependency you should not declare. Two new optional inputs carry the
29
+ detail the contract records — `use` for provenance tokens (`ref:invoice.customer`,
30
+ `extends:parties.party`, `role:fin_admin`) and `pks` to override a provider primary
31
+ key that is not `<entity>_id`. Both are guessed when omitted and the result says so.
32
+
33
+ Declaring a system module (`admin`, `metadata`, `workspace`) now warns: they are
34
+ provisioned into every tenant before any other module installs, so depending on one
35
+ buys nothing on its own — the only meaningful use is gating a minimum platform
36
+ version for a feature, and that still needs a contract naming the entity the feature
37
+ introduced. The tool also refuses to overwrite an existing contract file.
38
+
39
+ ### Fixed — the manifest reference told authors to always depend on `admin`
40
+
41
+ `dforge://reference/manifest` carried **"Always depend on `admin`"** in bold, and
42
+ put `"admin": ">=0.0.1"` in its minimal example and its bridge-module example. That
43
+ advice predates dependency contracts and now produces a module that cannot be
44
+ packed. All three are corrected, and the reference gains a **Dependency contracts**
45
+ section: the file shape, the version-match rule, storage classes (`int8` for a
46
+ `cuid` primary key), which columns must never be declared, and the provenance kinds.
47
+
48
+ ### Added — `dforge://schema/deps`
49
+
50
+ `deps.schema.json` shipped in the package but was never registered as a resource, so
51
+ an author who found the file name still had no way to read its shape. It is now
52
+ served like every other schema.
53
+
54
+ ### Added — scheduled jobs resolve folder-scoped settings
55
+
56
+ `dforge://reference/jobs` and `.../settings` document the folder a cron fire runs in.
57
+ A scheduled action has no user, so nothing supplies a folder the way a request does;
58
+ the scheduler binds `scheduled_job.folder_id` when an admin has set one, and the
59
+ owning module's root folder otherwise. That is what makes `getSetting()` and
60
+ `nextNumber()` resolve inside a job. There is no `folder` key in the manifest — the
61
+ binding is operator state, and an upgrade never overwrites it.
62
+
63
+ ### Changed — scaffolded entities ship a `name` column
64
+
65
+ `@dforge-core/dforge-cli` 0.2.17 gives the `identity` trait a visible `name`
66
+ column (`varchar`, `VEM`) and points the entity's `toString` at `{name}`. An
67
+ entity with only a PK and audit stamps had nothing renderable: its grid view
68
+ failed the data-view column check and `toString` had nothing to interpolate.
69
+ That is the upstream fix `withIdentityToString`'s `{id}` repair was standing in
70
+ for, so the repair no longer fires on freshly scaffolded modules.
71
+
72
+ ### Added — `dforge_module_create` writes `.zed/tasks.json`
73
+
74
+ Zed has no extension command API, so the pack / install / auth dev loop ships as
75
+ project-local tasks. Read off the CLI templates module by feature detection rather
76
+ than imported: the helper landed after `@dforge-core/dforge-cli` 0.2.16, and
77
+ scaffolding must not break for anyone still on an older CLI.
78
+
7
79
  ## 0.2.18
8
80
 
9
81
  Every counter a module updates — an on-hand quantity, a running balance, a used
package/dist/server.js CHANGED
@@ -3226,8 +3226,8 @@ var require_utils = __commonJS({
3226
3226
  }
3227
3227
  return ind;
3228
3228
  }
3229
- function removeDotSegments(path17) {
3230
- let input = path17;
3229
+ function removeDotSegments(path18) {
3230
+ let input = path18;
3231
3231
  const output = [];
3232
3232
  let nextSlash = -1;
3233
3233
  let len = 0;
@@ -3479,8 +3479,8 @@ var require_schemes = __commonJS({
3479
3479
  wsComponent.secure = void 0;
3480
3480
  }
3481
3481
  if (wsComponent.resourceName) {
3482
- const [path17, query] = wsComponent.resourceName.split("?");
3483
- wsComponent.path = path17 && path17 !== "/" ? path17 : void 0;
3482
+ const [path18, query] = wsComponent.resourceName.split("?");
3483
+ wsComponent.path = path18 && path18 !== "/" ? path18 : void 0;
3484
3484
  wsComponent.query = query;
3485
3485
  wsComponent.resourceName = void 0;
3486
3486
  }
@@ -6899,12 +6899,12 @@ var require_dist = __commonJS({
6899
6899
  throw new Error(`Unknown format "${name}"`);
6900
6900
  return f;
6901
6901
  };
6902
- function addFormats(ajv, list, fs14, exportName) {
6902
+ function addFormats(ajv, list, fs15, exportName) {
6903
6903
  var _a3;
6904
6904
  var _b;
6905
6905
  (_a3 = (_b = ajv.opts.code).formats) !== null && _a3 !== void 0 ? _a3 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
6906
6906
  for (const f of list)
6907
- ajv.addFormat(f, fs14[f]);
6907
+ ajv.addFormat(f, fs15[f]);
6908
6908
  }
6909
6909
  module2.exports = exports2 = formatsPlugin;
6910
6910
  Object.defineProperty(exports2, "__esModule", { value: true });
@@ -7271,8 +7271,8 @@ function getErrorMap() {
7271
7271
 
7272
7272
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v3/helpers/parseUtil.js
7273
7273
  var makeIssue = (params) => {
7274
- const { data, path: path17, errorMaps, issueData } = params;
7275
- const fullPath = [...path17, ...issueData.path || []];
7274
+ const { data, path: path18, errorMaps, issueData } = params;
7275
+ const fullPath = [...path18, ...issueData.path || []];
7276
7276
  const fullIssue = {
7277
7277
  ...issueData,
7278
7278
  path: fullPath
@@ -7387,11 +7387,11 @@ var errorUtil;
7387
7387
 
7388
7388
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v3/types.js
7389
7389
  var ParseInputLazyPath = class {
7390
- constructor(parent, value, path17, key) {
7390
+ constructor(parent, value, path18, key) {
7391
7391
  this._cachedPath = [];
7392
7392
  this.parent = parent;
7393
7393
  this.data = value;
7394
- this._path = path17;
7394
+ this._path = path18;
7395
7395
  this._key = key;
7396
7396
  }
7397
7397
  get path() {
@@ -11311,10 +11311,10 @@ function mergeDefs(...defs) {
11311
11311
  function cloneDef(schema2) {
11312
11312
  return mergeDefs(schema2._zod.def);
11313
11313
  }
11314
- function getElementAtPath(obj, path17) {
11315
- if (!path17)
11314
+ function getElementAtPath(obj, path18) {
11315
+ if (!path18)
11316
11316
  return obj;
11317
- return path17.reduce((acc, key) => acc?.[key], obj);
11317
+ return path18.reduce((acc, key) => acc?.[key], obj);
11318
11318
  }
11319
11319
  function promiseAllObject(promisesObj) {
11320
11320
  const keys = Object.keys(promisesObj);
@@ -11723,11 +11723,11 @@ function explicitlyAborted(x, startIndex = 0) {
11723
11723
  }
11724
11724
  return false;
11725
11725
  }
11726
- function prefixIssues(path17, issues) {
11726
+ function prefixIssues(path18, issues) {
11727
11727
  return issues.map((iss) => {
11728
11728
  var _a3;
11729
11729
  (_a3 = iss).path ?? (_a3.path = []);
11730
- iss.path.unshift(path17);
11730
+ iss.path.unshift(path18);
11731
11731
  return iss;
11732
11732
  });
11733
11733
  }
@@ -11874,16 +11874,16 @@ function flattenError(error51, mapper = (issue2) => issue2.message) {
11874
11874
  }
11875
11875
  function formatError(error51, mapper = (issue2) => issue2.message) {
11876
11876
  const fieldErrors = { _errors: [] };
11877
- const processError = (error52, path17 = []) => {
11877
+ const processError = (error52, path18 = []) => {
11878
11878
  for (const issue2 of error52.issues) {
11879
11879
  if (issue2.code === "invalid_union" && issue2.errors.length) {
11880
- issue2.errors.map((issues) => processError({ issues }, [...path17, ...issue2.path]));
11880
+ issue2.errors.map((issues) => processError({ issues }, [...path18, ...issue2.path]));
11881
11881
  } else if (issue2.code === "invalid_key") {
11882
- processError({ issues: issue2.issues }, [...path17, ...issue2.path]);
11882
+ processError({ issues: issue2.issues }, [...path18, ...issue2.path]);
11883
11883
  } else if (issue2.code === "invalid_element") {
11884
- processError({ issues: issue2.issues }, [...path17, ...issue2.path]);
11884
+ processError({ issues: issue2.issues }, [...path18, ...issue2.path]);
11885
11885
  } else {
11886
- const fullpath = [...path17, ...issue2.path];
11886
+ const fullpath = [...path18, ...issue2.path];
11887
11887
  if (fullpath.length === 0) {
11888
11888
  fieldErrors._errors.push(mapper(issue2));
11889
11889
  } else {
@@ -11910,17 +11910,17 @@ function formatError(error51, mapper = (issue2) => issue2.message) {
11910
11910
  }
11911
11911
  function treeifyError(error51, mapper = (issue2) => issue2.message) {
11912
11912
  const result = { errors: [] };
11913
- const processError = (error52, path17 = []) => {
11913
+ const processError = (error52, path18 = []) => {
11914
11914
  var _a3, _b;
11915
11915
  for (const issue2 of error52.issues) {
11916
11916
  if (issue2.code === "invalid_union" && issue2.errors.length) {
11917
- issue2.errors.map((issues) => processError({ issues }, [...path17, ...issue2.path]));
11917
+ issue2.errors.map((issues) => processError({ issues }, [...path18, ...issue2.path]));
11918
11918
  } else if (issue2.code === "invalid_key") {
11919
- processError({ issues: issue2.issues }, [...path17, ...issue2.path]);
11919
+ processError({ issues: issue2.issues }, [...path18, ...issue2.path]);
11920
11920
  } else if (issue2.code === "invalid_element") {
11921
- processError({ issues: issue2.issues }, [...path17, ...issue2.path]);
11921
+ processError({ issues: issue2.issues }, [...path18, ...issue2.path]);
11922
11922
  } else {
11923
- const fullpath = [...path17, ...issue2.path];
11923
+ const fullpath = [...path18, ...issue2.path];
11924
11924
  if (fullpath.length === 0) {
11925
11925
  result.errors.push(mapper(issue2));
11926
11926
  continue;
@@ -11952,8 +11952,8 @@ function treeifyError(error51, mapper = (issue2) => issue2.message) {
11952
11952
  }
11953
11953
  function toDotPath(_path) {
11954
11954
  const segs = [];
11955
- const path17 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
11956
- for (const seg of path17) {
11955
+ const path18 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
11956
+ for (const seg of path18) {
11957
11957
  if (typeof seg === "number")
11958
11958
  segs.push(`[${seg}]`);
11959
11959
  else if (typeof seg === "symbol")
@@ -23065,11 +23065,11 @@ function normalizeObjectSchema(schema2) {
23065
23065
  }
23066
23066
  return void 0;
23067
23067
  }
23068
- function getDotPath(path17) {
23069
- if (path17.length === 0) {
23068
+ function getDotPath(path18) {
23069
+ if (path18.length === 0) {
23070
23070
  return "object root";
23071
23071
  }
23072
- return path17.reduce((acc, seg, index) => {
23072
+ return path18.reduce((acc, seg, index) => {
23073
23073
  if (index === 0) {
23074
23074
  return String(seg);
23075
23075
  }
@@ -25094,13 +25094,13 @@ function resolveRef(ref, ctx) {
25094
25094
  if (!ref.startsWith("#")) {
25095
25095
  throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
25096
25096
  }
25097
- const path17 = ref.slice(1).split("/").filter(Boolean);
25098
- if (path17.length === 0) {
25097
+ const path18 = ref.slice(1).split("/").filter(Boolean);
25098
+ if (path18.length === 0) {
25099
25099
  return ctx.rootSchema;
25100
25100
  }
25101
25101
  const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
25102
- if (path17[0] === defsKey) {
25103
- const key = path17[1];
25102
+ if (path18[0] === defsKey) {
25103
+ const key = path18[1];
25104
25104
  if (!key || !ctx.defs[key]) {
25105
25105
  throw new Error(`Reference not found: ${ref}`);
25106
25106
  }
@@ -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.16/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.17/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 = {
@@ -31023,19 +31023,36 @@ function buildManifest(opts, moduleId) {
31023
31023
  if (opts.license) manifest.license = opts.license;
31024
31024
  if (opts.dependencies.length > 0) {
31025
31025
  manifest.dependencies = Object.fromEntries(
31026
- opts.dependencies.map((d) => [d, ">=0.0.1"])
31026
+ opts.dependencies.map(
31027
+ (d) => typeof d === "string" ? [d, DEPENDENCY_RANGE] : [d.module, { version: DEPENDENCY_RANGE, entities: d.entities }]
31028
+ )
31027
31029
  );
31028
31030
  }
31029
31031
  return manifest;
31030
31032
  }
31033
+ var DEPENDENCY_RANGE = ">=0.0.1";
31034
+ var NAME_FIELD = "name";
31031
31035
  function buildEntity(entity) {
31032
31036
  const traits2 = entity.traits === "identity+audit" ? ["identity", "audit"] : ["identity"];
31033
31037
  const built = {
31034
31038
  description: entity.label,
31035
31039
  dbObject: entity.name,
31036
- toString: "{id}",
31040
+ // The traits only add a PK and audit stamps, so an entity with no fields
31041
+ // of its own has nothing renderable: its grid view fails the data-view
31042
+ // column check and `toString` has nothing to interpolate. Scaffold one
31043
+ // visible name column so the module validates and the grid shows a row.
31044
+ toString: `{${NAME_FIELD}}`,
31037
31045
  traits: traits2,
31038
- fields: {}
31046
+ fields: {
31047
+ [NAME_FIELD]: {
31048
+ dbDatatype: "varchar",
31049
+ fieldTypeCd: "text",
31050
+ flags: "VEM",
31051
+ maxLen: 200,
31052
+ orderNum: 10,
31053
+ description: "Name"
31054
+ }
31055
+ }
31039
31056
  };
31040
31057
  if (entity.constraints?.length) {
31041
31058
  built.constraints = Object.fromEntries(
@@ -31120,7 +31137,8 @@ function buildTranslations(opts) {
31120
31137
  for (const e of opts.entities) {
31121
31138
  const fields = {
31122
31139
  // identity trait → primary key column
31123
- [`${e.name}_id`]: { label: `${e.label} ID` }
31140
+ [`${e.name}_id`]: { label: `${e.label} ID` },
31141
+ [NAME_FIELD]: { label: "Name" }
31124
31142
  };
31125
31143
  if (e.traits === "identity+audit") {
31126
31144
  fields.created_date = { label: "Created Date" };
@@ -31161,8 +31179,9 @@ function buildTranslations(opts) {
31161
31179
  }
31162
31180
  };
31163
31181
  }
31164
- function buildSeedData() {
31165
- return [];
31182
+ function buildSeedData(entity) {
31183
+ const entityCode = typeof entity === "string" ? entity : entity?.name ?? "";
31184
+ return { entityCode, records: [] };
31166
31185
  }
31167
31186
  function buildGitignore() {
31168
31187
  return [
@@ -31184,30 +31203,80 @@ var SCHEMA_BINDINGS = [
31184
31203
  { fileMatch: ["ui/folders.json"], schema: "folders" },
31185
31204
  { fileMatch: ["ui/menus.json"], schema: "menus" },
31186
31205
  { fileMatch: ["ui/reports.json"], schema: "reports" },
31206
+ { fileMatch: ["ui/print_templates.json"], schema: "print-templates" },
31187
31207
  { fileMatch: ["security/roles.json"], schema: "roles" },
31188
31208
  { fileMatch: ["logic/jobs.json"], schema: "jobs" },
31209
+ { fileMatch: ["logic/triggers.json"], schema: "triggers" },
31210
+ { fileMatch: ["logic/webhooks.json"], schema: "webhooks" },
31211
+ { fileMatch: ["deps/*.json"], schema: "deps" },
31189
31212
  { fileMatch: ["seed-data/*.json"], schema: "seed-data" },
31190
31213
  { fileMatch: ["settings.json"], schema: "settings" }
31191
31214
  ];
31215
+ function schemaEntries() {
31216
+ return SCHEMA_BINDINGS.map((b) => ({
31217
+ fileMatch: b.fileMatch,
31218
+ url: `${SCHEMA_BASE}/${b.schema}.schema.json`
31219
+ }));
31220
+ }
31192
31221
  function buildVscodeSettings() {
31193
- return {
31194
- "json.schemas": SCHEMA_BINDINGS.map((b) => ({
31195
- fileMatch: b.fileMatch,
31196
- url: `${SCHEMA_BASE}/${b.schema}.schema.json`
31197
- }))
31198
- };
31222
+ return { "json.schemas": schemaEntries() };
31199
31223
  }
31200
31224
  function buildZedSettings() {
31201
31225
  return {
31202
- languages: {
31203
- JSON: {
31204
- "json.schemas": SCHEMA_BINDINGS.map((b) => ({
31205
- fileMatch: b.fileMatch,
31206
- url: `${SCHEMA_BASE}/${b.schema}.schema.json`
31207
- }))
31226
+ lsp: {
31227
+ "json-language-server": {
31228
+ settings: { json: { schemas: schemaEntries() } }
31208
31229
  }
31209
- }
31210
- };
31230
+ },
31231
+ context_servers: {
31232
+ dforge: {
31233
+ source: "custom",
31234
+ command: "npx",
31235
+ args: ["-y", "@dforge-core/dforge-mcp@latest"],
31236
+ env: {}
31237
+ }
31238
+ }
31239
+ };
31240
+ }
31241
+ function buildZedTasks() {
31242
+ const CLI = "npx";
31243
+ const CLI_ARGS = ["-y", "@dforge-core/dforge-cli@latest"];
31244
+ const DEFAULT_TENANT = "http://localhost:5179";
31245
+ const task = (label, args) => ({
31246
+ label: `dForge: ${label}`,
31247
+ command: CLI,
31248
+ args: [...CLI_ARGS, ...args],
31249
+ cwd: "$ZED_WORKTREE_ROOT",
31250
+ env: { DFORGE_URL: DEFAULT_TENANT },
31251
+ use_new_terminal: false,
31252
+ allow_concurrent_runs: false,
31253
+ reveal: "always"
31254
+ });
31255
+ return [
31256
+ task("Pack module", ["module", "pack", "$ZED_WORKTREE_ROOT"]),
31257
+ // --force matches the extension's dev-loop default: without it the CLI
31258
+ // skips the reinstall when the version hasn't changed and your edits
31259
+ // silently never reach the tenant.
31260
+ task("Install module to tenant", [
31261
+ "module",
31262
+ "install",
31263
+ "--path",
31264
+ "$ZED_WORKTREE_ROOT",
31265
+ "--url",
31266
+ "$DFORGE_URL",
31267
+ "--force"
31268
+ ]),
31269
+ task("Sign in to tenant", [
31270
+ "auth",
31271
+ "login",
31272
+ "--url",
31273
+ "$DFORGE_URL",
31274
+ "--login-url",
31275
+ "$DFORGE_URL"
31276
+ ]),
31277
+ task("Show signed-in user", ["auth", "whoami", "--url", "$DFORGE_URL"]),
31278
+ task("List installed modules", ["module", "list", "--url", "$DFORGE_URL"])
31279
+ ];
31211
31280
  }
31212
31281
 
31213
31282
  // src/tools/_helpers.ts
@@ -31759,6 +31828,8 @@ function createModuleFiles(args) {
31759
31828
  writeText(".gitignore", buildGitignore());
31760
31829
  write(".vscode/settings.json", buildVscodeSettings());
31761
31830
  write(".zed/settings.json", buildZedSettings());
31831
+ const buildZedTasks2 = buildZedTasks;
31832
+ if (buildZedTasks2) write(".zed/tasks.json", buildZedTasks2());
31762
31833
  if (opts.preset === "full") {
31763
31834
  write("settings.json", buildSettings());
31764
31835
  write("translations/en-US.json", buildTranslations(opts));
@@ -35182,6 +35253,8 @@ ${warnings.map((w) => ` \u2022 ${w}`).join("\n")}` : void 0
35182
35253
  }
35183
35254
 
35184
35255
  // src/tools/adds.ts
35256
+ var fs10 = __toESM(require("fs"));
35257
+ var path13 = __toESM(require("path"));
35185
35258
  var reportAddSchema = {
35186
35259
  moduleDir: external_exports.string(),
35187
35260
  code: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).describe("Report code (keys ui/reports.json)."),
@@ -35326,16 +35399,23 @@ function folderAdd(args) {
35326
35399
  }
35327
35400
  );
35328
35401
  }
35402
+ var SYSTEM_MODULES = /* @__PURE__ */ new Set(["admin", "metadata", "workspace"]);
35329
35403
  var dependencyAddSchema = {
35330
35404
  moduleDir: external_exports.string(),
35331
35405
  moduleCode: external_exports.string().regex(/^[a-z][a-z0-9_-]*$/).describe("Module to depend on."),
35332
35406
  version: external_exports.string().default(">=0.1.0").describe("Semver range."),
35333
- entities: external_exports.array(external_exports.string()).optional().describe(
35334
- "If only specific entities from the dependency are used, list them \u2014 produces the object-style dep `{ version, entities }` for partial coupling. Omit to depend on the whole module."
35407
+ entities: external_exports.array(external_exports.string()).min(1).describe(
35408
+ "The provider entities this module consumes, e.g. ['party']. Required: every dependency needs a deps/<module>.json contract naming at least one entity, and a dependency you cannot name a consumed entity for should not be declared at all. Also narrows the manifest dep to the object form `{ version, entities }`."
35409
+ ),
35410
+ use: external_exports.array(external_exports.string()).optional().describe(
35411
+ "Provenance tokens recorded against each declared entity: '<kind>:<symbol>' where kind is ref|extends|formula|action|view|report|print|role, e.g. 'ref:invoice.customer'. 'extends' must be fully qualified ('extends:parties.party'). Defaults to a placeholder you must refine."
35412
+ ),
35413
+ pks: external_exports.record(external_exports.string(), external_exports.string()).optional().describe(
35414
+ `Override the provider PK column per entity, e.g. { "party": "party_uid" }. Defaults to '<entity>_id', which is what the 'identity' trait produces.`
35335
35415
  )
35336
35416
  };
35337
35417
  function dependencyAdd(args) {
35338
- const { manifest } = loadManifest(args.moduleDir);
35418
+ const { manifest, paths } = loadManifest(args.moduleDir);
35339
35419
  if (manifest.code === args.moduleCode) {
35340
35420
  throw new Error("A module can't depend on itself.");
35341
35421
  }
@@ -35345,14 +35425,57 @@ function dependencyAdd(args) {
35345
35425
  `Dependency on '${args.moduleCode}' already exists. Edit manifest.json directly to change the version.`
35346
35426
  );
35347
35427
  }
35348
- const value = args.entities && args.entities.length > 0 ? { version: args.version, entities: args.entities } : args.version;
35428
+ const contractPath = path13.join(paths.root, "deps", `${args.moduleCode}.json`);
35429
+ if (fs10.existsSync(contractPath)) {
35430
+ throw new Error(
35431
+ `deps/${args.moduleCode}.json already exists but '${args.moduleCode}' is not a manifest dependency. Add the manifest entry by hand or delete the stale contract \u2014 this tool will not overwrite it.`
35432
+ );
35433
+ }
35349
35434
  const newManifest = withTodayStamp({
35350
35435
  ...manifest,
35351
- dependencies: { ...deps, [args.moduleCode]: value }
35436
+ dependencies: {
35437
+ ...deps,
35438
+ [args.moduleCode]: { version: args.version, entities: args.entities }
35439
+ }
35352
35440
  });
35441
+ const placeholderUse = !args.use || args.use.length === 0;
35442
+ const use = placeholderUse ? [`ref:${manifest.code}`] : args.use;
35443
+ const contract = {
35444
+ module: args.moduleCode,
35445
+ version: args.version,
35446
+ entities: Object.fromEntries(
35447
+ args.entities.map((e) => [
35448
+ e,
35449
+ { pk: args.pks?.[e] ?? `${e}_id`, use: [...use] }
35450
+ ])
35451
+ )
35452
+ };
35453
+ const warnings = [];
35454
+ if (placeholderUse) {
35455
+ warnings.push(
35456
+ `deps/${args.moduleCode}.json was written with a placeholder provenance token 'ref:${manifest.code}'. Replace it with the real usage (e.g. 'ref:<my_entity>.<my_column>', 'extends:${args.moduleCode}.<entity>', 'role:<role_cd>') \u2014 it is the record of WHY the dependency exists.`
35457
+ );
35458
+ }
35459
+ if (!args.pks) {
35460
+ warnings.push(
35461
+ `PKs were guessed as '<entity>_id'. Check them against ${args.moduleCode}'s entity files \u2014 a wrong pk fails the install-time satisfaction check.`
35462
+ );
35463
+ }
35464
+ if (SYSTEM_MODULES.has(args.moduleCode)) {
35465
+ warnings.push(
35466
+ `'${args.moduleCode}' is a system module \u2014 always provisioned, so this dependency is only meaningful as a minimum-platform-version gate. If you are not gating a platform feature, drop it.`
35467
+ );
35468
+ }
35469
+ warnings.push(
35470
+ `Declare the columns you actually read, write or join on under each entity's 'columns' \u2014 see dforge://schema/deps.`
35471
+ );
35353
35472
  return makeResult(
35354
- `Added dependency on '${args.moduleCode}' (${args.version})${args.entities ? ` for entities [${args.entities.join(", ")}]` : ""}.`,
35355
- { "manifest.json": jsonText(newManifest) }
35473
+ `Added dependency on '${args.moduleCode}' (${args.version}) for entities [${args.entities.join(", ")}], with the required deps/${args.moduleCode}.json contract.`,
35474
+ {
35475
+ "manifest.json": jsonText(newManifest),
35476
+ [`deps/${args.moduleCode}.json`]: jsonText(contract)
35477
+ },
35478
+ warnings.join("\n")
35356
35479
  );
35357
35480
  }
35358
35481
 
@@ -35396,8 +35519,8 @@ function roleRightSet(args) {
35396
35519
  }
35397
35520
 
35398
35521
  // src/tools/module-inspect.ts
35399
- var fs10 = __toESM(require("fs"));
35400
- var path13 = __toESM(require("path"));
35522
+ var fs11 = __toESM(require("fs"));
35523
+ var path14 = __toESM(require("path"));
35401
35524
  var moduleInspectSchema = {
35402
35525
  moduleDir: external_exports.string()
35403
35526
  };
@@ -35405,7 +35528,7 @@ function moduleInspect(args) {
35405
35528
  const { paths, manifest } = loadManifest(args.moduleDir);
35406
35529
  const entities = manifest.entities ?? {};
35407
35530
  const entitySummaries = Object.entries(entities).map(([name, relPath]) => {
35408
- const abs = path13.join(paths.root, relPath.replace(/^\.\//, ""));
35531
+ const abs = path14.join(paths.root, relPath.replace(/^\.\//, ""));
35409
35532
  const e = readJsonOrDefault(abs, {});
35410
35533
  const fields = e.fields ?? {};
35411
35534
  return {
@@ -35489,8 +35612,8 @@ function moduleInspect(args) {
35489
35612
  readJsonOrDefault(paths.printTemplates, {})
35490
35613
  );
35491
35614
  const domains = Object.keys(readJsonOrDefault(paths.domains, {}));
35492
- const seedFiles = fs10.existsSync(paths.seedDataDir) ? fs10.readdirSync(paths.seedDataDir).filter((f) => f.endsWith(".json")).sort() : [];
35493
- const translations = fs10.existsSync(paths.translationsDir) ? fs10.readdirSync(paths.translationsDir).filter((f) => f.endsWith(".json")).sort() : [];
35615
+ const seedFiles = fs11.existsSync(paths.seedDataDir) ? fs11.readdirSync(paths.seedDataDir).filter((f) => f.endsWith(".json")).sort() : [];
35616
+ const translations = fs11.existsSync(paths.translationsDir) ? fs11.readdirSync(paths.translationsDir).filter((f) => f.endsWith(".json")).sort() : [];
35494
35617
  const summary = {
35495
35618
  module: {
35496
35619
  code: manifest.code,
@@ -35534,8 +35657,8 @@ function computeDepth(folder, current = 1) {
35534
35657
  }
35535
35658
 
35536
35659
  // src/tools/module-validate.ts
35537
- var fs11 = __toESM(require("fs"));
35538
- var path14 = __toESM(require("path"));
35660
+ var fs12 = __toESM(require("fs"));
35661
+ var path15 = __toESM(require("path"));
35539
35662
  var moduleValidateSchema = {
35540
35663
  moduleDir: external_exports.string().describe("Path to the module root. Run this after authoring and before dforge_module_pack.")
35541
35664
  };
@@ -35555,12 +35678,12 @@ function hasVisibleScalarColumn(fields) {
35555
35678
  return false;
35556
35679
  }
35557
35680
  function resolveTranslationFile(translationsDir, locale) {
35558
- const exact = path14.join(translationsDir, `${locale}.json`);
35559
- if (fs11.existsSync(exact)) return exact;
35560
- if (!fs11.existsSync(translationsDir)) return void 0;
35681
+ const exact = path15.join(translationsDir, `${locale}.json`);
35682
+ if (fs12.existsSync(exact)) return exact;
35683
+ if (!fs12.existsSync(translationsDir)) return void 0;
35561
35684
  const want = `${locale}.json`.toLowerCase();
35562
- for (const f of fs11.readdirSync(translationsDir)) {
35563
- if (f.toLowerCase() === want) return path14.join(translationsDir, f);
35685
+ for (const f of fs12.readdirSync(translationsDir)) {
35686
+ if (f.toLowerCase() === want) return path15.join(translationsDir, f);
35564
35687
  }
35565
35688
  return void 0;
35566
35689
  }
@@ -35588,14 +35711,14 @@ function moduleValidate(args) {
35588
35711
  const fieldDefsOf = {};
35589
35712
  for (const [name, relPath] of Object.entries(entityMap)) {
35590
35713
  if (name.includes(".")) continue;
35591
- const abs = path14.join(paths.root, relPath.replace(/^\.\//, ""));
35592
- if (!fs11.existsSync(abs)) {
35714
+ const abs = path15.join(paths.root, relPath.replace(/^\.\//, ""));
35715
+ if (!fs12.existsSync(abs)) {
35593
35716
  err(`manifest.entities.${name}`, `points to '${relPath}' which does not exist on disk`);
35594
35717
  continue;
35595
35718
  }
35596
35719
  let e;
35597
35720
  try {
35598
- e = JSON.parse(fs11.readFileSync(abs, "utf8"));
35721
+ e = JSON.parse(fs12.readFileSync(abs, "utf8"));
35599
35722
  } catch (ex) {
35600
35723
  err(`entities/${name}.json`, `invalid JSON: ${ex.message}`);
35601
35724
  continue;
@@ -35831,7 +35954,7 @@ function moduleValidate(args) {
35831
35954
  const abs = resolveTranslationFile(paths.translationsDir, locale);
35832
35955
  if (abs) {
35833
35956
  try {
35834
- tx = JSON.parse(fs11.readFileSync(abs, "utf8"));
35957
+ tx = JSON.parse(fs12.readFileSync(abs, "utf8"));
35835
35958
  } catch {
35836
35959
  tx = null;
35837
35960
  }
@@ -35947,7 +36070,7 @@ function moduleValidate(args) {
35947
36070
  where,
35948
36071
  `script '${script}' must be a BARE filename \u2014 no path, no '.dsl' extension (e.g. "script": "${script.replace(/\.dsl$/, "").split(/[\\/]/).pop()}").`
35949
36072
  );
35950
- } else if (!fs11.existsSync(path14.join(paths.logicDir, "actions", `${script}.dsl`))) {
36073
+ } else if (!fs12.existsSync(path15.join(paths.logicDir, "actions", `${script}.dsl`))) {
35951
36074
  err(where, `script '${script}' has no file at logic/actions/${script}.dsl.`);
35952
36075
  }
35953
36076
  const ent = act.entityCode ?? act.entity;
@@ -35987,11 +36110,11 @@ function moduleValidate(args) {
35987
36110
  if (act && act in actions) {
35988
36111
  const script = actions[act]?.script;
35989
36112
  if (typeof script === "string") {
35990
- const dslPath = path14.join(paths.logicDir, "actions", `${script}.dsl`);
35991
- if (fs11.existsSync(dslPath)) {
36113
+ const dslPath = path15.join(paths.logicDir, "actions", `${script}.dsl`);
36114
+ if (fs12.existsSync(dslPath)) {
35992
36115
  let body = "";
35993
36116
  try {
35994
- body = fs11.readFileSync(dslPath, "utf8");
36117
+ body = fs12.readFileSync(dslPath, "utf8");
35995
36118
  } catch {
35996
36119
  }
35997
36120
  for (const issue2 of checkDsl(body, { viaJob: true })) {
@@ -36015,11 +36138,11 @@ function moduleValidate(args) {
36015
36138
  for (const [acode, a] of Object.entries(actions)) {
36016
36139
  const script = a?.script;
36017
36140
  if (typeof script !== "string" || !script) continue;
36018
- const dslPath = path14.join(paths.logicDir, "actions", `${script}.dsl`);
36019
- if (!fs11.existsSync(dslPath)) continue;
36141
+ const dslPath = path15.join(paths.logicDir, "actions", `${script}.dsl`);
36142
+ if (!fs12.existsSync(dslPath)) continue;
36020
36143
  let body;
36021
36144
  try {
36022
- body = fs11.readFileSync(dslPath, "utf8");
36145
+ body = fs12.readFileSync(dslPath, "utf8");
36023
36146
  } catch {
36024
36147
  continue;
36025
36148
  }
@@ -36177,7 +36300,7 @@ function moduleValidate(args) {
36177
36300
  }
36178
36301
  }
36179
36302
  const roleCodes = Object.keys(roles);
36180
- const localeFiles = fs11.existsSync(paths.translationsDir) ? fs11.readdirSync(paths.translationsDir).filter((f) => f.toLowerCase().endsWith(".json")).sort() : [];
36303
+ const localeFiles = fs12.existsSync(paths.translationsDir) ? fs12.readdirSync(paths.translationsDir).filter((f) => f.toLowerCase().endsWith(".json")).sort() : [];
36181
36304
  for (const raw of supportedLocales) {
36182
36305
  const locale = raw.trim();
36183
36306
  if (!locale) continue;
@@ -36197,7 +36320,7 @@ function moduleValidate(args) {
36197
36320
  for (const file2 of localeFiles) {
36198
36321
  let tx;
36199
36322
  try {
36200
- tx = JSON.parse(fs11.readFileSync(path14.join(paths.translationsDir, file2), "utf8"));
36323
+ tx = JSON.parse(fs12.readFileSync(path15.join(paths.translationsDir, file2), "utf8"));
36201
36324
  } catch (ex) {
36202
36325
  err(`translations/${file2}`, `invalid JSON: ${ex.message}`);
36203
36326
  continue;
@@ -36382,18 +36505,18 @@ function webhookAdd(args) {
36382
36505
  }
36383
36506
 
36384
36507
  // src/tools/apply.ts
36385
- var fs12 = __toESM(require("fs"));
36386
- var path15 = __toESM(require("path"));
36508
+ var fs13 = __toESM(require("fs"));
36509
+ var path16 = __toESM(require("path"));
36387
36510
  var applyInput = {
36388
36511
  apply: external_exports.boolean().default(false).describe(
36389
36512
  "When true, WRITE the resulting files to disk (and remove any `deletes`) instead of returning their contents for the client to write. The response then lists the paths touched. Use it for routine patches the user isn't reviewing line-by-line; leave it false when you want to preview a diff first."
36390
36513
  )
36391
36514
  };
36392
- var isReportPayload = (relPath) => path15.basename(relPath).startsWith("_");
36515
+ var isReportPayload = (relPath) => path16.basename(relPath).startsWith("_");
36393
36516
  function resolveInside(root, relPath) {
36394
- const abs = path15.resolve(root, relPath);
36395
- const rel2 = path15.relative(root, abs);
36396
- if (rel2.startsWith("..") || path15.isAbsolute(rel2)) {
36517
+ const abs = path16.resolve(root, relPath);
36518
+ const rel2 = path16.relative(root, abs);
36519
+ if (rel2.startsWith("..") || path16.isAbsolute(rel2)) {
36397
36520
  throw new Error(
36398
36521
  `Refusing to write '${relPath}' \u2014 it resolves outside the module directory (${abs}).`
36399
36522
  );
@@ -36401,7 +36524,7 @@ function resolveInside(root, relPath) {
36401
36524
  return abs;
36402
36525
  }
36403
36526
  function applyToDisk(moduleDir, result) {
36404
- const root = path15.resolve(moduleDir);
36527
+ const root = path16.resolve(moduleDir);
36405
36528
  const written = [];
36406
36529
  const skipped = [];
36407
36530
  for (const [relPath, contents] of Object.entries(result.files ?? {})) {
@@ -36410,8 +36533,8 @@ function applyToDisk(moduleDir, result) {
36410
36533
  continue;
36411
36534
  }
36412
36535
  const abs = resolveInside(root, relPath);
36413
- fs12.mkdirSync(path15.dirname(abs), { recursive: true });
36414
- fs12.writeFileSync(abs, contents, "utf8");
36536
+ fs13.mkdirSync(path16.dirname(abs), { recursive: true });
36537
+ fs13.writeFileSync(abs, contents, "utf8");
36415
36538
  written.push(relPath);
36416
36539
  }
36417
36540
  const deleted = [];
@@ -36419,7 +36542,7 @@ function applyToDisk(moduleDir, result) {
36419
36542
  const abs = resolveInside(root, relPath);
36420
36543
  let stat;
36421
36544
  try {
36422
- stat = fs12.lstatSync(abs);
36545
+ stat = fs13.lstatSync(abs);
36423
36546
  } catch {
36424
36547
  continue;
36425
36548
  }
@@ -36428,7 +36551,7 @@ function applyToDisk(moduleDir, result) {
36428
36551
  `Refusing to delete '${relPath}': it is a directory, and \`deletes\` is a file-only contract (it exists to remove a renamed entity's old JSON or an orphaned seed file). Remove the directory by hand if that is really what you want.`
36429
36552
  );
36430
36553
  }
36431
- fs12.rmSync(abs, { force: true });
36554
+ fs13.rmSync(abs, { force: true });
36432
36555
  deleted.push(relPath);
36433
36556
  }
36434
36557
  const out = {
@@ -36443,10 +36566,10 @@ function applyToDisk(moduleDir, result) {
36443
36566
  }
36444
36567
 
36445
36568
  // src/resources/index.ts
36446
- var fs13 = __toESM(require("fs"));
36447
- var path16 = __toESM(require("path"));
36448
- var RESOURCES_DIR = path16.resolve(__dirname, "..", "resources");
36449
- var SKILL_DIR = path16.resolve(__dirname, "..", "skills", "dforge-mcp-author");
36569
+ var fs14 = __toESM(require("fs"));
36570
+ var path17 = __toESM(require("path"));
36571
+ var RESOURCES_DIR = path17.resolve(__dirname, "..", "resources");
36572
+ var SKILL_DIR = path17.resolve(__dirname, "..", "skills", "dforge-mcp-author");
36450
36573
  function schema(name, label, description) {
36451
36574
  return {
36452
36575
  uri: `dforge://schema/${name}`,
@@ -36498,6 +36621,11 @@ var resources = [
36498
36621
  "Module manifest JSON schema",
36499
36622
  "JSON Schema for manifest.json. Consult before emitting manifest content \u2014 covers required fields, semver patterns, and the entities map."
36500
36623
  ),
36624
+ schema(
36625
+ "deps",
36626
+ "Dependency contract JSON schema",
36627
+ "JSON Schema for deps/<module_code>.json \u2014 the import list of what this module consumes from one dependency. Every manifest 'dependencies' entry needs one or the package will not validate, pack or install."
36628
+ ),
36501
36629
  schema(
36502
36630
  "entity",
36503
36631
  "Entity JSON schema",
@@ -36639,20 +36767,20 @@ var resources = [
36639
36767
  columnSecurityExample("README.md", "Why entity views exist, what each of the two views demonstrates, and the five ways a view fails the install.")
36640
36768
  ];
36641
36769
  function readVendored(rel2) {
36642
- const p = path16.join(RESOURCES_DIR, rel2);
36643
- if (!fs13.existsSync(p)) {
36770
+ const p = path17.join(RESOURCES_DIR, rel2);
36771
+ if (!fs14.existsSync(p)) {
36644
36772
  return `// Resource missing at build time: ${rel2}
36645
36773
  // Run scripts/vendor-resources.sh in dforge-mcp to refresh from dForge-core.`;
36646
36774
  }
36647
- return fs13.readFileSync(p, "utf8");
36775
+ return fs14.readFileSync(p, "utf8");
36648
36776
  }
36649
36777
  function readSkill(rel2) {
36650
- const p = path16.join(SKILL_DIR, rel2);
36651
- if (!fs13.existsSync(p)) {
36778
+ const p = path17.join(SKILL_DIR, rel2);
36779
+ if (!fs14.existsSync(p)) {
36652
36780
  return `// Skill resource missing: ${rel2}
36653
36781
  // Expected under skills/dforge-mcp-author/ \u2014 check the package 'files' list.`;
36654
36782
  }
36655
- return fs13.readFileSync(p, "utf8");
36783
+ return fs14.readFileSync(p, "utf8");
36656
36784
  }
36657
36785
 
36658
36786
  // src/server.ts
@@ -37020,7 +37148,7 @@ server.registerTool(
37020
37148
  "dforge_dependency_add",
37021
37149
  {
37022
37150
  title: "Add a module dependency",
37023
- description: "Add a dependency on another dForge module to manifest.json. Use the entities list form when only specific entities are imported (smaller coupling).",
37151
+ description: "Add a dependency on another dForge module. Writes BOTH halves the platform requires: the manifest.json entry and the deps/<module>.json contract declaring what this module consumes \u2014 a manifest dependency without a contract fails validate, pack and install. Name the consumed entities in `entities`; do not declare a dependency you cannot name one for, and do not depend on the system modules (admin, metadata, workspace) except to gate a minimum platform version.",
37024
37152
  inputSchema: { ...dependencyAddSchema, ...applyInput }
37025
37153
  },
37026
37154
  envelope(dependencyAdd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
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,7 +35,7 @@
35
35
  "install-skills": "node scripts/install-skills.mjs"
36
36
  },
37
37
  "dependencies": {
38
- "@dforge-core/dforge-cli": "^0.2.16",
38
+ "@dforge-core/dforge-cli": "^0.2.17",
39
39
  "@dforge-core/metadata": "^0.0.22",
40
40
  "@modelcontextprotocol/sdk": "^1.30.0",
41
41
  "zod": "^4.4.3"
@@ -35,7 +35,7 @@ Lives in: `logic/jobs.json` at the module root. Schema: [`docs/schemas/jobs.sche
35
35
  | `concurrency` | No | `1` | Max parallel runs `[1, 5]`. `1` = single-instance. |
36
36
  | `idempotencyKey` | No | | Template like `"{job_cd}:{run.month}"`. Successful run with same key blocks re-dispatch. |
37
37
  | `timeZone` | No | tenant tz | IANA name. Falls back to `auth.tenant.time_zone`. |
38
- | `params` | No | `{}` | Static parameters passed to the action as `__params`. For runtime values, use folder-scoped module settings. |
38
+ | `params` | No | `{}` | Static parameters passed to the action as `__params`. Manifest-fixed — an admin cannot change them. For tenant-configurable values use module settings, resolved from the job's folder (below). |
39
39
  | `enabled` | No | `true` | `false` fully disables — no scheduler, no manual trigger. |
40
40
  | `paused` | No | `false` | Soft pause. Scheduler skips, but "Run now" still works. |
41
41
  | `description` | No | | Human-readable description. |
@@ -58,10 +58,21 @@ minute hour day-of-month month day-of-week
58
58
  Scheduled actions run with **no record bound**. Specifically:
59
59
 
60
60
  - `user_id = 0` (system user sentinel)
61
- - No `folder_id` — query / insert against the tenant DB directly
61
+ - A folder, but no record — see **Folder context** below
62
62
  - `notify()` writes to inbox only (no live SSE in Phase 1)
63
63
  - `sendEmail()` raw mode only in Phase 1
64
64
 
65
+ ### Folder context
66
+
67
+ A cron fire has no user, so nothing supplies a folder the way a request does. The scheduler binds one anyway, because `getSetting()` and `nextNumber()` resolve by walking a folder chain:
68
+
69
+ 1. `scheduled_job.folder_id`, if an admin bound one (Admin → Scheduled Jobs → **Change folder**)
70
+ 2. Otherwise the owning module's **root folder** — where the module's own settings page writes
71
+
72
+ So a setting an admin sets on the module's settings page reaches that module's jobs with no configuration. There is **no `folder` key in the manifest**: the binding is operator state, and module upgrades never overwrite it.
73
+
74
+ Inheritance stops at a folder whose `inherit_security` is false, exactly as it does for a user request.
75
+
65
76
  **Record context is forbidden.** The install pipeline rejects any action whose compiled DSL references `[field]` or `for x in records { … }` — those compile to `__r.` / `__records.` which would `ReferenceError` at fire time. Refactor to operate via `select()` / `insert()` / `update()`:
66
77
 
67
78
  ```
@@ -18,9 +18,7 @@ Every dForge module has a `manifest.json` at its root. This file declares the mo
18
18
  "category": "other",
19
19
  "tags": ["tag1", "tag2"],
20
20
  "icon": "bi-box",
21
- "dependencies": {
22
- "admin": ">=0.0.1"
23
- },
21
+ "dependencies": {},
24
22
  "created": "2026-04-08",
25
23
  "updated": "2026-04-08",
26
24
  "entities": {
@@ -50,7 +48,7 @@ Every dForge module has a `manifest.json` at its root. This file declares the mo
50
48
  | `category` | string | Marketplace category slug (e.g. `"sales"`, `"hr"`, `"other"`) |
51
49
  | `tags` | string[] | Search tags |
52
50
  | `icon` | string | Bootstrap icon name (e.g. `"bi-briefcase"`) or similar |
53
- | `dependencies` | object | Map of `module_code → semver range` |
51
+ | `dependencies` | object | Map of `module_code → semver range`. Each entry also requires a `deps/<module_code>.json` contract — see [Dependencies](#dependencies). |
54
52
  | `created`, `updated` | ISO date | Timestamps |
55
53
 
56
54
  ## Content declarations
@@ -107,23 +105,50 @@ Dependencies are a map of module codes to semver ranges. The installer checks th
107
105
 
108
106
  ```json
109
107
  "dependencies": {
110
- "admin": ">=0.0.1",
111
108
  "fin": ">=0.1.0 <0.2.0"
112
109
  }
113
110
  ```
114
111
 
115
- **Always depend on `admin`** unless you have a very unusual module — admin provides the user/role system every other module needs.
112
+ **Declare a dependency only on a module you actually consume something from** — an entity you reference with a link column, extend, grant rights on, or read in a view, report, formula or action. Every entry you write here obliges you to write a matching `deps/<module>.json` contract (below), so a speculative dependency is not free.
113
+
114
+ **Do not depend on `admin`.** It — like `metadata` and `workspace` — is a system module, provisioned into every tenant before any other module installs. Depending on it buys nothing. The one legitimate reason to name a system module is to require a **minimum platform version** for a feature you use, e.g. `"metadata": ">=1.5.0"` for record-report attachments; that still needs a contract naming the entity the feature introduced.
116
115
 
117
116
  **For bridge modules** (`crm-fin`, `wms-fin`, etc.), depend on both sides:
118
117
 
119
118
  ```json
120
119
  "dependencies": {
121
- "admin": ">=0.0.1",
122
120
  "crm": ">=0.1.0",
123
121
  "fin": ">=0.1.0"
124
122
  }
125
123
  ```
126
124
 
125
+ ### Dependency contracts (`deps/<module>.json`)
126
+
127
+ Every entry in `dependencies` needs a contract file at `deps/<module_code>.json` declaring what this module consumes from that dependency. `dforge_module_validate`, `dforge_module_pack` and the installer all fail without it. `dforge_dependency_add` writes both files for you.
128
+
129
+ ```json
130
+ {
131
+ "module": "fin",
132
+ "version": ">=0.1.0 <0.2.0",
133
+ "entities": {
134
+ "invoice": {
135
+ "pk": "invoice_id",
136
+ "use": ["ref:my_entity.invoice"],
137
+ "columns": {
138
+ "invoice_no": { "type": "varchar", "use": ["formula:my_entity.invoice_label"] }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ ```
144
+
145
+ - `version` must be the same range as the manifest's dependency entry.
146
+ - `entities` needs at least one entry; each needs a `pk` and a non-empty `use`.
147
+ - `columns` is optional — omit it when you touch no specific column of that entity. Declare only real storage columns you read, write or join on, at their **storage class** (`int8`, `varchar`, `numeric`, `bool`, `date`, `timestamptz`, …; a `cuid` PK is `int8`). Never declare a virtual column (`R`/`S`/`F`) or a column your own extension adds.
148
+ - `use` tokens are `<kind>:<symbol>` where kind is `ref`, `extends`, `formula`, `action`, `view`, `report`, `print` or `role`. `extends` must be fully qualified: `extends:parties.party`.
149
+
150
+ Full schema: `dforge://schema/deps`.
151
+
127
152
  ## Extension entities
128
153
 
129
154
  Modules can extend entities owned by other modules. Declare these in `entities` with dotted keys:
@@ -142,7 +167,7 @@ The extension file has `"extends": "fin.invoice"` inside. See MODULE_CONVENTIONS
142
167
  - **Do not** add a `translations` key (e.g. `"translations": { "en-US": "..." }`). There is **no** such manifest field — translation files are auto-discovered at `./translations/{locale}.json`, and non-English locales are declared in `supportedLocales` (English is never listed). The manifest schema is `additionalProperties: false`, so a stray `translations` key fails install.
143
168
  - **Do not** put entity definitions inline. Always reference external files.
144
169
  - **Do not** list sample or test files — only content that ships with the module.
145
- - **Do not** include `system: true` unless this is a dForge platform module (`admin`, `metadata`). Regular modules omit it (defaults to `false`).
170
+ - **Do not** include `system: true` unless this is a dForge platform module (`admin`, `metadata`, `workspace`). Regular modules omit it (defaults to `false`).
146
171
  - **Do not** hardcode absolute paths. Everything is relative to the manifest.
147
172
 
148
173
  ## Versioning notes
@@ -70,13 +70,17 @@ From number sequence patterns:
70
70
 
71
71
  Settings resolve at runtime through the folder hierarchy:
72
72
 
73
- 1. The folder the user is currently in → its override (if any)
73
+ 1. The context's folder → its override (if any)
74
74
  2. Parent folder → override (if any)
75
75
  3. ... up the tree ...
76
76
  4. Module default (from `settings.json`)
77
77
 
78
78
  This means different folders can have different VAT rates, different prefixes, different currencies — without any code changes.
79
79
 
80
+ The walk stops at a folder whose `inherit_security` is false; that folder's own override still applies, but it does not inherit from its parent.
81
+
82
+ **Which folder is "the context's folder"** depends on what is reading. A user request uses the folder the user navigated to; an action uses the folder of the record or the queue entry; a **scheduled job** uses `scheduled_job.folder_id`, defaulting to its module's root folder. A job is the one context with no user, which is why it carries an explicit binding — without one it would see only the module default, and module defaults are not writable through the API (`settings.set` requires a folder).
83
+
80
84
  ## Field types supported in settings
81
85
 
82
86
  Any field type that makes sense for a single scalar value: