@frontera-sdk/automation 1.43.9 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/automation",
3
- "version": "1.43.9",
3
+ "version": "1.44.0",
4
4
  "description": "Author Frontera automations: manifest, triggers and the typed handler contract.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -38,7 +38,7 @@
38
38
  "typescript": "^5.9.3"
39
39
  },
40
40
  "dependencies": {
41
- "@frontera-sdk/blueprint": "1.43.6",
41
+ "@frontera-sdk/blueprint": "1.43.10",
42
42
  "cron-parser": "^5.0.6"
43
43
  }
44
44
  }
package/src/index.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  export { automation } from './define'
2
2
  export { validateManifest } from './manifest'
3
3
  export type { ValidationResult } from './manifest'
4
- export { duplicateStepMessage, missingGrantMessage } from './messages'
4
+ export {
5
+ duplicateStepMessage,
6
+ duplicateSubmissionMessage,
7
+ emptySubmissionKeyMessage,
8
+ lostStepRowMessage,
9
+ missingGrantMessage,
10
+ submitOutsideStepMessage,
11
+ } from './messages'
5
12
  export { createTestContext } from './testing'
6
13
  export type { TestCall, TestContext, TestContextOptions } from './testing'
7
14
  export type * from './types'
package/src/manifest.ts CHANGED
@@ -33,11 +33,22 @@ const HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0
33
33
  // and then never be satisfiable — no such secret can be created. A validator that
34
34
  // accepts the unsatisfiable is worse than one that is strict.
35
35
  const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/
36
+ // Matches `apiNameSchema` in the Blueprint Action definition schema exactly.
37
+ // Same reasoning as SECRET_NAME_RE: a looser grammar here would accept
38
+ // `governed:Approve_Invoice`, validate cleanly, and name an Action that can
39
+ // never exist — no published Action carries that apiName, so the grant is
40
+ // unsatisfiable and the automation fails at its first submit instead of at
41
+ // deploy.
42
+ const ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/
36
43
 
37
44
  /** Namespaces whose value is not a SEGMENT. */
38
45
  const TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {
39
46
  http: { re: HOST_RE, hint: 'a hostname, e.g. "http:api.stripe.com" (no scheme, no path, no wildcard)' },
40
47
  secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. "secret:STRIPE_KEY"' },
48
+ governed: {
49
+ re: ACTION_API_NAME_RE,
50
+ hint: 'one published Action apiName, e.g. "governed:approveInvoice" (camelCase, no wildcard)',
51
+ },
41
52
  }
42
53
 
