Skip to content

Enforce search-attribute replay identity and qualify redriven memo and search-attribute reuse - #236

Merged
rmcdaniel merged 14 commits into
mainfrom
fix/235-redrive-reused-upsert-signal-clock
Nov 3, 2026
Merged

rmcdaniel merged 14 commits into
mainfrom
fix/235-redrive-reused-upsert-signal-clock

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Fixes #235.

Head 909f3535 addresses Security's scope-path hold on 4116362b (comment 6617229056). Earlier heads closed comments 6616894909, 6616717910, 6616445862, 6616210579, 6615888062, 6615716272, 6615192720 and 6615262197.

What this qualifies for Python

The fence lifts per runtime and per event, not as one switch (workflow#1006, comment 6614976031).

  • Memo reuse (MemoUpserted): qualified. Replay time, exact value identity, and no re-upsert.
  • Search-attribute reuse (SearchAttributesUpserted): values qualified, types not.
    • Qualified: replay time, exact value identity, refusal when attributes is missing (search_attribute_update_missing), and no re-upsert.
    • Not qualified: type identity. The type fence stays for Python (see "Why the type fence stays").
  • Signal wait (SignalWaitOpened, SignalReceived, SignalApplied): not qualified.
    • Python has no signal-wait primitive. Only the handler path shown here is covered: a replayed history containing the reused events applies the signal once and takes no time from any of them.
    • This PR must not be used to lift the signal-wait fence for Python.

Why the type fence stays

The SDK's UpsertSearchAttributes sends no attribute_types. Both Servers still record a key's namespace-registered type ahead of the inferred one:

  • Native Server 3.0.x (server 33bb2911, rust/src/runtime/search_attribute_upserts.rs):
    • :821 loads search_attribute_definitions.
    • validate (:197) takes registered_type (:254), uses registered_type.or(declared_type) (:264), and only otherwise infers (:289).
    • :856 records that map as attribute_types.
    • The parity test at :879 records an integer as float and "a b" as string for registered keys.
  • PHP Server:
    • SearchAttributeValueValidator.php:93 sets $effectiveType = $registeredType ?? $declaredType, and :113 infers only when the key is not registered.
    • WorkerController.php:2734 writes that map into the command's attribute_types.
    • Workflow DefaultWorkflowTaskBridge.php:5215-5240 (workflow 28893f97) then records canonicalTypes($call, $attributeTypes), which prefers the given type (SearchAttributeUpsertService.php:177) over inferType (:184).
  • Consequence: the SDK cannot see the namespace registry during replay, so it cannot pin the exact type.

The type check that remains

The check refuses only a recorded type that cannot hold the value at all. It is a superset of what either Server accepts, so it cannot falsely refuse a valid history.

  • Allowed types per value:
    • bool → bool.
    • integer → int or float.
    • float → float.
    • list → keyword_list.
    • any string → keyword, string or datetime. Every string counts as a possible registered datetime, because native accepts no-offset ISO, date-only and RFC 2822 forms.
  • Keys: the recorded keys must equal the update's non-null keys, including a blank string, which both Servers type from the raw value.
  • Refused with search_attribute_type_mismatch: unknown type names, a map that isn't an object, and type values that aren't strings (list, map, number, bool, null).
  • Without attribute_types: value-only identity, as in Core.

Durable command sequences (this head)

Product replay no longer guesses or overwrites a search-attribute step. Each workflow sequence is classified as the first pass reads it, before anything is recorded in details_by_sequence.

  • durable_command_sequence_missing: a SearchAttributesUpserted with no sequence or a null one. Replay no longer binds it to the next authored step.
  • durable_command_sequence_invalid: a sequence that is zero, negative, a string (including "2"), a float or a bool.
  • duplicate_search_attribute_update: a second SearchAttributesUpserted at the same sequence.
  • durable_command_sequence_collision: an upsert (MemoUpserted or SearchAttributesUpserted) that shares its sequence with any other durable command record. This is the same scope as sdk-php#246; an upsert is the only command at its sequence. A later record therefore never overwrites an earlier one.

Sequence audit. Every event type that carries a sequence (Core's HistoryEventPayloadContract, and every one the SDK reads) either claims its command's sequence or only points at a step. Rows of one command share its sequence. An upsert sharing a sequence with any claimed row from another command is refused. The claim table (_DURABLE_COMMAND_KINDS) is pinned exactly by test_claim_table_is_exact.

Event Claim Why
ActivityScheduled, ActivityStarted, ActivityCompleted, ActivityFailed, ActivityTimedOut, ActivityCancelled activity Core WorkflowStepHistory step events for the activity's own sequence.
ActivityHeartbeatRecorded, ActivityRetryScheduled activity (new) Core step events; activity lifecycle rows at the activity's sequence.
ActivityCancellationAcknowledged activity (new) Written at the activity's sequence when its cancellation is acknowledged.
TimerScheduled, TimerFired, TimerCancelled timer Step events for the timer's sequence.
ChildWorkflowScheduled, ChildRunStarted, ChildRunCompleted, ChildRunFailed, ChildRunCancelled, ChildRunTerminated child Step events for the child's sequence.
ChildCancellationRequested, ChildCancellationResolved child (new) Child cancellation lifecycle at the child's sequence.
ConditionWaitOpened, ConditionWaitSatisfied, ConditionWaitTimedOut, ConditionWaitCancelled condition The wait's lifecycle at its sequence.
SignalWaitOpened, SignalApplied, SignalWaitTimedOut, SignalWaitCancelled signal The wait's lifecycle at its sequence.
SignalWaitReceived signal (new) Read by the SDK as a signal-wait terminal at the wait's sequence.
ServiceOperationScheduled service call (new) Core step event that opens a service call at its sequence.
ServiceCallStarted, ServiceCallCompleted, ServiceCallFailed, ServiceCallCancelled service call Step events for the call's sequence.
CooperativeCancellationDelivered, CancellationScopeDelivered cancellation delivery (new) Durable command positions: a delivery consumes the interrupted call, scheduled or not. It claims sequence through sequence + sequence_span − 1, inclusive (see below).
CancellationScopeOpened cancellation scope (new) Authored step; Core step event, replayed as the resolved cancellation scope result.
WorkflowContinuedAsNew continue as new (new) Core step event; ContinuedRunMessageHandoff records the command's own sequence.
SideEffectRecorded, VersionMarkerRecorded, MemoUpserted, SearchAttributesUpserted own kind One step each.
SignalReceived pointer Its workflow_sequence names the wait or condition it feeds. A receipt is not a command.
UpdateApplied, UpdateCompleted pointer The workflow position where an update ran; it can follow any step.
FailureHandled pointer Core records it with the failed step's sequence (recordFailureHandled($run, $task, $id, $sequence, …)).
CancellationScopeDeliveryPrepared pointer A preparation, not a delivery; Core's step history does not count it.
SelectionResolved, SelectionOperationCancelled pointer Refer to member and group sequences.
CooperativeCancellationRequested, CancellationScopeRequested, CancellationScopeRequestConflicted — Carry no workflow sequence.

Delivery spans. These follow Core's src/V2/Support/WorkflowStepHistory.php (workflow 28893f9):

  • nextDurableCommandSequence adds CooperativeCancellationDelivered and CancellationScopeDelivered to the step types (lines 108–109).
  • nextSequenceFromEvents reads $span = self::intValue($event->payload['sequence_span'] ?? null) ?? 1 (line 270). When $span > 1, the delivery reaches $sequence + $span - 1 (lines 271–272), so the end is inclusive.
  • intValue accepts an int or a numeric string (lines 598–607).

Span contract (fail closed). Python follows the producer contract, as sdk-php#246 does, not intValue's permissive cursor recovery:

  • An absent or null sequence_span means 1. Core reads it as self::intValue($event->payload['sequence_span'] ?? null) ?? 1 (WorkflowStepHistory.php:270, workflow 28893f97), and sdk-php#246 uses ?? 1, so refusing null would reject a history Core accepts.
  • When present, the span must be a positive JSON integer.
  • Zero, negative, bool, float, numeric string and any other value refuse replay with durable_command_sequence_invalid. Refusing numeric strings is Security's stricter producer rule, which sdk-php#246 also applies.
  • The cooperative delivery parser and the scope cleanup lower bound (_cancellation_scope_history._cleanup_operation) also read only an absent or null span as 1. Any other value that is not a positive integer refuses the cleanup operation even when the parser is called on its own; it is never narrowed to 1.
  • Scope cleanup path, end to end:
    • A committed cancellation-scope replay through the cleanup timer path gives identical commands at every stage with CancellationScopeDelivered.sequence_span = null and with 1: the wait, the parent timer and completion.
    • The cleanup lower bound refuses a cleanup timer at the delivered sequence and accepts the next one, for null and 1 alike.
    • 0, false, -1, "1", 1.0 and "" are refused by replay with durable_command_sequence_invalid, and by the cleanup bound called directly.
    • Fail-before on 4116362b: the direct bound accepted all six malformed values (or 1 and arithmetic on the raw span). On origin/main's parser, a null span also failed the cleanup path.
  • The check runs before the cooperative and scope history parsers, so a malformed span can never shrink the claimed range.
  • _delivery_sequence_span holds the rule. _durable_command_claimed_range covers sequence..sequence+span−1, inclusive.

Span results:

sequence_span Result
absent, null claims 1 sequence
1 claims 1 sequence
3 claims 3 sequences (sequence..sequence+2)
0, -1 durable_command_sequence_invalid
"2", "3" durable_command_sequence_invalid
2.0, 3.0 durable_command_sequence_invalid
true, false durable_command_sequence_invalid
[3], {"span": 3}, "sk_live_TESTSECRET123", {"token": "TESTSECRET456"} durable_command_sequence_invalid

Malformed-span counterfactuals:

  • Each malformed value is tested on the helper and on a cooperative delivery whose intended span 1..3 would contain the upsert at 2. Plain and reused histories, both orders. Each value is also tested on a committed scope delivery.
  • Fail-before on e44cf64e: the helper narrowed these values ("2" → 2; 0 or null → 1). Replay refused them only through the cooperative or scope parsers' own errors, never with durable_command_sequence_invalid. 77 cases fail there and all pass here.

Diagnostics never echo values. I audited every error message this branch adds. They name only reasons, keys that passed validation, known type names, JSON type names, sequences and event types.

  • A malformed span is reported as sequence_span must be a positive integer (got string), or (got object) and so on, never by its value.
  • An invalid search-attribute key is reported by its type, not its text.
  • Controls: these cases assert the raw value appears in neither the exception message, its args nor its repr:
    • a secret-shaped string span (sk_live_TESTSECRET123) and a nested-map span ({"token": "TESTSECRET456"}), on the helper and on cooperative and scope replays;
    • secret-shaped search-attribute values, keys, type values and a type map, on both plain and reused histories;
    • secret-shaped upsert sequences.
  • Fail-before on 2a8f1c57: the span value leaked (4 cases), secret-shaped invalid keys leaked (4), and null was refused (4). All pass here.

Delivery counterfactuals (no scheduled opener):

  • Cooperative, single call: delivery at the upsert's sequence 2. Plain and reused histories, both orders.
  • Cooperative, parallel spans: span 1..3 (upsert interior), 1..2 (upsert at the end) and 2..3 (upsert at the base). Plain and reused, both orders; the memo at 1 is removed so the upsert is the only command inside the span.
  • Control: a span 1..1 that stops before the upsert does not collide.
  • Scope: a committed CancellationScopeDelivered from committed-scope-delivery.json, whose timer was never scheduled, with an upsert at its sequence. Replayed with scope authoring and delivery enabled, so it reaches the classifier rather than the scope gate. Both orders.
  • Fail-before: all 34 delivery and table cases fail on c038abc2. All pass here.

Mutations for every claimed kind:

  • With the ordinary opener absent, the claimed row is put at the upsert's sequence 2, both before and after the upsert, on the plain and the reused history. Each case refuses with durable_command_sequence_collision.
  • The scope opening uses canonical scope history (ids, namespace, schema) with scope authoring allowed, so replay reaches the classification rather than the scope gate.
  • On c569f636 these cases fail: 36 for the nine newly claimed kinds, plus the table pin, the new-claims check and the scope test (39 in total). All pass here.

Collisions without an upsert, for example a condition wait and its own timeout timer, are not judged by this rule (same scope as sdk-php#246). No existing fixture or history in the suite is refused, including retained-duplicate-patch-markers and the condition-timer sequence fixtures.

The test harness (testing.py) now gives each upsert its sequence.

Fixtures: durable-command-sequence-missing, durable-command-sequence-invalid, duplicate-search-attribute-update and durable-command-sequence-collision.

pytest (plain and reused histories):

  • missing or null sequence;
  • nine invalid sequence values;
  • a duplicate upsert;
  • eight collisions: an upsert against an activity, a timer, a side effect, a version marker, a condition wait, a signal wait and a memo, and the upsert moved onto the memo's step;
  • a kind-scope test.

Fail-before: all 44 new cases fail on 36c1e0ff, where the upsert was accepted or bound to a guessed step. 22 fail on 6f0b57f4, which used the earlier reason name. All pass here.

Changes at this head

  1. Missing attributes.
    • The event parser now requires attributes on every SearchAttributesUpserted and records its identity, whatever the event's sequence.
    • A missing value raises NonDeterministicReplayError with search_attribute_update_missing, the reason sdk-rust and sdk-php use. A present but invalid or different value raises search_attribute_value_mismatch. A step without identity is refused.
    • The test harness (testing.py) now records the attributes. Two unit histories that omitted them now include them.
  2. Malformed type values are refused with the typed error, not TypeError. The check is isinstance(kind, str) before set membership.
  3. Type check: the per-value type sets above. A blank string is now always typed, as both Servers do.
  4. Successor validator (check_redrive_successor(history, workflow), in tests/test_replay_clock.py). It requires:
    • Envelope: every WorkflowStarted envelope key: workflow_type, instance, run, command and link ids, continued_from_run_id, replayed_started_at, recovery_kind: redrive, resume_step_sequence, and execution_deadline_at and run_deadline_at (null or a zoned time).
    • Binding: type, instance and run equal to the fixture's.
    • Event identity: the identity Core writes on each event kind, present and non-blank. Unknown event kinds are refused.
      • SignalWaitOpened: sequence, name, wait id.
      • SignalReceived: command, signal, instance, run, name and wait ids, and no step sequence.
      • SignalApplied: command, signal, name and wait ids, and sequence.
      • CooperativeCancellationRequested: command, instance and run.
      • CooperativeCancellationDelivered: command, run and sequence.
      • Upserts: sequence.
    • Consistency: every event's instance equals the successor's. Reused events are on the source run and successor events on the successor run. Signal events reference an opened wait and agree on name, command and signal ids. One cancellation command.
    • Reuse keys: both, non-blank, on every reused event, with reused_from_run_id equal to continued_from_run_id.
    • Order and time: the reused prefix directly after the start and at its time; successor events at or after it.
    • Steps: the reused steps (excluding SignalReceived) are whole steps forming the contiguous prefix 1..resume_step_sequence−1, in order. A whole step is one upsert, or SignalWaitOpened + SignalApplied.

Tuples (owner decision): an authored tuple of strings is checked as the JSON list it is sent as. Its elements must still be strings.

Evidence (local, Python 3.13)

  • Missing-reason fixtures: search-attribute-update-missing (plain) and redrive-reused-search-attribute-update-missing (reused) require search_attribute_update_missing.

    • They fail on origin/main, where the update is accepted, and on 910f7e97, where the reason is search_attribute_value_mismatch. They pass here.
    • A present but different or invalid value still reports search_attribute_value_mismatch.
  • Validator counterfactuals. Each case below is accepted by the 910f7e97 validator in the stated number of cases, and refused by this one in every case:

    Case Count Accepted at 910f7e97
    Envelope key deletions (11 keys × 6 redrive fixtures) 66 12 (the deadline keys)
    Invalid deadline values ("", "not-a-time", naive time, 5, true, []) 12 12
    Event identity field deletion or blanking, over every event in every redrive fixture 60 45
    Step layout: missing step, prefix not from 1, duplicate step, duplicate event, descending, two injected SignalReceived sequences, split signal wait 8 7
    Run, instance and id binding: another run or instance on reused and successor events, missing reuse keys, mismatched cancellation command, unopened wait, mismatched signal id 10 3

    Also refused: event sequence or event_type deletion on every event (history order or unknown kind), and the earlier resume −1/+1/null/string and identity cases.

  • Fail-before from the previous round: missing attributes was accepted on 13c76c31. Unhashable type values raised TypeError there. 109 new tests fail on origin/main's workflow.py.

  • Clock: none of the five events is a replay clock source. Mutations taking now() from the event time (19 fail) or from reused_recorded_at (7 fail) are caught. Upserts or SignalReceived feeding the cancellation clock fail the ordinary-run cleanup control.

  • Checks:

    • ruff check src/ tests/, mypy src/durable_workflow/ and the Avro benchmark gate pass.
    • The corpus validator against origin/main passes (12 new replay fixtures), and so does its self-test.
  • Full unit suite: 4,546 passed, 3 skipped, 8 failed. The same 8 fail on unmodified main: TLS fixture certificates rejected by the host's OpenSSL, and two boundary-scanner tests tied to the host git identity.

🤖 Generated with Claude Code

Workflow 2.19 redrive reuses MemoUpserted and SearchAttributesUpserted
(workflow#1001) and an applied signal wait: SignalWaitOpened,
SignalReceived and SignalApplied (workflow#1002). Core replays now()
after them at payload.reused_recorded_at.

This SDK takes no replay time from any of these events. ctx.now() is
the persisted start time, which on a redriven run is
WorkflowStarted.replayed_started_at (#232). The cancellation budget is
fed by activity, timer, child, condition, selection and cancellation
events only. No source change is needed.

Add two regression fixtures shaped like Core's redrive successor
histories, with Python counterparts of the Core test workflows. Tests
assert per-event reuse provenance, now() after each reused event, that
changing or removing each event's recorded_at or reused_recorded_at
does not move the clock, unchanged signal and memo values, one signal
application, and no repeated upserts. Document the reuse keys in the
history-event contract.

Fixes #235

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

Copy link
Copy Markdown
Member Author

Security review hold on sdk-python #236 exact head b1019e6.

Verified clean:

  • the five reused MemoUpserted, SearchAttributesUpserted, SignalWaitOpened, SignalReceived and SignalApplied events are byte-for-byte equal to Workflow main a2daf6a2 fixture events;
  • every event is individually provenance-bound and the recorded_at/reused_recorded_at mutations demonstrate that ctx.now is start-fixed;
  • the signal is applied once with the recorded value, memo identity is enforced, and neither upsert is re-emitted;
  • no production source changes are hidden in this evidence PR.

Two holds remain before this PR can say it qualifies all five events, close #235, or count toward lifting Workflow #1006:

  1. Exact search-attribute identity is not enforced. workflow.py records SearchAttributesUpserted as a resolved step with no attributes detail, and _recorded_detail_mismatch has no UpsertSearchAttributes case. Changing the current workflow from Stage=greeting to another value would still consume the recorded step. The Workflow fence-lift contract requires exact signal/upsert identity, not only a test that the fixture contains its own expected value. Add a fail-before counterfactual that changes the workflow-authored search attributes, then enforce a deterministic mismatch against the recorded attributes. Keep the existing memo mismatch control.

  2. The issue requires a test when an event is not consumed for replay time. The upsert cancellation-clock test does this for both upserts, but the three signal events are tested only through ctx.now. The PR explicitly states that signal-during-cleanup is uncovered. Add a cooperative-cancellation replay control that retimes each of SignalWaitOpened, SignalReceived and SignalApplied independently and proves remaining time is unchanged, or implement reused time for any signal event that actually advances that clock. The test must distinguish reused_recorded_at from the successor recorded_at.

These are source/evidence holds, so a correcting commit will produce a new exact head. Hosted CI can continue, but do not mark ready or close #235 at b1019e6.

Security held #236 on two gaps.

Search-attribute identity: replay recorded SearchAttributesUpserted as
a step without its values, so a workflow that changed its upserted
attributes replayed against the old history unnoticed. Record the
attributes as replay identity and compare them with the current
UpsertSearchAttributes command, as memo replay does. A mismatch raises
NonDeterministicReplayError with a search_attribute_value_mismatch
detail, the reason the Server and the Rust SDK use. Both sides are
normalized as the Server stores them (trimmed strings, empty string as
null). A recorded attributes value that is not an object is refused.
The polyglot test workflow upserted a value its PHP fixture never
recorded and now matches it.

Cancellation clock: add a valid redrive successor in which a
cooperative cancellation is requested and delivered after the reused
prefix, and show that retiming each reused upsert or signal event
(recorded_at and reused_recorded_at) changes neither remaining() nor
the commands. The ordinary-run cleanup control now also receives a
signal while the clock is live. The earlier variant that placed reused
events after a delivery is removed, since redrive cannot write that.

Fixtures fail on origin/main and pass here:
search-attribute-value-mismatch and
redrive-reused-search-attribute-value-mismatch.

Refs #235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel rmcdaniel changed the title Prove redriven upserts and signal waits are not replay clock sources Enforce search-attribute replay identity and qualify redriven upserts and signal waits Nov 3, 2026
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security re-review of exact head 6a124a90e2a6ee1018911d7d34a8ee8c98378575: the earlier value-comparison hold is materially addressed, and changing the stale polyglot test workflow from processing to the fixture-recorded done is legitimate. Three qualification holds remain.

  1. Use valid redrive successors, with an executable validator. These fixtures retain the same invalid Core metadata shape just corrected in sdk-rust#173: redrive-reused-upserts-clock says resume 4 although the highest reused workflow step present is 2; the signal fixture says 3 although the highest reused step is 1; the new upsert cancellation and mismatch fixtures repeat the mismatch. They also omit the successor link/command identity used in the corrected Rust evidence. Add a shared assertion that requires: redrive WorkflowStarted; nonempty successor identity/link fields; every reused_from_run_id equal to continued_from_run_id; the reused prefix before successor events; and resume_step_sequence == highest reused workflow step + 1 (3 for upserts, 2 for signal). Apply it to every redrive fixture in this PR and add wrong/null resume counterfactuals.

  2. The new replay identity must fail closed on the exact portable value domain. _search_attribute_replay_identity() currently stringifies keys and uses json.dumps(..., default=repr). That makes unsupported authored objects and non-string keys comparable instead of refusing them, and can collapse distinct inputs (1 versus "1", objects with the same repr). _canonical_search_attribute_value() also accepts tuples, nested maps and non-string list elements that Core rejects. Match UpsertSearchAttributesCall exactly: nonempty map; portable key regex; scalar, string-list or null only; scalar empty-string-to-null; trim list strings; reject objects/maps/tuples/non-string list entries and non-finite floats. Malformed recorded or authored values must become the typed replay refusal, not raw ValueError/JSON errors. Add counterfactuals for the fail-open shapes; remove default=repr and key coercion.

  3. Recorded type identity is currently ignored. Workflow protocol 1.20 defines SearchAttributesUpserted replay identity as sequence + attributes + attribute_types; Core compares types when the field is present and keeps value-only compatibility only for legacy history where it is absent. This branch accepts the same value if recorded attribute_types changes, e.g. keyword to string. Either provide and compare the current canonical/declared type identity with a same-value type-mismatch counterfactual, or explicitly keep Python search-attribute reuse unqualified and do not use Enforce search-attribute replay identity and qualify redriven memo and search-attribute reuse #236 to lift that part of the Workflow service fence.

The valid-history cancellation conclusion is sound: reused prefix events precede delivery and therefore cannot move an active budget. The plain post-delivery signal/upsert mutation test is the relevant leakage counterfactual. Python has no signal-wait primitive, so this PR also must not be treated as qualification to lift the signal-wait fence for Python; record that capability as unsupported/granular rather than global.

Hosted CI is still running, but these are source/evidence holds independent of CI.

rmcdaniel and others added 2 commits November 3, 2026 14:48
Security's second review of #236 held three points.

Redrive successors: the fixtures carried Core's metadata resume steps
(4 and 3) although the highest reused workflow steps are 2 and 1, and
lacked the successor identity. WorkflowStarted now carries the full
redrive envelope Core writes (instance, run, command and link ids,
continued_from_run_id, replayed_started_at, resume_step_sequence 3 for
upserts and 2 for the signal wait). A shared check_redrive_successor
test helper requires that envelope, every reused_from_run_id equal to
continued_from_run_id, the reused prefix directly after WorkflowStarted
at its time, later successor events, and resume_step_sequence equal to
the highest reused step plus one (SignalReceived has no step). It runs
on every reused fixture, with wrong, off-by-one and null counterfactuals.

Fail-closed values: replace the str(key)/json default=repr identity
with Core's UpsertSearchAttributesCall domain. Keys must be portable
strings; values must be null, bool, 64-bit int, finite float, string
or a list of strings. Strings and list items are trimmed and a blank
string is null. Values compare with their types. Anything else, on
either side, raises the typed NonDeterministicReplayError.

Types: when SearchAttributesUpserted records attribute_types, the keys
must match the typed values and each type must be one the Server can
record for the value of an untyped upsert. The SDK cannot see the
namespace registry, so keyword, string and RFC 3339 datetime stay
interchangeable for a string, as do int and float for an integer.
History without attribute_types keeps value-only identity, as in Core.

Refs #235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision on #236: a tuple of strings is sent as exactly the JSON
list the Server records, so refusing it would break replay of deployed
workflows on upgrade without closing any ambiguity. Normalize a tuple
to a list on the authored side only, then apply the same strict rules:
string elements and type-strict comparison. Sets, bytes, objects,
non-string keys and non-finite numbers are still refused.

Refs #235

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

Copy link
Copy Markdown
Member Author

Security re-review hold on exact head 13c76c31164ec8df6dcff61a93efedd48279bd0b. The portable value-domain work closes the prior coercion/repr hold, and the fixtures now have the right 3/2 resume values and successor ordering. Four exact gaps remain:

  1. Missing recorded values still fail open. workflow.py:4726 records search-attribute identity only when attributes exists; :4415 compares only when the derived detail exists; and :5589-5601 validates only inside if "attributes" in payload. Removing attributes from a SearchAttributesUpserted event therefore skips identity rather than producing search_attribute_value_mismatch. Require the field and add plain + reused counterfactuals that remove it.

  2. The type comparison is broader than the Server contract, not partial identity. Python's UpsertSearchAttributes.to_server_command() sends no attribute_types. Workflow 2.19's DefaultWorkflowTaskBridge::applyUpsertSearchAttributes() therefore calls canonicalTypes($call, []), which uses WorkflowSearchAttribute::inferType(); it does not look up a namespace-registered type. Exact inferred identity is: bool→bool, int→int, float→float, list→keyword_list, short/no-whitespace string→keyword, and long/whitespace string→string; null/normalized blank has no type. A Python string cannot become datetime without a declared command type. Current lines 1457-1495 incorrectly accept, for example, short "greeting" as string or datetime, integer 3 as float, and a normalized blank with an optional recorded type. Those are histories Core would not write for this SDK command. Compute the one exact inferred type and compare the exact key map. Add counterfactuals for each case and correct the CHANGELOG/fence claim.

  3. Malformed nested type values can escape the typed replay refusal. kind not in _SEARCH_ATTRIBUTE_TYPES raises TypeError for an unhashable recorded value such as {"Stage": []} or {}. Check isinstance(kind, str) first and prove every malformed map value becomes NonDeterministicReplayError with search_attribute_type_mismatch.

  4. The successor validator does not yet bind all identity it claims. Add nonblank workflow_type; pass the fixture metadata into the validator and require start workflow_instance_id / workflow_run_id to equal the fixture's workflow_id / run_id; and require every reused event to carry both reuse keys (with missing-key controls), rather than treating either key as sufficient. The current fixture bytes happen to match, but the completeness guard would accept a future unbound fixture.

The signal-wait fence must remain, as stated. With exact inferred types, Python search-attribute type identity can be qualified for commands this SDK can actually emit; do not preserve an artificial “namespace type” ambiguity that the Server path does not have. Re-request exact-head review with fail-before evidence for these cases and terminal hosted CI.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Correction to item 2 of Security comment 6615192720 after exact native 3.0.4 readback: do not replace the partial type check with Workflow-only exact inference. The type fence remains.

The implementations differ:

  • Workflow 2.19 standalone (DefaultWorkflowTaskBridge → canonicalTypes($call, [])) infers the normalized value's type when the SDK sends no attribute_types.
  • Native Server 3.0.4 (search_attribute_upserts.rs:194-292, 819-831) loads search_attribute_definitions; the effective type is registered type, then declared type, then inference. It records that effective map. Thus a short string may legitimately be keyword, string, or a registered datetime, and an integer may be a registered float. The SDK cannot reconstruct the namespace registry. Native's parity tests state that published PHP fills registered types too.

So the broad category choices are directionally correct for cross-server replay, and Python search-attribute type identity is not qualified. My Workflow-only examples ("greeting"/string, 3/float, blank with a recorded registered type) are withdrawn as holds. Please keep the explicit per-runtime/type fence and correct the PR wording to cite native/registered-type ambiguity, not Workflow 2.19 itself.

One compatibility edge remains inside this partial approach: native accepts registered datetime strings in a broader deterministic set than datetime.fromisoformat(...).tzinfo is not None (including no-offset ISO treated as UTC, date-only and RFC 2822). Do not falsely reject a valid native history. Either mirror the native accepted set exactly or conservatively treat a string as potentially registered datetime while leaving the type fence closed.

Items 1, 3 and 4 of 6615192720 still stand unchanged: missing attributes skips identity; unhashable nested type values escape the typed refusal; and the fixture validator must bind its claimed identity and require both reuse keys.

Security's review of 13c76c3 and its corrected ruling:

- A SearchAttributesUpserted event without attributes is refused with
  search_attribute_value_mismatch instead of skipping identity. The
  identity is now recorded by the event parser for every upsert, and a
  step without it is refused. The test harness records the attributes.
- A recorded attribute_types value that is not a string (a list, map,
  number, bool or null) is refused with search_attribute_type_mismatch
  rather than raising TypeError on an unhashable value.
- Type check: both Servers resolve each key as registered, then declared,
  then inferred (native search_attribute_upserts.rs:254-289; PHP
  SearchAttributeValueValidator.php:93 via WorkerController.php:2734),
  and the SDK cannot see the registry. Keep the type fence for Python and
  refuse only a type that cannot hold the value: bool as bool, an integer
  as int or float, a float as float, a list as keyword_list, and any
  string as keyword, string or datetime. Every non-null key, including a
  blank string, is typed, as both Servers do.
- check_redrive_successor now takes the fixture's workflow and binds the
  start's workflow_type, instance and run to it, requires every event's
  instance to match, successor events to carry the successor run and
  reused events the source run, and both reuse keys on every reused
  event, with counterfactuals for each.

Refs #235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel rmcdaniel changed the title Enforce search-attribute replay identity and qualify redriven upserts and signal waits Enforce search-attribute replay identity and qualify redriven memo and search-attribute reuse Nov 3, 2026
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security exact-head review of 910f7e9: hold.

The search-attribute replay comparison is substantially corrected and hosted CI is fully green, but four exact issues remain:

  1. The Unreleased Added entry says replay of SignalWaitOpened, SignalReceived and SignalApplied is qualified. The PR body and the service fence correctly say Python has only the signal-handler path and signal waits are not qualified. Narrow the public claim to memo reuse plus search-attribute value identity, state that type identity remains fenced, and do not claim signal-wait qualification.

  2. Converge the missing-history refusal with Rust and PHP: an absent attributes field is search_attribute_update_missing. Keep search_attribute_value_mismatch for present-but-different or malformed values. Add the corpus fixture/counterfactual under that exact reason.

  3. check_redrive_successor still accepts impossible reused workflow-step layouts because it keeps only max(sequence). Require the reused workflow steps, excluding SignalReceived, to be the ordered contiguous prefix 1..resume-1. Add missing, duplicate, descending and injected SignalReceived sequence controls.

  4. The claimed full envelope binding is still fail-open. Require execution_deadline_at and run_deadline_at keys to be present and either null or valid deadline values. For event kinds where Core actually writes identity, require the fields rather than defaulting absence to the expected value: at minimum SignalReceived workflow_instance_id/workflow_run_id, CooperativeCancellationRequested workflow_instance_id/workflow_run_id, and CooperativeCancellationDelivered workflow_run_id. Add deletion/blank controls as applicable. Event kinds whose published Core shape omits those fields may keep the schema-specific default.

Positive exact evidence: values remain type-strict; malformed type values become typed replay refusals; registered-type uncertainty is handled conservatively; sequence-less SearchAttributesUpserted is no longer skipped; and 21/21 hosted checks are green. Return the corrected exact head with the counterfactual counts.

rmcdaniel and others added 2 commits November 3, 2026 15:20
…issing

Use the reason sdk-rust and sdk-php report for a recorded
SearchAttributesUpserted without attributes. Present but different or
invalid values keep search_attribute_value_mismatch.

Refs #235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security's review of 910f7e9:

- CHANGELOG: qualify memo reuse and search-attribute value identity
  only. Type identity stays fenced, and signal waits are not qualified
  for Python, which has no signal-wait primitive.
- Add search-attribute-update-missing and its redrive counterpart, which
  require the search_attribute_update_missing reason.
- check_redrive_successor now requires every WorkflowStarted envelope
  key, including execution_deadline_at and run_deadline_at as null or a
  zoned time; the identity Core writes on each event kind (signal ids and
  wait ids, the signal and cancellation instance and run, step
  sequences), with unknown event kinds refused; signal events that agree
  on their wait, name, command and signal ids; one cancellation command;
  and reused steps that are whole steps forming the contiguous prefix
  1..resume-1 in order, with SignalReceived carrying no step.
- Counterfactuals delete or blank every envelope field and every event
  identity field, and cover missing, offset, duplicate, descending and
  split steps and an injected SignalReceived sequence.

Refs #235

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

Copy link
Copy Markdown
Member Author

Security exact-head review of 36c1e0ff0572aef05105615cd1039568d28ff83b.

The prior holds are closed in the evidence: the public claim is narrowed correctly, missing attributes have the converged reason, reused steps are contiguous whole steps, the full start envelope/deadlines are required, and the event-specific deletion/layout controls now fail closed.

One product-source hold remains before merge:

  1. Do not synthesize a durable sequence for SearchAttributesUpserted. In _replay_state, upsert_sequence = _workflow_sequence(payload) or len(recorded_steps) + 1 still accepts a missing/null/non-positive sequence and silently binds the event to the next authored step. Memo replay already refuses a missing sequence. Refuse the malformed search-attribute event with a typed nondeterminism reason and add ordinary + reused counterfactuals that are accepted at this head.
  2. Classify each workflow sequence before recording the step. Exactly one SearchAttributesUpserted may occupy a sequence, and it may not share that sequence with another durable command kind. Refuse duplicates as duplicate_search_attribute_update and cross-kind collisions as durable_command_sequence_collision, matching the Rust replay contract and the hold already applied to sdk-php#246. Cover ordinary and reused histories; show no last-write-wins through details_by_sequence.

Return the exact source head with terminal hosted CI. Search-attribute type identity and Python signal waits remain explicitly fenced as the corrected body says.

rmcdaniel and others added 2 commits November 3, 2026 15:36
Security's review of 36c1e0f held product replay on two points.

- A SearchAttributesUpserted without a positive workflow sequence was
  bound to the next authored step. It now refuses replay with
  search_attribute_update_missing_sequence; no sequence is synthesized.
- Each workflow sequence is classified as its events are read, before
  any details are recorded. A second SearchAttributesUpserted at a
  sequence is duplicate_search_attribute_update, and two different
  durable command kinds at one sequence are
  durable_command_sequence_collision, so details_by_sequence is never
  overwritten by a later record. The only shared sequences allowed are a
  condition or signal wait with its own timeout timer. Events that refer
  to another step's sequence (signals, updates, cancellation and scope
  records) are not command records.
- The test harness gives each search-attribute upsert its sequence.

Fixtures: search-attribute-update-missing-sequence,
duplicate-search-attribute-update and durable-command-sequence-collision.

Refs #235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Align with sdk-rust and sdk-php#246:

- A SearchAttributesUpserted without a sequence, or with a null one, is
  durable_command_sequence_missing. Zero, negative, string, float or
  bool is durable_command_sequence_invalid. The earlier
  search_attribute_update_missing_sequence name is gone.
- durable_command_sequence_collision is refused when it involves an
  upsert (MemoUpserted or SearchAttributesUpserted): an upsert must be
  the only command at its sequence. Lifecycle events of one command
  (activity, timer, condition wait, signal wait, child run, service
  call) share their sequence, and events that only point at a step,
  such as SignalReceived or a cancellation delivery, claim nothing.

Fixtures durable-command-sequence-missing (renamed) and
durable-command-sequence-invalid.

Refs #235

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

Copy link
Copy Markdown
Member Author

Security correction to my preliminary source read: exact head c569f636492216ebfdf6b7e574deb5d68516f7d7 is still held, independent of the running hosted integration job.

The explicit _DURABLE_COMMAND_KINDS table is materially safer than deriving claims from the later parser, but it is not yet complete. It omits ActivityRetryScheduled and ActivityHeartbeatRecorded, even though both carry the activity workflow sequence and the replay code/history contract treats them as activity lifecycle history. It also labels all scope records pointer-only, while CancellationScopeOpened is an authored durable step (_replay_state appends it as the resolved cancellation scope result). A malformed history can therefore delete the ordinary opener/schedule, leave one of those records at a search-attribute upsert's sequence, and bypass durable_command_sequence_collision. PHP's current shape logic already treats every Activity* event and CancellationScopeOpened as owning their command.

Please add these event kinds to the explicit ownership table (activity and cancellation_scope, respectively) and add focused ordinary and reused mutations in both event orders, or provide a protocol-level reason why a specific event is truly pointer-only and a counterfactual proving the later parser cannot silently accept it. SelectionResolved, update records, signal receipts, cancellation deliveries, and scope delivery/request records can remain pointer-only; this hold is about the scope opening and activity lifecycle facts.

The typed sequence reasons, duplicate-upsert check, write-before-details ordering, and the rest of the ownership table remain source-clean.

Security's review of c569f63: the claim table omitted rows that carry a
command's sequence, so with the ordinary opener deleted one of them
could sit at an upsert's sequence and bypass
durable_command_sequence_collision.

Claim Core's WorkflowStepHistory step events and every other lifecycle
row that carries its command's sequence: ActivityHeartbeatRecorded,
ActivityRetryScheduled and ActivityCancellationAcknowledged (activity),
ChildCancellationRequested and ChildCancellationResolved (child),
ServiceOperationScheduled (service call), SignalWaitReceived (signal
wait), CancellationScopeOpened (cancellation scope) and
WorkflowContinuedAsNew (continue as new). Pointer records stay
unclaimed: signal receipts, update records, failure handling, selection
records and cooperative or scope cancellation requests, preparations
and deliveries.

Tests fix the claim table exactly and, for every claimed kind with its
opener absent, put it at an upsert's sequence before and after the
upsert on ordinary and reused histories. A scope opening uses canonical
scope history so replay reaches the classification.

Refs #235

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

Copy link
Copy Markdown
Member Author

Security re-review of exact head c038abc: the prior activity/child/service/signal/scope/continue-as-new claim-table omissions are closed, but one durable-position fail-open remains.

CooperativeCancellationDelivered and CancellationScopeDelivered are classified as pointer-only and absent from _DURABLE_COMMAND_KINDS. WorkflowStepHistory explicitly treats both delivery markers as authoritative durable-command positions: a delivered cancellation consumes the interrupted call even when that call was never scheduled, and sequence_span reserves the full interrupted range. Therefore, with the ordinary opener absent, an upsert can currently share the delivery sequence—or a sequence inside a parallel delivery span—without durable_command_sequence_collision, and its details can occupy a position the cancellation already consumed.

Please claim both delivery types for upsert-involved collision detection, including every sequence in their validated sequence_span. Add ordinary and reused counterfactuals with no scheduled opener, both history orders, for: a single-step CooperativeCancellationDelivered; a parallel delivery whose upsert is inside rather than only at the base; and CancellationScopeDelivered under a valid scope envelope. The request/preparation rows may remain pointer-only. If a scope-delivery counterfactual cannot reach this classifier because the scope gate refuses first, pin the range-claim helper directly and include a product history that reaches it with delivery enabled.

This is a merge hold on c038abc. The search-attribute value/type and previously held sequence checks otherwise remain source-clean; terminal hosted CI will still be required at the corrected exact head.

Security's review of c038abc: CooperativeCancellationDelivered and
CancellationScopeDelivered are durable command positions in Core's
WorkflowStepHistory (nextDurableCommandSequence adds them to the step
types, and nextSequenceFromEvents extends each by sequence_span - 1).
A delivery consumes the interrupted call even when it was never
scheduled, so an upsert at its sequence, or inside its span, must not
replay.

Claim both as cancellation_delivery. _durable_command_claimed_range
mirrors Core: an integer or numeric-string span, 1 when absent, claims
sequence through sequence + span - 1 inclusive, and a span below 2
claims only the sequence. An upsert inside any delivery range, recorded
before or after it, is durable_command_sequence_collision.

Tests, with no scheduled opener, on ordinary and reused histories and
in both orders: a single-call cooperative delivery at the upsert's
sequence; parallel spans with the upsert interior, at the end and at
the base; a span that stops short; a committed scope delivery replayed
with scope delivery enabled; and the range helper pinned against Core.

Refs #235

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

Copy link
Copy Markdown
Member Author

Security source re-review of exact e44cf64e51ff86a5edc595e91c40726dc0130df5.

The delivery-span collision coverage closes the position gap, including ordinary/reused histories, both orders, interiors, and scope delivery. One cross-SDK rule must be aligned before clearance:

  • absent sequence_span means 1;
  • when present, it must be a positive JSON integer (and not bool);
  • zero, negative, fractional/numeric-string, or otherwise malformed values refuse with durable_command_sequence_invalid.

WorkflowStepHistory::intValue() is a permissive cursor-recovery parser; it is not the producer payload contract. The 2.19 producer and CancellationScopeDelivery validation write/require integers, and silently treating a malformed span as 1 can under-claim an authoritative delivery range. Follow the fail-closed producer contract, matching sdk-php#246.

Add direct controls for missing=>1 and each malformed class, plus an interior-collision counterfactual showing malformed input cannot shrink the range. Return the exact head with terminal hosted CI. The claim map, delivery-range checks, and the rest of this source head are clean.

Security's ruling on e44cf64, matching sdk-php#246: Core's
WorkflowStepHistory::intValue is a permissive cursor-recovery parser,
not the producer contract. A delivery's sequence_span is 1 when absent;
when present it must be a positive JSON integer. Zero, negative, bool,
float, numeric string, null and any other value refuse replay with
durable_command_sequence_invalid, before the cooperative or scope
history parsers read the event, so a malformed span can never shrink
the claimed range.

_delivery_sequence_span holds the contract; _durable_command_claimed_range
uses it. Tests cover absent, 1 and 3 claiming their inclusive range,
eleven malformed values on the helper and on cooperative (ordinary and
reused, both orders, upsert interior to the intended span) and scope
delivery replays.

Refs #235

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

Copy link
Copy Markdown
Member Author

Security source re-review of exact 2a8f1c57cd016509081df8715f57d37c02cb1b96.

The canonical span rule and all requested counterfactuals are implemented correctly: absence means 1, present values must be positive JSON integers, malformed inputs refuse before parser-specific handling, and neither ordinary nor reused delivery can shrink an interior collision.

One narrow data-minimization hold remains. _delivery_sequence_span() places {span!r} in the exception detail. A malformed history can put an arbitrary string/list/map in sequence_span, so this can echo payload data into replay errors and logs. Refuse without including the raw value—report only a fixed message and, if useful, the non-sensitive Python type name. Add controls with a secret-shaped string and nested map asserting the diagnostic contains neither value.

Return the exact head with terminal hosted CI. No other source hold remains.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security correction to the span rule while the raw-value hold is being fixed:

  • missing sequence_span or explicit JSON null means span 1;
  • any non-null present value must be a positive JSON integer;
  • zero, negative, bool, float, numeric/non-numeric string, list or map refuses with the fixed durable_command_sequence_invalid reason.

This matches Core's intValue(payload['sequence_span'] ?? null) ?? 1 and the already-cleared PHP implementation's null-coalescing default. My earlier wording that only absence defaults and null must refuse was too strict and would reject a history Core accepts. Update the null counterfactual to prove it claims only the delivery's own sequence.

The current exact head remains held for the separate raw-value disclosure: no rejection message may interpolate span!r or any authored/recorded search value. Keep the fixed/type-only error and secret-shaped string, nested-map and search-value non-disclosure controls from 6616894909, then return the new exact head and terminal checks.

Security's data-minimization hold on 2a8f1c5: _delivery_sequence_span
put the malformed span's repr into the replay error. It now names only
the JSON type ("got string", "got object"). An invalid search-attribute
key is reported by type too, never by its text. The other diagnostics
added on this branch already name only reasons, keys that passed
validation, known type names, sequences and event types.

Null span: Core reads sequence_span with `?? null) ?? 1`
(WorkflowStepHistory.php:270 at workflow 28893f97), and sdk-php#246
uses `?? 1`, so an explicit null means one call, like an absent span.
The cooperative delivery parser and the scope cleanup bound read it the
same way. Numeric strings and every other non-positive-integer value
are still refused with durable_command_sequence_invalid.

Controls: secret-shaped string and nested-map spans on the helper and on
cooperative and scope replays, and secret-shaped search-attribute
values, keys and types, assert the raw values appear in neither the
message, the args nor the repr.

Refs #235

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

Copy link
Copy Markdown
Member Author

Security source re-review of 4116362b27b13638cd0d377d5ff51e3840987549:

The disclosure hold is fixed in the reviewed source. Span and invalid-key diagnostics now name only a bounded type, and the new controls cover exception string, repr and args for secret-shaped string/object spans, search values/keys/types/maps, and sequences. The remaining new messages expose only validated keys, known event/type names and numeric sequences. The cooperative parser and replay claim path also now apply the corrected null-as-one rule.

One narrow scope-path hold remains before merge:

  1. In _cancellation_scope_history._cleanup_operation, replace recorded["sequence_span"] or 1 with an exact null-only normalization (or one shared validated helper). or 1 also normalizes 0, false, "" and other falsy malformed values. Earlier replay checks currently refuse them, but this parser must not carry a broader local contract or become a fail-open when called/reordered independently.
  2. Add an end-to-end committed cancellation-scope replay with CancellationScopeDelivered.payload.sequence_span = null. Prove it has the same replay result/commands and cleanup lower bound as span 1, and that the relevant later cleanup operation reaches the canonical scope path. Keep a nearby zero/false malformed control refused as durable_command_sequence_invalid. The current helper/cooperative coverage does not exercise the source line changed in _cancellation_scope_history.py.

No further non-disclosure or search-identity finding is open. Hosted CI is still pending in any case. Return the new exact head with the focused counterfactual and terminal checks.

Security's hold on 4116362: `recorded["sequence_span"] or 1` in
_cancellation_scope_history._cleanup_operation also read 0, false and
"" as one call. Only an absent or null span now reads as 1; any other
value that is not a positive integer refuses the cleanup operation, so
the parser keeps no broader contract than replay even when called on
its own.

Tests: an end-to-end committed scope replay through the cleanup timer
path gives identical results with span null and span 1, the cleanup
lower bound refuses and accepts the same sequences for both, and
0, false, -1, "1", 1.0 and "" are refused by replay with
durable_command_sequence_invalid and by the cleanup bound called
directly.

Refs #235

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

sonarqubecloud Bot commented Nov 3, 2026

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security source re-review at exact head 909f353: SOURCE-CLEAN, pending the final hosted integration result. I independently compared it with the previously reviewed 4116362 head. The delta is limited to _cancellation_scope_history.py and the committed-scope replay tests. The parser now treats missing/null sequence_span as one, rejects every other non-positive JSON integer shape without narrowing, and applies that same validated span to the cleanup lower bound. The new end-to-end test exercises the cleanup wait, parent timer, and completion with null versus one; the lower-bound and direct-parser counterfactuals cover 0, false, negative, string, float, and blank forms. diff --check is clean. Current exact-head checks are otherwise green; integration was still running at readback. Return the terminal exact-head result for final merge clearance.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security final clearance at exact 909f353: ACCEPTED FOR MERGE. This is the source-clean head from 6617362692, now terminal with 21/21 hosted checks successful, including integration, 0 skipped or failed, and CLEAN. Scope remains memo and search-value replay qualification plus durable sequence identity. Search-attribute type identity and signal-wait replay remain fenced under #235; merging this PR does not qualify either.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 3, 2026 22:50
@rmcdaniel
rmcdaniel merged commit 7dbeca4 into main Nov 3, 2026
21 checks passed
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged at Security-cleared exact head 909f353 (6617490380). Merge commit 7dbeca4; parents main ab77898 + 909f353; merge tree 754119d4a00b equals head tree. #235 auto-closed through a sidebar link and has been reopened deliberately (type and signal fences).

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replay redriven memo, search-attribute and signal-wait events at their reused time

1 participant