@frontera-sdk/cli 1.52.39 → 1.52.41

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 (235) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +49 -5
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -0,0 +1,516 @@
1
+ import { actionSemanticConflicts, semanticFactsOf } from './action-semantic-fit'
2
+ import type { AuthoredFile } from './model'
3
+
4
+ /**
5
+ * The rules a governed Action file must follow that its shape alone does not
6
+ * show, checked against the tree before anything is written.
7
+ *
8
+ * Each rule is one the platform enforces, said here with the file's path and
9
+ * what to write instead, so an author learns it from `blueprint plan` rather
10
+ * than from a refusal at apply or, worse, at deploy:
11
+ *
12
+ * - `refused`: the Action draft route refuses the file (the service's Action
13
+ * contract: `action-definition-schema.ts`, `action-reference-resolution.ts`,
14
+ * and `action-semantic-fit.ts`: a value the set property's semantic type
15
+ * refuses, unless the Action is still as the workspace gave it: then it is
16
+ * `inherited`, a warning).
17
+ * `blueprint apply` stops before its first write, so a run never lands half.
18
+ * - `undeployable`: the file applies, but the write path the local runtime
19
+ * derives for it (`ontology-edit-plan.ts`) or the deployment's checks
20
+ * (`action-binding-service.ts`, `mutation-plan-schema.ts`) refuse it. Said as
21
+ * a warning: the Blueprint is still usable, only that Action does not run.
22
+ *
23
+ * Only what those validators check is checked here; anything else is left to
24
+ * them, so this is never stricter than the platform.
25
+ */
26
+
27
+ export interface ActionRuleProblems {
28
+ refused: string[]
29
+ undeployable: string[]
30
+ /** The Actions with a `refused` problem, by apiName. */
31
+ refusedActions: string[]
32
+ /**
33
+ * A semantic-type conflict in an Action still as the workspace gave it
34
+ * (`inheritedFromPull`): not a refusal. It is applied as the workspace runs
35
+ * it, and said as a warning, with whose fix it is.
36
+ */
37
+ inherited?: string[]
38
+ /** The Actions with an `inherited` line, by apiName: written to the local runtime as inherited. */
39
+ inheritedActions?: string[]
40
+ }
41
+
42
+ /** Is this Action's conflict, resting on these files, the workspace's? (`inheritedFromPull`) */
43
+ export type InheritedConflict = (action: string, refs: readonly string[]) => boolean
44
+
45
+ /** What starts every inherited conflict's line, so a reader of the apply's output can tell it from an adaptation. */
46
+ export const INHERITED_FROM_PULL = 'inherited from the workspace (unchanged since the pull)'
47
+
48
+ /** An inherited conflict's line: what it is, that it runs, and whose fix it is. */
49
+ export function inheritedConflictLine(message: string): string {
50
+ return `${INHERITED_FROM_PULL}: ${message} It is applied as the workspace runs it, and a request with such a value is refused when it is submitted; the Action's owner fixes its contract in the workspace.`
51
+ }
52
+
53
+ type Doc = Record<string, unknown>
54
+
55
+ const isRecord = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
56
+ const list = (v: unknown): Doc[] => (Array.isArray(v) ? v.filter(isRecord) : [])
57
+
58
+ /** An input's or output's apiName (`apiNameSchema`). */
59
+ export const ACTION_MEMBER_NAME = /^[a-z][A-Za-z0-9]{0,99}$/
60
+ /** An outcome's, criterion's or postcondition's code (`stableCodeSchema`). */
61
+ export const ACTION_STABLE_CODE = /^[a-z][a-z0-9_]{0,99}$/
62
+
63
+ const VALUE_KINDS = new Set(['string', 'number', 'boolean', 'date', 'timestamp', 'json', 'object_reference', 'geopoint'])
64
+ const VALUE_KEYS: Record<string, string[]> = {
65
+ string: ['kind', 'minLength', 'maxLength', 'pattern', 'enum'],
66
+ number: ['kind', 'integer', 'minimum', 'maximum'],
67
+ object_reference: ['kind', 'objectType', 'objectTypeId'],
68
+ }
69
+ const EDITS = new Set(['create', 'update', 'delete'])
70
+
71
+ const isNonNegativeInteger = (v: unknown) => typeof v === 'number' && Number.isInteger(v) && v >= 0
72
+
73
+ const PATTERN_MAX_LENGTH = 256
74
+ const PATTERN_MAX_REPEAT = 64
75
+
76
+ /**
77
+ * Why a string pattern is outside the subset the platform accepts, or null:
78
+ * the service's `isSafeBlueprintActionPattern`, said as a reason. It must match
79
+ * the whole value (^…$), use no groups or alternation, and have at most one
80
+ * variable-width repetition (`*`, `+`, `?`, `{m,n}`) of at most 64.
81
+ */
82
+ export function patternProblem(pattern: string): string | null {
83
+ if (pattern.length < 2 || pattern.length > PATTERN_MAX_LENGTH) return `must be 2 to ${PATTERN_MAX_LENGTH} characters`
84
+ if (!pattern.startsWith('^') || !pattern.endsWith('$')) return 'must match the whole value: start it with ^ and end it with $'
85
+ if (pattern !== pattern.normalize('NFC') || /[\u0000-\u001f\u007f]/.test(pattern)) return 'must be plain NFC text with no control characters'
86
+ try {
87
+ new RegExp(pattern)
88
+ } catch {
89
+ return 'is not a valid regular expression'
90
+ }
91
+ const body = pattern.slice(1, -1)
92
+ let i = 0
93
+ let width = 0
94
+ let variable = 0
95
+ while (i < body.length) {
96
+ const c = body[i]!
97
+ if (c === '\\') {
98
+ const escaped = body[i + 1]
99
+ if (!escaped || !/[dDwWsS^$\\.*+?()[\]{}|\-]/.test(escaped)) return `uses an escape (\\${escaped ?? ''}) outside the accepted set: \\d \\w \\s and their capitals, or an escaped symbol`
100
+ i += 2
101
+ } else if (c === '[') {
102
+ let j = i + 1
103
+ if (body[j] === '^') j += 1
104
+ const first = j
105
+ let closed = false
106
+ while (j < body.length) {
107
+ if (body[j] === '\\') {
108
+ if (j + 1 >= body.length) return 'ends inside a character class'
109
+ j += 2
110
+ continue
111
+ }
112
+ if (body[j] === ']') {
113
+ closed = true
114
+ break
115
+ }
116
+ j += 1
117
+ }
118
+ if (!closed || j === first) return 'has an unclosed or empty character class'
119
+ i = j + 1
120
+ } else {
121
+ if (c === '(' || c === ')' || c === '|') return 'cannot use groups or alternation ( ( ) | ); write one character class per position'
122
+ if (/[*+?{}^$\]]/.test(c)) return `has a stray "${c}": a repetition follows a character or class, and ^ $ only open and close it`
123
+ i += 1
124
+ }
125
+ let repeat = 1
126
+ if (body[i] === '{') {
127
+ const close = body.indexOf('}', i + 1)
128
+ const q = close < 0 ? null : /^(\d+)(?:,(\d*))?$/.exec(body.slice(i + 1, close))
129
+ if (!q) return 'has a malformed {m,n} repetition'
130
+ const min = Number(q[1])
131
+ const ranged = q[2] !== undefined
132
+ const max = ranged && q[2] !== '' ? Number(q[2]) : min
133
+ if (min > max || max > PATTERN_MAX_REPEAT) return `repeats more than ${PATTERN_MAX_REPEAT} times, or {m,n} has m above n`
134
+ if (ranged) variable += 1
135
+ repeat = ranged && q[2] === '' ? min : max
136
+ i = close + 1
137
+ } else if (body[i] === '*' || body[i] === '+' || body[i] === '?') {
138
+ variable += 1
139
+ i += 1
140
+ }
141
+ if (variable > 1) return 'has more than one variable-width repetition (* + ? {m,n}); keep one, and fix the other widths ({4})'
142
+ width += repeat
143
+ if (width > PATTERN_MAX_LENGTH) return `can match more than ${PATTERN_MAX_LENGTH} characters`
144
+ }
145
+ return null
146
+ }
147
+
148
+ /** `BLUEPRINT_ACTION_VALUE_MAX_BYTES`: the most an Action value may carry. */
149
+ const VALUE_MAX_BYTES = 65_536
150
+
151
+ function isIsoDate(value: string): boolean {
152
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
153
+ const date = new Date(`${value}T00:00:00.000Z`)
154
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
155
+ }
156
+
157
+ function isIsoTimestamp(value: string): boolean {
158
+ const match = /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.exec(value)
159
+ if (match === null || !isIsoDate(match[1]!)) return false
160
+ const time = Date.parse(value)
161
+ return !Number.isNaN(time) && /^\d{4}-/.test(new Date(time).toISOString())
162
+ }
163
+
164
+ const bytes = (value: string) => new TextEncoder().encode(value).length
165
+ const show = (value: unknown) => JSON.stringify(value)
166
+
167
+ /** The note an `object_reference` field with a default gets: a portable file names no record's UUID. */
168
+ export const OBJECT_REFERENCE_DEFAULT_PROBLEM = 'takes no portable default (only a record\'s UUID fits); leave it off, or make it required'
169
+
170
+ /**
171
+ * Why a field's default does not fit its value type, or null: the service's
172
+ * `defaultMatchesField`, said as a reason. A default is a JSON scalar; `null`
173
+ * fits only a json field. An `object_reference` takes only a record's UUID,
174
+ * which differs per runtime, so a portable file carries none.
175
+ */
176
+ export function defaultProblem(value: unknown, vt: Doc): string | null {
177
+ const kind = vt.kind
178
+ if (kind === 'object_reference') return OBJECT_REFERENCE_DEFAULT_PROBLEM
179
+ if (kind === 'geopoint') return 'takes no default: a point is { lat, lon }, and a default is a single value; leave it off'
180
+ if (value !== null && !['boolean', 'number', 'string'].includes(typeof value)) return `must be a single value (text, a number or true/false), not ${Array.isArray(value) ? 'a list' : 'an object'}`
181
+ if (value === null && kind !== 'json') return `is null, which only a json field takes; leave the default off for "no value"`
182
+ if (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value, -0))) return 'must be a finite number other than -0'
183
+ switch (kind) {
184
+ case 'string': {
185
+ if (typeof value !== 'string') return `must be text for a string field (it is ${show(value)})`
186
+ if (value !== value.normalize('NFC')) return 'must be NFC-normalized text'
187
+ if (bytes(value) > VALUE_MAX_BYTES) return `is longer than ${VALUE_MAX_BYTES} bytes`
188
+ if (isNonNegativeInteger(vt.minLength) && value.length < (vt.minLength as number)) return `${show(value)} is shorter than minLength ${String(vt.minLength)}`
189
+ if (isNonNegativeInteger(vt.maxLength) && value.length > (vt.maxLength as number)) return `${show(value)} is longer than maxLength ${String(vt.maxLength)}`
190
+ if (Array.isArray(vt.enum) && !vt.enum.includes(value)) return `${show(value)} is not one of its enum values (${vt.enum.map(show).join(', ')})`
191
+ if (typeof vt.pattern === 'string' && patternProblem(vt.pattern) === null) {
192
+ const match = new RegExp(vt.pattern).exec(value)
193
+ if (!match || match.index !== 0 || match[0].length !== value.length) return `${show(value)} does not match its pattern ${vt.pattern}`
194
+ }
195
+ return null
196
+ }
197
+ case 'number':
198
+ if (typeof value !== 'number') return `must be a number for a number field (it is ${show(value)})`
199
+ if (vt.integer === true && !Number.isInteger(value)) return `${value} is not a whole number, and the field is integer: true`
200
+ if (typeof vt.minimum === 'number' && value < vt.minimum) return `${value} is below minimum ${vt.minimum}`
201
+ if (typeof vt.maximum === 'number' && value > vt.maximum) return `${value} is above maximum ${vt.maximum}`
202
+ return null
203
+ case 'boolean':
204
+ return typeof value === 'boolean' ? null : `must be true or false for a boolean field (it is ${show(value)})`
205
+ case 'date':
206
+ return typeof value === 'string' && isIsoDate(value) ? null : `must be a date, YYYY-MM-DD (it is ${show(value)})`
207
+ case 'timestamp':
208
+ return typeof value === 'string' && isIsoTimestamp(value) ? null : `must be an ISO 8601 time with a zone, like 2026-10-18T09:00:00Z (it is ${show(value)})`
209
+ case 'json':
210
+ return bytes(JSON.stringify(value)) > VALUE_MAX_BYTES ? `is longer than ${VALUE_MAX_BYTES} bytes` : null
211
+ default:
212
+ return null
213
+ }
214
+ }
215
+
216
+ function fieldProblems(fields: Doc[], at: string, out: string[]): void {
217
+ fields.forEach((field, i) => {
218
+ const where = `${at}[${i}]`
219
+ const name = field.apiName
220
+ if (typeof name === 'string' && !ACTION_MEMBER_NAME.test(name)) out.push(`${where}.apiName "${name}" must be camelCase: a lowercase letter, then letters and digits`)
221
+ if (field.required === true && Object.prototype.hasOwnProperty.call(field, 'default')) {
222
+ out.push(`${where} is required and has a default; a required field cannot carry one — drop the default, or make it required: false`)
223
+ }
224
+ const vt = field.valueType
225
+ if (!isRecord(vt)) return
226
+ const kind = typeof vt.kind === 'string' ? vt.kind : ''
227
+ if (!VALUE_KINDS.has(kind)) return
228
+ const allowed = VALUE_KEYS[kind] ?? ['kind']
229
+ for (const key of Object.keys(vt)) {
230
+ if (!allowed.includes(key)) out.push(`${where}.valueType.${key} is not part of a ${kind} value type (it takes ${allowed.filter((k) => k !== 'kind' && k !== 'objectTypeId').join(', ') || 'only kind'})`)
231
+ }
232
+ if (kind === 'number') {
233
+ if (typeof vt.integer !== 'boolean') out.push(`${where}.valueType.integer is required for a number: true for a whole number, false otherwise`)
234
+ for (const bound of ['minimum', 'maximum'] as const) {
235
+ if (vt[bound] === undefined) continue
236
+ if (typeof vt[bound] === 'string') out.push(`${where}.valueType.${bound} must be a number (${bound}: ${Number(vt[bound]) || 0}), not the text "${String(vt[bound])}" — decimal strings are a semantic type's spelling, not an Action's`)
237
+ else if (typeof vt[bound] !== 'number') out.push(`${where}.valueType.${bound} must be a number`)
238
+ }
239
+ }
240
+ if (kind === 'string') {
241
+ for (const bound of ['minLength', 'maxLength'] as const) {
242
+ if (vt[bound] !== undefined && !isNonNegativeInteger(vt[bound])) out.push(`${where}.valueType.${bound} must be a whole number, 0 or more`)
243
+ }
244
+ if (typeof vt.pattern === 'string') {
245
+ const why = patternProblem(vt.pattern)
246
+ if (why) out.push(`${where}.valueType.pattern ${why}`)
247
+ }
248
+ if (Array.isArray(vt.enum) && new Set(vt.enum).size !== vt.enum.length) out.push(`${where}.valueType.enum lists a value twice; each value once`)
249
+ }
250
+ if (kind === 'object_reference' && typeof vt.objectType !== 'string' && typeof vt.objectTypeId !== 'string') {
251
+ out.push(`${where}.valueType needs objectType: the object type it points at, by apiName`)
252
+ }
253
+ // A required field's default is already said above; this is what an optional one's must fit.
254
+ if (field.required !== true && Object.prototype.hasOwnProperty.call(field, 'default')) {
255
+ const why = defaultProblem(field.default, vt)
256
+ if (why) out.push(`${where}.default ${why}`)
257
+ }
258
+ })
259
+ }
260
+
261
+ function codeProblems(items: Doc[], at: string, what: string, out: string[]): void {
262
+ items.forEach((item, i) => {
263
+ if (typeof item.code === 'string' && !ACTION_STABLE_CODE.test(item.code)) {
264
+ out.push(`${at}[${i}].code "${item.code}" must be lowercase snake_case (${what}): a lowercase letter, then lowercase letters, digits or _`)
265
+ }
266
+ })
267
+ }
268
+
269
+ interface ObjectTypeFacts {
270
+ primaryKey?: string
271
+ /** Each property's value type, by apiName. */
272
+ valueTypes?: ReadonlyMap<string, string>
273
+ /** `backing.editable`: what a governed Action may write; null when the file declares none. */
274
+ editable?: ReadonlySet<string> | null
275
+ }
276
+
277
+ /** The input kinds that may set a property of each base type. */
278
+ const FITS: Record<string, string[]> = {
279
+ string: ['string', 'object_reference'],
280
+ number: ['number'],
281
+ date: ['date'],
282
+ timestamp: ['timestamp'],
283
+ boolean: ['boolean'],
284
+ geopoint: ['geopoint'],
285
+ }
286
+
287
+ /** Every rule problem in the tree's Action files, by file. `inheritedConflict` says which semantic-type conflicts are the workspace's. */
288
+ export function actionRuleProblems(files: AuthoredFile[], inheritedConflict?: InheritedConflict): ActionRuleProblems {
289
+ const types = new Map<string, ObjectTypeFacts>()
290
+ for (const f of files) {
291
+ if (f.kind !== 'object-type') continue
292
+ const properties = list(f.document.properties)
293
+ const backing = isRecord(f.document.backing) ? f.document.backing : {}
294
+ types.set(f.apiName, {
295
+ primaryKey: typeof f.document.primaryKey === 'string' ? f.document.primaryKey : undefined,
296
+ valueTypes: new Map(properties.flatMap((p) => (typeof p.apiName === 'string' && typeof p.valueType === 'string' ? [[p.apiName, p.valueType] as const] : []))),
297
+ editable: Array.isArray(backing.editable) ? new Set(backing.editable.map(String)) : null,
298
+ })
299
+ }
300
+ const refused: string[] = []
301
+ const undeployable: string[] = []
302
+ const refusedActions: string[] = []
303
+ const inherited: string[] = []
304
+ const inheritedActions: string[] = []
305
+ // The semantic types of the properties an Action sets (the draft route's `addedSemanticConflicts`):
306
+ // refused when the person made the conflict, said as a warning when the workspace's Action has it.
307
+ const facts = semanticFactsOf(files)
308
+ for (const file of files) {
309
+ if (file.kind !== 'action') continue
310
+ const r: string[] = []
311
+ const u: string[] = []
312
+ const i: string[] = []
313
+ checkAction(file.document, types, r, u)
314
+ for (const c of actionSemanticConflicts(file.document, facts)) {
315
+ if (inheritedConflict?.(file.apiName, c.refs)) i.push(`${c.path}: ${inheritedConflictLine(c.message)}`)
316
+ else r.push(`${c.path}: ${c.message}`)
317
+ }
318
+ if (r.length) refusedActions.push(file.apiName)
319
+ else if (i.length) inheritedActions.push(file.apiName)
320
+ refused.push(...r.map((p) => `${file.path}: ${p}`))
321
+ undeployable.push(...u.map((p) => `${file.path}: ${p}`))
322
+ inherited.push(...i.map((p) => `${file.path}: ${p}`))
323
+ }
324
+ return { refused, undeployable, refusedActions, ...(inherited.length ? { inherited, inheritedActions } : {}) }
325
+ }
326
+
327
+ /** One Action document: what the draft route refuses (`refused`) and what will not deploy (`undeployable`). */
328
+ export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts>, refused: string[], undeployable: string[]): void {
329
+ const subject = isRecord(doc.subject) ? doc.subject : {}
330
+ const effect = isRecord(doc.effect) ? doc.effect : {}
331
+ const concurrency = isRecord(doc.concurrency) ? doc.concurrency : {}
332
+ const mode = subject.mode
333
+ const effectKind = typeof effect.kind === 'string' ? effect.kind : ''
334
+ const strategy = concurrency.strategy
335
+ const inputs = list(doc.inputs)
336
+ const outcomes = list(doc.businessOutcomes)
337
+
338
+ fieldProblems(inputs, 'inputs', refused)
339
+ outcomes.forEach((o, i) => fieldProblems(list(o.output), `businessOutcomes[${i}].output`, refused))
340
+ codeProblems(outcomes, 'businessOutcomes', 'an outcome code', refused)
341
+ codeProblems(list(doc.submissionCriteria), 'submissionCriteria', 'a criterion code', refused)
342
+ outcomes.forEach((o, i) => codeProblems(list(o.postconditions), `businessOutcomes[${i}].postconditions`, 'a postcondition code', refused))
343
+
344
+ // A criterion, an approval condition or a postcondition reads only inputs that always have a value.
345
+ const optional = new Set(inputs.filter((f) => typeof f.apiName === 'string' && f.required !== true && !Object.prototype.hasOwnProperty.call(f, 'default')).map((f) => f.apiName as string))
346
+ if (optional.size) {
347
+ const reads = (where: string, node: unknown) => {
348
+ for (const field of new Set(inputReads(node))) {
349
+ if (optional.has(field)) refused.push(`${where} reads input ${field}, which is optional with no default: make it required: true, or give it a default`)
350
+ }
351
+ }
352
+ list(doc.submissionCriteria).forEach((c, i) => reads(`submissionCriteria[${i}]`, c.assertion))
353
+ const approval = isRecord(doc.governance) && isRecord(doc.governance.approval) ? doc.governance.approval : null
354
+ list(approval?.stages).forEach((s, i) => reads(`governance.approval.stages[${i}].when`, s.when))
355
+ outcomes.forEach((o, i) => list(o.postconditions).forEach((pc, j) => reads(`businessOutcomes[${i}].postconditions[${j}]`, pc.assertion)))
356
+ }
357
+
358
+ if (mode === 'existing') {
359
+ if (effectKind === 'create') refused.push('subject.mode is existing but effect.kind is create: an Action on an existing record updates, deletes, transitions or runs a command; use subject.mode: create to create one')
360
+ if (strategy !== undefined && strategy !== 'expected_version') refused.push(`concurrency.strategy must be expected_version for an existing subject (it is ${String(strategy)})`)
361
+ } else if (mode === 'create') {
362
+ if (effectKind && effectKind !== 'create') refused.push(`subject.mode is create, so effect.kind must be create (it is ${effectKind})`)
363
+ if (strategy !== undefined && strategy !== 'create_if_absent' && strategy !== 'append_once') refused.push(`concurrency.strategy must be append_once for a create (it is ${String(strategy)})`)
364
+ }
365
+ // One open request per record: only on an existing record, and only that value.
366
+ if (concurrency.openRequests !== undefined) {
367
+ if (concurrency.openRequests !== 'one_per_subject') refused.push(`concurrency.openRequests must be one_per_subject (it is ${JSON.stringify(concurrency.openRequests)})`)
368
+ else if (mode !== 'existing') refused.push('concurrency.openRequests: one_per_subject keeps one open request per existing record, so it takes subject.mode: existing')
369
+ }
370
+
371
+ // Every change names the object type it is on; the property, transition and input by name.
372
+ const sets: Array<{ property: string; value: Doc; where: string; outcome: number }> = []
373
+ outcomes.forEach((o, i) => {
374
+ const changes = list(o.expectedChanges)
375
+ if (o.effect === 'confirmed_not_applied' && changes.length) refused.push(`businessOutcomes[${i}] is confirmed_not_applied, which declares no expected changes: move them to a confirmed_applied outcome`)
376
+ changes.forEach((c, j) => {
377
+ const where = `businessOutcomes[${i}].expectedChanges[${j}]`
378
+ const named = typeof c.objectType === 'string' || typeof c.objectTypeId === 'string'
379
+ if (!named && ['property_set', 'object_created', 'object_deleted', 'lifecycle_transition'].includes(String(c.kind))) {
380
+ const subjectType = typeof subject.objectType === 'string' ? subject.objectType : '<ObjectType>'
381
+ refused.push(`${where} (${String(c.kind)}) needs objectType: the object type it changes, by apiName (objectType: ${subjectType})`)
382
+ }
383
+ if (c.kind === 'property_set') {
384
+ if (typeof c.property !== 'string' && typeof c.propertyId !== 'string') refused.push(`${where} needs property: the property it sets, by apiName`)
385
+ const value = isRecord(c.value) ? c.value : null
386
+ if (!value) refused.push(`${where} needs value: { kind: input, field: <input apiName> }, { kind: output, field: … } or { kind: literal, value: … }`)
387
+ else {
388
+ // An optional input with no default may set a property: absent, it leaves the property unset (a create)
389
+ // or unchanged (an edit). Only criteria, approval conditions and postconditions need a value (above).
390
+ if (typeof c.property === 'string') sets.push({ property: c.property, value, where, outcome: i })
391
+ }
392
+ }
393
+ })
394
+ })
395
+
396
+ // The write path the local runtime derives (`deriveOntologyEditPlan`) and the deployment's
397
+ // checks (`action-binding-service.ts`, `mutation-plan-schema.ts`).
398
+ if (!EDITS.has(effectKind)) return
399
+ const typeName = typeof subject.objectType === 'string' ? subject.objectType : undefined
400
+ const subjectType = typeName ? types.get(typeName) : undefined
401
+ const pk = subjectType?.primaryKey
402
+ const byName = new Map(inputs.flatMap((f) => (typeof f.apiName === 'string' ? [[f.apiName, f] as const] : [])))
403
+ const kindOf = (f: Doc | undefined) => (f && isRecord(f.valueType) && typeof f.valueType.kind === 'string' ? f.valueType.kind : undefined)
404
+
405
+ // The key: an edit or delete finds its record by it; a create files the new record under it.
406
+ const key = pk ? byName.get(pk) : undefined
407
+ if (pk && !key) {
408
+ undeployable.push(
409
+ effectKind === 'create'
410
+ ? `a create files the new record under its primary key, so it takes a required "${pk}" input (valueType: { kind: string, minLength: 1 }); add one`
411
+ : `${effectKind === 'delete' ? 'a delete' : 'an edit'} finds its record by the primary key, so it takes a required "${pk}" input (valueType: { kind: string, minLength: 1 }); add one`,
412
+ )
413
+ } else if (key && key.required !== true) undeployable.push(`inputs: "${pk}" is the record’s key, so it must be required: true`)
414
+
415
+ if (strategy === 'expected_version') {
416
+ // Deploy refuses only an Action with no "expectedVersion" input to map (the plan has nothing to
417
+ // compare and set on). Its value type is not a deploy rule: the version is checked as a whole
418
+ // number from 0 when the Action is submitted, and the platform itself writes `{ kind: number }`
419
+ // with `integer: false` and no minimum on Actions that deploy.
420
+ if (!byName.get('expectedVersion')) undeployable.push('concurrency expected_version needs an "expectedVersion" input: { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 }, required: true } — the record version the change was decided on (a query row’s _meta.recordVersion)')
421
+ }
422
+
423
+ const changes = outcomes.flatMap((o) => list(o.expectedChanges))
424
+ if (effectKind === 'create') {
425
+ if (strategy === 'create_if_absent') undeployable.push('a create over the Blueprint’s records takes its identity from its primary key input, so concurrency.strategy must be append_once (create_if_absent has the platform mint an identity the record is never filed under)')
426
+ if (!changes.some((c) => c.kind === 'object_created')) undeployable.push(`a create declares the record it makes: add { kind: object_created, objectType: ${typeName ?? '<ObjectType>'} } to its outcome`)
427
+ }
428
+ if (effectKind === 'delete') {
429
+ if (!changes.some((c) => c.kind === 'object_deleted')) undeployable.push(`a delete declares the record it removes: add { kind: object_deleted, objectType: ${typeName ?? '<ObjectType>'} } to its outcome`)
430
+ for (const st of sets) undeployable.push(`${st.where} sets ${st.property}, but a delete removes the whole record and sets nothing; remove this change`)
431
+ return
432
+ }
433
+
434
+ const feeds = new Map<string, string[]>()
435
+ const byLiteral = new Set(sets.filter((x) => x.value.kind === 'literal').map((x) => x.property))
436
+ let fromInput = 0
437
+ for (const st of sets) {
438
+ if (subjectType?.editable && !subjectType.editable.has(st.property)) {
439
+ undeployable.push(`${st.where} sets ${st.property}, which ${typeName}’s backing.editable doesn’t list; add it there (blueprint/object-types/${typeName}.yaml)`)
440
+ } else if (subjectType && subjectType.editable === null) {
441
+ undeployable.push(`${st.where} sets ${st.property}, but ${typeName} declares no backing.editable; list the properties its Actions write there`)
442
+ }
443
+ if (st.value.kind === 'output') {
444
+ undeployable.push(`${st.where} sets ${st.property} from an output; a write sets each value from an input or a constant`)
445
+ continue
446
+ }
447
+ if (pk && st.property === pk) {
448
+ if (st.value.kind === 'literal') undeployable.push(`${st.where} sets the primary key ${pk} to a constant; the key comes from its "${pk}" input, so remove this change`)
449
+ else if (effectKind !== 'create' || st.value.field === pk) {
450
+ undeployable.push(
451
+ effectKind === 'create'
452
+ ? `${st.where} sets the primary key ${pk}; a create already files the record under its "${pk}" input, so remove this change`
453
+ : `${st.where} sets the primary key ${pk}, which addresses the record and cannot be changed; remove this change`,
454
+ )
455
+ }
456
+ continue
457
+ }
458
+ if (st.value.kind === 'literal') continue
459
+ if (st.value.kind !== 'input' || typeof st.value.field !== 'string') continue
460
+ fromInput++
461
+ if (byLiteral.has(st.property)) undeployable.push(`${st.where} sets ${st.property} from input ${st.value.field}, and another change sets it to a constant; keep one`)
462
+ if (st.value.field === 'expectedVersion' && strategy === 'expected_version') {
463
+ undeployable.push(`${st.where} sets ${st.property} from expectedVersion, which already carries the record version; give ${st.property} its own input`)
464
+ }
465
+ // The input's kind fits the property's base type.
466
+ const base = subjectType?.valueTypes?.get(st.property)
467
+ const kind = kindOf(byName.get(st.value.field))
468
+ if (base && kind && FITS[base] && !FITS[base]!.includes(kind)) {
469
+ undeployable.push(`${st.where} sets ${st.property} (${base}) from input ${st.value.field} (${kind}); a ${base} property takes ${FITS[base]!.join(' or ')}`)
470
+ } else if (kind === 'geopoint' && base && base !== 'geopoint') {
471
+ undeployable.push(`${st.where} sets ${st.property} (${base}) from a geopoint input; only a geopoint property takes one`)
472
+ }
473
+ feeds.set(st.value.field, [...(feeds.get(st.value.field) ?? []), st.property])
474
+ }
475
+ for (const [input, properties] of feeds) {
476
+ if (new Set(properties).size > 1) undeployable.push(`input ${input} sets ${[...new Set(properties)].join(' and ')}; each input feeds one property, so give each its own input`)
477
+ }
478
+ if (fromInput === 0) {
479
+ undeployable.push(byLiteral.size
480
+ ? `it sets only constants (${[...byLiteral].join(', ')}); its write path needs at least one property set from an input ({ kind: input, field: <input apiName> })`
481
+ : 'it sets no property from an input, so there is nothing to write: add a property_set with value { kind: input, field: <input apiName> }')
482
+ }
483
+
484
+ // What an outcome checks after the write: only properties that outcome sets, and no required output.
485
+ outcomes.forEach((o, i) => {
486
+ const setHere = new Set(sets.filter((x) => x.outcome === i).map((x) => x.property))
487
+ list(o.postconditions).forEach((pc, j) => {
488
+ for (const read of observedProperties(pc.assertion)) {
489
+ if (!setHere.has(read)) undeployable.push(`businessOutcomes[${i}].postconditions[${j}] reads ${read}, which this outcome doesn’t set; a postcondition reads only what its outcome sets`)
490
+ }
491
+ })
492
+ list(o.output).forEach((f, j) => {
493
+ if (f.required === true) undeployable.push(`businessOutcomes[${i}].output[${j}] (${String(f.apiName)}) is required, and a write over the Blueprint’s records returns no outputs; make it required: false or remove it`)
494
+ })
495
+ })
496
+ }
497
+
498
+ /** The inputs an expression reads (`{ kind: input, field }`). */
499
+ function inputReads(node: unknown, out: string[] = []): string[] {
500
+ if (Array.isArray(node)) node.forEach((n) => inputReads(n, out))
501
+ else if (isRecord(node)) {
502
+ if (node.kind === 'input' && typeof node.field === 'string') out.push(node.field)
503
+ for (const v of Object.values(node)) if (v && typeof v === 'object') inputReads(v, out)
504
+ }
505
+ return out
506
+ }
507
+
508
+ /** The record properties an expression reads (`observation_property`). */
509
+ function observedProperties(node: unknown, out: string[] = []): string[] {
510
+ if (Array.isArray(node)) node.forEach((n) => observedProperties(n, out))
511
+ else if (isRecord(node)) {
512
+ if (node.kind === 'observation_property' && typeof node.property === 'string') out.push(node.property)
513
+ for (const v of Object.values(node)) if (v && typeof v === 'object') observedProperties(v, out)
514
+ }
515
+ return out
516
+ }