ledgence-worker 0.4.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ledgence
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,563 @@
1
+ Metadata-Version: 2.4
2
+ Name: ledgence-worker
3
+ Version: 0.4.1
4
+ Summary: Dependency-free Python authoring helpers for Ledgence tasks and checkpoint workflows
5
+ Author-email: Ledgence <dev@ledgence.com>
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Project-URL: Changelog, https://github.com/Ledgence/ledgence/releases
11
+ Project-URL: Documentation, https://github.com/Ledgence/ledgence/blob/develop/sdk/python/README.md
12
+ Project-URL: Homepage, https://github.com/Ledgence/ledgence
13
+ Project-URL: Issues, https://github.com/Ledgence/ledgence/issues
14
+ Project-URL: Repository, https://github.com/Ledgence/ledgence
15
+
16
+ # Ledgence Python worker helper
17
+
18
+ The Rust worker supplies `ledgence.worker` with its Python runner. It supports
19
+ ordinary program handlers, invocation context and logs, and protocol 3 workflows
20
+ with registered entrypoints, durable local results, tasks, subworkflows, forks,
21
+ events, timers, and action approvals. The separately installed
22
+ [`ledgence.client`](../python-client/README.md) submits and observes work.
23
+
24
+ Use a runtime helper matching your worker. The helper is standard-library-only;
25
+ application integrations and their prepared dependencies belong in the program
26
+ package. No agent framework or model provider is required.
27
+
28
+ ## Install for local development
29
+
30
+ This package provides `ledgence-worker` version **0.4.1**, requiring Python
31
+ 3.11+. For a version published on PyPI, install the version matching your worker
32
+ in your application's existing uv project as a development dependency:
33
+
34
+ ```sh
35
+ uv add --dev "ledgence-worker==0.4.1"
36
+ ```
37
+
38
+ For pip, use `python -m pip install "ledgence-worker==0.4.1"` in the application's
39
+ activated virtual environment. Check
40
+ [registry availability](https://pypi.org/project/ledgence-worker/0.4.1/) before
41
+ using these commands. For an unreleased checkout, use the local source
42
+ installation below. The historical `v0.4.0` source tag does not contain this
43
+ package's `pyproject.toml`.
44
+
45
+ ### Install from source
46
+
47
+ From your application's existing uv project, add the local package as a normal
48
+ development dependency, substituting your Ledgence checkout's absolute path:
49
+
50
+ ```sh
51
+ uv add --dev /absolute/path/to/ledgence/sdk/python
52
+ uv run python -c "from ledgence.worker.workflow import Workflow; from ledgence.worker import current_invocation; print('worker imports ready')"
53
+ ```
54
+
55
+ This installs the versioned helper into the project's environment and records
56
+ the local source in its dependency configuration. Select that environment in
57
+ your editor. No `PYTHONPATH` change is needed. Keep the checkout available for
58
+ future dependency synchronization, and use a helper version matching the deployed
59
+ worker release; installation does not check the deployed worker's compatibility.
60
+ A local source entry or lockfile does not freeze the directory's contents;
61
+ record and retain the exact checkout commit when sharing or reproducing a
62
+ development setup.
63
+
64
+ If you contribute to the helper itself, use
65
+ `uv add --dev --editable /absolute/path/to/ledgence/sdk/python` so edits to its
66
+ Python source are reflected directly. Ordinary program authors should use the
67
+ normal installation above. In an existing activated Python virtual environment,
68
+ the pip alternative is:
69
+
70
+ ```sh
71
+ python -m pip install /absolute/path/to/ledgence/sdk/python
72
+ python -c "from ledgence.worker.workflow import Workflow; print('worker imports ready')"
73
+ ```
74
+
75
+ The installed package includes inline type information and a
76
+ `ledgence/worker/py.typed` marker for compatible editor/type-checking tools. It
77
+ shares the native `ledgence` namespace with the independently installed
78
+ `ledgence-client`; neither distribution provides a root `ledgence/__init__.py`.
79
+
80
+ Use this environment to import your programs, register workflow entrypoints,
81
+ and test ordinary business functions. `current_invocation()` and
82
+ `workflow_context()` still require an active invocation; installing the package
83
+ does not create a local workflow execution harness. The package supplies no CLI,
84
+ server, Python client, or replacement for the worker's `--runner` configuration.
85
+ At execution, the Rust worker keeps supplying its matching bundled helper,
86
+ which the bootstrap loads before a copy vendored in a program artifact.
87
+
88
+ Keep `ledgence-worker` out of application runtime dependencies: the worker
89
+ supplies it. Prepare all actual application dependencies separately for the
90
+ worker's platform and exact Python major/minor before publishing a program.
91
+ See [Develop Python programs](../../docs-site/src/content/docs/how-to/develop-python-programs.md)
92
+ for a complete authoring example and [program packages](../../docs/program-packages.md)
93
+ for the runtime artifact contract.
94
+
95
+ ## Durable model and tool calls
96
+
97
+ Protocol 3 workflows can give an application-owned model or tool call an explicit
98
+ durable identity without adopting an agent framework:
99
+
100
+ ```python
101
+ from ledgence.worker.workflow import OperationKind
102
+
103
+ response = await ctx.operation(
104
+ f"turn:{round}:model", call_model,
105
+ kind=OperationKind.MODEL, version="adapter-1",
106
+ arguments={
107
+ "model": "demo-model-2026-01",
108
+ "messages": messages,
109
+ "temperature": 0,
110
+ "max_tokens": 256,
111
+ "tools": tool_schemas,
112
+ },
113
+ )
114
+ result = await ctx.operation(
115
+ f"turn:{round}:tool:0", lookup,
116
+ kind=OperationKind.TOOL, version="lookup-1",
117
+ arguments={"query": response["query"]},
118
+ )
119
+ ```
120
+
121
+ `kind` requires an `OperationKind` member; plain strings and other enums are
122
+ rejected. `version` identifies the application adapter/tool behavior. Include the
123
+ effective provider/model identifier or snapshot, prompts, settings, tool schemas,
124
+ and other behavior-affecting configuration in `arguments`. A provider's mutable
125
+ model alias does not pin its underlying behavior. A model-generated tool request
126
+ is application input: validate and normalize it before selecting an allowed tool
127
+ and calling `operation`.
128
+
129
+ Before starting the callable, the helper binds its Python signature, materializes
130
+ keyword defaults, and freezes the effective JSON arguments. The callable receives
131
+ an independent copy as `fn(**effective_arguments)`. Each record binds the explicit
132
+ key, operation kind, original callable module/qualified name, version, and effective
133
+ arguments. Repeating that key in the same activation with the identical binding
134
+ reuses the owned execution or acknowledged result; a changed binding fails before
135
+ another call starts. Object order is ignored, but numbers such as `1`, `1.0`,
136
+ `True`, and positive/negative zero retain distinct bindings. Caller or callable
137
+ mutation cannot change the saved request, and each awaited result is a fresh copy.
138
+
139
+ Keep clients and credentials outside arguments; JSON requests and results are
140
+ stored as supplied. Sockets, SDK response objects, closures, and process memory
141
+ are not checkpointed. Application adapters convert completed responses to bounded
142
+ JSON. Partial streams, generators, and
143
+ provider exceptions are not successful records. The helper imports no model SDK
144
+ and performs no provider calls itself. Synchronous and asynchronous callables use
145
+ the same execution, cancellation, and acknowledgment rules as `ctx.local`.
146
+
147
+ The result is returned only after the existing Rust local-result commit is
148
+ acknowledged. An activation retry replays accepted results without deliberately
149
+ executing their callables. If an effect succeeds before its record is accepted,
150
+ it can repeat; pass a stable business idempotency key in the effective arguments
151
+ or reconcile with the external system. A lost or invalid acknowledgment prevents
152
+ successful completion of the original activation, even if the controller catches
153
+ the error. Durable recovery does not guarantee exactly-once provider execution.
154
+
155
+ Operation keys share the activation-local journal with `ctx.local`; their semantic
156
+ binding keeps them distinct from ordinary local calls. The approval-reserved key
157
+ prefix remains unavailable. Operations have the same limits: 128 records and
158
+ 256 KiB combined per activation, 128 KiB per full binding/result record. The
159
+ versioned input envelope counts toward the ordinary JSON depth and byte limits.
160
+ Control flow remains ordinary Python: bound rounds and tool counts, then return
161
+ `ctx.continue_(continuation=..., state=...)` with the round, messages, and results
162
+ needed next. A new activation receives that explicit checkpoint and a new journal;
163
+ Python frames and ordinary locals are not restored. A local operation cannot
164
+ start another durable operation, stage children, or call workflow control APIs.
165
+
166
+ ## Durable approvals
167
+
168
+ Protocol 3 workflows can return `ctx.request_approval(key, action=...,
169
+ continuation=..., state=..., timeout_ms=...)` to checkpoint an action and release
170
+ their worker slot. `resume=` is an alternative to `continuation=` and
171
+ `ctx.wait_approval(...)` is an alias. Normalize effective arguments before
172
+ requesting approval; optional `proposed_arguments` preserves the original input
173
+ as audit context. Every request has a finite deadline of at most 365 days.
174
+
175
+ Import `ApprovalAction` and `ApprovalStatus` from `ledgence.worker.workflow`.
176
+ `ApprovalAction.for_callable(fn, version="1", arguments={...})` binds the callable's
177
+ module and qualified name and freezes its keyword arguments, including defaults.
178
+ It performs Python signature binding; application validation and domain-specific
179
+ normalization remain the caller's responsibility. `ApprovalAction(name,
180
+ version="1", arguments={...})` supports actions defined by other adapters.
181
+
182
+ On the resumed entrypoint, `ctx.approval` is an immutable typed observation.
183
+ Check `ctx.approval.status is ApprovalStatus.APPROVED`, then
184
+ `await ctx.approved_local(fn, version="1")` to execute only the saved effective
185
+ arguments through a durable local step. This helper accepts no replacement
186
+ arguments and verifies the callable identity/version. Copies returned by
187
+ `ctx.wake`, `ctx.approval.action.arguments`, or `ctx.approval.to_dict()` cannot
188
+ change its private execution binding. Rejected and expired requests resume with
189
+ their corresponding status; workflow cancellation closes the request without
190
+ resuming its controller. See the runnable
191
+ [durable approval example](../../examples/durable-approval/README.md).
192
+
193
+ Execution also checks that signature binding introduces no new effective defaults.
194
+ When constructing an `ApprovalAction` directly, include all effective arguments
195
+ before review; `approved_local` will not add an omitted, unreviewed default.
196
+
197
+ Approvals are for operator-trusted code. A Python callable remains responsible
198
+ for its behavior and external authorization. Approval does not make an external
199
+ effect exactly once: an effect can succeed before its durable local result is
200
+ acknowledged, so use external idempotency keys or reconciliation as needed.
201
+
202
+ ## Runtime helper
203
+
204
+ MIT-licensed, standard-library-only program support. The Rust worker starts the
205
+ configured CPython executable with `-I -S -B` and `ledgence/worker/bootstrap.py`. Python
206
+ is a separately installed runtime; Ledgence does not embed or download CPython.
207
+ Programs declare an exact supported Python major/minor (at least 3.11), with code
208
+ and vendored dependencies in their immutable artifact directory.
209
+
210
+ The worker supplies the `ledgence.worker` context helper alongside the runner;
211
+ applications do not need to vendor that helper. Application dependencies belong
212
+ in the program artifact. The bootstrap loads its own helper first, then adds the
213
+ absolute artifact directory to Python's import path. Bytecode generation is
214
+ explicitly disabled, so ordinary imports do not add `__pycache__` directories to
215
+ an artifact even when filesystem permissions would allow it.
216
+
217
+ `ledgence` is a native Python namespace package; it has no `__init__.py`.
218
+ Keep the complete `ledgence/worker` directory and its `ledgence` parent together
219
+ when distributing the runner. The separately installed `ledgence-client` SDK
220
+ provides `ledgence.client` in the same namespace without adding client dependencies
221
+ to the worker helper. A program using the client must include that SDK and its
222
+ prepared dependencies in its artifact, just like any other application dependency.
223
+ Do not add a root `ledgence/__init__.py` to either component or the artifact.
224
+
225
+ The public helper import is `ledgence.worker`, replacing the legacy
226
+ `ledgence_worker` path. Update existing program imports and publish a new immutable
227
+ program version/digest. The old import and runner path are not aliases; protocol
228
+ versions 1, 2, and 3 retain their wire behavior.
229
+
230
+ The worker sets a separate temporary working directory for each session. Relative
231
+ writes go there and persist across that session's invocations. Confirmed session
232
+ cleanup removes this directory. Read packaged resources relative to the program
233
+ module's `__file__`, not the working directory. Directory separation is file
234
+ lifecycle management and does not change OS access permissions.
235
+
236
+ Expose a callable as `module:function`; protocols 1/2 require a synchronous
237
+ handler, while protocol 3 also supports `async def`. Its module and package
238
+ parents must originate inside the artifact. Names already loaded by the bootstrap
239
+ (such as `json`, `os`, `ledgence`, and `ledgence.worker`) cannot be handler modules; conflicts
240
+ are rejected before readiness. Use an application-specific module name. Regular
241
+ and namespace packages are supported, and application code may still import the
242
+ standard library normally.
243
+
244
+ The callable receives the complete
245
+ CloudEvent dictionary and returns a JSON-compatible value. `current_invocation()`
246
+ provides transport execution identifiers when needed. The helper starts a fresh
247
+ `contextvars.Context` for every invocation; process/module globals intentionally
248
+ persist. Programs must clean up other invocation state and background work.
249
+
250
+ One process handles one invocation at a time. Results support null, booleans,
251
+ Unicode scalar strings, integers from `-2**63` through `2**64 - 1`, finite binary64
252
+ floating-point values, arrays, and objects with string keys. Python tuples are
253
+ encoded as JSON arrays. At most 64 nested containers are permitted; a scalar has
254
+ depth zero. Cycles, non-string keys, lone surrogate characters, out-of-range
255
+ integers, non-finite numbers, unawaited values, and oversized results produce typed
256
+ `invalid_output` failures and leave the process reusable. Rust checks the shared
257
+ wire profile when accepting the response.
258
+
259
+ The default protocol frame limit is 2 MiB, including result/input envelopes and
260
+ the newline. The worker supplies the configured limits explicitly when starting
261
+ the helper. The delivery submission limit remains 1 MiB of application data;
262
+ the additional frame capacity carries CloudEvent metadata and protocol fields.
263
+
264
+ Business exceptions produce typed failure results. Error text is shortened by the
265
+ complete frame's encoded UTF-8 byte budget, including event and attempt IDs, JSON
266
+ escaping, and the newline. The handler is not called if those IDs cannot fit even
267
+ an empty failure response. A protocol
268
+ fault terminates the process. Python and native stdout writes are redirected to
269
+ stderr; an isolated descriptor carries bounded JSON-lines responses. Parent-side
270
+ log capture must continue draining stderr even after its retention limit.
271
+
272
+ This process boundary executes trusted code with the worker's OS permissions.
273
+ The helper is not a sandbox and cannot undo external effects on retries.
274
+
275
+ ## Protocol versions and contextual logs
276
+
277
+ Existing `runtime.protocol: 1` packages keep the original invoke/result protocol.
278
+ Protocol 2 adds contextual logs; protocol 3 also enables asynchronous handlers and
279
+ workflow control exchanges. The ready version must match the manifest; a mismatch
280
+ fails before calling the handler. Protocols 2/3 pass an invocation-local
281
+ `processing_context` outside the complete, unchanged CloudEvent. It is null when
282
+ worker tracing is disabled. It never replaces the event's origin `traceparent`.
283
+
284
+ `current_invocation()` provides `event_id`, `attempt_id`, `source`, `tenant_id`,
285
+ `namespace`, `run_id`, `task_id`, `attempt_no`, and the optional frozen W3C
286
+ `processing_context`. Optional `workflow_id` and `activation_id` come from envelope
287
+ extensions. Nested workflow invocations also expose `parent_workflow_id` and
288
+ `root_workflow_id`; root workflow invocations expose a null parent and their own
289
+ workflow ID as the root. Ordinary tasks outside workflows have neither. Protocol 3
290
+ logs include the paired ancestor IDs only for nested workflows, preserving older
291
+ root log shapes. These identifiers never enter user data. Context is reset on
292
+ success, business exception, and invalid
293
+ output. No invocation context is written to process-global environment variables.
294
+
295
+ ```python
296
+ from ledgence.worker import current_invocation, get_logger
297
+
298
+ log = get_logger(__name__)
299
+
300
+ def handle(event):
301
+ log.info("Invoice issued", extra={"attributes": {"invoice.id": event["data"]["invoice_id"]}})
302
+ return {"task_id": current_invocation().task_id}
303
+ ```
304
+
305
+ `get_logger` returns a normal `logging.Logger`, adding one Ledgence handler while
306
+ preserving existing handlers and root configuration. An unset logger level becomes
307
+ INFO. Each `LogRecord` is handled once by Ledgence, including when it propagates
308
+ through parent loggers configured with `get_logger`. Re-dispatching that same
309
+ record does not emit another Ledgence frame; separate logging calls create
310
+ independent records, even when their messages match. Application and root handlers
311
+ continue to receive records according to normal Python logging rules.
312
+ User attributes occupy a separate map. A record snapshots correlation and
313
+ attributes synchronously at emission; intentionally copied old contexts retain
314
+ their original IDs even if a background thread logs during a later invocation.
315
+ Outside an invocation, records carry process identity only unless the application
316
+ explicitly activates its own OTel span. Raw stdout/stderr remains process-level.
317
+
318
+ One dedicated writer in protocols 2/3 owns ready, result, log and closing frames. Its optional
319
+ queue is bounded to 64 records and 1 MiB; each log frame is at most 16 KiB or the
320
+ configured output limit, including the newline. Encoding bounds the complete
321
+ attribute tree (64 nodes, four nested levels, shared text budget). Oversized
322
+ optional content is shortened; a record whose correlation cannot fit is dropped.
323
+ Telemetry encoding errors and queue overflow drop logs and increment a saturating
324
+ local counter; they do not replace a handler result. This Python-side count stays
325
+ in the process: it is not carried in result or closing frames, durable task
326
+ history, or a collector export. Worker-side optional-record validation and output
327
+ budget drops have separate bounded diagnostics. Results/control have a
328
+ reserved priority slot. At most one already-writing bounded log precedes a newly
329
+ queued result; IPC itself still has backpressure. Closing discards remaining
330
+ optional records. Telemetry is best effort and may be lost on shutdown or crash.
331
+
332
+ ## Optional OpenTelemetry API bridge
333
+
334
+ Programs may vendor `opentelemetry-api` and call
335
+ `from ledgence.worker.otel import enable_context; enable_context()` once at module
336
+ initialization. The bridge activates only the invocation's processing carrier
337
+ using the fixed W3C propagator, and attaches an empty context when it is null.
338
+ It resets the OTel context in `finally`. It neither installs a provider nor creates
339
+ a duplicate span for the worker's execution span. Logging inside application
340
+ child spans captures those children's active trace/span IDs.
341
+
342
+ The default helper imports no OpenTelemetry dependency. To record custom spans,
343
+ the application supplies and owns its SDK/provider and dependencies, with their
344
+ legal notices. Use bounded asynchronous processors for any application exporter.
345
+ Ledgence does not bundle a Python network exporter or serialize Python spans over
346
+ the result channel. The worker exports its own execution spans independently.
347
+
348
+ `register_shutdown(provider.shutdown)` optionally registers an application-owned
349
+ callback. Each registered callback runs once before the graceful closing ACK;
350
+ exceptions are reported on stderr and other callbacks continue. The parent's
351
+ existing process shutdown deadline bounds callbacks, including a hanging exporter.
352
+ Forced retirement/crashes do not promise a callback or complete flush. There is no
353
+ per-invocation flush or reliance on `atexit`.
354
+
355
+ The default tests require only the standard library. Optional API/SDK parentage
356
+ tests accept an already prepared dependency directory and make no network calls:
357
+
358
+ ```sh
359
+ LEDGENCE_PYTHON_OTEL_TEST_PACKAGES=/path/to/reviewed-api-sdk-packages \
360
+ python3 -m unittest discover -s sdk/python/tests -v
361
+ ```
362
+
363
+ The reviewed test set is `opentelemetry-api==1.44.0`,
364
+ `opentelemetry-sdk==1.44.0`, `opentelemetry-semantic-conventions==0.65b0`, and
365
+ `typing_extensions==4.16.0`. The test exporter is in memory. Tests copy prepared
366
+ packages into the temporary artifact to exercise the same isolated import path as
367
+ real programs; nothing is installed at execution time.
368
+
369
+ ## Registered workflow entrypoints (protocol 3)
370
+
371
+ Register ordinary Python handlers and export the built workflow as the package's
372
+ handler, for example `program:handle`:
373
+
374
+ ```python
375
+ from enum import StrEnum
376
+ from ledgence.worker.workflow import Workflow
377
+
378
+ class Entry(StrEnum):
379
+ START = "start"
380
+ RESUME = "resume"
381
+
382
+ workflow = Workflow(Entry)
383
+
384
+ @workflow.entrypoint(Entry.START, default=True)
385
+ def start(event, ctx):
386
+ return ctx.sleep("delay:0", 1000, continuation=Entry.RESUME,
387
+ state={"saved": event["data"]})
388
+
389
+ @workflow.entrypoint(Entry.RESUME)
390
+ def resume(event, ctx):
391
+ return ctx.complete(ctx.state["saved"])
392
+
393
+ handle = workflow.build()
394
+ ```
395
+
396
+ `Workflow` accepts a nonempty `StrEnum` family without aliases. Register every
397
+ member once and choose exactly one default. `build()` validates and freezes that
398
+ registry and returns an async handler; individual entrypoints may be sync or
399
+ async and receive `(event, ctx)`. Public submissions invoke the default. If the
400
+ enum declares the wire value `"start"`, it must be the default.
401
+
402
+ In a registered workflow, use members of that exact enum for branch and resume
403
+ targets, including approval continuations. `ctx.entrypoint` exposes the selected
404
+ member. No graph declaration or string-routing phase is required; Python code
405
+ chooses the next decision and explicitly saves any state it needs later.
406
+
407
+ For independent branches of this exact pinned package, `ctx.branch(key,
408
+ entrypoint=..., queue=..., data=...)` builds an immutable specification without
409
+ scheduling. `await ctx.fork(key, branches=[...])` durably registers those owned
410
+ workflows and returns a `ForkRef` while the parent stays in its current activation.
411
+ The parent can continue local work before returning
412
+ `ctx.join(group, resume=..., state=...)` to wait for every branch's terminal
413
+ outcome. Branch execution overlaps only when matching worker capacity is free.
414
+ Fork keys are workflow-wide, branch keys share the child namespace, and exact
415
+ retries reuse the original registration. See
416
+ [entrypoints and forks](../../docs/workflow-entrypoints.md) for a complete example,
417
+ reconciliation, and bounds.
418
+
419
+ ## Explicit checkpoint workflows (protocol 3)
420
+
421
+ Existing controllers can also use
422
+ `from ledgence.worker.workflow import workflow_context` with string continuations and
423
+ return `ctx.suspend(...)`, `ctx.continue_(...)`, `ctx.wait_event(...)`,
424
+ `ctx.sleep(...)`, `ctx.request_approval(...)`, `ctx.complete(output)`, or
425
+ `ctx.fail(kind, message)`. `ctx.continuation` starts as `"start"`; `ctx.state` is
426
+ explicit JSON state and `ctx.inputs` is the frozen batch of child outcomes.
427
+ The complete CloudEvent, including user-owned `data`, remains the handler argument.
428
+
429
+ `await ctx.local(key, fn, **json_kwargs)` executes in the current process and
430
+ returns only after the Rust owner acknowledges durable storage of the result.
431
+ Calls sharing an activation-local key and the same callable/input reuse the result.
432
+ The callable does application work and may make ordinary nested Python calls.
433
+ It cannot call workflow control APIs, start another journaled local step, or stage
434
+ distributed tasks or subworkflows; the controller does those after awaiting its result. Otherwise
435
+ replaying the cached result would skip those workflow operations. This boundary
436
+ also applies through asynchronous child tasks and `asyncio.to_thread`.
437
+ Pass all changing inputs explicitly: closure variables and process memory are not
438
+ part of the persisted binding. Ordinary calls have no durable result record. An
439
+ external effect can still repeat after a crash before its commit acknowledgement;
440
+ use an external idempotency key for effects that require deduplication.
441
+
442
+ `ctx.local(...)` starts an owned operation immediately and returns an awaitable.
443
+ Use `await ctx.gather(ctx.local(...), ctx.local(...))` to overlap asynchronous I/O.
444
+ A synchronous callable retains its normal blocking semantics. The activation
445
+ retains ownership of started local operations and drains them before returning a
446
+ checkpoint, even when an observer stops awaiting one. An unobserved local failure
447
+ fails the activation; explicitly caught local errors remain under controller control.
448
+ A failed commit cannot be
449
+ caught and converted into successful workflow completion.
450
+
451
+ `ctx.task(key, program=..., version=..., queue=..., data=...)` stages a distributed
452
+ child command. The following checkpoint atomically registers those commands;
453
+ calling `task()` alone makes no network call. Child keys belong to the whole
454
+ workflow, and an identical binding reuses the original child. Use iteration
455
+ suffixes such as `"invoice:3"` to request a new child in a loop. A changed binding
456
+ for an existing key conflicts. `ctx.suspend(continuation=..., state=..., until=[ref])`
457
+ waits until all listed children are terminal; the next activation can read
458
+ `ctx.get_result(ref_or_key)` for successful output or inspect `ctx.inputs` for
459
+ failures. String keys can also refer to children from earlier activations.
460
+
461
+ `ctx.workflow(key, program=..., version=..., queue=..., data=...)` stages an owned
462
+ subworkflow with the same retry and attempt-timeout options as `ctx.task`. Both
463
+ methods return references that are not awaitable. Return a checkpoint to dispatch
464
+ work, mixing both reference kinds in `until` when needed:
465
+
466
+ ```python
467
+ child = ctx.workflow("invoice-flow", program="invoice-flow", version="1.0.0",
468
+ queue="billing", data=event["data"])
469
+ summary = ctx.task("summary", program="summary", version="1.0.0",
470
+ queue="billing", data=event["data"])
471
+ return ctx.suspend(continuation="collect", state=None, until=[child, summary])
472
+ ```
473
+
474
+ Tasks and workflows share the parent-wide key namespace. Reusing a key with a
475
+ different kind conflicts, as does changing its program, input, or scheduling
476
+ options. A child workflow wakes the parent only when the whole child workflow is
477
+ terminal. A completed controller activation by itself does not resolve the wait.
478
+ On resume, use the original string keys with `ctx.get_result(...)`, or inspect
479
+ `ctx.inputs[key]`: task results retain `task_id`, while workflow results have
480
+ `kind="workflow"` and `workflow_id`. Failed or cancelled children are inspectable
481
+ outcomes, and `get_result` raises for them.
482
+
483
+ Parent cancellation or failure drains owned descendants before becoming terminal.
484
+ Completion with unfinished children is rejected. A decision can stage at most 64
485
+ combined child commands and wait on at most 64 children. Each parent may have at
486
+ most 64 live subworkflows; nested depth is capped at 16 (root depth is zero).
487
+ See [`docs/subworkflows.md`](../../docs/subworkflows.md) for the lifecycle,
488
+ lineage, limits, and upgrade requirements.
489
+
490
+ External events and durable timers also use explicit checkpoint decisions:
491
+
492
+ ```python
493
+ from ledgence.worker.workflow import workflow_context
494
+
495
+ def handle(event):
496
+ ctx = workflow_context()
497
+ if ctx.continuation == "start":
498
+ return ctx.wait_event(
499
+ "callback:1", continuation="received", state={}, timeout_ms=60_000,
500
+ )
501
+ wake = ctx.wake
502
+ if wake["kind"] == "event":
503
+ return ctx.complete(wake["event"]["data"])
504
+ return ctx.fail("callback_timeout", "No callback arrived before the deadline")
505
+ ```
506
+
507
+ `ctx.wake` is `None` initially and when no external wait resumed the activation.
508
+ An event wake is `{"kind": "event", "key": ..., "event": <full CloudEvent>,
509
+ "accepted_at": <milliseconds>}`. Event timeouts carry `{"kind": "timeout",
510
+ "key": ..., "deadline": <milliseconds>}`; timers use `kind="timer"` with the same
511
+ key/deadline fields. Returned wake/state/input values are independent JSON copies.
512
+ `ctx.inputs` continues to contain only child outcomes. Event `data` and its original
513
+ context envelope are preserved separately from the controller invocation event.
514
+ Generic events provide application input; only `request_approval` and a durable
515
+ review decision grant the action-bound authority used by `approved_local`.
516
+
517
+ Return `ctx.sleep("retry:1", 5_000, continuation="retry", state={...})` to register
518
+ a durable timer. Both helpers stage the existing child commands in the same
519
+ checkpoint and end this activation; the Rust orchestrator owns the persisted wait
520
+ and later activation. They do not call `asyncio.sleep` or hold a worker slot until
521
+ the deadline. Timeout/delay values are integer milliseconds from zero through
522
+ 31,536,000,000 (365 days). `timeout_ms=None` waits for an event without a deadline.
523
+ Timer deadlines are persisted by the orchestrator when the checkpoint is applied.
524
+
525
+ Wait keys are one-shot across the workflow run, including later activations.
526
+ Use a fresh key such as `"retry:2"` for the next loop iteration. Reusing a closed
527
+ key does not start another wait. Events can be accepted before their wait is
528
+ registered. An event wake carries at most 64 KiB of complete encoded CloudEvent;
529
+ child inputs plus wake share a 256 KiB encoded budget, and the entire activation
530
+ context retains its 640 KiB budget. Application event data retains depth64 while
531
+ workflow envelope metadata has separate room.
532
+
533
+ External event IDs are limited to 128 UTF-8 bytes and sources to 2,048 UTF-8
534
+ bytes, in addition to the complete event budget. Sources must be valid URI
535
+ references; encode non-ASCII URI characters with percent escapes.
536
+
537
+ Rust and Python may spell the same finite float differently. Reading a context
538
+ already accepted by Rust therefore allows a bounded encoding expansion: for each
539
+ float, at most `max(0, len(repr(value)) - 3)` additional bytes. Rust's canonical
540
+ float tokens have at least three bytes; CPython JSON uses that float representation
541
+ (and the helper bounds it at 32 bytes). Existing traversal, string, node and depth
542
+ checks still apply. The allowance covers received state, child inputs, event
543
+ wakes, journal records, copied getters and exact committed-step replay.
544
+
545
+ New decisions, child commands, local inputs/results and added journal entries
546
+ retain the strict Python encoding limits. Copying a near-boundary received value
547
+ into a new write, or adding to a ledger whose Python encoding expanded, can be
548
+ rejected conservatively even when Rust's representation would fit. Pure reading
549
+ and exact committed replay do not spend a new-write budget.
550
+
551
+ Each protocol 3 invocation uses a fresh event loop inside the reused Python
552
+ process. Create and close loop-bound clients inside the handler, rather than
553
+ saving them in module globals. Started asynchronous work is drained or cancelled
554
+ before the process is reused. While local operations run, the activation holds
555
+ one consumer/process slot; a committed suspension releases it for other tasks.
556
+
557
+ Unexpected controller exceptions are retryable activation runtime failures.
558
+ `ctx.fail(...)` explicitly requests workflow failure. Ordinary task output is
559
+ never interpreted as a workflow decision, even when it contains a `kind` field.
560
+
561
+ See [`docs/workflows.md`](../../docs/workflows.md) for the workflow contract,
562
+ checkpoint limits, cancellation, and recovery semantics.
563
+