Skip to content

Bind the redrive successor cancellation to Core's full shape - #242

Merged
rmcdaniel merged 3 commits into
mainfrom
fix/239-tighten-redrive-successor-validator
Nov 4, 2026
Merged

rmcdaniel merged 3 commits into
mainfrom
fix/239-tighten-redrive-successor-validator

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Fixes #239. Tests, one added fixture and the CHANGELOG only. No product code changes, the corpus policy and scripts/ci are unchanged, and every existing fixture is byte-identical to main (407f1ed).

Rules, from Workflow 28893f97

These are the rules for a successor's own cancellation in check_redrive_successor (tests/test_replay_clock.py). sdk-rust#179 and sdk-php#250 can use the same list.

  1. Root binding. The context is a root context: parent_request_id is present and null, and root_request_id is the request. WorkflowStub::requestCancellation, through recordCancellationRequest (src/V2/WorkflowStub.php ~2376), builds exactly this for a run's own cancellation. A propagated parent or scope context is rooted at another run, so it is not the successor's own cancellation.
  2. Lineage. The lineage equals [{request_id, workflow_instance_id, workflow_run_id}] for this request on the successor, and nothing else. CancellationContext::fromArray requires lineage[0] to be the root and run ids to be unique. With the root at the successor, that leaves exactly one entry. A prepended source-run entry or a repeated entry is refused.
  3. Delivery step (CooperativeCancellationDelivery::validateCallBoundary / resolvedBeforeRequest):
    • If the successor recorded a cancellable call at the delivered step (ActivityScheduled, TimerScheduled, SignalWaitOpened), the delivery's call_kind must be the kind Core callKindAt derives. ActivityScheduled is local_activity when local_activity is true and activity otherwise. TimerScheduled is condition or signal for a condition_timeout or signal_timeout timer_kind, and timer otherwise. SignalWaitOpened is signal. The call must not be resolved (ActivityCompleted, TimerFired, SignalApplied) before the request. A resolution between request and delivery is still allowed, as in Core.
    • Otherwise the delivery consumes the first unreused step (resume_step_sequence), a call not yet scheduled. No successor event may occupy that step before or after it.
    • As before, Python refuses an unrecorded step beyond the resume step.
  4. Fields Core always writes:
    • The request has command_type = request_cancellation.
    • The delivery has a single-call call_kind from CALL_SHAPES (activity, local_activity, timer, condition, signal, child, service_operation), and no sequence_span / operation_sequence / operation_sequence_span. This is a deliberate narrowing: Core also accepts parallel and selection_handle deliveries, but no successor fixture uses those shapes, so the check refuses them rather than half-modelling them.
    • The context schema is durable-workflow.cancellation-context/v1, with no scope_origin or scope_authority_deadline_at.
    • The context requested_at parses and lies between the successor start and the request's recorded_at. Core takes now() for the context before it records the event.
    • cleanup_deadline_at is strictly after requested_at, which matches fromArray. Before this change it was only compared with recorded_at, and equal times were allowed.
    • requester is a non-empty map whose keys are among type/id/label and whose values are strings other than ''. Like Core, it does not trim them. source is non-blank.
    • The request's reason equals the context's.
  5. Copied timer: already closed by Replace the invalid 2.13.1 reused cancellation-clock evidence with valid redrive successors #238 (isPlainTimer and source-run binding).

Fixture

The #236 fixture redrive-reused-upserts-cancellation-clock is unreleased: it merged after 2.13.1. Its CooperativeCancellationRequested has no command_type, and Core never writes that shape. The corpus is immutable, so the fixture stays byte-identical:

  • It is registered as non-qualifying, with SHA-256 f8c40d18…4856 and error the request's command_type is request_cancellation.
  • redrive-successor-reused-upserts-cancellation-clock replaces it. It is the same history, with a new id and command_type added to the request.
  • The tests that used the old fixture now use the replacement: the upsert and signal cancellation-budget tests, the run/instance binding cases, the exact-binding matrix and DELIVERY_FIXTURE.

Evidence

  • Fail-before: I ran 49 new mutations against main's check_redrive_successor, taken from git show origin/main:tests/test_replay_clock.py. main accepted all 49, and this head refuses all 49. That is 24 shapes on each of the two cancellation successors, plus the recorded-call kind mismatch.
  • Controls (accepted): the delivery to the unscheduled resume step accepts any single call kind (activity, timer, child, service_operation). A call completed after the request but before the delivery is still deliverable. Every registered successor passes.
  • Verdict sweep: I ran main and this head over all 75 fixtures that have a history. Exactly one verdict changes: redrive-reused-upserts-cancellation-clock goes from valid to the request's command_type is request_cancellation. The new fixture passes on both checks.

Local runs at b921016 (lab2, Python 3.13)

  • ruff check src/ tests/: clean.
  • validate-regression-corpus.py --base-ref origin/main: status: pass. Replay goes from 90 to 91 fixtures (1 added), related_change: false.
  • Full unit suite (tests, excluding tests/integration): 4851 passed, 4 skipped.
  • I didn't run Python 3.10 locally. CI's 3.10 job is the check for it. The new code parses times only through redrive_time.

