@dforge-core/dforge-mcp 0.2.47 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +2 -1
  3. package/skills/dforge-1c-import/scripts/README.md +27 -0
  4. package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
  5. package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
  6. package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
  7. package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
  8. package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
  9. package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
  10. package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
  11. package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
  12. package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
  13. package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
  14. package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
  15. package/skills/dforge-1c-import/scripts/lib/delta.py +40 -12
  16. package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
  17. package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
  18. package/skills/dforge-mcp-author/references/action-dsl.md +48 -45
  19. package/skills/dforge-mcp-author/references/formulas.md +6 -5
  20. package/skills/dforge-mcp-author/references/jobs.md +1 -1
  21. package/skills/dforge-mcp-author/references/registers.md +2 -0
  22. package/skills/dforge-mcp-author/references/security.md +2 -0
  23. package/skills/dforge-mcp-author/references/validation-checklist.md +6 -8
package/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ **1C migration readiness and regressions.** `delta --apply` now reports per-module
10
+ `ready` / `partial` status and exits 2 if state, totals or imported markers remain
11
+ unsettled; retained retry markers are settled on a later run even without new rows.
12
+ Dry runs do not certify readiness. `pnpm test:1c` covers conversion precision,
13
+ reference/date semantics, failed settlement/retry and the per-entity reload restart
14
+ contract using synthetic fixtures and mocked database boundaries. The loader remains
15
+ a non-atomic migration-target reload; its restart procedure is documented.
16
+ A new CI job (`.github/workflows/onec-tests.yml`) runs it on changes to the 1C scripts or tests. A module's retry
17
+ markers (`df1c.unsettled`) are now cleared only for the entities it settled, not for the whole module.
18
+ Placeholder, balance-state and yearly totals writers roll back failed transactions before
19
+ restoring autocommit; settlement continues to the next module only after connection
20
+ recovery, and failed recovery stops the command. Strict transaction-state tests cover
21
+ original-error preservation, partial yearly commits, continuation and unrecoverable connections.
22
+ Jobs authoring/schema guidance now describes `idempotencyKey` as reserved and not enforced.
23
+
24
+ **Action scripts bypass entity rights.** `action-dsl.md` and `security.md` now state that `insert()` / `update()` /
25
+ `delete()` / `query()` in an action script check neither entity rights nor column security nor the folder's row filter
26
+ (core #1580): only `E` on the action is checked, so treat `E` as write access to everything the script touches.
27
+
28
+ **`canExecute` is enforced in full on the server.** `action.execute` now computes arithmetic, `IN`, `BETWEEN`, formula
29
+ functions, a bare boolean column, `[ref].[col]` navigation and extension columns in SQL, instead of letting them through, and
30
+ `TODAY()` names the requester's day. A condition it cannot compute refuses the action. Install rejects `$[setting]`,
31
+ `SUM` / `COUNT` / `AVG` and unknown function names in `canExecute`, so a module that used them stops installing: read
32
+ settings and count children in `execute:` instead (a count belongs in a `G` column). `references/action-dsl.md`
33
+ (`canExecute:`), `formulas.md` and `validation-checklist.md` are rewritten to match. The button still evaluates over the
34
+ grid row, which has no extension columns and throws on `[ref].[col]`, so keep both out of a gate that must also disable the
35
+ button. **Needs platform 1.28.12.**
36
+
37
+ **Registers: `postAction` / `unpostAction`.** `references/registers.md` documents the register-binding column params that make a direct
38
+ `data.insert` / `data.update` flipping the state flag need `E` on the post or unpost action, and the "May edit is not may
39
+ post" pitfall: declare both and grant `E` only to the roles that may post. Each direction is checked only when its own key
40
+ is set; actions and DSL `update()` are not re-checked. A binding with neither gets a pack / validate warning, silenced by
41
+ `"editorMayPost": true`. **Needs a platform release after 1.28.12 (not in it).**
42
+
9
43
  ## 0.2.46 — 2026-10-29
10
44
 
11
45
  Requires `@dforge-core/dforge-cli` 0.2.37 and `@dforge-core/metadata` 0.0.47; the vendored `dforge://schema/data-views` now accepts the grouping keys.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.47",
3
+ "version": "0.2.48",
4
4
  "description": "MCP server for dForge module authoring. Exposes 35 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",
@@ -42,6 +42,7 @@
42
42
  "sync-schemas": "node scripts/vendor-schemas.cjs",
43
43
  "typecheck": "tsc --noEmit",
44
44
  "test": "vitest run",
45
+ "test:1c": "python3 -m unittest discover -s test/onec -v",
45
46
  "install-skills": "node scripts/install-skills.mjs"
46
47
  }
