@dforge-core/dforge-mcp 0.1.0-rc.10 → 0.1.0-rc.11

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/dist/server.js CHANGED
@@ -31381,8 +31381,12 @@ function modulePaths(moduleDir) {
31381
31381
  menus: path3.join(root, "ui", "menus.json"),
31382
31382
  actions: path3.join(root, "ui", "actions.json"),
31383
31383
  reports: path3.join(root, "ui", "reports.json"),
31384
+ queries: path3.join(root, "ui", "queries.json"),
31385
+ printTemplates: path3.join(root, "ui", "print_templates.json"),
31384
31386
  roles: path3.join(root, "security", "roles.json"),
31385
31387
  jobs: path3.join(root, "logic", "jobs.json"),
31388
+ triggers: path3.join(root, "logic", "triggers.json"),
31389
+ webhooks: path3.join(root, "logic", "webhooks.json"),
31386
31390
  settings: path3.join(root, "settings.json")
31387
31391
  };
31388
31392
  }
@@ -31932,6 +31936,158 @@ function computeDepth(folder, current = 1) {
31932
31936
  return childDepths.length === 0 ? current : Math.max(...childDepths);
31933
31937
  }
31934
31938
 
31939
+ // src/tools/behavior.ts
31940
+ var triggerAddSchema = {
31941
+ moduleDir: external_exports.string(),
31942
+ code: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).describe("Trigger code, unique within the module."),
31943
+ entity: external_exports.string().describe(
31944
+ "Entity whose events fire this trigger. Cross-module dotted form supported ('fin.invoice')."
31945
+ ),
31946
+ event: external_exports.enum(["insert", "update", "delete", "status_change", "any"]).describe(
31947
+ "insert/update/delete are obvious. status_change fires only when status field changes value. 'any' fires for all insert/update/delete."
31948
+ ),
31949
+ action: external_exports.string().describe("Action code to invoke. Cross-module dotted form ('module.action') supported."),
31950
+ description: external_exports.string().optional(),
31951
+ condition: external_exports.string().optional().describe(
31952
+ "Optional formula expression (same shape as canExecute) using `[field]` syntax. Single-line boolean. If omitted, trigger fires on every matching event."
31953
+ ),
31954
+ params: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
31955
+ "Static params merged into the action invocation alongside auto-injected record_id."
31956
+ ),
31957
+ async: external_exports.boolean().default(false).describe(
31958
+ "When true, the action runs in background (recommended for slow actions). When false, runs in the same transaction \u2014 action failure rolls back the original DB change."
31959
+ )
31960
+ };
31961
+ function triggerAdd(args) {
31962
+ const { paths, manifest } = loadManifest(args.moduleDir);
31963
+ const file2 = readJsonOrDefault(
31964
+ paths.triggers,
31965
+ { triggers: [] }
31966
+ );
31967
+ if (file2.triggers.some((t) => t.code === args.code)) {
31968
+ throw new Error(`Trigger '${args.code}' already exists.`);
31969
+ }
31970
+ const entry = {
31971
+ code: args.code,
31972
+ entity: args.entity,
31973
+ event: args.event,
31974
+ action: args.action,
31975
+ async: args.async
31976
+ };
31977
+ if (args.description) entry.description = args.description;
31978
+ if (args.condition) entry.condition = args.condition;
31979
+ if (args.params) entry.params = args.params;
31980
+ file2.triggers.push(entry);
31981
+ return makeResult(
31982
+ `Added trigger '${args.code}' on ${args.entity}.${args.event} \u2192 action '${args.action}'${args.async ? " (async)" : ""}.`,
31983
+ {
31984
+ [rel(paths.root, paths.triggers)]: jsonText(file2),
31985
+ "manifest.json": jsonText(withTodayStamp(manifest))
31986
+ }
31987
+ );
31988
+ }
31989
+ var jobAddSchema = {
31990
+ moduleDir: external_exports.string(),
31991
+ code: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).describe("Job code, unique within the module."),
31992
+ action: external_exports.string().describe(
31993
+ "Action code to invoke on schedule. MUST NOT use record-context (`[field]`) syntax \u2014 scheduled jobs run as system user with no current record. Wrap record-context actions in a thin action that queries first."
31994
+ ),
31995
+ schedule: external_exports.string().describe(
31996
+ "5-field cron expression (minute hour day-of-month month day-of-week). Evaluated in the tenant's time zone (auth.tenant.time_zone) or per-job override."
31997
+ ),
31998
+ timeout: external_exports.number().int().min(1).max(3600).describe(
31999
+ "Hard kill timeout in seconds. Required. If > 300, you must also set `class: 'long_running'`."
32000
+ ),
32001
+ description: external_exports.string().optional(),
32002
+ concurrency: external_exports.number().int().min(1).default(1).describe("Max overlapping executions. Default 1 (skip new fire if previous still running)."),
32003
+ jobClass: external_exports.enum(["standard", "long_running"]).default("standard").describe(
32004
+ "Required to be 'long_running' when timeout > 300. Affects scheduler thread-pool routing."
32005
+ ),
32006
+ paused: external_exports.boolean().default(false)
32007
+ };
32008
+ function jobAdd(args) {
32009
+ if (args.timeout > 300 && args.jobClass !== "long_running") {
32010
+ throw new Error(
32011
+ "Jobs with timeout > 300s must set jobClass: 'long_running' \u2014 see logic/jobs.json convention."
32012
+ );
32013
+ }
32014
+ const { paths, manifest } = loadManifest(args.moduleDir);
32015
+ const file2 = readJsonOrDefault(
32016
+ paths.jobs,
32017
+ { jobs: [] }
32018
+ );
32019
+ if (file2.jobs.some((j) => j.code === args.code)) {
32020
+ throw new Error(`Job '${args.code}' already exists.`);
32021
+ }
32022
+ const entry = {
32023
+ code: args.code,
32024
+ action: args.action,
32025
+ schedule: args.schedule,
32026
+ timeout: args.timeout,
32027
+ concurrency: args.concurrency,
32028
+ class: args.jobClass
32029
+ };
32030
+ if (args.description) entry.description = args.description;
32031
+ if (args.paused) entry.paused = true;
32032
+ file2.jobs.push(entry);
32033
+ return makeResult(
32034
+ `Added job '${args.code}' (schedule '${args.schedule}', timeout ${args.timeout}s) \u2192 action '${args.action}'.`,
32035
+ {
32036
+ [rel(paths.root, paths.jobs)]: jsonText(file2),
32037
+ "manifest.json": jsonText(withTodayStamp(manifest))
32038
+ }
32039
+ );
32040
+ }
32041
+ var webhookAddSchema = {
32042
+ moduleDir: external_exports.string(),
32043
+ code: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).describe("Subscription code, unique within the module."),
32044
+ entity: external_exports.string().describe("Entity whose events fire this webhook."),
32045
+ event: external_exports.enum(["insert", "update", "delete", "status_change", "any"]).describe("Same event taxonomy as triggers."),
32046
+ endpointUrl: external_exports.string().url().describe("Destination URL \u2014 receives POST with the payload."),
32047
+ description: external_exports.string().optional(),
32048
+ condition: external_exports.string().optional().describe("Optional formula filter; fires only when true. Same shape as trigger condition."),
32049
+ payload: external_exports.object({
32050
+ include: external_exports.array(external_exports.string()).optional().describe("Whitelist of field codes to include. Omit to send all fields."),
32051
+ exclude: external_exports.array(external_exports.string()).optional().describe("Blacklist (applied after include). Use for stripping sensitive cols."),
32052
+ includeOld: external_exports.boolean().optional().describe(
32053
+ "For update/status_change events, also include the pre-change record as `old`."
32054
+ )
32055
+ }).optional().describe(
32056
+ "Payload shape. If omitted, sends full new record. The platform always wraps in `{ event, entity, record, old?, occurred_at }`."
32057
+ ),
32058
+ headers: external_exports.record(external_exports.string(), external_exports.string()).optional().describe(
32059
+ "Extra HTTP headers (e.g. authentication tokens). Use `getSecret(...)` semantics: header values starting with `$secret:` are resolved at fire time."
32060
+ )
32061
+ };
32062
+ function webhookAdd(args) {
32063
+ const { paths, manifest } = loadManifest(args.moduleDir);
32064
+ const file2 = readJsonOrDefault(
32065
+ paths.webhooks,
32066
+ { subscriptions: [] }
32067
+ );
32068
+ if (file2.subscriptions.some((s) => s.code === args.code)) {
32069
+ throw new Error(`Webhook subscription '${args.code}' already exists.`);
32070
+ }
32071
+ const entry = {
32072
+ code: args.code,
32073
+ entity: args.entity,
32074
+ event: args.event,
32075
+ endpointUrl: args.endpointUrl
32076
+ };
32077
+ if (args.description) entry.description = args.description;
32078
+ if (args.condition) entry.condition = args.condition;
32079
+ if (args.payload) entry.payload = args.payload;
32080
+ if (args.headers) entry.headers = args.headers;
32081
+ file2.subscriptions.push(entry);
32082
+ return makeResult(
32083
+ `Added webhook subscription '${args.code}' on ${args.entity}.${args.event} \u2192 POST ${args.endpointUrl}.`,
32084
+ {
32085
+ [rel(paths.root, paths.webhooks)]: jsonText(file2),
32086
+ "manifest.json": jsonText(withTodayStamp(manifest))
32087
+ }
32088
+ );
32089
+ }
32090
+
31935
32091
  // src/resources/index.ts