Interim review fixes (head 7cf1a63)

  • Blocker: the delivered kind is now derived the way callKindAt does it.
    • A delivery of local_activity to an ActivityScheduled with local_activity: true is now accepted. b9210162 refused it.
    • A delivery of activity to that call is now refused. b9210162 accepted it.
    • A delivery of local_activity to an ordinary activity is refused.
  • Requester: a value of " " is now accepted on both cancellation successors, as Core accepts it. b9210162 refused it.
  • Citations: the registry reason and the docstring now cite WorkflowStub::requestCancellation / recordCancellationRequest. The pinned error string is unchanged.
  • Unchanged: the 49-mutation comparison against main (all 49 accepted by main and refused here) and the 75-fixture sweep (one verdict change).
  • Local runs: the full suite passes (4856 passed, 4 skipped), and the corpus check reports status: pass.

Not merged or released.

🤖 Generated with Claude Code

The successor check now requires a root v1 cancellation context whose
lineage is exactly the request's own address, a null parent_request_id,
requested_at and schema, the request's command_type and the delivery's
single-call call_kind. A delivery to a call resolved before its request,
or to an unscheduled step that is recorded later, is refused (Workflow
28893f97 CancellationContext::fromArray, WorkflowStub::cancel and
CooperativeCancellationDelivery).

The unreleased #236 fixture redrive-reused-upserts-cancellation-clock has
no command_type. It stays byte-identical, is registered as non-qualifying,
and redrive-successor-reused-upserts-cancellation-clock replaces it.

Fixes #239.

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), exact head b92101628557a34f838c97ea1d12ed1d56743adb: HOLD on one small fidelity fix. The reviewer was independent of the author.

Verified:

  • PR state: head and base match, CLEAN, CI 20/20.
  • Scope: only tests, one added fixture and the CHANGELOG change. No src/, corpus-policy or scripts/ci change.
  • Corpus: all 201 base fixtures are byte-identical. redrive-reused-upserts-cancellation-clock.json keeps sha f8c40d18…, matching its non-qualifying pin, and the replacement differs only in id and command_type.
  • Full suite: passes in a bounded py3.12 container (4844 passed, 133 skipped).
  • Counterfactual: with main's validator restored, 51 tests fail, each with the intended message.
  • Sweep: across 73 fixtures, only the re-registered fixture's verdict changes. sdk-php's redrive fixtures get the same verdicts.
  • Real Core histories: two successor histories generated by Workflow 28893f97's own harness pass the new validator. One delivers to a recorded ActivityScheduled, the other to the unscheduled resume step.
  • Rules matching Core: root/v1, exact lineage, command_type, CALL_SHAPES, requested_at ≤ request time, the deadline strictly after, reason equality, resolved-before-request, and delivery-consumes-step.

Blocking (fidelity): REDRIVE_CANCELLABLE_EVENTS maps every ActivityScheduled to activity.

  • Core treats local_activity: true as local_activity and refuses an activity delivery to it (CooperativeCancellationDelivery::callKindAt, and WorkflowStepHistory::assertCompatible via isLocalActivityEvent).
  • So a Core-valid local-activity successor is refused, though main accepted it, and a Core-invalid activity delivery is accepted.
  • Fix: derive the kind the way callKindAt does, and add tests for both directions.

Non-blocking:

  1. The requester check strips whitespace, but Core refuses only ''. Match Core.
  2. The registry reason and docstring cite WorkflowStub::cancel, which is the terminal Cancel command. The request is written by requestCancellation/recordCancellationRequest. Fix it now, because the reason is pinned.
  3. Refusing parallel/selection_handle deliveries is narrower than Core. That's fine, but say it's deliberate in the PR description.
  4. Follow-up issue (also true on main): Core writes ActivityCancelled between the request and delivery for a recorded activity, and the validator's event list doesn't include it. Also, an unscheduled delivery is accepted only at the resume step.

Re-review on the new head.

An ActivityScheduled with local_activity true is a local_activity call,
and a TimerScheduled's timer_kind maps condition and signal timeouts, so
a valid local-activity delivery is accepted and an activity delivery to
it is refused. The requester check refuses only an empty string, as
CancellationContext::fromArray does. Citations now name
WorkflowStub::requestCancellation / recordCancellationRequest, and the
parallel and selection_handle refusal is documented as deliberate.

Refs #239.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 re-review (Security unavailable): exact head 850baf5975668667669476b9e23cab463be92429 is CLEARED. The reviewer was independent of the author.

The fix (7cf1a63)

  • _recorded_call_kind matches every reachable branch of Core 28893f97's callKindAt:
    • an ActivityScheduled with local_activity === true becomes local_activity, otherwise activity;
    • condition_timeout and signal_timeout timers become condition and signal, other timers timer;
    • SignalWaitOpened becomes signal.
  • Core always writes the local_activity flag, so this check is equivalent to Core's isLocalActivityEvent.
  • The requester now refuses only '', and the citations are corrected.

The merge (850baf5) brings in exactly #243's two files, with no source, script, policy or fixture changes.

Evidence

  • Core's own CooperativeCancellationDelivery::validate accepts a local_activity delivery to a local activity in a redriven successor, and refuses an activity delivery.
  • Core's recorded successor passes this validator, and fails it when the delivery is changed to activity.
  • Full suite: 4850 passed.
  • Counterfactual: with main's validator, 53 tests fail.
  • CI 20/20, merge state CLEAN.

Merge only this SHA.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 4, 2026 03:12
@rmcdaniel
rmcdaniel merged commit 1be839f into main Nov 4, 2026
20 checks passed
@rmcdaniel
rmcdaniel deleted the fix/239-tighten-redrive-successor-validator branch November 4, 2026 03:12
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged at General's clearance of exact head 850baf5975668667669476b9e23cab463be92429.

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.

Tighten redrive successor validator: cancellation lineage, root, step and field binding

1 participant