47
48
  }
@@ -162,3 +162,30 @@ reproducible.
162
162
  This copy is kept in step with the port it was developed on (`tools/lib`, `tools/1c.py`
163
163
  there). Fixes made in a port go back to the library here. A project's own files never
164
164
  come here.
165
+
166
+ ### Regression checks and restart contract
167
+
168
+ From the MCP repository root: `pnpm test:1c` (Python 3, standard library only).
169
+ These tests use synthetic inputs and mocked database boundaries, never a tenant or
170
+ source infobase. They cover conversions, COPY encoding, settlement failure/retry and
171
+ the initial loader's committed-per-entity restart contract; they do not replace
172
+ Parquet/DuckDB/PostgreSQL integration or real source-to-target reconciliation.
173
+
174
+ `delta --apply` prints each module's `ready` or `partial` status. Exit 0 means all
175
+ selected modules settled; exit 2 means data may have committed but settlement is
176
+ incomplete (state/totals drift or markers not converged, including `--no-mark`
177
+ when markers are needed). Resolve the reported problem and rerun `--apply`;
178
+ persisted unsettled entities are checked even when the new delta has no changes.
179
+ Dry runs report `dry_run` and do not certify readiness. Settlement write failures
180
+ roll back the current transaction before restoring autocommit; ordinary failures
181
+ report `partial` and allow the next module to run on a clean connection. Failure to
182
+ rollback or restore the connection stops the command immediately. Previously
183
+ committed entities/years remain committed; a rollback does not undo the whole import.
184
+ Exit 0 does not replace the wider migration reconciliation.
185
+
186
+ `migrate run --truncate` is a destructive reload into an isolated migration target,
187
+ not an atomic refresh of a live application: clearing commits before loading and
188
+ each entity commits separately. If interrupted, keep users out, repair the cause,
189
+ rerun the same complete selection with `--truncate` from the same export, then
190
+ reconcile again. Do not use an append rerun to recover a partially loaded target.
191
+ For an active target, load/reconcile a separate database and switch only afterward.
@@ -56,6 +56,7 @@ taken for a deletion. While 1C is the system of record, nothing is entered in th
56
56
  from __future__ import print_function
57
57
 
58
58
  import argparse
59
+ import collections
59
60
  import datetime
60
61
  import re
61
62
  import struct
@@ -65,6 +66,7 @@ import time
65
66
  import migrate
66
67
  import paths
67
68
  import store
69
+ import transactions
68
70
 
69
71
  SCHEMA = "df1c"
70
72
  KEYS_IN_LIST = 500
@@ -515,6 +517,14 @@ def begin(cur):
515
517
 
516
518
  # --------------------------------------------------------------------------- after a module
517
519
 
520
+ SettleResult = collections.namedtuple("SettleResult", "status notes")
521
+
522
+
523
+ def settlement_exit_code(outcomes, apply):
524
+ """Dry runs only estimate; an applied but unfinished module must block cutover."""
525
+ return 2 if apply and any(o.status != "ready" for o in outcomes) else 0
526
+
527
+
518
528
  def settle(pg, module, results, apply, mark_documents):
519
529
  """What a module needs once its rows moved: placeholders, balances, markers."""
520
530
  import posted
@@ -526,11 +536,11 @@ def settle(pg, module, results, apply, mark_documents):
526
536
  notes = []
527
537
  settled = True
528
538
  if apply and touched:
529
- cur = pg.cursor()
530
- cur.execute("BEGIN")
531
- cur.execute("SET LOCAL session_replication_role = 'replica'")
532
- filled = migrate.fill_missing(pg, schemas, verbose=False)
533
- cur.execute("COMMIT")
539
+ with transactions.writing(pg):
540
+ cur = pg.cursor()
541
+ cur.execute("SET LOCAL session_replication_role = 'replica'")
542
+ filled = migrate.fill_missing(pg, schemas, verbose=False)
543
+ pg.commit()
534
544
  if filled:
535
545
  notes.append("%d placeholder(s)" % sum(f[4] for f in filled))
