Repository navigation
Replace the invalid 2.13.1 reused cancellation-clock evidence with valid redrive successors - #238
Conversation
The 2.13.1 test test_a_reused_step_keeps_its_original_time_for_the_ remaining_budget (#233) used a history Workflow cannot write: no redrive WorkflowStarted, the cancellation delivered before a "reused" step, and reused_recorded_at after the delivery. Its 18.123456 depends on that order (the valid order gives 22.123456). It backed the 2.13.1 clause "The cancellation clock prefers a reused step's reused_recorded_at". Remove that unit test (it is not a corpus fixture) and add three valid successor fixtures built to Workflow 28893f97 FailedRunRedrivePlan::successorEvents, each with the full redrive envelope, a Core-identical reused prefix and resume = highest + 1: - redrive-successor-reused-markers: side effect 1, version marker 2, activity 3, resume 4. The side effect does not run again, and the next activity receives ctx.now() = replayed_started_at, which replaces the start-clock claim of redrive-start-replayed-clock. - redrive-successor-reused-timer: activity 1, Core's copied TimerScheduled and the reused TimerFired 2, resume 3. - redrive-successor-reused-cancellation-clock: the timer successor, its own ActivityScheduled 3, and its own cancellation after the prefix: remaining() is 29.0 from the delivery. The claim becomes non-leakage: valid retiming of reused times changes nothing; a leaking-clock probe reads 11.0 where the SDK keeps 29.0; and a positive control on the delivery gives 11.0. check_redrive_successor accepts Core's activity, timer, side-effect and version-marker shapes, and Core's copied TimerScheduled only when it has no reuse keys, is recorded at the successor start and is directly followed by the reused TimerFired with the same timer_id and step. A reused TimerFired requires that copy, and no successor step may reuse a reused step. A sweep over every corpus fixture changes exactly the three new verdicts. tests/redrive_fixture_registry.py registers every redrive fixture. The 2.13.1 redrive-start-replayed-clock stays byte-identical, pinned by SHA-256, failing the check with "history order", and replaced. The gate requires the registry to match the corpus and exactly the registered successors to pass. The CHANGELOG corrects the 2.13.1 claim under Unreleased. Fixes #237 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security source hold on exact head The withdrawal direction, immutable-fixture registry, copied-timer exception, and valid replacement shapes are sound. This head is not clear yet:
Each counterfactual should pass the current validator and fail the corrected one. The Release-body edit remains unapplied and separate. Integration is still running, but this head is already non-qualifying because of the 3.10 failure. |
Security's hold on bd41be6: - Python 3.10 rejects a trailing Z in datetime.fromisoformat, so the non-leakage tests failed on Core's 00:05:31.000000Z deadline. The validator and the evidence tests now parse history times through redrive_time, which maps a trailing Z to +00:00 and requires an offset. - The successor cancellation is now exactly one request followed by exactly one delivery. Both carry the same non-blank context, the delivery names its request, and it is not earlier than the request. The context is that request, is rooted at the successor run and instance, and its lineage ends at them; a root request is its own root; the request's cleanup deadline is the context's and follows the request. The delivery targets the first unreused step, a call not yet scheduled, or a cancellable call (activity, timer or signal wait) the successor recorded before it, never an unrecorded later step. Fifteen mutations (context deleted from either or both events, duplicate or missing request or delivery, mismatched context, delivery earlier than its request, context request, root or lineage identity, deadline, and delivery to unrecorded step 4) run on both cancellation successors; the bd41be6 check accepted all 30 and this one refuses all 30. A delivery to step 4 after the successor recorded step 4 is accepted. The fixture verdict sweep still changes exactly the three new successors. Refs #237 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
General interim review (Security unavailable; operator instruction recorded at cloud#293 6617758869): exact head Reviewed by an independent reviewer, not the authoring agent. Confirmed:
Blocking, F1: the copied
Also required, cheap and Core-aligned: refuse a copied schedule that carries Non-blocking, tracked as a cross-SDK follow-up: validator strictness gaps shared with sdk-rust and sdk-php:
The release body stays unedited until source clearance and a separate exact-text go. |
Interim review of 52940fb (F1): a copied TimerScheduled in the reused prefix skipped the run checks, so one naming any run was accepted. The copy is the source run's schedule payload (Core 28893f97 FailedRunRedrivePlan.php:437-455), so a workflow_run_id must be absent or the continued_from_run_id. Redrive copies only plain timers (isPlainTimer, :497), so a copy carrying a non-null timer_kind, cancellation_cleanup, cancellation_scope_id, parallel_group_id, condition_wait_id or signal_wait_id is refused. Counterfactuals on both timer successors: a bogus run, the successor run, and each non-plain key fail here and passed at 52940fb (16 cases). The source run and null keys are accepted. The fixture verdict sweep still changes exactly the three new successors. Refs #237 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
General interim review (Security unavailable): exact head The delta from the reviewed
Counterfactuals on both timer successors: the bogus-run, successor-run and six plain-timer-key cases (16) were accepted on 52940fb and are refused here. The source-run and null-key controls are accepted. The 72-fixture verdict sweep still changes only the 3 new successors. Hosted CI: 20/20 success, including Python 3.10, CLEAN. The 2.13.1 Release body edit is a separate step: recompute the hashes, then a body-only PATCH and readback. |
|
Merged and 2.13.1 Release corrected (General interim, Security unavailable). Merge: 2.13.1 Release body: a body-only PATCH appended the reviewed correction paragraph to the raw body, with one blank line and a final newline.
|



Fixes #237. Head
262eb6efaddresses the interim review of52940fb7(F1: copied timer schedule bound to the source run and to plain timers), on top of Security's hold onbd41be60(comment 6617712901: Python 3.10 time parsing and exact successor-cancellation binding). This changes tests, fixtures and the CHANGELOG only. There is no product change, the corpus policy andscripts/ciare unchanged, and every existing fixture is byte-identical tomain(7dbeca44).What was invalid
test_a_reused_step_keeps_its_original_time_for_the_remaining_budget(tests/test_cancellation_remaining_time.py, Replay a redriven run from the failed run's start time #233, shipped in 2.13.1). Its history has:WorkflowStarted;reused_recorded_at(00:12) later than the delivery.Its 18.123456 depends on that order; the valid order gives 22.123456. It is the only evidence for the 2.13.1 clause "The cancellation clock prefers a reused step's
reused_recorded_at".redrive-start-replayed-clock.json(2.13.1) has a partial envelope: onlyrecovery_kindandreplayed_started_at, with no history sequence, identity, link or resume step. It has no reused steps and asserts onlyctx.now(), which is correct.What replaces it
The unit test is removed, not rewritten. It is not a corpus fixture, and no valid history can observe the preference it asserted, so there is nothing true to rewrite it into. A comment in its place points to the new evidence.
Three successor fixtures (protocol 1.20) are built to Workflow
28893f97FailedRunRedrivePlan::successorEvents()(lines 406-485) and the redriveWorkflowStartedfromDefaultWorkflowControlPlane::redrive(). They mirror sdk-php#249's fixtures, with Python workflows and activity types. Each has the full envelope, a Core-identical reused prefix andresume_step_sequence= highest reused step + 1:redrive-successor-reused-markers"0123456789abcdef"and version 1.tests.redrive.second(["Hello, Taylor!", "2026-11-03T00:00:00+00:00"]). The second argument isctx.now(), i.e.replayed_started_at. This is the replacement forredrive-start-replayed-clock.redrive-successor-reused-timerTimerScheduledwith a newtimer_idand no reuse keys, followed by the reusedTimerFired. No timer is started again.redrive-successor-reused-cancellation-clockActivityScheduledstep 3 at 00:05:00.5.command_typeandcancellationcontext, as Core writes them.remaining()= 29.0.Corrected claim: non-leakage
In a valid history the reused prefix comes before the successor's delivery, and Python's cancellation clock starts at the delivery. Reused times therefore cannot reach
remaining().This is not fail-before/pass-after evidence for preferring
reused_recorded_at: no valid history can observe that preference. Product code is unchanged;_observe_cancellation_replay_timestill prefers it, which is harmless.Tests in
tests/test_redrive_successor_evidence.py(118 cases):Valid retiming. Every reused event's
reused_recorded_atis moved to 00:00:00 and to 00:04:59.999999, on all three successors. Each history still passes the check, and the commands are unchanged.Leaking-clock probe. The reused
ActivityCompleted, and separately the reusedTimerFired, is retimed to 00:05:20 (recorded_atandreused_recorded_at), after the 00:05:02 delivery.Positive control. The same 00:05:20 retiming applied to the delivery gives 11.0, so the assertion detects any event that does reach the clock.
2.13.1 order. Moving the successor's request and delivery ahead of the reused prefix fails the check (
reused events must directly follow WorkflowStarted).Resume: −1, +1, null and the string form, on each new successor.
Copied timer. On the timer and cancellation successors, each of these fails:
reused_from_run_id, or carryingreused_recorded_at;timer_id, or a blank one;timer_kind,cancellation_scope_id,cancellation_cleanup,parallel_group_id,condition_wait_idorsignal_wait_id.Fail-before: these last 8 shapes × 2 successors (16 cases) passed at
52940fb7. Controls (accepted): the schedule naming the source run, or carrying a nulltimer_kindorcancellation_scope_id.Successor cancellation. Each of these fails:
ActivityScheduledon reused step 2.Exact cancellation binding (
test_the_successor_cancellation_is_bound_exactly). 15 mutations, each on both cancellation successors (redrive-successor-reused-cancellation-clockand Enforce search-attribute replay identity and qualify redriven memo and search-attribute reuse #236'sredrive-reused-upserts-cancellation-clock):Fail-before: the
bd41be60check accepted all 30 cases; this one refuses all 30. Control: a delivery to step 4 after the successor recorded step 3's completion and scheduled step 4 is accepted.Per-field coverage. The existing parametrized envelope and per-event deletion and blank controls in
tests/test_replay_clock.pynow also run on the three new successors.Validator change (
check_redrive_successor,tests/test_replay_clock.py)Core shapes it now recognises:
ActivityCompleted(sequence,activity_type,activity_execution_id);SideEffectRecorded,VersionMarkerRecordedandTimerFired;ActivityScheduled.Copied timer: the one narrow allowance, the PHP rule. A
TimerScheduledis accepted in the reused prefix only if it:carries neither reuse key;
is recorded at the successor start;
is directly followed by a reused
TimerFiredwith the same non-blanktimer_idand the same step.names no run, or the source run (
continued_from_run_id), asworkflow_run_id; the copy is the source run's schedule payload (FailedRunRedrivePlan.php:437-455);is a plain timer, per Core's
isPlainTimer(:497): no non-nulltimer_kind,cancellation_cleanup,cancellation_scope_id,parallel_group_id,condition_wait_idorsignal_wait_id.Conversely, a reused
TimerFirednow requires that copy directly before it.No reused step in the successor. No successor event that occupies a step, including a cancellation delivery, may use a reused step's sequence.
Successor cancellation, bound exactly (follows the request-identity part of sdk-rust#175's rule):
CooperativeCancellationRequested, followed by exactly oneCooperativeCancellationDelivered.cancellationcontext, and the delivery names its request (workflow_command_id).request_idis the request;parent_request_idnull) is its own root;cleanup_deadline_atequals the context's and does not precede the request.resume_step_sequence, a call not yet scheduled; Core lets a delivery consume an unscheduled call), or a cancellable call the successor itself recorded before the delivery (ActivityScheduled,TimerScheduledorSignalWaitOpened).Python 3.10. The check and the evidence tests parse history times through
redrive_time, which maps a trailingZto+00:00(Python 3.10'sfromisoformatrejectsZ) and requires an offset.Verdict sweep. I ran the merged (
7dbeca44) and new checks over every corpus fixture that has a history (72). Exactly 3 verdicts change, re-run at this head: the 3 new successors go fromunexpected event ActivityCompleted/unexpected event SideEffectRecordedto valid. All 69 others keep their verdict and message. Nothing that failed before passes now, except these Core-shaped successors.Registry and gate
tests/redrive_fixture_registry.py:redrive-reused-upserts-clock(3),redrive-reused-signal-wait-clock(2),redrive-reused-upserts-cancellation-clock(3),redrive-reused-search-attribute-value-mismatch(3),redrive-reused-search-attribute-type-mismatch(3),redrive-reused-search-attribute-update-missing(3), and the 3 new ones (4, 3, 3)redrive-start-replayed-clock70653171faf5ddba5ed264d0a7ce8bb1af97112dbd6e3bb0e39c6f155fee0fd2; fails withhistory order(itsWorkflowStartedhas no history sequence); replacementredrive-successor-reused-markersmust passtest_every_redrive_fixture_is_registeredselects every replay fixture whose name containsredrive, that carriesreused_from_run_idorreused_recorded_at, or whoseWorkflowStartedhasrecovery_kind=redrive. That set must equal the registry, so an unregistered fixture or a stale entry fails.test_exactly_the_registered_successors_passrequires the set that passes the check to be exactly the registered successors.ctx.now()result is true.tests/test_replay_clock.py::redriven_historybuilds in-test histories for the start-clock tests. They check onlyctx.now()fromreplayed_started_atand make no remaining-budget claim, so they are unchanged.CHANGELOG
A new
### Correctedsection under Unreleased withdraws the 2.13.1 observable claim. The 2.13.1 section, tag and package are untouched.Proposed text for the 2.13.1 GitHub Release (not applied)
I have not edited the release, tag or package.
5d90b2f916630c6224ec319e5cf403e30a29b9177a4ef3da38795b9eafca17c0, from the raw API body. It does not end with a newline. The release is not a draft or prerelease and has no assets.body + "\n\n" + paragraph + "\n", the same layout as the 4.11.1 sdk-rust edit. The full proposed body has SHA-256db167b0e2e36fa0294aa6879e654dc0f442262bd68f0099c25c6327649ef678f.Local runs at 262eb6e
ruff check src/ tests/: clean.mypy src/durable_workflow/: no issues.validate-regression-corpus.py --base-ref origin/main:status: pass.test-validate-regression-corpus.py: OK.52940fb7. In a boundedpython:3.10-slimcontainer (general-py310,--cpus 2 --memory 2g --pids-limit 512 --init --user 501:20, removed afterwards):test_ci_checkout.py/test_public_boundary_scanner.py. The image has nogit(FileNotFoundError: 'git').Not merged or released, and the 2.13.1 GitHub Release is unedited.
🤖 Generated with Claude Code