@astrale-os/sdk 0.6.0-beta.39 → 0.6.0-beta.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/workflow/authoring.d.ts +1 -1
- package/dist/application/workflow/durable.d.ts +24 -0
- package/dist/application/workflow/index.d.ts +1 -1
- package/dist/execution/index.d.ts +1 -1
- package/dist/execution/index.js +1 -1
- package/dist/execution/workflows/engine.d.ts +9 -0
- package/dist/execution/workflows/index.d.ts +2 -2
- package/dist/execution/workflows/index.js +1 -1
- package/dist/execution/workflows/options.d.ts +6 -1
- package/dist/execution/workflows/options.js +21 -3
- package/dist/execution/workflows/run.js +95 -9
- package/dist/testing/durable/engine.js +23 -1
- package/dist/tooling/linter/implementations/source/shared.d.ts +5 -0
- package/dist/tooling/linter/implementations/source/shared.js +15 -7
- package/dist/tooling/linter/implementations/source/workflows.js +77 -7
- package/dist/tooling/linter/policy/generated.js +2 -2
- package/dist/tooling/linter/requirements/registry.js +1 -0
- package/docs/admission-age.md +88 -0
- package/docs/auth-api-migration.md +55 -0
- package/docs/business-errors.md +42 -0
- package/docs/domain-definition-migration.md +44 -0
- package/docs/domain-schema-migration.md +36 -0
- package/docs/durable-workflows.md +418 -0
- package/docs/handler-dependencies.md +213 -0
- package/docs/release.md +155 -0
- package/docs/schema-cost.md +124 -0
- package/package.json +3 -2
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# Durable Workflows
|
|
2
|
+
|
|
3
|
+
A callable whose Schema declares `execution: { durable: true }` runs on the Kernel's workflow
|
|
4
|
+
engine. A call returns at once with the `K.Execution` it recorded; the engine journals the run,
|
|
5
|
+
keeps its timers and promises, retries it and survives restarts of the Domain, the Kernel and the
|
|
6
|
+
engine itself.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// schema/schema.ts, in the Invoice Class
|
|
10
|
+
remind: method({
|
|
11
|
+
auth: 'authenticated',
|
|
12
|
+
input: z.object({ note: z.string() }),
|
|
13
|
+
output: z.object({ reminder: z.string() }),
|
|
14
|
+
execution: { durable: true },
|
|
15
|
+
})
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A durable callable needs `authenticated` or `authorized` auth, a value input and output, and a
|
|
19
|
+
remote binding. Implement it with `defineWorkflow`; `defineAction` refuses a durable callable.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
export const remind = defineWorkflow<typeof schema>()(
|
|
23
|
+
'Invoice.remind',
|
|
24
|
+
async ({ input, self, execution, step, sleep, awaitSignal }) => {
|
|
25
|
+
const sent = await step.query('count-reminders', remindersOf, { invoice: self })
|
|
26
|
+
const reminder = await step.mutate('record', recordReminder, {
|
|
27
|
+
invoice: self,
|
|
28
|
+
rank: sent.length + 1,
|
|
29
|
+
note: input.note,
|
|
30
|
+
execution: execution.id,
|
|
31
|
+
})
|
|
32
|
+
await sleep(24 * 60 * 60_000)
|
|
33
|
+
const approval = await awaitSignal<{ readonly by: string }>('approval')
|
|
34
|
+
return { reminder: `${reminder}:${approval.by}` }
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Context
|
|
40
|
+
|
|
41
|
+
| Member | Meaning |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `input`, `self`, `domain` | As for any Workflow; `self` is the receiver of an instance Method. |
|
|
44
|
+
| `execution` | Attested facts: `id`, `function {id, version}`, `kernel`, `initiator {principal, caller}`, `receiver`. Data, never authority. |
|
|
45
|
+
| `step.run(step, execute)` | Runs `execute` once and journals its portable output; a replay returns the journaled output. `execute` receives the capabilities of an Action, acting as the Domain alone: `kernel`, `graph`, `query`, `mutate`, `dependencies` and `integrations`. `step` is the Step's id, or `{ id, retry, timeoutMs }` (see [Retry budgets and timeouts](#retry-budgets-and-timeouts)). |
|
|
46
|
+
| `step.query(step, query, input?)` | One journaled Query; a replay returns the result of its first run. |
|
|
47
|
+
| `step.mutate(step, mutation, input)` | One journaled Mutation. |
|
|
48
|
+
| `step.parallel(declare)` | Runs the Steps `declare` declares together, in one round trip to the engine; see [Parallel Steps](#parallel-steps). |
|
|
49
|
+
| `sleep(ms)` | Durable timer, kept by the engine even while the Domain is down. |
|
|
50
|
+
| `awaitSignal(name)` | Waits for the value `Execution.signal({ name, value })` delivers once under `name`, sent before or during the wait. |
|
|
51
|
+
| `awaitSignal(name, { timeoutMs })` | Waits at most `timeoutMs`: `{ kind: 'signal', value }` or `{ kind: 'timeout' }`. |
|
|
52
|
+
| `cancelled(error)` | Whether `error` is the engine's cooperative cancellation; catch it to compensate. |
|
|
53
|
+
| `exhausted(error)` | Whether `error` is a Step that spent its retry budget; catch it to take another path. |
|
|
54
|
+
| `retryAfter(ms, cause?)` | The failure to throw inside a Step whose next attempt must wait `ms` (at most one hour), as an HTTP `Retry-After` asks. |
|
|
55
|
+
| `awaitExecution(ref)` | Waits durably for another Execution by journaled polling of its `result`. A poll has no Step budget: while the Kernel cannot answer, the run is retried until it pauses. |
|
|
56
|
+
| `error(code, details)` | Declared business errors, as for any Workflow; they become the `failed` outcome. |
|
|
57
|
+
|
|
58
|
+
A durable run acts as its Domain, never as its caller: the Workflow and its Steps have no `caller`,
|
|
59
|
+
and `kernel`, `graph` and `dependencies` offer only `self`. The Kernel is reachable only inside a
|
|
60
|
+
Step, so every Kernel call is journaled and a replay never repeats it. A Step whose outcome was not
|
|
61
|
+
journaled runs again. What a retried Step repeats depends on the call:
|
|
62
|
+
|
|
63
|
+
| Call inside a Step | On a retry of the Step |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| Mutation (`step.mutate`, `mutate`, `graph.self.mutate`, `kernel.mutate`) | Carries a key derived from the Execution, the Step and its position. The Kernel commits a receipt with the write and answers with the first result, without writing again. |
|
|
66
|
+
| `kernel.invoke` or `dependencies.<alias>.self.invoke` of a durable callable | Carries such a key too, so it reaches the same child Execution. The call answers with that Execution's `ExecutionRef`. |
|
|
67
|
+
| `kernel.invoke` or `dependencies.<alias>.self.invoke` of any other callable | Carries no key, because the Kernel accepts one only for a durable Function. The call runs again, so its effects must be idempotent: declare the callee durable, or give it an identity in its input. |
|
|
68
|
+
| Integration operation, `keyed` with source `execution` | Its call id is the next key of the same sequence, and the Provider receives it as `idempotencyKey`: every attempt sends the same key. |
|
|
69
|
+
| Integration operation, `keyed` with source `input` | The Provider receives the key the decision takes from the input. |
|
|
70
|
+
| Integration operation, `safe` | Runs again. |
|
|
71
|
+
| Integration operation that may be `unsafe` | Absent from `integrations`: a retry would repeat it. An operation whose decision turns out `unsafe` is refused before its Provider runs. |
|
|
72
|
+
|
|
73
|
+
A Provider called from a Step receives no `invoke`, since no caller exists, and its `auth.mint` and
|
|
74
|
+
deadline are bounded by the Step's deadline. An operation whose replay is a function counts as
|
|
75
|
+
replayable only when that function never answers `unsafe`.
|
|
76
|
+
|
|
77
|
+
The Host keeps receipts longer than the engine keeps a run, so a Step is never retried after its
|
|
78
|
+
receipt is gone. A callable is known durable from the Schema closure of the Domain: an untyped Path,
|
|
79
|
+
or a callable outside that closure, is keyed only when the call passes its own `idempotencyKey`.
|
|
80
|
+
|
|
81
|
+
This requires a Kernel that keeps Mutation receipts. An older Kernel refuses the Mutation key.
|
|
82
|
+
|
|
83
|
+
Step ids are unique within a run. Steps and waits run one after another: the engine follows one
|
|
84
|
+
awaited operation at a time, and loses track of a run that awaits several together through
|
|
85
|
+
`Promise.all`, `allSettled`, `race` or `any`. A Step, `sleep`, `awaitSignal` or `awaitExecution`
|
|
86
|
+
started while another runs is therefore refused with a `TypeError`, which pauses the run as any
|
|
87
|
+
error between Steps does. Independent reads may share one Step, whose Kernel calls are ordinary
|
|
88
|
+
calls that `Promise.all` may combine:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const [invoice, reminders] = await step.run('read-invoice', ({ query }) =>
|
|
92
|
+
Promise.all([query(invoiceOf, { invoice: self }), query(remindersOf, { invoice: self })]),
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Code between Steps must be deterministic.
|
|
97
|
+
|
|
98
|
+
### Parallel Steps
|
|
99
|
+
|
|
100
|
+
Each Step awaited alone costs one round trip to the engine. `step.parallel` is the one way to run
|
|
101
|
+
several Steps together: the engine follows them as one operation, through its own combinator, and
|
|
102
|
+
they cost one round trip together.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const [invoice, sent, receipt] = await step.parallel((s) => [
|
|
106
|
+
s.query('read-invoice', invoiceOf, { invoice: self }),
|
|
107
|
+
s.query({ id: 'count-reminders', retry: { maxAttempts: 5 } }, remindersOf, { invoice: self }),
|
|
108
|
+
s.run('charge', ({ integrations }) => integrations.ledger.charge({ amount: 12 })),
|
|
109
|
+
])
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`s` declares Steps with the arguments of `step.run`, `step.query` and `step.mutate`, and starts
|
|
113
|
+
nothing; `declare` returns exactly the Steps it declares, as an array. Each Step keeps its id,
|
|
114
|
+
retry budget, timeout and keys, as if it ran alone. Once every Step has settled, `step.parallel`
|
|
115
|
+
resolves with their outputs in declaration order, or throws the failure of the first failed Step in
|
|
116
|
+
that order, as that Step alone would throw it: a declared error, a `STEP_EXHAUSTED` that `exhausted`
|
|
117
|
+
recognizes, or a cancellation. The other Steps have run by then, and their effects stand. An empty
|
|
118
|
+
list resolves with `[]`. Parallel Steps do not nest, and a Step that depends on another's output
|
|
119
|
+
runs after it.
|
|
120
|
+
|
|
121
|
+
Run reads and independent effects together. Keep in sequence the effects a failure would make you
|
|
122
|
+
compensate, so the Workflow knows which ones committed.
|
|
123
|
+
|
|
124
|
+
## From an inline Workflow
|
|
125
|
+
|
|
126
|
+
The same `defineWorkflow` implements an inline and a durable Workflow; the Schema decides which.
|
|
127
|
+
Declaring `execution: { durable: true }` on a callable changes the context its Workflow receives:
|
|
128
|
+
|
|
129
|
+
| Inline Workflow | Durable Workflow |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| `query` and `mutate` in the context, as the Domain; `kernel`, `graph` and `dependencies`, as the Domain or the caller | Only inside a Step, as the Domain alone: `step.query`, `step.mutate`, or `step.run(id, ({ query, mutate, kernel, graph, dependencies }) => …)` |
|
|
132
|
+
| `integrations` in the context, with every operation | Inside `step.run` only, without the operations that may be `unsafe` |
|
|
133
|
+
| `caller` | Absent; `execution.initiator` records who started the run, as data |
|
|
134
|
+
| `execution`: `signal`, `deadline`, `idempotencyKey`, `background`, `sleep`, `request` | `execution`: `id`, `function`, `kernel`, `initiator`, `receiver`; `sleep` is a member of the context |
|
|
135
|
+
| `step.run(id, () => …)`, not journaled | `step.run`, `step.query`, `step.mutate` and `step.parallel`, journaled, each Step under its retry budget |
|
|
136
|
+
| A failing Step throws into the Workflow at once | A failing Step is attempted again within its budget, then throws `STEP_EXHAUSTED` into the Workflow |
|
|
137
|
+
| Callers receive the output | Callers receive `ExecutionRef<Output>`; the Workflow still returns the output |
|
|
138
|
+
|
|
139
|
+
The type checker reports every use of a member the durable context lacks, and every caller that
|
|
140
|
+
reads the output. Three changes it cannot see:
|
|
141
|
+
|
|
142
|
+
- A business refusal a Step throws as anything but `error(...)` is attempted again until the Step
|
|
143
|
+
spends its budget, and the Execution ends `STEP_EXHAUSTED` instead of the refusal. Turn a refusal
|
|
144
|
+
into its declared error inside the Step that detects it, not around the Step.
|
|
145
|
+
- Code between Steps runs again on every replay. A value it reads from the clock or randomness
|
|
146
|
+
differs on the next attempt; read it in a Step. `FNC-REPLAY-VALUES` warns on such a read.
|
|
147
|
+
- A caller that ignores the output, such as a View that refreshes after the call, compiles unchanged
|
|
148
|
+
but refreshes before the Execution ends. Follow the Execution, and refresh when it ends.
|
|
149
|
+
|
|
150
|
+
`createTestEngine` (see [Testing](#testing)) shows the first two: a refusal path ends
|
|
151
|
+
`STEP_EXHAUSTED`, with what the last attempt threw in `failure`, and code whose path differs on
|
|
152
|
+
replay fails with `JournalMismatch`.
|
|
153
|
+
|
|
154
|
+
## Reads and effects
|
|
155
|
+
|
|
156
|
+
The engine retries a Step as a whole, so running it again must be safe. A Step may make several
|
|
157
|
+
reads, and the keys of the table above make its effects safe to repeat. What a Step must not do is
|
|
158
|
+
read before its effect: an effect Step builds its request only from the Workflow input and the
|
|
159
|
+
results of earlier Steps, so it reads what it needs with `step.query` first:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const sent = await step.query('count-reminders', remindersOf, { invoice: self })
|
|
163
|
+
await step.mutate('record', recordReminder, { invoice: self, rank: sent.length + 1 })
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A Step can commit its effect and lose its journal entry; the engine then runs it again with the same
|
|
167
|
+
keys. The Kernel answers a Mutation from its receipt only when the request is the same. Rebuilt from a
|
|
168
|
+
read made earlier in the same Step, the request can differ, since that read now sees the Step's own
|
|
169
|
+
write: the Kernel refuses it with `MUTATION_IDEMPOTENCY_CONFLICT` on every attempt until the Step
|
|
170
|
+
spends its budget and the Execution fails, or a guard on the read takes the other branch and the
|
|
171
|
+
Execution fails although its effect committed. A read from an earlier Step replays unchanged, so the retried request is
|
|
172
|
+
identical. A read after the effect sees the same write on every attempt.
|
|
173
|
+
|
|
174
|
+
Two effects in one Step replay safely, each under its own key, but when the second fails after the
|
|
175
|
+
first committed, the Step reports only its failure and the Workflow cannot tell what to compensate.
|
|
176
|
+
Give each effect its own Step; two Mutations that can commit together are one Mutation.
|
|
177
|
+
|
|
178
|
+
The Domain linter checks these rules in a durable Workflow. `FNC-STEP-READS` reports a Step
|
|
179
|
+
that reads before its effect, unless the two take exclusive branches. `FNC-EFFECT-STEP` warns on a
|
|
180
|
+
Step where one run can make two effects, or repeat one in a loop. `FNC-STEP-RETRY` warns on a call a retried
|
|
181
|
+
Step repeats with no key: a callable the types prove not durable, an untyped Path, or `fetch`. A call
|
|
182
|
+
whose kind the source does not prove is not reported. `FNC-REPLAY-VALUES` warns on a read of the clock
|
|
183
|
+
or randomness (`Date.now()`, `new Date()`, `Math.random()`, `crypto.randomUUID()`) in the code a replay
|
|
184
|
+
runs again: the Workflow's body outside its Steps, the arguments of a Step call included.
|
|
185
|
+
|
|
186
|
+
A journaled read is the value at the moment its Step ran; a Step that runs later, after retries or a
|
|
187
|
+
pause, does not read it again. A fact that must still hold when the effect commits is a precondition
|
|
188
|
+
of the Mutation (`mutation.expect`). Map `MUTATION_PRECONDITION_FAILED` in the Mutation's `reject` to
|
|
189
|
+
a result the Workflow handles, such as a declared error; unmapped, the engine retries it until the
|
|
190
|
+
Step spends its budget. A retried Step whose Mutation already committed still receives that commit's
|
|
191
|
+
receipt, even though its precondition no longer holds.
|
|
192
|
+
|
|
193
|
+
## Testing
|
|
194
|
+
|
|
195
|
+
`createTestEngine` from `@astrale-os/sdk/testing` runs a durable Workflow in memory with the
|
|
196
|
+
receiver a Host serves; only the engine is simulated. Each Step journals its result, every attempt
|
|
197
|
+
replays the journal, timers follow a virtual clock, signals are buffered, a failing Step is retried
|
|
198
|
+
within its budget and then fails into the Workflow, a run failing outside any Step is retried until it
|
|
199
|
+
pauses, and cancellation is cooperative.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { createTestEngine } from '@astrale-os/sdk/testing'
|
|
203
|
+
|
|
204
|
+
const engine = createTestEngine()
|
|
205
|
+
const execution = await engine.start(remind, {
|
|
206
|
+
schema: Billing, // the resolved Schema
|
|
207
|
+
input: { note: 'soon' },
|
|
208
|
+
self: invoice, // receiver of an instance Method
|
|
209
|
+
capabilities: {
|
|
210
|
+
query: (query, input) => 2,
|
|
211
|
+
mutate: (mutation, input, { idempotencyKey }) => 'reminder-3',
|
|
212
|
+
kernel: { invoke: async (reference, input, options) => ({ id: 'execution-child' }) },
|
|
213
|
+
},
|
|
214
|
+
providers: { ledger: testLedger }, // Providers of the Workflow's Integrations
|
|
215
|
+
})
|
|
216
|
+
await engine.advance(24 * 60 * 60_000) // wake the sleeping run
|
|
217
|
+
await execution.signal('approval', { by: 'alice' })
|
|
218
|
+
expect(execution.outcome).toEqual({ kind: 'completed', value: { reminder: 'reminder-3' } })
|
|
219
|
+
await execution.replay() // the journal alone reaches the same outcome
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
| Member | Meaning |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| `capabilities` | What a Step reaches instead of a Kernel. `query` and `mutate` answer the Step's Queries and Mutations; `mutate` receives the key a Host sends. `kernel` is the Step's Session: `kernel.invoke` and every `dependencies` call reach its `invoke`, keyed for a durable callee. |
|
|
225
|
+
| `providers` | Providers bound through the Steps' key sequence, as on a Host. |
|
|
226
|
+
| `retry` option | The deployment's `durable.retry`: the budget of every Step that declares none, and with `maxAttempts` the attempts of a run failing outside any Step. |
|
|
227
|
+
| `state`, `outcome`, `failure` | `running`, `waiting`, `paused`, `completed`, `failed` or `cancelled`; the outcome as `Execution.result` answers it; what the last failed attempt threw, which a `STEP_EXHAUSTED` outcome does not carry. |
|
|
228
|
+
| `signal`, `cancel`, `resume` | As the `K.Execution` Methods; each settles the run again. |
|
|
229
|
+
| `advance(ms)` | Moves the virtual clock and settles every waiting run. |
|
|
230
|
+
| `replay()` | Runs the Workflow from its journal alone and fails if it runs a Step again, diverges or ends differently. |
|
|
231
|
+
| `interruptAfter(id)`, or the `interruptAfter` option | Loses Step `id`'s first result before the journal records it, as a crash after its effect: the engine retries the Step, which presents the same keys. |
|
|
232
|
+
| `journal`, `attempts` | The recorded entries, each Step with the times it ran, and the attempts of the run. |
|
|
233
|
+
|
|
234
|
+
A Workflow whose code between Steps is not deterministic fails its next attempt with
|
|
235
|
+
`JournalMismatch` and pauses. Retries do not wait out a backoff, but the backoff a retry skips, or the
|
|
236
|
+
delay its failure asks for, counts toward the Step's `maxDurationMs`. Code should await only Steps and
|
|
237
|
+
the context's own operations; the engine counts a run as waiting once it waits on a timer or a signal
|
|
238
|
+
and nothing else runs.
|
|
239
|
+
|
|
240
|
+
## Signals
|
|
241
|
+
|
|
242
|
+
A signal is delivered once per name: `Execution.signal` resolves it, a second signal of the same name
|
|
243
|
+
is refused, and a signal sent before the wait is kept for it. A bounded wait races the signal against
|
|
244
|
+
a durable timer:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
const consent = await awaitSignal<{ readonly code: string }>('consent', { timeoutMs: 15 * 60_000 })
|
|
248
|
+
if (consent.kind === 'timeout') throw error('CONSENT_EXPIRED', {})
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The engine journals which came first, so a replay answers the same even when the signal arrived
|
|
252
|
+
after the timeout; such a signal is kept for a later wait under the same name. Race durable waits
|
|
253
|
+
only through `awaitSignal`: `Promise.race` over `sleep` and `awaitSignal` is not journaled and does
|
|
254
|
+
not settle on the engine.
|
|
255
|
+
|
|
256
|
+
## Retry budgets and timeouts
|
|
257
|
+
|
|
258
|
+
A declared business error ends the Execution as `failed { family, code, message, details }`, with no
|
|
259
|
+
retry. Any other error a Step throws fails that attempt, and the engine attempts the Step again within
|
|
260
|
+
its retry budget. A Step that spends its budget throws into the Workflow; uncaught, the Execution
|
|
261
|
+
ends `failed` with code `STEP_EXHAUSTED`:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
{ kind: 'failed', error: { code: 'STEP_EXHAUSTED', message: 'Workflow Step notify exhausted its retries.', details: { step: 'notify' } } }
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The outcome never carries what the last attempt threw. When the Kernel refused that attempt, the
|
|
268
|
+
outcome names the refusal by its codes alone, never its message:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
details: { step: 'archive', refusal: { code: 2002, reason: 'AUTH_INVALID' } }
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`code` is the Kernel error code; `reason`, when the Kernel gave one, its reason code. A refusal
|
|
275
|
+
behind another error, as its `cause`, counts as well; any other failure leaves `refusal` out. A failed Execution is never resumed: a new
|
|
276
|
+
attempt is a new Execution, under a new key, so its Steps run their effects again under new keys.
|
|
277
|
+
|
|
278
|
+
Each Step runs under the deployment's budget and timeout, or under the ones it declares:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
await step.mutate('record', recordReminder, input) // the deployment's budget and timeout
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await step.run({ id: 'notify', retry: { maxAttempts: 5 }, timeoutMs: 120_000 }, ({ integrations }) =>
|
|
285
|
+
integrations.mailer.send(reminder),
|
|
286
|
+
)
|
|
287
|
+
} catch (cause) {
|
|
288
|
+
if (!exhausted(cause)) throw cause
|
|
289
|
+
await step.mutate('follow-up', recordManualFollowUp, { invoice: self })
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
| `retry` field | Meaning | Bounds |
|
|
294
|
+
| --- | --- | --- |
|
|
295
|
+
| `maxAttempts` | Attempts, the first one included. | 1 to 1,000 |
|
|
296
|
+
| `maxDurationMs` | Time from the first attempt after which a failed attempt is not retried; an attempt already scheduled still runs. | 1 ms to 365 days |
|
|
297
|
+
| `initialIntervalMs` | Delay before the first retry, doubled on each failure. | 1 ms to one hour |
|
|
298
|
+
| `maxIntervalMs` | Longest delay between two attempts. | `initialIntervalMs` to one day |
|
|
299
|
+
|
|
300
|
+
The limits, `maxAttempts` and `maxDurationMs`, go together: a Step that declares either replaces both
|
|
301
|
+
limits of the deployment, and the one it omits does not bound it. The budget is spent at the first
|
|
302
|
+
limit reached. An interval the Step leaves out is the deployment's. `timeoutMs` bounds one attempt,
|
|
303
|
+
its Kernel calls included, from one second to ten minutes: past it, the attempt fails, what it holds
|
|
304
|
+
is aborted, and the failure counts toward the budget. Each Workflow service declares to the engine
|
|
305
|
+
an abort timeout of fifteen minutes, so a Step's own timeout always ends its attempt before the
|
|
306
|
+
engine would cut it.
|
|
307
|
+
|
|
308
|
+
A Kernel refusal that prescribes a delay (capacity, draining, maintenance) waits that delay before
|
|
309
|
+
the next attempt, instead of the backoff. Throw `retryAfter(ms, cause)` inside a Step for the same
|
|
310
|
+
behavior, for instance on an HTTP `429` with `Retry-After`. Both attempts count toward the budget.
|
|
311
|
+
|
|
312
|
+
A run that fails outside any Step keeps the engine's pause. An input or receiver the code cannot
|
|
313
|
+
read, an output the Schema refuses, a journal whose Steps the code no longer takes, or an error
|
|
314
|
+
thrown between two Steps fails every attempt; after the deployment's `maxAttempts` the Execution is
|
|
315
|
+
`paused` until `Execution.resume()` or `Execution.cancel()`. `resume` attempts the run again with
|
|
316
|
+
the code served at its URL: it helps only once that code can replay the journal. A run that fails
|
|
317
|
+
again before it awaits anything never sees a cooperative cancel; `cancel({ force: true })`, which
|
|
318
|
+
the owner Domain holds, ends it without compensation.
|
|
319
|
+
|
|
320
|
+
## Redeployments
|
|
321
|
+
|
|
322
|
+
A new Release gives new calls a new engine service; running Executions keep theirs, with its URL,
|
|
323
|
+
and every attempt of a run is served by the code at that URL. A run started on one Release ends on
|
|
324
|
+
it, under the following rules.
|
|
325
|
+
|
|
326
|
+
**Serve each Release at its own URL.** `adapter-cloudflare` and `adapter-astrale` deploy each
|
|
327
|
+
Release at an immutable URL, so a run keeps the code that started it, whatever is deployed after.
|
|
328
|
+
Code redeployed in place at the same URL replays the journals of the runs in flight: the engine
|
|
329
|
+
refuses a journal whose Steps no longer match, in order and by id, and pauses the run; code with the
|
|
330
|
+
same Steps continues the run. Renaming, reordering or removing a Step that runs have journaled is
|
|
331
|
+
therefore an incompatible change in place, and no option of the Workflow declares it.
|
|
332
|
+
|
|
333
|
+
**Keep the Schema additive while runs are in flight.** A Step calls the Kernel as installed when it calls,
|
|
334
|
+
not when its run started: a Class, Function or Edge the run uses and a later install
|
|
335
|
+
removed or narrowed refuses its calls until the Step spends its budget, with `STEP_EXHAUSTED`.
|
|
336
|
+
Remove them once the runs that use them have ended.
|
|
337
|
+
|
|
338
|
+
**Do not retire a Release, or remove a durable callable, while runs are in flight.** A retired
|
|
339
|
+
Release no longer answers its URL: its runs no longer progress, a signal to them times out, and a
|
|
340
|
+
cooperative `cancel` leaves them `paused`; only `cancel({ force: true })` ends them. A durable
|
|
341
|
+
callable a new install no longer declares has its runs cancelled by the Kernel, without
|
|
342
|
+
compensation, within minutes.
|
|
343
|
+
|
|
344
|
+
**Keep runs that call the Kernel shorter than the drain bound.** After an issuer change, the
|
|
345
|
+
replaced issuer remains accepted for the runs in flight at the change for at most the Host drain
|
|
346
|
+
bound (7 days by default), described below; a Step of such a run calling the Kernel later is
|
|
347
|
+
refused until it spends its budget, with `STEP_EXHAUSTED`. A run that only waits and journals is
|
|
348
|
+
not bounded.
|
|
349
|
+
|
|
350
|
+
A Step's credential names its Execution (the `execution` claim, `EXECUTION_CLAIM` of
|
|
351
|
+
`@astrale-os/kernel-protocol/issuer/claims`). After a consented issuer change, the Kernel keeps
|
|
352
|
+
accepting the replaced issuer for any call during the Host window (300 s by default), then only for
|
|
353
|
+
the Steps of runs that were in flight at the change and that the engine still holds, for at most
|
|
354
|
+
the Host drain bound (7 days by default). While the engine is unreachable those Steps are refused
|
|
355
|
+
as unavailable and retried. A run started after the change is never accepted on the replaced
|
|
356
|
+
issuer. The replaced deployment signs this claim itself, so it can keep calling for its in-flight
|
|
357
|
+
runs until they end or the drain bound passes; consent with `previous: 'revoke'` to cut it at once.
|
|
358
|
+
|
|
359
|
+
## Serving
|
|
360
|
+
|
|
361
|
+
`serve({ durable })` serves the engine at
|
|
362
|
+
`<public URL>/invoke/restate/<encoded Kernel issuer>/<declaration>/<service>`. Retained engine
|
|
363
|
+
services registered before issuer-prefixed paths keep their `<declaration>/<service>` callbacks.
|
|
364
|
+
Canonical requests discover their engine's signing keys at
|
|
365
|
+
`<Kernel issuer>/.well-known/astrale/workflow-engine.json` over HTTPS. The discovered document must
|
|
366
|
+
name that issuer, and the signed run must name the same Kernel before any Workflow code runs.
|
|
367
|
+
Discovery follows no redirects, has a ten-second deadline and a 16 KiB document limit, and retains
|
|
368
|
+
at most 64 issuer documents for one minute. An authentication refusal expires its cached authority
|
|
369
|
+
for the next request, at most once per minute; the receiver never replays a consumed request.
|
|
370
|
+
Invalid or unavailable discovery fails the canonical request without falling back to static keys.
|
|
371
|
+
Provide the Restate SDK Fetch build of the platform; every engine request requires its signature.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
serve({
|
|
375
|
+
// runtime, load, identity, server…
|
|
376
|
+
durable: async () => ({
|
|
377
|
+
engine: await import('@restatedev/restate-sdk/fetch'),
|
|
378
|
+
}),
|
|
379
|
+
})
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The provider runs only on the first engine request. Concurrent requests share initialization;
|
|
383
|
+
a successful receiver is reused, and a failed or unconfigured provider can be retried by a later
|
|
384
|
+
request. Health, release and ordinary invocation requests do not load the engine. An already
|
|
385
|
+
loaded engine can still be supplied synchronously.
|
|
386
|
+
|
|
387
|
+
| Option | Default |
|
|
388
|
+
| --- | --- |
|
|
389
|
+
| `retentionMs` | 7 days; at least the Host minimum (one day by default). |
|
|
390
|
+
| `retry` | The budget of every Step that declares none: 20 attempts from 1 s to 60 s. `maxAttempts` also bounds a run failing outside any Step, which then pauses. The Host caps them. |
|
|
391
|
+
| `stepTimeoutMs` | 60 s per Step attempt that declares no `timeoutMs`, including its Kernel calls; ten minutes at most. |
|
|
392
|
+
|
|
393
|
+
The provider receives the durable `Request`. Its route identifies the HTTPS Kernel whose
|
|
394
|
+
workflow-engine discovery document supplies signing authority. `DurableServing.fetch` supplies
|
|
395
|
+
that discovery capability and is captured when the receiver initializes. Static signing keys and
|
|
396
|
+
routes without a Kernel issuer are refused; they do not initialize the engine.
|
|
397
|
+
|
|
398
|
+
Without `durable`, the Domain refuses the engine and only durable calls fail. An ordinary
|
|
399
|
+
invocation of a durable callable is refused: it never runs inline.
|
|
400
|
+
|
|
401
|
+
### Cloudflare Workers
|
|
402
|
+
|
|
403
|
+
The Cloudflare adapter serves the engine itself when the Schema declares a durable callable: it
|
|
404
|
+
retains the Restate Workers build and its WebAssembly core in a separate deployed module. The
|
|
405
|
+
module is imported only on the first engine request, so frontend, health, release and ordinary
|
|
406
|
+
invocations do not initialize Restate. Callbacks discover signing authority from their Kernel issuer. Cloudflare owns
|
|
407
|
+
compilation of declared WebAssembly modules; this separation defers their instantiation and the
|
|
408
|
+
JavaScript engine's evaluation. The options above go in the adapter's `durable` parameter.
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
cloudflare({
|
|
412
|
+
namespace: { name: 'deployments', routingDomain: 'deployments.example.com' },
|
|
413
|
+
durable: { retry: { maxAttempts: 10 } },
|
|
414
|
+
})
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Each Release is an immutable deployment with its own URL: a new Release gives new calls a new engine
|
|
418
|
+
service, and running Executions keep being served by the deployment that started them.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Handler authority and dependency calls
|
|
2
|
+
|
|
3
|
+
Actions and Workflows receive `kernel`, `graph`, `query`, `mutate`, and `dependencies`.
|
|
4
|
+
Local capabilities default to the executing Domain's self authority. Direct dependency calls
|
|
5
|
+
select `caller` or `self` explicitly.
|
|
6
|
+
|
|
7
|
+
| Surface | Default authority | Explicit selection |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `kernel` | Executing Domain | `.caller`, `.self` |
|
|
10
|
+
| `graph` | Executing Domain | `.caller`, `.self` |
|
|
11
|
+
| `query`, `mutate` | Executing Domain | Through `graph` |
|
|
12
|
+
| `dependencies.alias` | Explicit selection required | `.caller.invoke`, `.self.invoke` |
|
|
13
|
+
|
|
14
|
+
`self` executes as the Domain. `caller` executes with the Domain as principal on behalf of the one
|
|
15
|
+
incoming caller Identity. These defaults belong to the API surface. A foreign target does not
|
|
16
|
+
silently change the authority selected by `kernel.invoke`.
|
|
17
|
+
|
|
18
|
+
## The caller
|
|
19
|
+
|
|
20
|
+
An authenticated handler receives `caller.identity` and `caller.principal`. `identity` is the one
|
|
21
|
+
Identity whose authority the invocation exercises: use it for ownership, attribution, and
|
|
22
|
+
per-user lookups. `principal` is the executor whose credential authenticated the call, never
|
|
23
|
+
the user once a Domain acts for them: after token exchange it is the Domain that exchanged the
|
|
24
|
+
token (with the Session `exchange` option, this Domain), and after a `caller` dependency call it is
|
|
25
|
+
the calling Domain. A caller is always exactly one Identity.
|
|
26
|
+
|
|
27
|
+
## Public callables
|
|
28
|
+
|
|
29
|
+
A callable declared `auth: 'anonymous'` admits a call that carries no credential. Such a call has
|
|
30
|
+
no caller, so its handler acts as the installed Domain alone:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
export const submitLead = defineAction<typeof schema>()('submitLead', ({ input, mutate }) =>
|
|
34
|
+
mutate(createLead, input),
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `caller` is `{ kind: 'anonymous' }`: there is no Identity to attribute the call to.
|
|
39
|
+
- `kernel`, `graph`, `query`, `mutate`, and `dependencies.alias.self` are the same Domain self
|
|
40
|
+
authority an authenticated handler receives. Installation requirements and ownership bound it;
|
|
41
|
+
nothing is granted to whoever sent the call.
|
|
42
|
+
- `kernel.caller`, `graph.caller`, `dependencies.alias.caller`, and a Provider's `execution.invoke`
|
|
43
|
+
are absent, and typed as possibly absent in an `anonymous` callable. Reach them through the
|
|
44
|
+
selection itself: `graph.caller?.query(...)`, or test `graph.caller !== undefined` first. A check
|
|
45
|
+
on `caller.kind` does not narrow them.
|
|
46
|
+
- The same callable called with a credential is authenticated as usual and receives its caller. An
|
|
47
|
+
invalid credential is refused; it never falls back to an anonymous call.
|
|
48
|
+
|
|
49
|
+
The Kernel admits a public call without asking who sent it, and the handler then runs with the
|
|
50
|
+
Domain's authority. The callable's input contract and its handler are therefore the only gate:
|
|
51
|
+
admit only what the public may submit, and return only what the public may read.
|
|
52
|
+
|
|
53
|
+
## Declaring and invoking a dependency
|
|
54
|
+
|
|
55
|
+
Declare the exact remote Schema and each consumed callable requirement in Domain composition:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { DirectorySchema } from '@example/directory-domain'
|
|
59
|
+
import { defineDomain, requirements } from '@astrale-os/sdk/domain'
|
|
60
|
+
import { defineSchema, schema as language } from '@astrale-os/sdk/schema'
|
|
61
|
+
|
|
62
|
+
const schema = defineSchema('consumer.example', {
|
|
63
|
+
name: 'Consumer',
|
|
64
|
+
dependencies: { directory: DirectorySchema },
|
|
65
|
+
// Compose local callable declarations here.
|
|
66
|
+
})
|
|
67
|
+
const directory = language.resolve(DirectorySchema)
|
|
68
|
+
export const domain = defineDomain({
|
|
69
|
+
schema,
|
|
70
|
+
runtime,
|
|
71
|
+
requirements: requirements({ functions: [directory.functions.lookup] }),
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The handler uses the declared alias without importing the remote Domain:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
export const lookup = defineAction<typeof schema>()('lookup', ({ dependencies, input }) =>
|
|
79
|
+
dependencies.directory.caller.invoke(d => d.functions.lookup, input),
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Other callable shapes use the same invoker and require their own exact Domain requirements:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
await dependencies.directory.caller.invoke(d => d.classes.Employee.static.methods.create, input)
|
|
87
|
+
await dependencies.directory.self.invoke(d => d.classes.Employee.methods.rename, instance, input)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`instance` is a canonical `NodeId`. Input is always explicit, including `null` for a null-input
|
|
91
|
+
contract. The selector runs once against the loaded dependency and must return its exact owned,
|
|
92
|
+
concrete callable object. Copied, stale, foreign, abstract, or asynchronous selections fail before
|
|
93
|
+
transport. Current published Class resolution may expose an inherited alias to the same owning
|
|
94
|
+
Method object; invocation still uses that exact owner. It never invents a new inherited target.
|
|
95
|
+
|
|
96
|
+
The invoker preserves Function and Method input/output inference, including value streams and binary
|
|
97
|
+
responses. A durable callable answers with the Execution it starts, so its call types as
|
|
98
|
+
`ExecutionRef<Output>`. For value input, its optional third argument (fourth for instance Methods) accepts only
|
|
99
|
+
`timeoutMs` and `idempotencyKey`, using the canonical Client session types. The Kernel accepts an
|
|
100
|
+
`idempotencyKey` only for a durable callable, where it names the Execution the call starts, and
|
|
101
|
+
refuses it for any other. Cancellation and the parent deadline are inherited. A timeout can shorten
|
|
102
|
+
the deadline; a child does not inherit the parent's idempotency key. Detached
|
|
103
|
+
`const { invoke } = dependencies.directory.caller` keeps its types and authority binding.
|
|
104
|
+
|
|
105
|
+
## Binary Function input
|
|
106
|
+
|
|
107
|
+
A Function can declare `input.binary(metadataSchema, { maximumBytes })`. The handler receives
|
|
108
|
+
validated JSON metadata in `ctx.input` and a single-consumer `ReadableStream<Uint8Array>` plus its
|
|
109
|
+
declared byte `length` in `ctx.body`. Consume the stream through EOF before returning a successful
|
|
110
|
+
result. The bytes stay outside `Call.input` and are sent as the HTTP request body.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const receipt = await dependencies.files.caller.invoke(
|
|
114
|
+
d => d.functions.upload,
|
|
115
|
+
{ mediaType: file.type },
|
|
116
|
+
{ body: file },
|
|
117
|
+
)
|
|
118
|
+
await kernel.invoke(uploadReference, { mediaType: file.type }, { body: file })
|
|
119
|
+
await kernel.call({ target: uploadReference.target, input: { mediaType: file.type } }, { body: file })
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The typed `invoke` overload requires `body` for a binary Function and forbids it for a value-input
|
|
123
|
+
Function. Raw `call` and `dispatch` also accept `{ body }`; the Server checks the resolved Function
|
|
124
|
+
contract. A binary input cannot use `idempotencyKey`.
|
|
125
|
+
|
|
126
|
+
`domain.dependencies` remains immutable loaded Schema metadata. `dependencies` is a separate frozen
|
|
127
|
+
map containing only direct non-Kernel aliases. Each alias exposes exactly `.caller` and `.self`,
|
|
128
|
+
each with its own `invoke`. Selectors do not nest, and there is no default `invoke` on the alias.
|
|
129
|
+
|
|
130
|
+
## Explicit authority
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { reference } from '@astrale-os/sdk/client/session'
|
|
134
|
+
import { Path } from '@astrale-os/sdk/graph/path'
|
|
135
|
+
|
|
136
|
+
const directory = domain.dependencies.directory
|
|
137
|
+
const lookup = reference(directory, directory.functions.lookup)
|
|
138
|
+
await kernel.invoke(lookup, input) // executing Domain
|
|
139
|
+
await kernel.caller.invoke(lookup, input) // incoming caller Identity
|
|
140
|
+
await kernel.self.invoke(lookup, input) // executing Domain
|
|
141
|
+
|
|
142
|
+
const rename = reference(directory, directory.classes.Employee.methods.rename)(Path.id(instance))
|
|
143
|
+
await kernel.self.invoke(rename, input)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`graph.query` and `graph.mutate` execute named SDK definitions. `kernel.graph` is the canonical raw
|
|
147
|
+
Kernel graph capability. Explicit named graph operations use `graph.caller.query` or
|
|
148
|
+
`graph.self.mutate`. Bare `query` and `mutate` are ordinary functions.
|
|
149
|
+
|
|
150
|
+
For U → A → B, a `caller` dependency call carries the incoming caller U and identifies A as the
|
|
151
|
+
outbound principal. Inside B, local defaults use B's self authority; B's next `caller` dependency
|
|
152
|
+
call forwards the same caller U with principal B. A caller is always one Identity. Existing
|
|
153
|
+
Kernel authentication, delegation, callable capability, and Policy gates still apply. This wrapper
|
|
154
|
+
neither grants permission nor retries denials under stronger authority.
|
|
155
|
+
|
|
156
|
+
## Workflow and lifecycle
|
|
157
|
+
|
|
158
|
+
A direct dependency invocation is one semantic effect. Workflows place it in one named step:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const employee = await step.run('lookup-employee', () =>
|
|
162
|
+
dependencies.directory.caller.invoke(d => d.functions.lookup, input),
|
|
163
|
+
)
|
|
164
|
+
await step.run('record-employee', () => mutate(recordEmployee, employee))
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
In a durable Workflow, the Step callback receives `dependencies` with the rest of its capabilities,
|
|
168
|
+
acting as the Domain alone: only `self` exists. A call to a durable callable carries the Step's
|
|
169
|
+
derived key, so a retried Step reaches the same child Execution; see
|
|
170
|
+
[Durable Workflows](durable-workflows.md).
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const shipment = await step.run('ship', ({ dependencies }) =>
|
|
174
|
+
dependencies.shipping.self.invoke(d => d.functions.ship, { parcel }),
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Inline Steps do not imply a distributed transaction, durable replay, or remote deduplication. Stream and
|
|
179
|
+
binary objects cannot become portable step results; a native streaming response belongs in an
|
|
180
|
+
Action. Invocation sessions remain open until a returned stream or binary body settles.
|
|
181
|
+
|
|
182
|
+
Integrations remain useful for consumer-owned substitution, replay metadata, or failure translation.
|
|
183
|
+
They are optional for direct declared Domain calls. Provider `execution.invoke` remains caller-bound,
|
|
184
|
+
and Provider auth minting retains its existing self binding.
|
|
185
|
+
|
|
186
|
+
## Migration
|
|
187
|
+
|
|
188
|
+
The handler `client` alias is removed. Replace former `client` use with `kernel.caller` to preserve
|
|
189
|
+
caller authority, or choose the new self default deliberately. Custom `InvocationAuthority`
|
|
190
|
+
implementations now return `{ caller, graph, close }` with
|
|
191
|
+
`caller: { kind, principal, identity, authentication }` and `graph: { caller, self }`; remove the
|
|
192
|
+
redundant `client` member and the former `caller.authority` Grant. Replace `caller.authority` reads
|
|
193
|
+
with `caller.identity`. The standalone ClientSession remains the canonical invocation surface,
|
|
194
|
+
including the binary-input `invoke`, `dispatch`, and `call` overloads described above.
|
|
195
|
+
|
|
196
|
+
The `union` authority mode is removed from `kernel`, `graph`, and `dependencies`. Replace
|
|
197
|
+
`kernel.union`, `graph.union`, and `dependencies.alias.union` with `self` to act as the executing
|
|
198
|
+
Domain, or with `caller` when the call must be authorized as the incoming caller. Admission rejects
|
|
199
|
+
an `InvocationAuthority` whose `graph` still carries a `union` session.
|
|
200
|
+
|
|
201
|
+
A call to an `anonymous` callable that carries no credential acts as the executing Domain: it
|
|
202
|
+
receives `kernel`, `graph`, `query`, `mutate`, and `dependencies.alias.self`, and no `caller`
|
|
203
|
+
selection (see [Public callables](#public-callables)). Before, `kernel`, `graph`, and `dependencies`
|
|
204
|
+
were `null` there, `query` and `mutate` were absent, and a Provider's `execution.auth` was absent.
|
|
205
|
+
A handler or Provider that used that absence to refuse calls without a credential still compiles and
|
|
206
|
+
no longer refuses them. Replace such a test (`kernel === null`, `graph === null`,
|
|
207
|
+
`dependencies === null`, `query === undefined`) with `caller.kind === 'anonymous'`, and a Provider's
|
|
208
|
+
`execution.auth === undefined` with `execution.invoke === undefined`. Custom `InvocationAuthority`
|
|
209
|
+
implementations add `self({ via, domain, context })`, which resolves to `{ graph: { self }, close }`.
|
|
210
|
+
|
|
211
|
+
The linter accepts direct declared dependencies and canonical Kernel references. It checks exact
|
|
212
|
+
callable requirements, rejects foreign handler imports, and reports opaque selectors as
|
|
213
|
+
indeterminate. Requirement analysis never executes selectors during compilation.
|