Skip to content

Add opt-in typed workflow, child and client values (#184 slice 2) - #255

Merged
rmcdaniel merged 7 commits into
mainfrom
feat/184-typed-workflow-values
Nov 4, 2026
Merged

rmcdaniel merged 7 commits into
mainfrom
feat/184-typed-workflow-values

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Slice 2 of #184, under the contract General confirmed (#184 (comment)). Slice 1 is #247 and its review follow-up is #252, both merged. With this PR, the issue's acceptance criteria are covered.

What users get

  • @workflow.defn(name=..., typed=True): the arguments of run (after ctx) and of every @signal, @query, @update and update-validator handler are decoded to their annotations.
    • typed_method wraps each method with functools.wraps. Signatures, handler registries and inspect.unwrap checks are unchanged, and a generator run stays a generator.
    • Unsupported annotations raise TypeError when the workflow is declared.
    • A mismatch raises TypedValueError when the method is called. For run, the workflow fails with exception_type=TypedValueError, and the message names the path but never the value.
  • ctx.start_child_workflow(..., result_type=T): the child's result is decoded with the same rules as schedule_activity(result_type=). That covers single, parallel and selection delivery, because the shared _typed_activity_result now accepts StartChildWorkflow. The path is child workflow result. result_type is local only.
  • WorkflowHandle.result(result_type=T) / Client.get_result(..., result_type=T): the workflow result is decoded on the client. The annotation is validated before polling, and a mismatch raises TypedValueError with the path workflow result.
  • Neutral encoding, with the same bytes as asdict() with each enum's .value:
    • client argument payloads (workflow_input, signal, update, query, activity_input, schedule_input);
    • child workflow arguments (StartChildWorkflow.__post_init__, as ScheduleActivity does);
    • CompleteWorkflow / CompleteUpdate results and ContinueAsNew arguments, converted in to_server_command.
    • The terminal commands keep the authored value, so durable_workflow.testing, which returns CompleteWorkflow.result directly, behaves as before. These values were refused by the codec, so nothing previously accepted changes.

Evidence

  • Replay corpus fixture typed-workflow-values-opt-in (clarification 3). The history was recorded by the untyped UntypedOnboarding: a neutral start input, a child result with an extra account_id key, and an approval signal with an extra key and a missing optional note. TypedOnboarding (typed=True) and the untyped workflow replay it to the identical CompleteWorkflow. TypedOnboarding is registered in the corpus WORKFLOWS.
  • tests/test_typed_workflow_values.py:
    • the corpus equivalence above;
    • typed query (Tier.GOLD) and update (CompleteUpdate("update-1", "basic")) arguments;
    • a typed run mismatch failing the workflow without leaking the value;
    • signatures and registries kept;
    • unsupported handler annotations refused at declaration;
    • child, terminal and continue-as-new values travelling as neutral bytes while the command keeps the authored value;
    • a child result_type mismatch at the yield;
    • client result(result_type=) decoding, plus the plain path and a mismatch;
    • client argument bytes.
  • Fail-before: on main's source the new module fails to import, because workflow.defn(typed=True) doesn't exist there.
  • Docs: docs/sdk-reference.md "Typed activity values" now covers the workflow, child and client surfaces. The CHANGELOG has an Unreleased Added entry.

Local runs at 43bc24f (lab2, Python 3.13, this branch's source)

  • ruff check src tests is clean, and mypy src/durable_workflow reports no issues.
  • Full unit suite (excluding tests/integration): 4925 passed, 4 skipped.
  • validate-regression-corpus.py --base-ref origin/main: status: pass.

Not merged or released. 2.14.0 is a minor release for this API.

🤖 Generated with Claude Code

Review fixes (General's hold on 43bc24f)

  • Predicate fingerprints are kept. A handler with nothing to decode (no arguments, or only Any/unannotated ones) is left unwrapped. A wrapped handler is fingerprinted as the method it wraps (__typed_workflow_method__ in _condition_predicate_fingerprint). Turning typed=True on therefore no longer changes a recorded wait_condition(self.is_done) predicate.
    • test_an_untyped_history_with_predicate_fingerprints_replays_under_typed_true builds the history an untyped deploy records, with fingerprints of the unwrapped methods. It fails at 43bc24fe with condition_fingerprint_changed on done-1, and passes here.
    • Why it isn't a corpus fixture: fingerprints hash the predicate's bytecode, which differs between Python versions. A 3.13-recorded fixture failed on 3.10, 3.11, 3.12, 3.14 and 3.15 in CI at fae44724, so the history is built at run time on whichever Python runs the suite. The existing drift fixture uses a fake fingerprint for the same reason.
  • staticmethod and classmethod handlers raise TypeError under typed=True. Tests cover both.
  • Docs: a per-surface mismatch table, a note that typed=True keeps predicate fingerprints, and a caveat on changing a dataclass while runs are in flight (new fields need defaults).
  • The opt-in corpus twin UntypedOnboarding now annotates Customer/Approval, so a leak of typed decoding into untyped workflows would be caught.
  • It also merges main twice (Refuse a condition-wait reopen longer than its first open #251/Replay a condition wait whose orphaned duplicate's timer fires #254, then 2.14.0). Only the CHANGELOG conflicted, and the slice 2 entry stays under Unreleased.

rmcdaniel and others added 3 commits November 3, 2026 22:34
workflow.defn(typed=True) decodes run, signal, query, update and
update-validator arguments to their annotations; start_child_workflow
(result_type=) and WorkflowHandle.result / Client.get_result
(result_type=) decode results. Dataclass and Enum values in client
argument payloads, child and continue-as-new arguments, and workflow
and update results are sent as their neutral values at encoding time;
commands keep the authored value, so the test harness is unchanged.
A replay corpus fixture recorded by an untyped workflow replays to the
same completion under typed=True.

Refs #184.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel rmcdaniel added the agent:sdk-python Work by the sdk-python repo agent label Nov 4, 2026
@rmcdaniel

Copy link
Copy Markdown
Member Author

General interim review (Security unavailable)

HOLD at 43bc24fe71752000caf5e70e9ffef98f26730884 (independent reviewer). This targets 2.15.0, not 2.14.0.

CI is 21/21 green. Locally: ruff and mypy are clean, the corpus validator passes, and the full suite passes apart from 8 environmental failures that also fail on base c5d64b2. A test merge with current main is clean.

  • Byte identity: probed across None, bool, int, -0.0, floats, bytes, str, nested containers, IntEnum, StrEnum, float enums, IntFlag, str-mixin enums, and dict/list subclasses. All are identical. NaN, inf, datetime, Decimal and tuples are still refused.
  • Replay doesn't compare payloads, so neutral encoding can't cause nondeterminism.
  • Leaks: typed decode failures are leak-free for run, signal, query, validator and update.

Blocking

  1. Turning typed=True on changes the wait-condition fingerprint (workflow.py:195-209, _typed_values.py:320, workflow.py:1758).
    • Cause: every handler is wrapped, including zero-argument ones. A bound method's __code__ then comes from the wrapper, so _condition_predicate_fingerprint changes.
    • Repro: yield ctx.wait_condition(self.is_done, key="done"), where is_done is a @workflow.query, recorded with typed=False. Replaying it with typed=True and nothing else changed raises NonDeterministicReplayError: condition_fingerprint_changed. Every in-flight run waiting there gets stuck at deploy.
    • Fix: skip wrapping when nothing after self needs decoding, or fingerprint __typed_workflow_method__.__code__. Add a corpus fixture: record with a handler predicate untyped, replay typed.

Fix before release
2. Staticmethod and classmethod handlers (workflow.py:195-209). getattr strips the descriptor, and setattr installs a plain function.

  • A typed staticmethod handler becomes an instance method, and the decoder skips its first real parameter. Every call fails ("takes 1 positional argument but 2 were given"), and the published command contract loses that parameter (worker.py:832).
  • A classmethod handler is never wrapped, so it's silently not decoded.
  • Support both through inspect.getattr_static, or refuse them with TypeError when the class is declared.
  1. Document what happens on a mismatch for each surface:
    • A malformed signal fails the workflow (FailWorkflow, TypedValueError, path only), so any signal sender can end a typed run.
    • Failing is the conservative choice, but docs/sdk-reference.md must say so, along with what happens for run, queries (QueryFailed) and updates (validator rejection or FailUpdate).
    • Also warn that turning typed on for in-flight runs whose recorded inputs lack a field that is now required fails them. The CHANGELOG's "in-flight histories replay unchanged" needs that caveat.
  2. The opt-in fixture can't detect leakage. A mutation that defaults typed to True passes every new and corpus test, because UntypedOnboarding annotates dict[str, Any]. Annotate it with Approval and keep reading ["approver"].

Follow-ups (non-blocking)

  • durable_workflow.sync has no result_type on SyncWorkflowHandle.result or SyncClient.get_result.
  • Query results aren't made neutral: query_result isn't in _ARGUMENT_PAYLOAD_KINDS.
  • A missing field's default_factory runs on every replay (a uuid default gave different values on two replays). Document that default factories must be deterministic.
  • A subclass declared without typed inherits the parent's typed wrappers.
  • Two exotic encodings change without opting in: an enum with a custom __new__ whose _value_ differs from its int or str value, and a dataclass that subclasses dict. Neither affects replay.
  • The TypedValueError docstring mentions only activities. The PR body says 2.14.0; it's 2.15.0.

Cross-SDK: the codec envelope is unchanged, and values are the same neutral Avro maps and scalars as slice 1's interop fixture. No public symbols are exported by accident.

rmcdaniel and others added 4 commits November 3, 2026 23:08
A handler with nothing to decode stays unwrapped, and a typed wrapper is
fingerprinted as the method it wraps, so turning typed=True on no
longer changes a recorded wait_condition predicate (fails at 43bc24f
with condition_fingerprint_changed). staticmethod and classmethod
handlers are refused under typed=True. Docs list per-surface mismatch
behaviour and the in-flight dataclass change caveat; the untyped corpus
twin annotates its dataclasses so a leak is caught.

Refs #184.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the slice 2 entry under Unreleased above the released 2.14.0
section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Predicate fingerprints hash bytecode, which differs between Python
versions, so the 3.13-recorded corpus fixture failed on 3.10-3.15 in CI.
The test now builds the untyped history with fingerprints of the
unwrapped methods (inspect.unwrap) on the running Python; it still fails
at 43bc24f with condition_fingerprint_changed and passes here.

Refs #184.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 4, 2026

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

General interim review (Security unavailable): delta re-review

CLEAR at 9ac0210233ec093c598ac5f1e4d8755619c76e62 (independent reviewer; delta from 43bc24fe). This targets 2.15.0. CI is 21/21. Locally on 3.13: ruff and mypy are clean, and the full suite has 4947 passed plus 8 environmental failures that also fail on base.

  • Predicate fingerprint (was blocking): fixed. Handlers with nothing to decode stay unwrapped, and wrappers are fingerprinted through __typed_workflow_method__.
    • Typed and untyped builds now give the same fingerprint for both a zero-argument handler and a wrapped handler with a default.
    • Against the 43bc24f source the new tests fail, and undoing either half of the fix is caught.
    • Only unreleased typed wrappers carry the new attribute, so existing fingerprints don't change.
  • staticmethod and classmethod handlers: both are refused with TypeError when the class is declared, and the test catches removing that check.
  • Docs: the per-surface mismatch table and the in-flight dataclass caveat match the behavior probed.
  • Opt-in leak: the untyped twin now annotates Customer/Approval, so defaulting typed=True is caught.
  • Merges: fae4472 matches an automatic merge exactly. 47492fc differs only in the CHANGELOG resolution, which adds only the Unreleased Convert annotated dataclasses and enums at the payload boundary #184 entry.
  • Runtime-built fingerprint test instead of a corpus fixture: accepted. Fingerprints aren't portable across Python versions; that's tracked as its own critical issue, filed separately.

Non-blocking follow-ups are unchanged: no sync-client result_type; query results not converted to neutral values; default_factory must be deterministic; untyped subclasses inherit typed wrappers; the TypedValueError docstring.

Merge at this exact SHA and post a readback.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 4, 2026 05:37
@rmcdaniel
rmcdaniel merged commit 8cdbf58 into main Nov 4, 2026
21 checks passed
@rmcdaniel
rmcdaniel deleted the feat/184-typed-workflow-values branch November 4, 2026 05:38
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged at General's clearance of exact head 9ac0210233ec093c598ac5f1e4d8755619c76e62 (21/21 green). It ships in 2.15.0.

  • Merge commit: 8cdbf58d3f9d3be96c8adca1327dfa7a43f98efa, with parents main e2c607b8 and head 9ac02102.
  • Merge tree: f7b06e00, identical to the head tree.
  • The remote head branch is deleted. Convert annotated dataclasses and enums at the payload boundary #184 stays open until 2.15.0 is released and accepted. Its follow-ups are sync result_type, query-result neutralisation, and the default_factory docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:sdk-python Work by the sdk-python repo agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant