@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,122 @@
1
+ import type { AuthoredFile } from './model'
2
+
3
+ /**
4
+ * What the draft refuses in a metric file, found before anything is written.
5
+ *
6
+ * A measure's `filters` is ONE where node, as a query's `where` is: a
7
+ * condition (`{ property, op, value }`) or a combinator (`{ and: [...] }`,
8
+ * `{ or: [...] }`, `{ not: {...} }`). A list of conditions reads naturally
9
+ * in YAML and `plan` used to show it as a clean change, while the service
10
+ * refused it at apply (`where node must be a JSON object`). The same shape
11
+ * rules the service checks (`metric-definition.ts`), minus the properties,
12
+ * which only the draft knows: a relative date window and a location
13
+ * condition are refused in a metric too.
14
+ */
15
+
16
+ const GEO_OPS = new Set(['nearby', 'withinBbox'])
17
+
18
+ /** Why a where node is not one, or null when its shape is right. */
19
+ export function whereShapeProblem(node: unknown): string | null {
20
+ if (Array.isArray(node)) return 'it is a list; it must be one where node: a condition, or several combined as { and: [ … ] }'
21
+ if (!node || typeof node !== 'object') return 'it must be a where node (a mapping): { property, op, value }, or { and: [ … ] }, { or: [ … ] }, { not: … }'
22
+ const o = node as Record<string, unknown>
23
+ if (typeof o.property === 'string') {
24
+ if (o.op === 'dateRange') return 'dateRange presets are not supported in metric filters: a compiled metric is static, so use explicit comparisons'
25
+ if (typeof o.op === 'string' && GEO_OPS.has(o.op)) return 'location conditions (nearby, withinBbox) are not supported in metric filters'
26
+ return null
27
+ }
28
+ for (const key of ['and', 'or'] as const) {
29
+ if (key in o) {
30
+ if (!Array.isArray(o[key])) return `\`${key}\` must be a list of where nodes`
31
+ for (const child of o[key] as unknown[]) {
32
+ const why = whereShapeProblem(child)
33
+ if (why) return why
34
+ }
35
+ return null
36
+ }
37
+ }
38
+ if ('not' in o) return whereShapeProblem(o.not)
39
+ return 'it must be a condition ({ property, op, value }) or and/or/not'
40
+ }
41
+
42
+ /**
43
+ * A property's type as the tree declares it, by `Type.property`: what a file
44
+ * says, `attribute` when it says nothing (`compile.ts` sends that default).
45
+ * A field carrying a shared field without its own `propertyType` takes the
46
+ * shared field's, which only the draft knows, so it is left out. So is a date
47
+ * or timestamp a file leaves unsaid: a new one is created `time`
48
+ * (`declaredPropertyType`), one already on the draft keeps its own.
49
+ */
50
+ function declaredPropertyTypes(files: readonly AuthoredFile[]): Map<string, { propertyType: string; valueType: string; file: string }> {
51
+ const out = new Map<string, { propertyType: string; valueType: string; file: string }>()
52
+ for (const file of files) {
53
+ if (file.kind !== 'object-type') continue
54
+ const properties = (file.document as { properties?: unknown }).properties
55
+ for (const p of Array.isArray(properties) ? (properties as Array<Record<string, unknown>>) : []) {
56
+ if (!p || typeof p.apiName !== 'string') continue
57
+ if (p.propertyType === undefined && p.sharedField !== undefined && p.sharedField !== null) continue
58
+ // A date or timestamp left unsaid is `time` when it is created, but one already on the draft keeps its own: unknown here.
59
+ if (p.propertyType === undefined && (p.valueType === 'timestamp' || p.valueType === 'date')) continue
60
+ out.set(`${file.apiName}.${p.apiName}`, {
61
+ propertyType: typeof p.propertyType === 'string' ? p.propertyType : 'attribute',
62
+ valueType: typeof p.valueType === 'string' ? p.valueType : 'string',
63
+ file: file.path,
64
+ })
65
+ }
66
+ }
67
+ return out
68
+ }
69
+
70
+ /**
71
+ * What the runtime checks of a metric against its type's properties
72
+ * (`validateMetricDefinitionAgainstType`): the timeseries is a `time`
73
+ * property, a dimension an `attribute` or `time` one, and a `sum` or `avg`
74
+ * reads a `measure`. `plan` passed a timeseries on an `attribute` timestamp
75
+ * and the runtime refused it. Checked only where the tree holds the type.
76
+ */
77
+ function metricTypeProblems(file: AuthoredFile, types: ReturnType<typeof declaredPropertyTypes>): string[] {
78
+ const doc = file.document as { objectType?: unknown; definition?: Record<string, unknown> }
79
+ if (typeof doc.objectType !== 'string') return []
80
+ const type = doc.objectType
81
+ const definition = doc.definition ?? {}
82
+ const out: string[] = []
83
+ const fix = (property: string, want: string) => {
84
+ const declared = types.get(`${type}.${property}`)
85
+ return declared ? ` — give ${property} \`propertyType: ${want}\` in ${declared.file}` : ''
86
+ }
87
+ if (typeof definition.timeseries === 'string') {
88
+ const p = types.get(`${type}.${definition.timeseries}`)
89
+ if (p && p.propertyType !== 'time') out.push(`${file.path}: timeseries "${definition.timeseries}" must be a time property, but ${type}.${definition.timeseries} is "${p.propertyType}"${fix(definition.timeseries, 'time')}`)
90
+ }
91
+ for (const dim of Array.isArray(definition.dimensions) ? definition.dimensions : []) {
92
+ if (typeof dim !== 'string') continue
93
+ const p = types.get(`${type}.${dim}`)
94
+ if (p && p.propertyType !== 'attribute' && p.propertyType !== 'time') out.push(`${file.path}: dimension "${dim}" must be an attribute or time property, but ${type}.${dim} is "${p.propertyType}"`)
95
+ }
96
+ for (const [i, measure] of (Array.isArray(definition.measures) ? (definition.measures as Array<Record<string, unknown>>) : []).entries()) {
97
+ if (!measure || (measure.agg !== 'sum' && measure.agg !== 'avg') || typeof measure.property !== 'string' || measure.expression !== undefined) continue
98
+ const p = types.get(`${type}.${measure.property}`)
99
+ const name = typeof measure.label === 'string' ? measure.label : `#${i + 1}`
100
+ if (p && p.propertyType !== 'measure') out.push(`${file.path}: measure "${name}": agg "${measure.agg}" requires a measure property, but ${type}.${measure.property} is "${p.propertyType}"${fix(measure.property, 'measure')}`)
101
+ }
102
+ return out
103
+ }
104
+
105
+ /** Each refusal in the tree's metric files, as `<file>: measure "<label>": filters …`. */
106
+ export function metricRuleProblems(files: readonly AuthoredFile[]): string[] {
107
+ const out: string[] = []
108
+ const types = declaredPropertyTypes(files)
109
+ for (const file of files) {
110
+ if (file.kind !== 'metric') continue
111
+ out.push(...metricTypeProblems(file, types))
112
+ const definition = (file.document as { definition?: { measures?: unknown } }).definition
113
+ const measures = Array.isArray(definition?.measures) ? (definition!.measures as Array<Record<string, unknown>>) : []
114
+ for (const [i, measure] of measures.entries()) {
115
+ if (!measure || typeof measure !== 'object' || measure.filters === undefined) continue
116
+ const why = whereShapeProblem(measure.filters)
117
+ const name = typeof measure.label === 'string' ? measure.label : typeof measure.apiName === 'string' ? measure.apiName : `#${i + 1}`
118
+ if (why) out.push(`${file.path}: measure "${name}": filters — ${why}`)
119
+ }
120
+ }
121
+ return out
122
+ }
@@ -0,0 +1,116 @@
1
+ import { readObjectSetFiles } from '../local/local-object-sets'
2
+ import { isUuid, refName } from '../local/local-sync-refs'
3
+ import { objectSetNames } from '../local/local-sync-remote'
4
+
5
+ /**
6
+ * Saved object sets as the CLI shows and addresses them.
7
+ *
8
+ * Two list routes answer with sets, in two shapes: the workspace's slice
9
+ * (`/v1/sync/object-sets`, a workspace key; each row names its object type)
10
+ * and the organization's (`/v1/blueprint/object-sets`, an organization key;
11
+ * rows carry the object type's id). Both are read into one row here.
12
+ *
13
+ * A set is addressed the way a project file names it: its id, its name
14
+ * (`Open work orders`), its name as a reference holds it
15
+ * (`objectSet/open-work-orders`), or, in a project, its file's name
16
+ * (`blueprint/object-sets/open-work-orders.yaml` → `objectSet/open-work-orders`).
17
+ */
18
+
19
+ export interface SavedObjectSet {
20
+ id: string
21
+ name: string
22
+ description: string | null
23
+ kind: string
24
+ /** The object type's API name, when the route says it; else its id. */
25
+ objectType: string
26
+ /** How a file names it: `objectSet/open-work-orders`. */
27
+ ref: string
28
+ }
29
+
30
+ const text = (v: unknown): string | null => (typeof v === 'string' && v ? v : null)
31
+
32
+ /**
33
+ * Rows from either list route, unwrapped from `{ data }` when the client left one.
34
+ *
35
+ * `files` are a project's object set files (file name → the set's `name:`, from
36
+ * `projectObjectSetNames`): a set one file saves takes that file's name as its
37
+ * reference, as `status` names it, rather than a slug of its name (which stops at
38
+ * 24 characters, so `Critical open work orders` was `critical-open-work-order`).
39
+ */
40
+ export function normalizeObjectSets(body: unknown, files: ReadonlyMap<string, string> = new Map()): SavedObjectSet[] {
41
+ const raw = Array.isArray(body) ? body : Array.isArray((body as { data?: unknown } | null)?.data) ? ((body as { data: unknown[] }).data) : []
42
+ const rows = raw.flatMap((r) => {
43
+ const o = (r ?? {}) as Record<string, unknown>
44
+ const id = text(o.id)
45
+ const name = text(o.name)
46
+ if (!id || !name) return []
47
+ return [{
48
+ id,
49
+ name,
50
+ description: text(o.description),
51
+ kind: text(o.kind) ?? 'dynamic',
52
+ objectType: text(o.objectType) ?? text(o.objectTypeApiName) ?? text(o.resultObjectTypeApiName) ?? text(o.resultObjectTypeId) ?? '',
53
+ }]
54
+ })
55
+ const refs = objectSetNames(rows.map((r) => ({ ...r, kind: r.kind as 'dynamic' | 'static' })))
56
+ const fileRefs = new Map<string, string[]>()
57
+ for (const [file, name] of files) fileRefs.set(name, [...(fileRefs.get(name) ?? []), file])
58
+ // Only when one file and one set share the name: else the file's name could mean either.
59
+ const fileOf = (name: string) => {
60
+ const own = fileRefs.get(name)
61
+ return own?.length === 1 && rows.filter((r) => r.name === name).length === 1 ? own[0] : undefined
62
+ }
63
+ return rows.map((r) => ({ ...r, ref: `objectSet/${fileOf(r.name) ?? refs.get(r.id) ?? refName(r.name)}` }))
64
+ }
65
+
66
+ /** A project's object set files by the reference their file name gives (`open-work-orders`) → the set's name. */
67
+ export function projectObjectSetNames(root: string | null): Map<string, string> {
68
+ const out = new Map<string, string>()
69
+ if (!root) return out
70
+ for (const f of readObjectSetFiles(root)) out.set(f.ref, typeof f.doc.name === 'string' && f.doc.name ? f.doc.name : f.ref)
71
+ return out
72
+ }
73
+
74
+ /**
75
+ * The set `wanted` names: an id, a name (any case), `objectSet/<ref>` or
76
+ * `objectSet:<ref>`, or a project file's name. `{ set }` when one matches,
77
+ * else why not, with the near names.
78
+ */
79
+ export function findObjectSet(sets: SavedObjectSet[], wanted: string, files: ReadonlyMap<string, string> = new Map()): { set: SavedObjectSet } | { problem: string; ambiguous?: SavedObjectSet[] } {
80
+ const raw = wanted.trim()
81
+ if (isUuid(raw)) {
82
+ const byId = sets.find((s) => s.id.toLowerCase() === raw.toLowerCase())
83
+ return byId ? { set: byId } : { problem: `no saved object set has the id ${raw}` }
84
+ }
85
+ const bare = raw.replace(/^objectSet[/:]/, '')
86
+ const lower = bare.toLowerCase()
87
+ const tries: Array<(s: SavedObjectSet) => boolean> = [
88
+ (s) => s.ref === `objectSet/${bare}`,
89
+ (s) => s.name.toLowerCase() === lower,
90
+ (s) => s.ref.toLowerCase() === `objectset/${refName(bare).toLowerCase()}`,
91
+ ]
92
+ // A project file's name, as `local dev` saves it: the set carries the file's `name:`.
93
+ const fileName = files.get(bare)
94
+ if (fileName) tries.push((s) => s.name === fileName)
95
+ for (const match of tries) {
96
+ const found = sets.filter(match)
97
+ if (found.length === 1) return { set: found[0]! }
98
+ if (found.length > 1) return { problem: `${found.length} saved object sets match "${raw}"`, ambiguous: found }
99
+ }
100
+ return { problem: `no saved object set is named "${raw}"` }
101
+ }
102
+
103
+ /**
104
+ * A query document with each `{ type: "reference", objectSetId }` that names
105
+ * a set in words resolved to the set's id. `resolve` answers an id for a name,
106
+ * or throws why it can't. The document is copied, never changed in place.
107
+ */
108
+ export async function resolveObjectSetReferences(doc: unknown, resolve: (name: string) => Promise<string>): Promise<unknown> {
109
+ if (Array.isArray(doc)) return Promise.all(doc.map((d) => resolveObjectSetReferences(d, resolve)))
110
+ if (!doc || typeof doc !== 'object') return doc
111
+ const o = doc as Record<string, unknown>
112
+ const out: Record<string, unknown> = {}
113
+ for (const [k, v] of Object.entries(o)) out[k] = await resolveObjectSetReferences(v, resolve)
114
+ if (o.type === 'reference' && typeof o.objectSetId === 'string' && !isUuid(o.objectSetId)) out.objectSetId = await resolve(o.objectSetId)
115
+ return out
116
+ }
@@ -73,7 +73,7 @@ export function ontologyEditCompatibilityFingerprint(
73
73
  ): string {
74
74
  const canonical = JSON.stringify({
75
75
  objectTypeId,
76
- usedProperties: [...new Set(usedProperties)].sort(),
76
+ usedProperties: [...new Set(usedProperties)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)),
77
77
  })
78
78
  return `sha256:${createHash('sha256').update(canonical).digest('hex')}`
79
79
  }
@@ -112,10 +112,18 @@ export function deriveOntologyEditPlan(
112
112
  // deployment that wrote a different set would be executing something other
113
113
  // than what was published and reviewed.
114
114
  const set: Record<string, string> = {}
115
+ // A constant (`{ kind: literal }`) is written by the service from the binding's pinned
116
+ // contract, not from a parameter: it stays out of `set` and out of the fingerprint.
117
+ const constants: string[] = []
115
118
  for (const outcome of action.businessOutcomes) {
116
119
  for (const change of outcome.expectedChanges ?? []) {
117
120
  if (change.kind !== 'property_set') continue
118
121
  const value = change.value as { kind?: string; fieldId?: string } | undefined
122
+ if (value?.kind === 'literal') {
123
+ const property = propertyApiNameById.get(String(change.propertyId))
124
+ if (property) constants.push(property)
125
+ continue
126
+ }
119
127
  if (value?.kind !== 'input' || !value.fieldId) continue
120
128
  const property = propertyApiNameById.get(String(change.propertyId))
121
129
  const parameter = inputApiNameById.get(value.fieldId)
@@ -124,10 +132,15 @@ export function deriveOntologyEditPlan(
124
132
  }
125
133
  }
126
134
 
135
+ // The plan must map at least one parameter, so constants alone cannot be deployed.
127
136
  if (op !== 'delete' && Object.keys(set).length === 0) {
128
137
  throw new PlanDerivationError(
129
- `Action "${action.apiName}" sets no properties, so there is nothing to deploy.`,
130
- 'Open the Action and tick at least one property under "What it changes".',
138
+ constants.length
139
+ ? `Action "${action.apiName}" sets only constants (${constants.join(', ')}), and its write path needs at least one property set from an input.`
140
+ : `Action "${action.apiName}" sets no properties, so there is nothing to deploy.`,
141
+ constants.length
142
+ ? 'Set at least one property from an input ({ kind: input, field: <input> }) beside the constants.'
143
+ : 'Open the Action and tick at least one property under "What it changes".',
131
144
  )
132
145
  }
133
146
 
@@ -146,10 +159,35 @@ export function deriveOntologyEditPlan(
146
159
  if (!pkParameter) {
147
160
  throw new PlanDerivationError(
148
161
  `Action "${action.apiName}" takes no "${pkApiName}" parameter, so an edit has no address.`,
149
- `Add a required "${pkApiName}" parameter to the Action, then publish.`,
162
+ `Add a required "${pkApiName}" input to the Action (valueType: { kind: string, minLength: 1 }): ${op === 'create' ? 'the new record is filed under it' : 'the write finds its record by it'}. Then publish.`,
150
163
  )
151
164
  }
152
165
 
166
+ if (constants.includes(pkApiName)) {
167
+ throw new PlanDerivationError(
168
+ `Action "${action.apiName}" sets its primary key ${pkApiName} to a constant.`,
169
+ `Remove that change: the key comes from the required "${pkParameter}" input.`,
170
+ )
171
+ }
172
+ // The key addresses the record (an edit) or files it (a create): it is never also a written value.
173
+ if (Object.prototype.hasOwnProperty.call(set, pkApiName) && (op !== 'create' || set[pkApiName] === pkParameter)) {
174
+ throw new PlanDerivationError(
175
+ `Action "${action.apiName}" sets its primary key ${pkApiName}, which ${op === 'create' ? 'a create already files the new record under (from its "' + pkParameter + '" parameter)' : 'addresses the record and cannot change'}.`,
176
+ `Remove the property_set of ${pkApiName} from its outcome; keep the required "${pkParameter}" input.`,
177
+ )
178
+ }
179
+ // One parameter, one role: the deployment refuses a parameter feeding two properties.
180
+ const byParameter = new Map<string, string[]>()
181
+ for (const [property, parameter] of Object.entries(set)) byParameter.set(parameter, [...(byParameter.get(parameter) ?? []), property])
182
+ for (const [parameter, properties] of byParameter) {
183
+ if (properties.length > 1) {
184
+ throw new PlanDerivationError(
185
+ `Action "${action.apiName}" sets ${properties.join(' and ')} from the same input, ${parameter}.`,
186
+ 'Give each property its own input: every mapped parameter carries exactly one role.',
187
+ )
188
+ }
189
+ }
190
+
153
191
  // Only when the Action asked for it. Compare-and-set is the Action's
154
192
  // concurrency contract, not a deployment preference.
155
193
  const expectedVersionParameter = action.concurrency.strategy === 'expected_version'
@@ -158,7 +196,13 @@ export function deriveOntologyEditPlan(
158
196
  if (action.concurrency.strategy === 'expected_version' && !expectedVersionParameter) {
159
197
  throw new PlanDerivationError(
160
198
  `Action "${action.apiName}" declares expected_version concurrency but takes no "expectedVersion" parameter.`,
161
- 'Add it to the Action, or change the concurrency strategy.',
199
+ 'Add a required input { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 } }: the record version the change was decided on (a query row\'s _meta.recordVersion).',
200
+ )
201
+ }
202
+ if (expectedVersionParameter && Object.values(set).includes(expectedVersionParameter)) {
203
+ throw new PlanDerivationError(
204
+ `Action "${action.apiName}" writes a property from expectedVersion, which carries the record version.`,
205
+ 'Give that property its own input: every mapped parameter carries exactly one role.',
162
206
  )
163
207
  }
164
208
 
@@ -80,10 +80,15 @@ function propertyToFile(
80
80
  sharedApiNameById: Map<string, string>,
81
81
  semanticApiNameById: Map<string, string>,
82
82
  siblingApiNameById: Map<string, string>,
83
+ objectClassification?: unknown,
83
84
  ): Record<string, unknown> {
84
85
  const body = rest(property, new Set(['id', 'sharedPropertyId', 'semanticTypeId']))
85
86
  const location = locationToFile(property, objectApiName, siblingApiNameById)
86
87
  if (location) body.calculation = location
88
+ // A field's own classification is written only where it differs from its object type's: a read
89
+ // gives every field its effective one, and writing the inherited value would pin it. A location
90
+ // takes its latitude's and longitude's, and declares none.
91
+ if (body.sensitivity !== undefined && (body.sensitivity === null || body.sensitivity === objectClassification || location)) delete body.sensitivity
87
92
  const semanticType = resolveName(
88
93
  property.semanticTypeId,
89
94
  semanticApiNameById,
@@ -220,7 +225,7 @@ function objectToFile(
220
225
  primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
221
226
  title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
222
227
  properties: object.properties.map((property) =>
223
- propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById, byId)),
228
+ propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById, byId, (object.governance as { sensitivity?: { classification?: unknown } } | undefined)?.sensitivity?.classification)),
224
229
  }
225
230
  }
226
231
 
@@ -303,6 +308,23 @@ function nameFieldRefs(node: unknown, fields: Map<string, string>, properties: M
303
308
  for (const value of Object.values(node)) nameFieldRefs(value, fields, properties)
304
309
  }
305
310
 
311
+ /**
312
+ * A postcondition reads the record after the change, and that is the only timing it
313
+ * may have: the service stores it spelled out (`timing: after`) whether the author
314
+ * wrote it or not. The file format takes no timing, so the implied one is dropped
315
+ * (in place, anywhere below `node`): a pulled file stays one an author can write, and
316
+ * `blueprint plan` compares a file with the live Action as equal.
317
+ */
318
+ export function withoutImpliedTiming(node: unknown): void {
319
+ if (Array.isArray(node)) {
320
+ for (const entry of node) withoutImpliedTiming(entry)
321
+ return
322
+ }
323
+ if (!isRecord(node)) return
324
+ if (node.kind === 'observation_property' && node.timing === 'after') delete node.timing
325
+ for (const value of Object.values(node)) withoutImpliedTiming(value)
326
+ }
327
+
306
328
  /**
307
329
  * An action as the bundle carries it → the portable file.
308
330
  *
@@ -375,6 +397,7 @@ function actionToFile(action: Record<string, unknown>, names: ActionNames): Reco
375
397
  if (!isRecord(postcondition)) continue
376
398
  delete postcondition.id
377
399
  nameFieldRefs(postcondition.assertion, scope, names.property)
400
+ withoutImpliedTiming(postcondition.assertion)
378
401
  }
379
402
  }
380
403
 
@@ -64,6 +64,89 @@ export function isSemanticTypeTemplate(value: string): value is SemanticTypeTemp
64
64
 
65
65
  export interface ScaffoldOptions {
66
66
  template?: SemanticTypeTemplate
67
+ /** An Action's shape: `edit` (the default) changes a record, `create` makes one. */
68
+ actionTemplate?: ActionTemplate
69
+ /** An Action's subject, and the name of its primary key (read from the tree when it has the type). */
70
+ objectType?: string
71
+ primaryKey?: string
72
+ }
73
+
74
+ export const ACTION_TEMPLATES = ['edit', 'create'] as const
75
+ export type ActionTemplate = (typeof ACTION_TEMPLATES)[number]
76
+
77
+ export function isActionTemplate(value: string): value is ActionTemplate {
78
+ return (ACTION_TEMPLATES as readonly string[]).includes(value)
79
+ }
80
+
81
+ /**
82
+ * A governed Action over the Blueprint's records that deploys as written once
83
+ * the placeholders are filled: every rule the write path holds it to is
84
+ * already met (`action-rules.ts`), so the reader starts from a file that
85
+ * works and changes what it sets.
86
+ *
87
+ * Every reference is a NAME. The service resolves them against the draft and mints
88
+ * the ids the contract owns, so nothing here carries a uuid — which is exactly what
89
+ * makes the file applicable to a second deployment.
90
+ */
91
+ function actionScaffold(apiName: string, options: ScaffoldOptions): Record<string, unknown> {
92
+ const create = options.actionTemplate === 'create'
93
+ const objectType = options.objectType ?? 'CHANGE_ME'
94
+ const pk = options.primaryKey ?? 'id'
95
+ const field = (name: string, displayName: string, description: string, valueType: Record<string, unknown>, extra: Record<string, unknown> = { required: true }) => ({
96
+ apiName: name, displayName, description, valueType, ...extra, sensitivity: 'internal',
97
+ })
98
+ const inputs = [
99
+ // The key: an edit finds its record by it, a create files the new record under it.
100
+ field(pk, create ? 'New record ID' : 'Record', create ? 'The new record’s key; the record is filed under it.' : 'The record to change, by its key.', { kind: 'string', minLength: 1 }),
101
+ // Compare-and-set: the version the change was decided on (a query row's `_meta.recordVersion`).
102
+ ...(create ? [] : [field('expectedVersion', 'Expected version', 'The record version the change was decided on.', { kind: 'number', integer: true, minimum: 0 })]),
103
+ field('value', 'CHANGE_ME', 'CHANGE_ME: the value it writes.', { kind: 'string', minLength: 1 }),
104
+ ]
105
+ return {
106
+ apiName,
107
+ displayName: apiName,
108
+ description: 'CHANGE_ME',
109
+ subject: { objectType, mode: create ? 'create' : 'existing' },
110
+ effect: {
111
+ kind: create ? 'create' : 'update',
112
+ // The Blueprint's own records: `active-catalog` is the source system the service
113
+ // adds to the draft with the first object type (`blueprint-draft-catalog.ts`).
114
+ // Source systems have no route or file of their own, so this is the one name an
115
+ // Action that writes Blueprint records can give, here and on every deployment.
116
+ authoritySourceSystem: 'active-catalog',
117
+ description: 'CHANGE_ME',
118
+ },
119
+ inputs,
120
+ // `min(1)` on the create route: an action that declares no outcome cannot say
121
+ // what it did. Each change names its object type; each input feeds one property,
122
+ // and a create never sets its key (it is filed under the key input).
123
+ businessOutcomes: [{
124
+ code: create ? 'created' : 'applied',
125
+ displayName: create ? 'Created' : 'Applied',
126
+ description: 'CHANGE_ME',
127
+ effect: 'confirmed_applied',
128
+ expectedChanges: [
129
+ ...(create ? [{ kind: 'object_created', objectType }] : []),
130
+ { kind: 'property_set', objectType, property: 'CHANGE_ME', value: { kind: 'input', field: 'value' } },
131
+ ],
132
+ postconditions: [],
133
+ output: [],
134
+ }],
135
+ // An edit compares the record's version; a create over the Blueprint's records is append_once.
136
+ concurrency: { strategy: create ? 'append_once' : 'expected_version' },
137
+ impact: { maxSubjects: 1 },
138
+ submissionCriteria: [],
139
+ governance: {
140
+ // Shaped, not `CHANGE_ME`: this one is validated by
141
+ // `/^[a-z][a-z0-9_-]{0,63}:[a-z][a-z0-9_-]{0,63}$/`, so a bare placeholder is
142
+ // the one field in this file that cannot apply as written — and it fails
143
+ // server-side, after the plan was confirmed.
144
+ invokeCapability: 'changeme:invoke',
145
+ approval: { mode: 'none' },
146
+ reason: 'optional',
147
+ sensitivity: 'internal',
148
+ },
149
+ }
67
150
  }
68
151
 
69
152
  export function scaffold(
@@ -141,48 +224,7 @@ export function scaffold(
141
224
  }
142
225
 
143
226
  if (kind === 'action') {
144
- // Every reference is a NAME. The service resolves them against the draft and mints
145
- // the ids the contract owns, so nothing here carries a uuid — which is exactly
146
- // what makes the file applicable to a second deployment.
147
- return {
148
- apiName,
149
- displayName: apiName,
150
- description: 'CHANGE_ME',
151
- subject: { objectType: 'CHANGE_ME', mode: 'existing' },
152
- effect: {
153
- kind: 'update',
154
- // Named, but note it cannot be authored from this tree: source systems have no
155
- // route, and one is synthesised per organization. On a second deployment this
156
- // name may resolve to nothing.
157
- authoritySourceSystem: 'CHANGE_ME',
158
- description: 'CHANGE_ME',
159
- },
160
- inputs: [],
161
- // `min(1)` on the create route: an action that declares no outcome cannot say
162
- // what it did, so a scaffold without one would not apply as written.
163
- businessOutcomes: [{
164
- code: 'applied',
165
- displayName: 'Applied',
166
- description: 'CHANGE_ME',
167
- effect: 'confirmed_applied',
168
- expectedChanges: [],
169
- postconditions: [],
170
- output: [],
171
- }],
172
- concurrency: { strategy: 'expected_version' },
173
- impact: { maxSubjects: 1 },
174
- submissionCriteria: [],
175
- governance: {
176
- // Shaped, not `CHANGE_ME`: this one is validated by
177
- // `/^[a-z][a-z0-9_-]{0,63}:[a-z][a-z0-9_-]{0,63}$/`, so a bare placeholder is
178
- // the one field in this file that cannot apply as written — and it fails
179
- // server-side, after the plan was confirmed.
180
- invokeCapability: 'changeme:invoke',
181
- approval: { mode: 'none' },
182
- reason: 'optional',
183
- sensitivity: 'internal',
184
- },
185
- }
227
+ return actionScaffold(apiName, options)
186
228
  }
187
229
 
188
230
  if (kind === 'metric') {
@@ -48,7 +48,7 @@ export const actionAvailable: Command = {
48
48
  authLane: 'session',
49
49
  scope: 'workspace' as const,
50
50
  args: [],
51
- flags: {},
51
+ flags: { as: 'string' },
52
52
  summary: 'List the Actions you can submit in this workspace, and their inputs',
53
53
  examples: ['frontera action available', 'frontera action available --json'],
54
54
  },
@@ -0,0 +1,84 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse } from 'yaml'
5
+
6
+ import { CliError } from '../../errors'
7
+ import { findSolutionRoot } from '../../local/local-project'
8
+
9
+ /** One criterion a refusal names: its code, and its message when known. */
10
+ export interface FailedCriterion {
11
+ code: string
12
+ message?: string
13
+ }
14
+
15
+ /**
16
+ * The criteria an `ACTION_CRITERIA_FAILED` refusal names (`details.criteria`,
17
+ * at the top or one level under `details`), with each one's message when the
18
+ * refusal carries it.
19
+ */
20
+ export function failedCriteria(details: unknown): FailedCriterion[] {
21
+ const body = (details && typeof details === 'object' ? details : null) as { criteria?: unknown; details?: { criteria?: unknown } } | null
22
+ const raw = Array.isArray(body?.criteria) ? body.criteria : Array.isArray(body?.details?.criteria) ? body.details.criteria : []
23
+ return raw.flatMap((c) => {
24
+ const entry = (c && typeof c === 'object' ? c : null) as { code?: unknown; criterionId?: unknown; message?: unknown } | null
25
+ const code = typeof entry?.code === 'string' ? entry.code : typeof entry?.criterionId === 'string' ? entry.criterionId : null
26
+ if (!code) return []
27
+ return [{ code, ...(typeof entry?.message === 'string' && entry.message.trim() ? { message: entry.message.trim() } : {}) }]
28
+ })
29
+ }
30
+
31
+ /**
32
+ * The messages the project's own file for an Action gives its submission
33
+ * criteria, by code: `blueprint/actions/<file>.yaml` whose `apiName` (or file
34
+ * name) is the Action's. Empty outside a project, or when no file matches.
35
+ */
36
+ export function projectCriteriaMessages(cwd: string, apiName: string): Record<string, string> {
37
+ const dir = join(findSolutionRoot(cwd), 'blueprint', 'actions')
38
+ if (!existsSync(dir)) return {}
39
+ for (const name of readdirSync(dir).filter((f) => /\.(ya?ml|json)$/.test(f))) {
40
+ let doc: { apiName?: unknown; submissionCriteria?: unknown } | null
41
+ try {
42
+ doc = parse(readFileSync(join(dir, name), 'utf8')) as typeof doc
43
+ } catch {
44
+ continue
45
+ }
46
+ const matches = doc?.apiName === apiName || (doc?.apiName === undefined && name.replace(/\.(ya?ml|json)$/, '') === apiName)
47
+ if (!matches || !Array.isArray(doc?.submissionCriteria)) continue
48
+ return Object.fromEntries(
49
+ doc.submissionCriteria.flatMap((c: { code?: unknown; message?: unknown }) => (typeof c?.code === 'string' && typeof c.message === 'string' ? [[c.code, c.message.trim()]] : [])),
50
+ )
51
+ }
52
+ return {}
53
+ }
54
+
55
+ /** The criterion every create carries: the record it would create must not exist yet. */
56
+ export const RECORD_ALREADY_EXISTS = 'record_already_exists'
57
+
58
+ /**
59
+ * A submission-criteria refusal said with each criterion's message, not only
60
+ * its code (`still_open: Only a request nobody has started on can be
61
+ * withdrawn`). The message comes from the refusal when the service sends it
62
+ * (it wins over the project's file, which may be ahead of what is deployed),
63
+ * else from the project's file for the Action. Null for any other refusal.
64
+ */
65
+ export function criteriaRefusal(err: unknown, apiName: string, cwd: string): CliError | null {
66
+ if (!(err instanceof CliError) || err.code !== 'ACTION_CRITERIA_FAILED') return null
67
+ const failed = failedCriteria(err.details)
68
+ if (!failed.length) return null
69
+ const known = failed.some((c) => !c.message) ? projectCriteriaMessages(cwd, apiName) : {}
70
+ const said = failed.map((c) => {
71
+ const message = c.message ?? known[c.code]
72
+ return message ? `${c.code}: ${message}` : c.code
73
+ })
74
+ // A create's own criterion (the platform's, with no project file behind it): the record is there already.
75
+ const exists = failed.length === 1 && failed[0]!.code === RECORD_ALREADY_EXISTS
76
+ return new CliError(`${apiName} refused this request: its submission criteria were not met (${said.join('; ')})`, {
77
+ code: err.code,
78
+ hint: exists
79
+ ? 'the record this request would create exists already, so the same request is refused again: give it another identity, or change the existing record with an Action that edits it (`frontera blueprint instance` shows it)'
80
+ : 'the record or the input does not meet the criteria, so the same request is refused again: change the input, or act on a record that meets them (`frontera blueprint instance` shows its current values)',
81
+ details: { reason: 'ACTION_CRITERIA_FAILED', criteria: failed.map((c) => ({ code: c.code, ...(c.message ?? known[c.code] ? { message: c.message ?? known[c.code] } : {}) })) },
82
+ cause: err,
83
+ })
84
+ }
@@ -33,11 +33,14 @@ function decision(verb: 'approve' | 'reject'): Command {
33
33
  // `--stage` names the approval-chain step being decided. Required for a
34
34
  // request approved in steps; a decision for a step that is no longer
35
35
  // the one waiting is refused, not recorded.
36
- flags: { reason: 'string', stage: 'string' },
36
+ flags: { reason: 'string', stage: 'string', as: 'string' },
37
37
  summary: verb === 'approve'
38
38
  ? 'Approve an Action request waiting on approval'
39
39
  : 'Reject an Action request waiting on approval',
40
- examples: [`frontera action ${verb} <requestId> --reason "Checked against the ticket"`],
40
+ details: [
41
+ 'On the local runtime, `--as <persona>` decides as a local persona: `local dev` seeds one per approval step in blueprint/actions/ (`--as dispatch_lead`), plus `submitter`. Refused anywhere else.',
42
+ ],
43
+ examples: [`frontera action ${verb} <requestId> --reason "Checked against the ticket"`, `frontera action ${verb} <requestId> --stage lead --reason "Checked" --as lead`],
41
44
  },
42
45
 
43
46
  async run(ctx: CommandContext) {