536
546
 
@@ -539,9 +549,8 @@ def settle(pg, module, results, apply, mark_documents):
539
549
  if reg.journal_entity not in touched:
540
550
  continue
541
551
  if apply:
542
- pg.autocommit = False
543
- rows, written, _ = seedstate.seed(pg, reg)
544
- pg.autocommit = True
552
+ with transactions.writing(pg):
553
+ rows, written, _ = seedstate.seed(pg, reg)
545
554
  off = seedstate.drift(pg.cursor(), reg)
546
555
  settled = settled and not off
547
556
  notes.append("reseeded %s.%s: %d tuple(s), check %s"
@@ -587,8 +596,9 @@ def settle(pg, module, results, apply, mark_documents):
587
596
  else:
588
597
  notes.append("would converge markers on %d document table(s)" % len(work))
589
598
  if apply and touched and settled:
590
- pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s" % SCHEMA, (module,))
591
- return notes
599
+ pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s AND entity = ANY(%%s)"
600
+ % SCHEMA, (module, sorted(touched)))
601
+ return SettleResult("dry_run" if not apply else ("ready" if settled else "partial"), notes)
592
602
 
593
603
 
594
604
  # --------------------------------------------------------------------------- command
@@ -733,6 +743,7 @@ def main():
733
743
  print("-" * len(header))
734
744
  totals = [0, 0, 0, 0, 0.0]
735
745
  all_notes = []
746
+ outcomes = []
736
747
  for module in modules:
737
748
  results = []
738
749
  for m, job in plan:
@@ -753,7 +764,20 @@ def main():
753
764
  res.owners if kind in ("lines", "movements") else "",
754
765
  res.changed, res.new, res.deleted, res.read, res.compare, res.write))
755
766
  sys.stdout.flush()
756
- for note in settle(pg, module, results, args.apply, jwt if args.apply else None):
767
+ try:
768
+ outcome = settle(pg, module, results, args.apply, jwt if args.apply else None)
769
+ except transactions.ConnectionRecoveryError:
770
+ raise # Never continue on a connection whose cleanup failed.
771
+ except Exception as failure:
772
+ if not args.apply:
773
+ raise
774
+ transactions.recover(pg)
775
+ outcome = SettleResult("partial", ["settlement FAILED: %s: %s"
776
+ % (type(failure).__name__, failure)])
777
+ outcomes.append(outcome)
778
+ all_notes.append("%s: status %s%s" % (module, outcome.status,
779
+ "" if args.apply else " (estimate only, not a readiness verdict)"))
780
+ for note in outcome.notes:
757
781
  all_notes.append("%s: %s" % (module, note))
758
782
  print("-" * len(header))