43
54
  const KNOWN_KEYS = new Set([
package/src/messages.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The two things an author reads when the platform turns their code away.
2
+ * Everything an author reads when the platform turns their code away.
3
3
  *
4
4
  * They live in the SDK, not in the runner, because three surfaces have to say
5
5
  * exactly the same sentence: the runner refusing a call before it makes it, the
@@ -34,3 +34,75 @@ export function duplicateStepMessage(name: string): string {
34
34
  `ctx.step.run(\`${name}:\${i}\`, ...)`
35
35
  )
36
36
  }
37
+
38
+ /**
39
+ * A submit inside a step whose own row never recorded.
40
+ *
41
+ * Recording a step is contractually non-fatal — telemetry must not fail a run —
42
+ * so the id comes back empty and everything else carries on. A submission
43
+ * cannot: the row is what the idempotency key is derived from, and improvising
44
+ * one is how the same effect happens twice. Named here rather than left to the
45
+ * service's generic invalid-submission, which would blame the payload.
46
+ */
47
+ export function lostStepRowMessage(stepName: string): string {
48
+ return (
49
+ `ctx.action.submit cannot run in step "${stepName}": the step's own record failed to write, ` +
50
+ 'so there is nothing stable to key the submission on and it is refused rather than sent ' +
51
+ 'twice. This is a transient service failure — retry the run.'
52
+ )
53
+ }
54
+
55
+ /**
56
+ * The same Action submitted twice from one step with no way to tell them apart.
57
+ *
58
+ * Refused HERE, locally, rather than left to the write plane, because the plane
59
+ * cannot refuse it: two identical submissions derive one idempotency key AND
60
+ * one semantic fingerprint, so it replays the first request and answers both
61
+ * calls with the same request id. Nothing errors, one effect happens, and the
62
+ * run reports success — the failure mode a batch loop hits on its second row
63
+ * and not on a one-row fixture.
64
+ *
65
+ * Naming the remedy matters more than usual: `submissionKey` is the one field
66
+ * an author has to reach for to fix this, and it is not guessable from a 409.
67
+ */
68
+ export function duplicateSubmissionMessage(apiName: string): string {
69
+ return (
70
+ `Step already submitted "${apiName}" with the same submissionKey. Two submissions the ` +
71
+ 'platform cannot tell apart become ONE request — the plane replays the first and the second ' +
72
+ 'effect never happens. If this is a batch, give each submission a distinct submissionKey ' +
73
+ "keyed on what it acts on: ctx.action.submit({ action: '" + apiName + "', submissionKey: " +
74
+ 'row.id, ... }). If it is a retry, it is already idempotent — drop the loop.'
75
+ )
76
+ }
77
+
78
+ /**
79
+ * An empty `submissionKey`.
80
+ *
81
+ * Refused rather than treated as absent, and refused in both layers: the
82
+ * service rejects it by name, so folding it into the no-key identity here
83
+ * would make the SDK and the service disagree about what the author asked for.
84
+ */
85
+ export function emptySubmissionKeyMessage(apiName: string): string {
86
+ return (
87
+ `ctx.action.submit("${apiName}") was given an empty submissionKey. Omit it entirely to mean ` +
88
+ '"this step submits once", or pass a value identifying what this submission acts on.'
89
+ )
90
+ }
91
+
92
+ /**
93
+ * `ctx.action.submit` called outside a step.
94
+ *
95
+ * States the consequence rather than the rule, because the rule on its own
96
+ * reads as ceremony: code outside a step runs again after EVERY step the
97
+ * handler completes, so a submit there is not one submission with a retry
98
+ * risk — it is one submission per step boundary, every time the run resumes.
99
+ * The step row is also what the idempotency key is derived from, so there is
100
+ * nothing to derive one from out here.
101
+ */
102
+ export function submitOutsideStepMessage(apiName: string): string {
103
+ return (
104
+ `ctx.action.submit("${apiName}") must be called inside ctx.step.run. Code outside a step ` +
105
+ 're-executes every time the run resumes, so this would submit once per step boundary. ' +
106
+ `Wrap it: ctx.step.run('submit-${apiName}', () => ctx.action.submit({ ... }))`
107
+ )
108
+ }
@@ -20,8 +20,14 @@ import { AsyncLocalStorage } from 'node:async_hooks'
20
20
  import {
21
21
  duplicateStepMessage as duplicateStepMessageText,
22
22
  missingGrantMessage as missingGrantMessageText,
23
+ submitOutsideStepMessage as submitOutsideStepMessageText,
24
+ lostStepRowMessage as lostStepRowMessageText,
25
+ duplicateSubmissionMessage as duplicateSubmissionMessageText,
26
+ emptySubmissionKeyMessage as emptySubmissionKeyMessageText,
23
27
  } from './messages'
24
28
  import type {
29
+ ActionSubmission,
30
+ ActionSubmitResult,
25
31
  AutomationContext,
26
32
  BlueprintQueryOptions,
27
33
  BlueprintQueryResult,
@@ -89,7 +95,14 @@ interface Deps {
89
95
  runnerToken?: string
90
96
  }
91
97
 
92
- export { duplicateStepMessage, missingGrantMessage } from './messages'
98
+ export {
99
+ duplicateStepMessage,
100
+ duplicateSubmissionMessage,
101
+ emptySubmissionKeyMessage,
102
+ lostStepRowMessage,
103
+ missingGrantMessage,
104
+ submitOutsideStepMessage,
105
+ } from './messages'
93
106
 
94
107
  export class DuplicateStepNameError extends Error {
95
108
  constructor(readonly stepName: string) {
@@ -139,7 +152,20 @@ export function buildContext(deps: Deps): AutomationContext {
139
152
  * `b` depending on interleaving. This is per-run, not module-global: two runs
140
153
  * in one process must never see each other's scope.
141
154
  */
142
- const stepScope = new AsyncLocalStorage<{ stepId: string }>()
155
+ const stepScope = new AsyncLocalStorage<{
156
+ stepId: string
157
+ stepName: string
158
+ /**
159
+ * `action` + `submissionKey` for every submit this step body has made.
160
+ *
161
+ * The write plane CANNOT catch a repeat: two identical submissions derive
162
+ * one key and one semantic fingerprint, so it replays the first request and
163
+ * answers both calls with the same id — no error, one effect, a green run.
164
+ * The check has to be local, and per step EXECUTION so that a genuine
165
+ * resumption or retry (which re-enters the body from scratch) is unaffected.
166
+ */
167
+ submitted: Set<string>
168
+ }>()
143
169
 
144
170
  /**
145
171
  * Append a row to the run's audit trail, returning the id the service gave it.
@@ -301,7 +327,16 @@ export function buildContext(deps: Deps): AutomationContext {
301
327
  })
302
328
  const t0 = Date.now()
303
329
  try {
304
- const out = await stepScope.run({ stepId }, fn)
330
+ // `stepName` rides alongside `stepId` because `ctx.action.submit` needs
331
+ // the NAME, not the row id: the id is fresh on every execution, and an
332
+ // idempotency key derived from it would differ on each resumption —
333
+ // which is the exact duplicate-submission this scope exists to prevent.
334
+ // The service reads the name off the row rather than trusting this
335
+ // copy; it travels here only so a refusal can name it.
336
+ const out = await stepScope.run(
337
+ { stepId, stepName: name, submitted: new Set<string>() },
338
+ fn,
339
+ )
305
340
  await completeStep(stepId, { status: 'ok', durationMs: Date.now() - t0 })
306
341
  return out
307
342
  } catch (err) {
@@ -366,6 +401,98 @@ export function buildContext(deps: Deps): AutomationContext {
366
401
  }) as Promise<HttpResponse>,
367
402
  },
368
403
 
404
+ action: {
405
+ submit: (request: ActionSubmission) =>
406
+ step('action', `action:${request.action}`, async () => {
407
+ // Step scope BEFORE the grant. Both are the author's mistake, but this
408
+ // one is structural: a submit outside a step is wrong even with every
409
+ // grant in place, and the remedy is a code change rather than a
410
+ // manifest change. Naming the manifest first would send them to the
411
+ // wrong file.
412
+ const scope = stepScope.getStore()
413
+ if (!scope) throw new Error(submitOutsideStepMessageText(request.action))
414
+ // A step whose own row was lost cannot be submitted from. `recordStep`
415
+ // is contractually non-fatal and hands back an empty id, which is
416
+ // right for telemetry — one lost row must not cost the calls made
417
+ // inside it — but a submission has nothing to key on without it, and
418
+ // improvising a key is how an effect happens twice. Refused HERE so
419
+ // the cause is named; the service would otherwise see an empty string
420
+ // and answer with a generic invalid-submission.
421
+ if (!scope.stepId) throw new Error(lostStepRowMessageText(scope.stepName))
422
+ // NUL-joined because both halves are author strings; `a:b` with no
423
+ // key must not collide with `a` keyed `b`.
424
+ // Refused locally, matching the service's own field-named rejection
425
+ // — folding `''` into the no-key identity would make the two layers
426
+ // disagree about what the author asked for.
427
+ if (request.submissionKey !== undefined && request.submissionKey.length === 0) {
428
+ throw new Error(emptySubmissionKeyMessageText(request.action))
429
+ }
430
+ // Ahead of the reservation, and synchronous so check-and-reserve still
431
+ // land in one tick. Below it, a missing grant left the identity
432
+ // reserved and the author's next attempt was told they had duplicated
433
+ // a submission that never left the process — pointing at
434
+ // `submissionKey` when the fix is one line in the manifest. A call
435
+ // that is both ungranted and a duplicate now reports the grant, which
436
+ // is the more actionable of the two.
437
+ requireGrant(`governed:${request.action}`)
438
+ const submissionIdentity = `${request.action}\u0000${request.submissionKey ?? ''}`
439
+ // RESERVE, synchronously. The check and the record must land in one
440
+ // tick: with an await between them, `Promise.all([submit(x),
441
+ // submit(x)])` passes both checks before either records, both reach
442
+ // the plane, and — same key, same fingerprint — the plane replays the
443
+ // first for the second. Two calls, one effect, a green run, which is
444
+ // the exact failure this guard exists to prevent.
445
+ //
446
+ // Released again in the catch below, so a submission that never
447
+ // landed does not burn its identity and the author's retry loop still
448
+ // works. Reserve-then-release is what satisfies both at once.
449
+ if (scope.submitted.has(submissionIdentity)) {
450
+ throw new Error(duplicateSubmissionMessageText(request.action))
451
+ }
452
+ scope.submitted.add(submissionIdentity)
453
+ // The step ROW id, which the service issued. The service resolves the
454
+ // row, takes the step NAME off it, and derives the key from that — so
455
+ // what identifies the submission comes from the database rather than
456
+ // from this process. No ordinal: a positional one made an in-body
457
+ // retry mint a fresh key and duplicate the effect, and reordered
458
+ // concurrent submits bind each other's keys. `submissionKey` is how an
459
+ // author says two submissions are genuinely two.
460
+ let res: Response
461
+ try {
462
+ res = await scoped('/ctx/action-submit', {
463
+ method: 'POST',
464
+ body: JSON.stringify({ ...request, stepId: scope.stepId }),
465
+ })
466
+ } catch (err) {
467
+ // Never reached the service. Release, so a retry is a retry rather
468
+ // than a duplicate accusation for a step that submitted zero times.
469
+ scope.submitted.delete(submissionIdentity)
470
+ throw err
471
+ }
472
+ if (!res.ok) {
473
+ // Refused, so nothing was bound to this identity. A 5xx is the
474
+ // interesting case: the author catches it and submits again, and
475
+ // that second call must be allowed through to the plane, where the
476
+ // key — unchanged — makes it a replay rather than a second effect.
477
+ scope.submitted.delete(submissionIdentity)
478
+ throw new Error(`ctx.action.submit → ${res.status} ${await refusal(res)}`)
479
+ }
480
+ try {
481
+ return ((await res.json()) as { data: ActionSubmitResult }).data
482
+ } catch (err) {
483
+ // The submission LANDED, so keeping the reservation would be
484
+ // defensible — but the reasoning that releases a 503 applies here
485
+ // with more force: the key is unchanged, so a resubmit can only
486
+ // replay, and replaying is the only way the author recovers a
487
+ // request id they never received. Keeping it ends the run accusing
488
+ // them of two submissions when there was one and an unreadable
489
+ // answer.
490
+ scope.submitted.delete(submissionIdentity)
491
+ throw err
492
+ }
493
+ }) as Promise<ActionSubmitResult>,
494
+ },
495
+
369
496
  blueprint: {
370
497
  query: <T = Record<string, unknown>>(objectType: string, options?: BlueprintQueryOptions) =>
371
498
  step('blueprint', `query:${objectType}`, async () => {
package/src/testing.ts CHANGED
@@ -1,5 +1,14 @@
1
- import { duplicateStepMessage, missingGrantMessage } from './messages'
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import {
3
+ duplicateStepMessage,
4
+ duplicateSubmissionMessage,
5
+ emptySubmissionKeyMessage,
6
+ missingGrantMessage,
7
+ submitOutsideStepMessage,
8
+ } from './messages'
2
9
  import type {
10
+ ActionSubmission,
11
+ ActionSubmitResult,
3
12
  AutomationContext,
4
13
  BlueprintQueryOptions,
5
14
  BlueprintQueryResult,
@@ -26,14 +35,25 @@ import type {
26
35
  * everything the handler did is recorded on `calls`.
27
36
  */
28
37
 
38
+ /**
39
+ * A call REFUSED before it happened is not recorded.
40
+ *
41
+ * A missing grant, a missing stub, a submit outside a step, a duplicate
42
+ * submission — none of these appear in `calls`, because none of them did
43
+ * anything. A real run differs here in one direction worth knowing: it writes
44
+ * an errored ctx-call row for a refused `ctx.action.submit`, so the Console
45
+ * trace shows the attempt where this list does not. Assert on the thrown error
46
+ * for a refusal, and on `calls` for what ran.
47
+ */
29
48
  export interface TestCall {
30
- kind: 'step' | 'log' | 'agent' | 'http' | 'blueprint'
31
- /** Step name, log message, agent slug, URL, or object type. */
49
+ kind: 'step' | 'log' | 'agent' | 'http' | 'blueprint' | 'action'
50
+ /** Step name, log message, agent slug, URL, object type, or Action apiName. */
32
51
  label: string
33
52
  /** Present on a step: how it ended. */
34
53
  status?: 'ok' | 'error'
35
54
  }
36
55
 
56
+
37
57
  export interface TestContextOptions {
38
58
  runId?: string
39
59
  workspaceId?: string
@@ -55,6 +75,19 @@ export interface TestContextOptions {
55
75
  /** Rows per object type. An unstubbed type returns no rows, which is a real
56
76
  * answer and usually the branch worth testing. */
57
77
  blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>
78
+ /**
79
+ * Per-apiName Action outcomes. An unstubbed Action throws rather than
80
+ * answering.
81
+ *
82
+ * Throws for the same reason the agent stub does, and the reason is sharper
83
+ * here: the returned `lifecycle` is a branch an author writes code against —
84
+ * `awaiting_approval` means a human still has to decide — so inventing
85
+ * `ready` would silently pick one arm and pass.
86
+ */
87
+ actions?: Record<
88
+ string,
89
+ (request: ActionSubmission) => Promise<ActionSubmitResult> | ActionSubmitResult
90
+ >
58
91
  }
59
92
 
60
93
  export interface TestContext {
@@ -71,6 +104,21 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
71
104
  const steps: string[] = []
72
105
  const logs: TestContext['logs'] = []
73
106
  const seenNames = new Set<string>()
107
+ /**
108
+ * Which step the running code is inside.
109
+ *
110
+ * `AsyncLocalStorage`, matching the real context exactly, and NOT a stack.
111
+ * A stack gets the concurrent case wrong in the direction that matters:
112
+ * `Promise.all([ctx.step.run('a', …), ctx.action.submit(…)])` is legal, and
113
+ * with a shared mutable stack the bare submit sees `a` open and is allowed —
114
+ * so the test double passes what production refuses, which is the one failure
115
+ * mode a test double must not have.
116
+ *
117
+ * Enforced here for the same reason grants and duplicate names are: a rule
118
+ * the unit test does not apply is a rule the author meets for the first time
119
+ * in a deployed run.
120
+ */
121
+ const stepScope = new AsyncLocalStorage<{ stepName: string; submitted: Set<string> }>()
74
122
 
75
123
  const requireGrant = (grant: string): void => {
76
124
  // No grant list means the test is not about grants. Enforcing an empty list
@@ -88,14 +136,16 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
88
136
  if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
89
137
  seenNames.add(name)
90
138
  steps.push(name)
91
- try {
92
- const out = await fn()
93
- calls.push({ kind: 'step', label: name, status: 'ok' })
94
- return out
95
- } catch (err) {
96
- calls.push({ kind: 'step', label: name, status: 'error' })
97
- throw err
98
- }
139
+ return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {
140
+ try {
141
+ const out = await fn()
142
+ calls.push({ kind: 'step', label: name, status: 'ok' })
143
+ return out
144
+ } catch (err) {
145
+ calls.push({ kind: 'step', label: name, status: 'error' })
146
+ throw err
147
+ }
148
+ })
99
149
  },
100
150
  },
101
151
 
@@ -145,6 +195,50 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
145
195
  },
146
196
  },
147
197
 
198
+ action: {
199
+ async submit(request: ActionSubmission): Promise<ActionSubmitResult> {
200
+ const scope = stepScope.getStore()
201
+ if (!scope) throw new Error(submitOutsideStepMessage(request.action))
202
+ // The batch loop is the shape this catches, and a one-row fixture never
203
+ // reaches it — so the double has to enforce it or an author meets it
204
+ // for the first time on their second production row, after the first
205
+ // has already been applied.
206
+ if (request.submissionKey !== undefined && request.submissionKey.length === 0) {
207
+ throw new Error(emptySubmissionKeyMessage(request.action))
208
+ }
209
+ requireGrant(`governed:${request.action}`)
210
+ const stub = options.actions?.[request.action]
211
+ // Both refusals that mean "this never happened" come BEFORE the
212
+ // reservation, matching the runtime's grant check: reserving first left
213
+ // an author who fixed the missing stub and re-ran a loop facing a
214
+ // duplicate accusation for a call that never answered.
215
+ if (!stub) {
216
+ throw new Error(
217
+ `No action stub for "${request.action}". Pass actions: { '${request.action}': ` +
218
+ "() => ({ requestId: 'req-1', lifecycle: 'ready' }) } to createTestContext.",
219
+ )
220
+ }
221
+ const submissionIdentity = `${request.action}\u0000${request.submissionKey ?? ''}`
222
+ // Reserved synchronously and released on failure, matching the runtime
223
+ // exactly. A double that checked and recorded across an await would let
224
+ // `Promise.all([submit(x), submit(x)])` through — and a double that
225
+ // permits what production refuses is the one failure mode a double must
226
+ // not have.
227
+ if (scope.submitted.has(submissionIdentity)) {
228
+ throw new Error(duplicateSubmissionMessage(request.action))
229
+ }
230
+ scope.submitted.add(submissionIdentity)
231
+ calls.push({ kind: 'action', label: request.action })
232
+ try {
233
+ return await stub(request)
234
+ } catch (err) {
235
+ // A throwing stub stands in for a submission that never landed.
236
+ scope.submitted.delete(submissionIdentity)
237
+ throw err
238
+ }
239
+ },
240
+ },
241
+
148
242
  blueprint: {
149
243
  async query<T = Record<string, unknown>>(
150
244
  objectType: string,
package/src/types.ts CHANGED
@@ -41,6 +41,13 @@ export type Grant =
41
41
  /** The NAME of a workspace secret. Its VALUE never enters this process: you
42
42
  * name it, the platform injects it server-side. */
43
43
  | `secret:${string}`
44
+ /** One EXACT published Action apiName. `governed:approveInvoice` permits
45
+ * submitting that Action and nothing else.
46
+ *
47
+ * Not wildcardable, for the same reason `http:` is not: a reviewer reading
48
+ * `governed:*` would have to know the whole current Action catalog — and the
49
+ * answer changes with every release — to know what the automation may do. */
50
+ | `governed:${string}`
44
51
 
45
52
  export interface AutomationManifest {
46
53
  name: string
@@ -126,7 +133,112 @@ export interface StepApi {
126
133
  run<T>(name: string, fn: () => Promise<T>): Promise<T>
127
134
  }
128
135
 
129
- /** Read-only for now. `governed`/`notify` arrive with Governed Writes. */
136
+ /**
137
+ * The 13 lifecycle states a governed Action Request can hold.
138
+ *
139
+ * A deliberate copy of a WIRE contract, not shared code — same reasoning as
140
+ * `RegistryEntry` in the runner: this package must install from public npm with
141
+ * a three-package dependency list, and importing the service's own enum would
142
+ * drag drizzle and the schema into an author's `bun install`. The service's
143
+ * `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves
144
+ * the two agree.
145
+ */
146
+ export type ActionRequestLifecycle =
147
+ | 'received'
148
+ | 'awaiting_approval'
149
+ | 'ready'
150
+ | 'executing'
151
+ | 'finalizing'
152
+ | 'succeeded'
153
+ | 'rejected'
154
+ | 'expired'
155
+ | 'cancelled'
156
+ | 'failed'
157
+ | 'outcome_unknown'
158
+ | 'awaiting_resolution'
159
+ | 'closed_unknown'
160
+
161
+ /**
162
+ * `subjectRef` and `expectedSubjectVersion` are paired deliberately.
163
+ *
164
+ * The plane requires BOTH for an Action over an existing subject and refuses
165
+ * BOTH for a create Action, so independently-optional fields would let an
166
+ * author write a submission that cannot be accepted and only find out at
167
+ * runtime. Which arm applies is the Action's decision, not the caller's — read
168
+ * it off the Action's `subject.mode`.
169
+ */
170
+ export type ActionSubmission = {
171
+ /** Published Action apiName. Requires a `governed:<apiName>` grant. */
172
+ action: string
173
+ input: Record<string, unknown>
174
+ /** Required when the Action's definition says so. */
175
+ reason?: string
176
+ /**
177
+ * Tells two submissions from the SAME step apart.
178
+ *
179
+ * A step submits once by default. The idempotency key is derived from the run
180
+ * and the step alone, so a submission re-reached by a resumption or by a
181
+ * retried attempt is the SAME key and the plane hands back the original
182
+ * request instead of making a second one. Your own retry loop behaves the
183
+ * same way: a submission that FAILED is not recorded, so submitting again
184
+ * after catching a transport error re-sends and the plane replays.
185
+ *
186
+ * What you cannot do by default is submit twice on purpose. Two submissions
187
+ * the platform cannot tell apart derive one key AND one semantic
188
+ * fingerprint, so the plane would replay the first and answer both calls with
189
+ * the same id — no error, one effect, a green run. Rather than let that
190
+ * happen, the second call is refused before it leaves your process, naming
191
+ * this field.
192
+ *
193
+ * Pass a distinct `submissionKey` per submission to say you meant it — a
194
+ * business identity is the right value, not a counter:
195
+ *
196
+ * ```ts
197
+ * await ctx.step.run('flag', async () => {
198
+ * for (const row of rows) {
199
+ * await ctx.action.submit({
200
+ * action: 'flagForAudit',
201
+ * // Stable for THIS row across every attempt. An array index is not:
202
+ * // if the re-read returns the rows in another order, an index would
203
+ * // bind row B's submission to row A's key.
204
+ * submissionKey: row.id,
205
+ * input: { rowId: row.id },
206
+ * })
207
+ * }
208
+ * })
209
+ * ```
210
+ *
211
+ * It must be stable across attempts for the same intended submission, which
212
+ * is why the platform cannot derive it for you — only your code knows which
213
+ * of two submissions is "the same one again". An empty string is refused;
214
+ * omit it entirely to mean "this step submits once".
215
+ */
216
+ submissionKey?: string
217
+ } & (
218
+ | {
219
+ subjectRef: { objectTypeId: string; objectId: string }
220
+ /** The version you believe the subject is at: a submission built from a
221
+ * stale read must lose rather than overwrite. */
222
+ expectedSubjectVersion: string
223
+ }
224
+ | { subjectRef?: never; expectedSubjectVersion?: never }
225
+ )
226
+
227
+ export interface ActionSubmitResult {
228
+ requestId: string
229
+ /**
230
+ * Where the request stopped, NOT whether the effect happened.
231
+ *
232
+ * `ready` means accepted and queued for dispatch. `awaiting_approval` means
233
+ * the Action requires a human and one has not decided yet — a normal return,
234
+ * not an error. Neither is a completed business fact.
235
+ */
236
+ lifecycle: ActionRequestLifecycle
237
+ }
238
+
239
+ /**
240
+ * `notify` still arrives with a later slice; `governed` is here.
241
+ */
130
242
  export interface AutomationContext {
131
243
  runId: string
132
244
  workspaceId: string
@@ -163,6 +275,41 @@ export interface AutomationContext {
163
275
  * resumed.
164
276
  */
165
277
  step: StepApi
278
+ action: {
279
+ /**
280
+ * Ask the governed write plane to perform one named business change.
281
+ *
282
+ * This is the ONLY way an automation changes a system of record. Your code
283
+ * never holds a write handle: you describe the change, and the plane
284
+ * authorizes it, approves it if the Action says so, dispatches it, confirms
285
+ * it and records it. An Action that declares `approval: required` cannot be
286
+ * talked out of it by the caller.
287
+ *
288
+ * Two rules:
289
+ *
290
+ * 1. **It must be called inside `ctx.step.run`.** Code outside a step
291
+ * re-executes after every step boundary, so a submit sitting there would
292
+ * fire once per boundary. Inside a step it runs once, and the step —
293
+ * identified by the row the service itself issued — is what makes the
294
+ * idempotency key stable across resumption and across a retried run.
295
+ *
296
+ * The service checks this rather than taking your word for it: the
297
+ * submission carries a step row id, and a submission whose id names no
298
+ * open step of this run is refused. What that check cannot do is make a
299
+ * determined bundle behave — your code runs unsandboxed in the same
300
+ * process as the run token, so it could open a step row purely to submit
301
+ * inside it. The bound is that such a step is a real row and shows up in
302
+ * the run trace, not that it is impossible.
303
+ * 2. **It never waits.** It returns as soon as the request is durably
304
+ * accepted. A run has nobody to ask for an approval and ten minutes to
305
+ * live, so blocking on a human is not something this can offer —
306
+ * `awaiting_approval` is a normal return value.
307
+ *
308
+ * The returned `lifecycle` is where the request stopped, not proof of
309
+ * effect. Poll the ledger, or let the Action's Business Event tell you.
310
+ */
311
+ submit(request: ActionSubmission): Promise<ActionSubmitResult>
312
+ }
166
313
  }
167
314
 
168
315
  export interface HttpRequest {