31936
32092
  var fs5 = __toESM(require("fs"));
31937
32093
  var path7 = __toESM(require("path"));
@@ -31994,7 +32150,17 @@ var resources = [
31994
32150
  schema(
31995
32151
  "webhooks",
31996
32152
  "Webhooks JSON schema",
31997
- "JSON Schema for ui/webhooks.json \u2014 outbound webhook definitions."
32153
+ "JSON Schema for logic/webhooks.json \u2014 outbound webhook subscriptions: entity + event filter \u2192 POST to endpointUrl with selected fields."
32154
+ ),
32155
+ schema(
32156
+ "triggers",
32157
+ "Triggers JSON schema",
32158
+ "JSON Schema for logic/triggers.json \u2014 intra-tenant DB-event \u2192 action invocation: entity + event + optional condition formula \u2192 fires an action (sync or async)."
32159
+ ),
32160
+ schema(
32161
+ "print-templates",
32162
+ "Print templates JSON schema",
32163
+ "JSON Schema for ui/print_templates.json \u2014 Liquid HTML print templates and reusable snippets, bound to entities for the print menu."
31998
32164
  ),
31999
32165
  schema(
32000
32166
  "settings",
@@ -32146,10 +32312,28 @@ server.tool(
32146
32312
  );
32147
32313
  server.tool(
32148
32314
  "dforge_action_add",
32149
- "PHASE 3: Add a DSL action targeting an entity. Writes logic/actions/<code>.dsl plus an entry in ui/actions.json. Phase 3 is optional \u2014 only call when business logic genuinely requires custom actions.",
32315
+ "PHASE 2: Add a DSL action targeting an entity. Writes logic/actions/<code>.dsl plus an entry in ui/actions.json. **Load dforge://docs/dsl before authoring.**",
32150
32316
  actionAddSchema,
32151
32317
  envelope(actionAdd)
32152
32318
  );
32319
+ server.tool(
32320
+ "dforge_trigger_add",
32321
+ "PHASE 2: Add a trigger that fires an action on a DB event (insert/update/delete/status_change/any) optionally gated by a condition formula. Appends to logic/triggers.json. **Use trigger when the platform should react to data changes WITHOUT user interaction**; use jobs for cron-driven; use webhooks for outbound HTTP.",
32322
+ triggerAddSchema,
32323
+ envelope(triggerAdd)
32324
+ );
32325
+ server.tool(
32326
+ "dforge_job_add",
32327
+ "PHASE 2: Schedule an existing action to fire on a 5-field cron. Appends to logic/jobs.json. Action must NOT use record-context (`[field]`) syntax \u2014 scheduled jobs run as system user with no current record.",
32328
+ jobAddSchema,
32329
+ envelope(jobAdd)
32330
+ );
32331
+ server.tool(
32332
+ "dforge_webhook_add",
32333
+ "PHASE 2: Subscribe an outbound HTTP endpoint to a DB event. Appends to logic/webhooks.json. Use for integrations with external systems (Slack, Zapier, custom dashboards).",
32334
+ webhookAddSchema,
32335
+ envelope(webhookAdd)
32336
+ );
32153
32337
  server.tool(
32154
32338
  "dforge_view_add",
32155
32339
  "PHASE 4: Add a data view to ui/data_views.json. viewType-specific viewConfig is supplied verbatim \u2014 pull dforge://schema/data-views first to know the shape.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.1.0-rc.10",
3
+ "version": "0.1.0-rc.11",
4
4
  "description": "MCP server for dForge module authoring. Exposes scaffold/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",
@@ -539,7 +539,7 @@ seed-data/
539
539
 
540
540
  ### 7. Scheduled Jobs (`logic/jobs.json`)
541
541
 
542
- Cron-driven action fires. Each entry pairs an existing action (declared in `ui/actions.json`) with a 5-field cron expression; the `dForge.Scheduler` worker fires it on schedule. Full reference: [module-package-format.md jobs.json](./module-package-format.md#jobsjson).
542
+ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/actions.json`) with a 5-field cron expression; the `dForge.Scheduler` worker fires it on schedule. Full reference: [Scheduled Jobs](../business-logic/jobs.md). Manifest schema: [`jobs.schema.json`](../schemas/jobs.schema.json).
543
543
 
544
544
  ```json
545
545
  {
@@ -926,7 +926,7 @@ When creating a module, ensure:
926
926
  - [ ] Constraints have clear user-facing `message` values
927
927
  - [ ] Check constraint `expression` uses standard SQL subset (test in PostgreSQL first)
928
928
  - [ ] Number sequences declared as `numberSequence` on entity definitions (auto-fills on INSERT, never manual counting)
929
- - [ ] Print templates defined in `ui/print_templates.json` with HTML files in `print_templates/`
929
+ - [ ] Print templates defined in `ui/print_templates.json` with HTML files in `print_templates/` (see [Print Templates](../ui/print-templates.md))
930
930
  - [ ] Scheduled jobs (if any) declared in `logic/jobs.json` with 5-field cron, explicit `timeout`, and `class: "long_running"` for any `timeout > 300`
931
931
 
932
932
  ---
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "dForge Module Print Templates",
4
+ "description": "Print template declarations for a dForge module package (ui/print_templates.json). Each key is the template_cd; the value declares either a printable template (default) or a reusable snippet pulled into other templates via {% include 'module_cd.snippet_cd' %}.",
5
+ "type": "object",
6
+ "additionalProperties": {
7
+ "type": "object",
8
+ "properties": {
9
+ "type": {
10
+ "type": "string",
11
+ "enum": ["template", "snippet"],
12
+ "default": "template",
13
+ "description": "'template' (default) — a full printable form bound to an entity. 'snippet' — a reusable Liquid partial, module-scoped, no entity binding, included from other templates."
14
+ },
15
+ "entityCd": {
16
+ "type": "string",
17
+ "description": "Entity code this template renders. Required when type='template'; ignored when type='snippet'. Cross-module form 'module.entity' is supported (e.g. fin-ch ships a template for fin.invoice)."
18
+ },
19
+ "description": {
20
+ "type": "string",
21
+ "description": "Human-readable label shown in the print menu."
22
+ },
23
+ "template": {
24
+ "type": "string",
25
+ "description": "Inline Liquid HTML. Either 'template' or 'file' must be set; 'file' takes precedence when both are present."
26
+ },
27
+ "file": {
28
+ "type": "string",
29
+ "description": "Relative path under the module's print_templates/ directory (e.g. 'invoice.html'). Loaded at install time."
30
+ },
31
+ "css": {
32
+ "type": "string",
33
+ "description": "Inline CSS, or a filename ending in .css (resolved relative to print_templates/). Snippets typically inherit CSS from the host template and omit this."
34
+ },
35
+ "pageSettings": {
36
+ "type": "object",
37
+ "description": "Default page geometry applied when the user prints from the UI. Stored as jsonb on the row; the editor lets users tweak per-template.",
38
+ "properties": {
39
+ "size": {
40
+ "type": "string",
41
+ "enum": ["A4", "letter", "legal"],
42
+ "default": "A4"
43
+ },
44
+ "orientation": {
45
+ "type": "string",
46
+ "enum": ["portrait", "landscape"],
47
+ "default": "portrait"
48
+ },
49
+ "margins": {
50
+ "type": "object",
51
+ "properties": {
52
+ "top": { "type": "string", "description": "CSS length, e.g. '15mm'." },
53
+ "right": { "type": "string" },
54
+ "bottom": { "type": "string" },
55
+ "left": { "type": "string" }
56
+ },
57
+ "additionalProperties": false
58
+ }
59
+ },
60
+ "additionalProperties": false
61
+ }
62
+ },
63
+ "additionalProperties": false,
64
+ "allOf": [
65
+ {
66
+ "if": { "properties": { "type": { "const": "snippet" } }, "required": ["type"] },
67
+ "then": {
68
+ "description": "Snippets must not declare entityCd and their template_cd must not contain '.' (collides with {% include 'module_cd.snippet_cd' %})."
69
+ },
70
+ "else": {
71
+ "required": ["entityCd"]
72
+ }
73
+ },
74
+ {
75
+ "anyOf": [
76
+ { "required": ["file"] },
77
+ { "required": ["template"] }
78
+ ]
79
+ }
80
+ ]
81
+ }
82
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://dforge.dev/schemas/triggers.schema.json",
4
+ "title": "dForge Module Triggers",
5
+ "description": "Trigger definitions for a dForge module (logic/triggers.json). A trigger watches an entity for an event (insert/update/delete/status_change/any), evaluates an optional formula condition using `[field]` syntax, and fires an action if true. Different from scheduled jobs (cron-driven) and webhooks (outbound HTTP) — triggers are intra-tenant DB-event-driven action invocations.",
6
+ "type": "object",
7
+ "required": ["triggers"],
8
+ "properties": {
9
+ "triggers": {
10
+ "type": "array",
11
+ "items": { "$ref": "#/$defs/trigger" }
12
+ }
13
+ },
14
+ "additionalProperties": false,
15
+ "$defs": {
16
+ "trigger": {
17
+ "type": "object",
18
+ "required": ["code", "entity", "event", "action"],
19
+ "properties": {
20
+ "code": {
21
+ "type": "string",
22
+ "pattern": "^[a-z][a-z0-9_]*$",
23
+ "description": "Trigger code, unique within the module."
24
+ },
25
+ "description": {
26
+ "type": "string",
27
+ "description": "Human-readable description of what the trigger does and when."
28
+ },
29
+ "entity": {
30
+ "type": "string",
31
+ "description": "Entity whose events this trigger watches. May be cross-module via dot notation (e.g. 'fin.invoice')."
32
+ },
33
+ "event": {
34
+ "type": "string",
35
+ "enum": ["insert", "update", "delete", "status_change", "any"],
36
+ "description": "DB event that fires this trigger. 'status_change' only fires when the entity's status field actually changes value. 'any' fires for all insert/update/delete."
37
+ },
38
+ "condition": {
39
+ "type": "string",
40
+ "description": "Optional formula expression — same shape as canExecute in action DSL. Uses `[field]` syntax to reference the affected record. Single-line, evaluates to boolean. If omitted, trigger fires for every matching event."
41
+ },
42
+ "action": {
43
+ "type": "string",
44
+ "description": "Action code to invoke. The action must NOT use record-context (`[field]`) syntax in a way that requires the original triggering record — the trigger passes the record id via params. Cross-module form 'module.action' is supported."
45
+ },
46
+ "params": {
47
+ "type": "object",
48
+ "description": "Optional static parameters passed to the action at invocation time. Merged with the runtime-injected `record_id` / `record_old` (if event uses old-record diff)."
49
+ },
50
+ "async": {
51
+ "type": "boolean",
52
+ "default": false,
53
+ "description": "When true, the action runs in the background — the triggering transaction commits before the action starts. Recommended for slow actions (emails, external API calls). When false, the action runs in the same transaction; failures roll back the original DB change."
54
+ }
55
+ },
56
+ "additionalProperties": false
57
+ }
58
+ }
59
+ }
@@ -175,15 +175,55 @@ A field is **batchable** only if ALL of these are true: scalar primitive (string
175
175
 
176
176
  **Exit criteria:** every entity has at least PK + audit traits + 1 user-visible field; FK references resolve; manifest's `entities` map reflects reality.
177
177
 
178
- ## Phase 2 — Actions (optional, skip-able)
178
+ ## Phase 2 — Behavior (optional sub-steps)
179
179
 
180
180
  **Preconditions:** Phase 1 complete.
181
181
 
182
- Skip entirely if the module is pure CRUD. Do **not** fabricate actions to fill the phase.
182
+ Phase 2 covers four kinds of behavior — all optional, all individually skip-able. Each fires action logic, but the **trigger** differs:
183
183
 
184
- When the user has a real business operation: **load `dforge://docs/dsl`** (the full action DSL reference — block structure, all 30 built-in functions, field-access syntax, batch-mode rules, JS subset, pitfalls), then call `dforge_action_add` per action — one at a time — with the full DSL body. `dforge://docs/conventions` is broader module-level guidance and does NOT cover the DSL grammar — load `docs/dsl` specifically.
184
+ | Sub-step | Fires when | File | Tool | Use when |
185
+ |---|---|---|---|---|
186
+ | 2a Actions | user clicks a button | `ui/actions.json` + `logic/actions/*.dsl` | `dforge_action_add` | bulk operations, business workflows, anything that needs user input via params |
187
+ | 2b Triggers | DB event happens (insert/update/delete/status_change) | `logic/triggers.json` | `dforge_trigger_add` | reactive automation: "when X happens, do Y" without user action |
188
+ | 2c Scheduled jobs | cron timer | `logic/jobs.json` | `dforge_job_add` | periodic work: nightly cleanup, daily summary, hourly poll |
189
+ | 2d Webhooks | DB event happens → POSTs to external URL | `logic/webhooks.json` | `dforge_webhook_add` | integrations: Slack notifications, Zapier, external dashboards, audit log shipping |
185
190
 
186
- **Exit criteria:** every action you added is intended, named, and has params/canExecute/execute blocks. Compilation is validated in Phase 6.
191
+ Skip a sub-step entirely if the user has no need for it. Do NOT fabricate behavior to fill a sub-step. Phase 2 can be completely skipped for pure CRUD modules.
192
+
193
+ ### 2a. Actions — user-triggered
194
+
195
+ **Load `dforge://docs/dsl`** (the full action DSL reference — block structure, all 30 built-in functions, field-access syntax, batch-mode rules, JS subset, common patterns, anti-patterns) before authoring any DSL. `dforge://docs/conventions` is broader module-level guidance and does NOT cover the DSL grammar.
196
+
197
+ Call `dforge_action_add` per action — one at a time — with the full DSL body. Confirm with the user before each call.
198
+
199
+ ### 2b. Triggers — DB-event-driven
200
+
201
+ **Load `dforge://schema/triggers`** for the shape; also re-read the trigger formula rules in `dforge://docs/dsl` (trigger conditions use the same syntax as `canExecute:`: single-line `[field] op value` formulas).
202
+
203
+ For each trigger, propose: entity + event + (optional) condition formula + target action + async flag. Use `dforge_trigger_add`. Triggers reference EXISTING actions — make sure the action exists first (Phase 2a or scaffolded admin actions).
204
+
205
+ **Async vs sync:** `async: true` runs the action in the background after the triggering transaction commits — recommended for slow actions (emails, external API calls). `async: false` runs in the same transaction; action failure rolls back the original DB change.
206
+
207
+ ### 2c. Scheduled jobs — cron-driven
208
+
209
+ **Load `dforge://schema/jobs`**.
210
+
211
+ Constraints baked into the tool:
212
+ - Action MUST NOT use record-context (`[field]`) syntax — jobs run as system user with NO current record. Wrap any record-context action in a thin job-friendly action that uses `query()` to fetch the records it needs.
213
+ - `timeout` is required, ≤ 3600s.
214
+ - If `timeout > 300`, you MUST set `jobClass: 'long_running'`.
215
+
216
+ Use `dforge_job_add` per job.
217
+
218
+ ### 2d. Webhooks — outbound HTTP
219
+
220
+ **Load `dforge://schema/webhooks`**.
221
+
222
+ For each webhook: entity + event + endpoint URL + (optional) condition + (optional) payload shape (include/exclude/includeOld). Use `dforge_webhook_add`.
223
+
224
+ For authenticated endpoints: put bearer tokens / API keys behind `getSecret()` (configure secret in module's secrets), reference in headers as `"Authorization": "$secret:<secret_cd>"` — the platform resolves at fire time.
225
+
226
+ **Exit criteria for Phase 2:** every action / trigger / job / webhook you added is intended (user-requested, not fabricated to fill space) and references existing entities + actions. Compilation is validated at install in Phase 6.
187
227
 
188
228
  ## Phase 3 — Views (required) + Reports (optional)
189
229