759
783
  print("%d entities changed %d new %d deleted %d (owners/recorders replaced %d)"
@@ -763,7 +787,11 @@ def main():
763
787
  print(" " + note)
764
788
  print("(rows of an entity with nothing changed and under 2s are not listed;"
765
789
  " lines/movements count rows replaced as new/deleted)")
766
- return 0
790
+ code = settlement_exit_code(outcomes, args.apply)
791
+ if code:
792
+ print("delta incomplete (%s): do not cut over; resolve the notes and rerun --apply."
793
+ % ", ".join(m for m, o in zip(modules, outcomes) if o.status != "ready"))
794
+ return code
767
795
 
768
796
 
769
797
  if __name__ == "__main__":
@@ -50,6 +50,7 @@ import sys
50
50
  import time
51
51
 
52
52
  import migrate
53
+ import transactions
53
54
 
54
55
  GRANULARITIES = ("day", "month", "quarter", "year")
55
56
 
@@ -334,8 +335,7 @@ def rebuild(pg, plan):
334
335
 
335
336
  insert = ("INSERT INTO %s (%s) VALUES (%s)"
336
337
  % (plan.totals, ", ".join(key + cols), ", ".join(["%s"] * (len(key) + len(cols)))))
337
- pg.autocommit = False
338
- try:
338
+ with transactions.writing(pg):
339
339
  for year in years:
340
340
  cur.execute("SET LOCAL session_replication_role = 'replica'")
341
341
  cur.execute("DELETE FROM %s WHERE %s >= %%s AND %s < %%s"
@@ -344,8 +344,6 @@ def rebuild(pg, plan):
344
344
  if by_year.get(year):
345
345
  cur.executemany(insert, by_year[year])
346
346
  pg.commit()
347
- finally:
348
- pg.autocommit = True
349
347
  print("%-28s %7d rows written over %d year(s) checks %.0fs, fold %.0fs, write %.0fs"
350
348
  % (name(plan), len(rows), len(years), scanned - started, read - scanned,
351
349
  time.time() - read))
@@ -0,0 +1,28 @@
1
+ """Transaction cleanup shared by the migration settlement writers."""
2
+ from contextlib import contextmanager
3
+
4
+
5
+ class ConnectionRecoveryError(RuntimeError):
6
+ """The connection cannot safely be reused; abort rather than try another module."""
7
+
8
+
9
+ def recover(pg):
10
+ """Rollback before changing mode: psycopg2 forbids it in an active transaction."""
11
+ try:
12
+ pg.rollback()
13
+ pg.autocommit = True
14
+ except Exception as error:
15
+ raise ConnectionRecoveryError("cannot restore migration connection: %s" % error) from error
16
+
17
+
18
+ @contextmanager
19
+ def writing(pg):
20
+ """Writers commit explicitly; on any failure, rollback before restoring autocommit."""
21
+ pg.autocommit = False
22
+ try:
23
+ yield
24
+ except BaseException:
25
+ recover(pg)
26
+ raise
27
+ else:
28
+ pg.autocommit = True
@@ -117,53 +117,54 @@ Two consequences worth designing around:
117
117
  - **`canExecute: false` is the idiom for an action only automation may fire.** The
118
118
  server refuses it through `action.execute`; triggers and scheduled jobs invoke
119
119
  the script engine directly and are unaffected.
120
- - **Both evaluators have gaps, and both fail open.** The server is a scalar walker
121
- — comparisons between fields and literals, combined with `AND` / `OR` / `NOT`,
122
- nothing else — and lets anything richer through. The client implements the full documented formula
123
- function set, so ordinary functions are fine there; it throws (and its `catch`
124
- **enables** the button) on **ref navigation** (`[ref].target`), on the set
125
- aggregates **`SUM` / `COUNT` / `AVG`** (deliberately not formula functions —
126
- counting is a Generated `G` column's job), and on **any name it does not know, a
127
- typo included** — a `canExecute` is never validated against the function set at
128
- install, so `TOADY()` ships and enables the button for every record.
129
- - **`$[setting]` references are a separate case.** They do not throw: the client's
130
- resolver is stubbed to `null`, so the predicate resolves however that operator
131
- treats null (an ordered comparison against null is `false`, so it tends to
132
- *disable* the button), while the server has no settings resolver and fails open.
133
- Keep settings out of `canExecute` and read them in `execute:` instead.
134
-
135
- | tier | construct | client | server |
136
- |---|---|---|---|
137
- | 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
138
- | 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
139
- | 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
140
- | — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
141
-
142
- **A bare boolean column is not tier 1.** The walker evaluates conditions, not
143
- values — a field is only resolvable as an operand of a comparison — so
144
- `canExecute: [is_active]` and `canExecute: NOT [is_blocked]` fail open. Write
145
- `[is_active] = true` and `NOT ([is_blocked] = true)`. (`canExecute: false` is fine:
146
- a bare literal is a complete condition.)
147
-
148
- Mixing tier 1 and tier 2 is the normal, healthy shape: the client evaluates the
149
- whole thing so the button state is right, and the server short-circuits `AND` /
150
- `OR` so the tier-1 half is still enforced.
151
-
152
- **Tier 3 is what breaks.** The server short-circuits, the client does not — so
153
- `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record still
154
- evaluates `COUNT` in the browser, throws, and the button is **enabled**, then the
155
- server refuses the call: an error where a greyed-out button was wanted. And a
156
- `canExecute` is not validated against the function set at install, so that spelling
157
- ships silently. Count with a `G` column and compare the scalar
158
- (`[detail_count] > 0`); denormalise instead of navigating; put anything genuinely
159
- unexpressible in `execute:` behind `error()`.
120
+ - **The server evaluates the whole formula, and refuses what it cannot read.**
121
+ (Needs platform 1.28.12; older platforms let these through, so the gate is not enforced there.)
122
+ Comparisons between fields and literals (with `AND` / `OR` / `NOT`, a bare
123
+ `true` / `false`) are checked in-process; anything richer — arithmetic, `IN`,
124
+ `BETWEEN`, formula functions, a bare boolean field, `[ref].[col]`
125
+ navigation — is computed in SQL over the record, by the same translator as formula
126
+ columns (chained hops are followed up to a depth cap; a null ref gives `NULL`, which
127
+ counts as not true). A condition the server cannot evaluate (`$[setting]` references, set
128
+ aggregates `SUM` / `COUNT` / `AVG`, an unknown function name, a typo included)
129
+ **refuses the action** with `ACTION_EXECUTION_FAIL` ("cannot be evaluated by the
130
+ server") instead of allowing it. A `NULL` result counts as not true.
131
+ Comparisons themselves are null-safe and two-valued (see `formulas.md`), so an empty
132
+ field does not make `NOT ([x] = 'A')` unknown.
133
+ - **The client has gaps of its own, and its failures enable the button.** It
134
+ throws (and its `catch` **enables** the button) on **ref navigation**
135
+ (`[ref].target`), on **`SUM` / `COUNT` / `AVG`** (deliberately not formula
136
+ functions — counting is a Generated `G` column's job) and on **any name it does
137
+ not know, a typo included** (install refuses the last two, see below).
138
+ - **`$[setting]` references** resolve to `null` in the client (an ordered comparison
139
+ against null is `false`, so it tends to *disable* the button) and are refused by
140
+ the server and at install (platform 1.28.12 and later). Keep settings out of `canExecute` and read them in `execute:` instead.
141
+ - **`TODAY()`** names the requester's calendar day on the server too (their time
142
+ zone: the request's, else the user profile's, else the workspace's, else UTC; a profile zone of
143
+ `UTC` counts as unset). The button is evaluated in the browser and is meant to name the same
144
+ day; `execute:` stays the authority.
145
+ - **Install refuses** a `canExecute` that uses `$[setting]`, `SUM` / `COUNT` / `AVG`
146
+ or an unknown function name (a typo included), since the server could never run
147
+ the action. Read settings and count children in `execute:` instead.
148
+
149
+ | construct | client | server |
150
+ |---|---|---|
151
+ | comparisons between fields and literals, `AND` / `OR` / `NOT`, bare `true` / `false` | evaluates | evaluates |
152
+ | arithmetic, `IN`, `BETWEEN`, formula functions, bare boolean field | evaluates | evaluates (SQL) |
153
+ | `[ref].[col]` navigation | **throws, button enabled** | evaluates (SQL) |
154
+ | `SUM` / `COUNT` / `AVG`, unknown function (a typo included) | **throws, button enabled** | **refused at install**; if one is already installed, `action.execute` refuses it |
155
+ | `$[setting]` references | evaluates as null | **refused at install**; if already installed, `action.execute` refuses it |
156
+
157
+ Both evaluators agreeing is the healthy shape; the one to design around is a
158
+ construct the client throws on, which leaves a button that is enabled and then
159
+ refused. Count with a `G` column and compare the scalar (`[detail_count] > 0`);
160
+ denormalise instead of navigating; put anything genuinely unexpressible in
161
+ `execute:` behind `error()`.
160
162
 
161
163
  > Never make `canExecute` the *only* guard for something that must hold. Between
162
- > the fail-open rules on both sides, the enqueue-only check on background runs, and
163
- > the fact that the client evaluates over the grid row (which does not carry
164
- > extension columns) while the server evaluates over a full record load, the
165
- > authoritative check belongs in `execute:` — validate and `error()` before the
166
- > first write.
164
+ > the enqueue-only check on background runs and the fact that the client evaluates
165
+ > over the grid row (which does not carry extension columns) while the server
166
+ > evaluates over a full record load, the authoritative check belongs in
167
+ > `execute:` — validate and `error()` before the first write.
167
168
 
168
169
  ```
169
170
  canExecute:
@@ -226,6 +227,8 @@ var note = params[note] // no quotes around param name in this DSL
226
227
 
227
228
  ### Data operations
228
229
 
230
+ **No entity rights are checked inside a script.** `insert()`, `update()`, `delete()` and `query()` do not look at the signed-in user's `S`/`I`/`U`/`D` on the entity, nor at column security or the folder's row filter. Only `E` on the action is checked, when it is run. A user with `S` only on an entity can still change it through an action they may execute (core #1580). Treat `E` on an action as write access to everything its script touches; to keep a log free of changes, grant `E` on no action that writes to it.
231
+
229
232
  | Function | Returns | Description |
230
233
  |---|---|---|
231
234
  | `insert('entity', { field: value, … })` | Inserted row object | Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences). Access returned fields with `result.field_name`. |
@@ -11,11 +11,12 @@ Formulas are used in:
11
11
  `F` column otherwise: it must parse, call only implemented functions, and not
12
12
  aggregate over a 1:N set. See `security.md` → Column-level security.
13
13
  - **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
14
- and **enforced** by `action.execute`, but only over a narrow subset: comparisons
15
- between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
16
- arithmetic, ref navigation, `$[setting]` reads and a bare boolean field all fail
17
- open server-side, and some of them throw client-side. See the `canExecute:`
18
- section in [action-dsl.md](action-dsl.md).
14
+ and **enforced** by `action.execute`: the server computes the whole formula
15
+ (functions, arithmetic, navigation included) and **refuses** the call when
16
+ it cannot read it (`$[setting]` reads, `SUM` / `COUNT` / `AVG`, unknown
17
+ functions). The client throws on navigation, set aggregates and unknown names,
18
+ which leaves the button enabled. See the `canExecute:` section in
19
+ [action-dsl.md](action-dsl.md).
19
20
  - **Default values** — a setting's `formula` (e.g. `"formula": "TODAY()"`) or a formula (`F`) column. Note: entity *data* columns have **no** `default`/`defaultValue` key — model a default with an `F` column or set it in action/trigger logic.
20
21
  - **Filter expressions** (partially)
21
22
  - **Validation expressions** (partially)
@@ -33,7 +33,7 @@ Lives in: `logic/jobs.json` at the module root. Schema: [`docs/schemas/jobs.sche
33
33
  | `timeout` | Yes | | Hard timeout in seconds. Range `(0, 3600]`. **No implicit default.** `timeout > 300` requires `class: "long_running"`. |
34
34
  | `class` | No | `"standard"` | `"standard"` (≤ 300 s) or `"long_running"` (≤ 3600 s). Explicit opt-in for heavy jobs. |
35
35
  | `concurrency` | No | `1` | Max parallel runs `[1, 5]`. `1` = single-instance. |
36
- | `idempotencyKey` | No | | Template like `"{job_cd}:{run.month}"`. Successful run with same key blocks re-dispatch. |
36
+ | `idempotencyKey` | No | | **Not enforced yet.** Accepted and stored; the Scheduler does not read it. Scheduled slots deduplicate `(job, scheduled_at)`, not business keys across slots. Guard business idempotency inside the action; manual triggers skip the slot check. |
37
37
  | `timeZone` | No | tenant tz | IANA name. Falls back to `auth.tenant.time_zone`. |
38
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. |
@@ -188,6 +188,7 @@ A register column is a `bool` state column. `A` writes `balance`, `turnover` and
188
188
  | `lineDateField` | On a line posting, a line column dating that line's movement instead of `dateField`; an empty one falls back to `dateField`. With `periodEntity` each line's period must be open, judged by the movement's stamped date (line's or document's, after `dateTrunc`); lines skipped by `skipPostingWhenEmpty` don't count. On a `recordChangesOnly` info register, one line per key (several dates on a key are refused). A `date` or zoneless `timestamp`, in `lockedLineFields` (and `groupLinesBy` when grouped), not with `periodDimField`. |
189
189
  | `skipPostingWhenEmpty` | Dimensions whose emptiness means "this row posts nothing". The exception, see below. |
190
190
  | `lockedFields` / `lockedLineFields` | Columns frozen while posted. `["*"]` freezes the whole row. |
191
+ | `postAction` / `unpostAction` | Params of the binding column (an `A` or `L` column), codes of this entity's actions that post and unpost the document. A direct `data.insert` / `data.update` that turns the state flag on or off needs `E` on the one for its direction, as the button does; actions and DSL `update()` are not re-checked. Each direction is gated on its own: with only one declared, the other direction needs just Insert/Update. A binding with neither gets a pack / validate warning, silenced by `"editorMayPost": true` (not allowed together with an action name). A name no action declared for the entity in `ui/actions.json` carries is refused by `module pack` / validate and by the write. **Unreleased: not in platform 1.28.12.** |
191
192
  | `movementMapping` | Journal attribute column to document or line column. |
192
193
 
193
194
  ### `map` value forms
@@ -286,6 +287,7 @@ For a history loaded from another system, an `accounting` register may declare `
286
287
  - **One constant for two resources.** Two resources mapped to the same constant read as two resources storing into one column and are refused.
287
288
  - **Period missing from the calendar.** With `periodEntity`, a month that has no row in the period entity counts as **closed**, and posting into it fails (`DF003: Period 1999-04 does not exist`). Seed or load the periods before the documents that post into them.
288
289
  - **A summed column that isn't a number.** A resource of a `balance`, `turnover` or `accounting` register, and a `totals` cache's columns, must be a scalar number. The editor and language server warn (`registers/column-not-numeric`, metadata 0.0.46) on `varchar`, `integer[]` and the like. An `info` register's resources are returned, not summed, and may be any type.
290
+ - **"May edit" is not "may post".** Without `postAction` / `unpostAction` a role with Insert/Update on the document can post it through the API (`data.update` with the state flag) though it cannot run the Post action. **Unreleased: not in platform 1.28.12**, so do not declare these keys against that platform. Declare both (declaring one leaves the other direction open), and grant `E` on the actions (`"action:post_entry": "E"`) only to the roles that may post. Each name must be declared for that entity in the `ui/actions.json` of the package that owns the column; `module pack` refuses any other, including an action a bridge adds to the host's entity. `E` guards only the direct API path: an action with `E` that calls `update()` on the state flag can still post, so guard that inside its `execute:` (check the role or state and `error()`).
289
291
  - **Missing or unlocked `dateField`.** Required once the register has `journalDate` or `period`, and it must be in `lockedFields`.
290
292
  - **Key over dimensions.** The journal, a subordinate `info` register's records and an `autoCreate` state table are written by the platform. Each needs **one** surrogate primary key of type `bigint` (`identity`/`cuid`) or `uuid`. A key over dimensions, a multi-column key, a text key and `int4` are refused.
291
293
  - **State column narrower than the journal column.** It must hold the journal's value exactly, or unposting cannot take back what posting added.
@@ -81,6 +81,8 @@ Example:
81
81
  |---|---|
82
82
  | `E` | Execute (run the action/report/stored procedure, access the folder) |
83
83
 
84
+ **`E` on an action is not limited by the user's entity rights.** The gate is on running the action, not on the statements inside it: `insert()` / `update()` / `delete()` / `query()` in its script work on an entity where the user has only `S`. Entity rights apply to the grid, the card and the API, not to action scripts (core #1580).
85
+
84
86
  ### Common combinations
85
87
 
86
88
  | Rights | Meaning |
@@ -122,14 +122,12 @@ For each action:
122
122
  - [ ] DSL file exists at `logic/actions/<script>.dsl`, where `<script>` is the bare filename in the action's `script` field (no path, no `.dsl` extension)
123
123
  - [ ] DSL file has `params:`, `canExecute:`, and `execute:` blocks
124
124
  - [ ] `canExecute:` is a valid formula expression
125
- - [ ] If `canExecute:` is meant to **gate** and not just grey out a button, its
126
- guard is expressed in the server-enforced subset — **comparisons** between
127
- fields and literals, combined with `AND` / `OR` / `NOT`. Function calls
128
- (`TODAY()`, `COUNT()`, `CURRENT_USER_ID()`), arithmetic, ref navigation and
129
- `$[setting]` reads are fail-open server-side and are advisory only
130
- - [ ] No **bare boolean field** as a whole condition — `[is_active]` and
131
- `NOT [is_blocked]` are not enforced (a field is only an operand). Write
132
- `[is_active] = true` / `NOT ([is_blocked] = true)`
125
+ - [ ] `canExecute:` uses only constructs the server can evaluate — fields,
126
+ literals, comparisons, arithmetic, `IN`, formula functions, a bare boolean
127
+ field. `$[setting]` reads, `SUM` / `COUNT` / `AVG` and unknown function
128
+ names are **refused at install**. `[ref].[col]` works on the
129
+ server but the client throws and **enables** the button: denormalise it
130
+ into a column, and count with a `G` column
133
131
  - [ ] Anything that MUST hold is also checked in `execute:` with `error()` — a
134
132
  `canExecute` alone is never the authoritative guard
135
133
  - [ ] `execute:` uses only documented built-in functions