From 41bd6a38ddf8eca3d887faf2238f478ad636d947 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Sat, 26 Sep 2026 00:41:30 -0700 Subject: [PATCH 1/5] Don't suppress re-surfaced offers in the delivery scheduler foldedOffers dropped an offer whose digest had ever been popped, but a re-surfaced identical digest is live again on both engines (Swift re-staples the same Upd bytes idempotently; Rust re-sets offered_proposal per frame). Suppressing it manufactured a no-offer the engines never see, stalling the receiver's send epoch. Staleness is already handled by the fold liveness gates. Co-Authored-By: Claude Code --- .../Differential/DeliveryScheduler.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Tests/TwoMLSPQMigrateTests/Differential/DeliveryScheduler.swift b/Tests/TwoMLSPQMigrateTests/Differential/DeliveryScheduler.swift index a1c9a75..b7e77b2 100644 --- a/Tests/TwoMLSPQMigrateTests/Differential/DeliveryScheduler.swift +++ b/Tests/TwoMLSPQMigrateTests/Differential/DeliveryScheduler.swift @@ -52,9 +52,6 @@ struct DeliveryScheduler { private(set) var sideLanes: [Role: [SideBandLeg]] = [:] /// Offers surfaced to `role` and not yet folded. private(set) var outstandingOffers: [Role: [OfferRecord]] = [:] - /// Digests already folded by `role` — an Update is idempotent, so a host never folds - /// the same proposal twice even if a reordered/duplicated frame re-offers it. - private var foldedOffers: [Role: Set] = [:] /// The highest RECEIVER-side watermark (the role's own persisted/advanced seq) observed /// when a frame was delivered to it. Gating on this keeps the comparison inside ONE /// engine's seq space — the earlier gate compared the EMITTER's `dependsOnSeq` against the @@ -83,9 +80,9 @@ struct DeliveryScheduler { mutating func offer(_ record: OfferRecord, to role: Role) { // Single-occupancy, latest-wins — both engines surface at most one pending offer - // (a new offer replaces the previous), so a host folds only the latest and never a - // digest it already folded. - guard !(foldedOffers[role]?.contains(record.digest) ?? false) else { return } + // (a new offer replaces the previous), so a host folds only the latest. + // A re-surfaced digest is live again on both engines; staleness is policed by the + // fold liveness gates, not suppressed here. outstandingOffers[role] = [record] } @@ -93,7 +90,6 @@ struct DeliveryScheduler { guard var offers = outstandingOffers[role], !offers.isEmpty else { return nil } let record = offers.removeFirst() outstandingOffers[role] = offers - foldedOffers[role, default: []].insert(record.digest) return record } From ba595cc5ef8190ed26c910d866e0d1592b9cb4a3 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Sat, 26 Sep 2026 15:41:39 -0700 Subject: [PATCH 2/5] Exercise twomlspq-swift main in the deployed-pin differential The deployed-pin run resolved twomlspq-swift as a version range, so engine changes merged ahead of the released tag were invisible to the differential. Package.swift now redirects the dependency to a local checkout when TWOMLSPQ_SWIFT_LOCAL is set, and the script defaults to a shallow clone of twomlspq-swift main (fetched/reset every run). TWOMLSPQ_SWIFT_LOCAL=release falls back to the released tag for pre/post comparison. Co-Authored-By: Claude Code --- Package.swift | 23 +++++++-- .../Differential/README.md | 31 +++++++++--- justfile | 4 +- scripts/differentialDeployed.sh | 50 +++++++++++++++++-- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/Package.swift b/Package.swift index 71869b9..3540ed0 100644 --- a/Package.swift +++ b/Package.swift @@ -40,6 +40,21 @@ let twoMLSPQrs: Target = // differential harness, which is all the pin run needs. let pinBinding = ProcessInfo.processInfo.environment["TWOMLSPQ_PIN_BINDING"] != nil +// The deployed-pin differential run (scripts/differentialDeployed.sh) drives engine MAIN, so +// it sets TWOMLSPQ_SWIFT_LOCAL to a local twomlspq-swift checkout and this manifest swaps the +// ranged remote dependency for a path one. "release" or empty keep the released tag, for +// pre/post comparison runs. The checkout's leaf directory MUST be named `twomlspq-swift`: +// SwiftPM derives the package identity from the leaf, and the `.product(package: +// "twomlspq-swift")` references below resolve by that identity. +let swiftLocal = ProcessInfo.processInfo.environment["TWOMLSPQ_SWIFT_LOCAL"] + .flatMap { $0.isEmpty || $0 == "release" ? nil : $0 } +let twomlspqSwiftDependency: Package.Dependency = swiftLocal.map { + .package(path: $0) +} ?? .package( + url: "https://github.com/germ-network/twomlspq-swift.git", + from: "0.3.0" +) + let package = Package( name: "TwoMLSPQ", // Import/link floors. The PQ backend's ML-KEM paths additionally require @@ -88,11 +103,9 @@ let package = Package( // `SessionMigration`). 0.3.0 is the first release that takes the per-group // signing keys and deployed-state inputs this export carries; its transitive // deps (swift-mls, swift-secret-bytes, swift-crypto, GermConvenience) resolve - // automatically. - .package( - url: "https://github.com/germ-network/twomlspq-swift.git", - from: "0.3.0" - ), + // automatically. Redirected to a local main checkout when TWOMLSPQ_SWIFT_LOCAL + // is set (see above). + twomlspqSwiftDependency, // Declared directly (not just transitively through twomlspq-swift) because // the migrate targets import their products. Library deps stay ranged // (`upToNextMinor`) — an exact pin here is what forces a coordinated diff --git a/Tests/TwoMLSPQMigrateTests/Differential/README.md b/Tests/TwoMLSPQMigrateTests/Differential/README.md index 71480ec..a747b8a 100644 --- a/Tests/TwoMLSPQMigrateTests/Differential/README.md +++ b/Tests/TwoMLSPQMigrateTests/Differential/README.md @@ -25,13 +25,29 @@ just differential-deployed which runs `scripts/differentialDeployed.sh`: 1. `git worktree add /tmp/ger-2566-pin c501f9d` (outside `~/tmp/worktrees/agent` — build scratch). -2. `scripts/buildIosDynamic.sh` there → the pin's `buildIos/` + `bindings/`. -3. Copy the pin's `two_mls_pq.swift` and `TwoMLSPQ.xcframework` into this tree, stashing +2. Fetch/reset the **twomlspq-swift main** scratch checkout (default + `/tmp/ger-2583-swift-main/twomlspq-swift`; override with `TWOMLSPQ_SWIFT_MAIN`) and point + `TWOMLSPQ_SWIFT_LOCAL` at it, so `Package.swift` resolves the Swift engine from main rather + than the released tag. +3. `scripts/buildIosDynamic.sh` there → the pin's `buildIos/` + `bindings/`. +4. Copy the pin's `two_mls_pq.swift` and `TwoMLSPQ.xcframework` into this tree, stashing main's. -4. `TWOMLSPQ_LOCAL_XCFRAMEWORK=1 TWOMLSPQ_PIN_BINDING=1 DIFFERENTIAL_DEPLOYED_PIN=1 +5. `TWOMLSPQ_LOCAL_XCFRAMEWORK=1 TWOMLSPQ_PIN_BINDING=1 DIFFERENTIAL_DEPLOYED_PIN=1 swift test --filter DifferentialHarnessTests`. -5. A trap restores main's binding + xcframework on exit, so an interrupted run never leaves - the tree swapped. +6. A trap restores main's binding + xcframework (+ `Package.resolved`, which the redirect + rewrites) on exit, so an interrupted run never leaves the tree swapped. + +**Swift engine leg: main by default.** The deployed-pin run drives the Rust reference at the +pin and the Swift engine at twomlspq-swift **main**, so engine changes merged ahead of the +released tag are visible to the differential. Set `TWOMLSPQ_SWIFT_LOCAL=release` (or an empty +value) to skip the redirect and resolve the released tag instead — that is the pre/post +comparison mode. + +**Leaf-name gotcha.** The local checkout's leaf directory **must** be named `twomlspq-swift` +(e.g. `/tmp/ger-2583-swift-main/twomlspq-swift`): SwiftPM derives the package identity from +the leaf, and the `.product(name: …, package: "twomlspq-swift")` references resolve by that +identity, so any other leaf name fails resolution with "unknown package". The script +sanity-checks the name and fails loudly otherwise. `TWOMLSPQ_PIN_BINDING=1` is required because the pin's binding predates the migration-export FFI: `TwoMLSPQMigrate` and every suite that imports it cannot compile @@ -71,10 +87,13 @@ So the legal subset the facade uses is the entire session/invitation/principal s # Standard suite (differential tests skip without the env var): TWOMLSPQ_LOCAL_XCFRAMEWORK=1 swift test -# Differential sweep against the deployed pin: +# Differential sweep against the deployed pin (Swift engine = twomlspq-swift main): just differential-deployed DIFFERENTIAL_SEED_MAX=250 just differential-deployed # wider sweep +# Pre/post comparison: same run against the RELEASED twomlspq-swift tag instead of main: +TWOMLSPQ_SWIFT_LOCAL=release just differential-deployed + # Against a local main-binding build instead of the pin (expected-signal run; see below): TWOMLSPQ_LOCAL_XCFRAMEWORK=1 DIFFERENTIAL_DEPLOYED_PIN=1 \ DIFFERENTIAL_SEED_MAX=5 swift test --filter DifferentialHarnessTests diff --git a/justfile b/justfile index 4a747ae..52ebf06 100644 --- a/justfile +++ b/justfile @@ -54,7 +54,9 @@ swift-test *ARGS: # The randomized differential harness against the DEPLOYED production pin # (two-mls-pq@c501f9d, mls-rs b43703f). Builds the pin's binding + xcframework in a worktree, # swaps them into the tree, runs only the differential suite, then restores main's binding. -# Extra args pass through to `swift test`; `DIFFERENTIAL_SEED_MAX` widens the sweep. +# The Swift engine leg resolves twomlspq-swift MAIN by default; TWOMLSPQ_SWIFT_LOCAL=release +# falls back to the released tag. Extra args pass through to `swift test`; +# `DIFFERENTIAL_SEED_MAX` widens the sweep. # See Tests/TwoMLSPQMigrateTests/Differential/README.md. differential-deployed *ARGS: bash scripts/differentialDeployed.sh {{ARGS}} diff --git a/scripts/differentialDeployed.sh b/scripts/differentialDeployed.sh index ec2fac1..96f426a 100644 --- a/scripts/differentialDeployed.sh +++ b/scripts/differentialDeployed.sh @@ -7,6 +7,10 @@ # xcframework into the working tree, runs only the differential suite, and RESTORES main's # on exit (a trap, so an interrupted run still restores). # +# The Swift engine leg runs against twomlspq-swift MAIN by default (its scratch checkout is +# fetched/reset every run), so engine changes merged ahead of the released tag are visible to +# the differential. TWOMLSPQ_SWIFT_LOCAL=release (or empty) opts into the released tag instead. +# # Fail-fast: the suite's `bindingContractMatchesLedger` asserts the linked binding's # contract version equals the ledger's (33 at the pin). Resolving the released v0.10.0 # binary instead of the local build surfaces as that mismatch, not a late FFI failure. @@ -18,8 +22,46 @@ PIN_WORKTREE="${DIFFERENTIAL_PIN_WORKTREE:-/tmp/ger-2566-pin}" BINDING="Sources/TwoMLSPQBinding/two_mls_pq.swift" BUILD="buildIos" +# The Swift engine leg runs against twomlspq-swift MAIN by default (Package.swift redirects +# the ranged dep to TWOMLSPQ_SWIFT_LOCAL). Escape hatch: TWOMLSPQ_SWIFT_LOCAL=release — or an +# empty value — skips the redirect and resolves the released tag, for pre/post comparison. +# The checkout's leaf directory MUST be `twomlspq-swift`: SwiftPM derives the package +# identity from it, so any other name fails resolution with "unknown package". +SWIFT_MAIN="${TWOMLSPQ_SWIFT_MAIN:-/tmp/ger-2583-swift-main/twomlspq-swift}" +SWIFT_REPO="${TWOMLSPQ_SWIFT_REPO:-https://github.com/germ-network/twomlspq-swift.git}" +SWIFT_BRANCH="${TWOMLSPQ_SWIFT_BRANCH:-main}" + cd "$ROOT" +# Decide the redirect up front. An explicit non-"release" path overrides the default scratch. +if [ -z "${TWOMLSPQ_SWIFT_LOCAL+x}" ]; then + SWIFT_REDIRECT=1 +elif [ -z "$TWOMLSPQ_SWIFT_LOCAL" ] || [ "$TWOMLSPQ_SWIFT_LOCAL" = release ]; then + SWIFT_REDIRECT=0 + echo "TWOMLSPQ_SWIFT_LOCAL=${TWOMLSPQ_SWIFT_LOCAL:-(empty)}: resolving the released twomlspq-swift tag." +else + SWIFT_REDIRECT=1 + SWIFT_MAIN="$TWOMLSPQ_SWIFT_LOCAL" +fi + +if [ "$SWIFT_REDIRECT" = 1 ]; then + if [ "$(basename "$SWIFT_MAIN")" != twomlspq-swift ]; then + echo "ERROR: local twomlspq-swift checkout must have leaf directory 'twomlspq-swift' (got '$(basename "$SWIFT_MAIN")'). SwiftPM derives the package identity from the leaf." >&2 + exit 1 + fi + if [ ! -e "$SWIFT_MAIN" ]; then + echo "Cloning twomlspq-swift $SWIFT_BRANCH → $SWIFT_MAIN" + git clone --depth 1 --branch "$SWIFT_BRANCH" "$SWIFT_REPO" "$SWIFT_MAIN" + elif [ ! -d "$SWIFT_MAIN/.git" ]; then + echo "ERROR: $SWIFT_MAIN exists but is not a git checkout. Remove it and re-run." >&2 + exit 1 + fi + echo "Updating $SWIFT_MAIN to origin/$SWIFT_BRANCH..." + git -C "$SWIFT_MAIN" fetch --depth 1 origin "$SWIFT_BRANCH" + git -C "$SWIFT_MAIN" reset --hard FETCH_HEAD + export TWOMLSPQ_SWIFT_LOCAL="$SWIFT_MAIN" +fi + # 1. A worktree of TwoMLSPQ at the pin, outside ~/tmp/worktrees/agent (it is a build # scratch, not agent work). if [ ! -d "$PIN_WORKTREE/rust" ]; then @@ -40,12 +82,14 @@ echo "Building the pin xcframework (this takes a while)..." bash "$PIN_WORKTREE/scripts/buildIosDynamic.sh" # 3. Stash main's binding + framework, then install the pin's. A single EXIT trap restores -# both and removes the stash, so an interrupted run leaves neither the tree swapped nor -# a temp dir behind. The buildIos restore is guarded: the original tree may have had none. +# both (and Package.resolved, which the twomlspq-swift redirect rewrites) and removes the +# stash, so an interrupted run leaves neither the tree swapped nor a temp dir behind. The +# buildIos restore is guarded: the original tree may have had none. STASH="$(mktemp -d)" -trap 'cp "$STASH/binding" "$BINDING"; rm -rf "$BUILD"; if [ -d "$STASH/buildIos" ]; then cp -R "$STASH/buildIos" "$BUILD"; fi; rm -rf "$STASH"; echo "Restored main binding + xcframework."' EXIT +trap 'cp "$STASH/binding" "$BINDING"; rm -rf "$BUILD"; if [ -d "$STASH/buildIos" ]; then cp -R "$STASH/buildIos" "$BUILD"; fi; if [ -f "$STASH/Package.resolved" ]; then cp "$STASH/Package.resolved" Package.resolved; fi; rm -rf "$STASH"; echo "Restored main binding + xcframework + Package.resolved."' EXIT cp "$BINDING" "$STASH/binding" +cp Package.resolved "$STASH/Package.resolved" rm -rf "$STASH/buildIos" if [ -d "$BUILD" ]; then cp -R "$BUILD" "$STASH/buildIos"; fi From 29647f0205f7301982302a65b5bfea6a0a9756aa Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Sat, 26 Sep 2026 21:49:33 -0700 Subject: [PATCH 3/5] Equates the post-2586/2587 receive-family errors in the class table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift's never-opened funnel and messageFromUnretainedEpoch fold (GER-2586/7) land as decryptionFailed on the Rust side too: the new Swift case is matched by text (it exists only on twomlspq-swift main, and this table must also compile against the released tag), and Rust's Mls — "a frame that never parsed" per the pin's own binding docs — moves to the receive family: an unparseable blob is indistinguishable from an out-of-window frame and garbage per the book's receive rule. Co-Authored-By: Claude Code --- .../Differential/EngineSession.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift b/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift index 6e2251a..6135592 100644 --- a/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift +++ b/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift @@ -597,7 +597,16 @@ func errorClass(_ error: Error) -> ErrorClass? { return .unopenable case .sessionNotReady, .notEstablished: return .notReady case .rotationInFlight: return .rotationInFlight - default: return .other + default: + // `messageFromUnretainedEpoch` is the classified fold (GER-2587) of what + // crossed unmapped; the deployed engine reports the identical calls as + // `DecryptionFailed`. Matched by text, not a typed case: it exists only on + // twomlspq-swift main (post-#97/#98), and this table must also compile + // against the released tag. + if String(describing: swift).contains("messageFromUnretainedEpoch") { + return .decryptionFailed + } + return .other } } if let rust = error as? TwoMlsPqError { @@ -606,8 +615,14 @@ func errorClass(_ error: Error) -> ErrorClass? { case .DuplicateWelcome, .DuplicateSideBand: return .duplicate case .EpochDesync: return .epochDesync case .DecryptionFailed: return .decryptionFailed - case .UnexpectedWelcome, .MissingWelcome, .InvalidKeyPackage, .Mls: + case .UnexpectedWelcome, .MissingWelcome, .InvalidKeyPackage: return .unopenable + // The pin's own binding docs define `Mls` as "a frame that never parsed" — an + // unparseable blob, which per the book's receive rule is indistinguishable from + // an out-of-window sealed frame and garbage. It is the receive family, not a tag + // rejection; Swift's post-GER-2586 never-opened funnel surfaces the same sites as + // `decryptionFailed`. + case .Mls: return .decryptionFailed case .SessionNotReady, .SessionNotEstablished: return .notReady default: return .other } From ee782894d632376eeb8656c43b03b13383cc14ad Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Sat, 26 Sep 2026 22:37:19 -0700 Subject: [PATCH 4/5] Dump per-send commit/error evidence under DIFFERENTIAL_DEBUG_SENDS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ±1 epoch class roots at send ops whose errors only print on findings; a per-send line (proposing, didCommit, epoch before/after, error class/text) pins the throw site directly. The fold debug lines get the direction so mixed-pair output separates without the pure-pair run's principal-id prefix. Co-Authored-By: Claude Code --- .../Differential/DifferentialRun.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift b/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift index 789117e..d9100e1 100644 --- a/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift +++ b/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift @@ -209,7 +209,7 @@ struct DifferentialRun { { FileHandle.standardError.write( Data( - "DBG fold seed=\(seed) op=\(opIndex) role=\(role.rawValue) folderEngine=\(pair.session(role).engine.rawValue) NO-OFFER\n" + "DBG fold dir=\(direction.rawValue) seed=\(seed) op=\(opIndex) role=\(role.rawValue) folderEngine=\(pair.session(role).engine.rawValue) NO-OFFER\n" .utf8)) } return @@ -290,8 +290,18 @@ struct DifferentialRun { } else { proposing = nil } + let epochBefore = session.sendEpoch() + func debugSend(_ rest: String) { + guard ProcessInfo.processInfo.environment["DIFFERENTIAL_DEBUG_SENDS"] != nil + else { return } + FileHandle.standardError.write( + Data( + "DBG send dir=\(direction.rawValue) seed=\(seed) op=\(opIndex) role=\(role.rawValue) engine=\(session.engine.rawValue) proposing=\(proposing.map { String(decoding: $0, as: UTF8.self) } ?? "-") epoch=\(epochBefore)->\(session.sendEpoch()) \(rest)\n" + .utf8)) + } do { let prep = try session.prepareToEncrypt(proposing: proposing) + debugSend("didCommit=\(prep.didCommit)") let frame = try session.encrypt(Data(payload.utf8)) // Persist-before-send: the emitter must have persisted the state this frame // depends on BEFORE sending it. A violation here is the harness wedging on the @@ -321,6 +331,9 @@ struct DifferentialRun { } record(opIndex, role, "send", OutcomeStep(), into: &result) } catch { + debugSend( + "ERR class=\(errorClass(error)?.rawValue ?? "nil") text=\(String(describing: error).prefix(60))" + ) record(opIndex, role, "send", error, into: &result) } } @@ -426,7 +439,7 @@ struct DifferentialRun { if ProcessInfo.processInfo.environment["DIFFERENTIAL_DEBUG_FOLDS"] != nil { FileHandle.standardError.write( Data( - "DBG fold seed=\(seed) op=\(opIndex) role=\(role.rawValue) folderEngine=\(session.engine.rawValue) NOT-LIVE atOp=\(offer.offeredAtOp) superseded=\(isSuperseded) senderEpochThen=\(offer.senderSendEpochAtOffer) senderEpochNow=\(senderEpochNow) stillVerifies=\(stillVerifiable.map(String.init) ?? "-")\n" + "DBG fold dir=\(direction.rawValue) seed=\(seed) op=\(opIndex) role=\(role.rawValue) folderEngine=\(session.engine.rawValue) NOT-LIVE atOp=\(offer.offeredAtOp) superseded=\(isSuperseded) senderEpochThen=\(offer.senderSendEpochAtOffer) senderEpochNow=\(senderEpochNow) stillVerifies=\(stillVerifiable.map(String.init) ?? "-")\n" .utf8)) } record(opIndex, role, "queueProposal", OutcomeStep(), into: &result) @@ -436,7 +449,7 @@ struct DifferentialRun { let contextMatch = (contextNow == offer.context) if ProcessInfo.processInfo.environment["DIFFERENTIAL_DEBUG_FOLDS"] != nil { let line = - "DBG fold seed=\(seed) op=\(opIndex) role=\(role.rawValue) folderEngine=\(session.engine.rawValue)" + "DBG fold dir=\(direction.rawValue) seed=\(seed) op=\(opIndex) role=\(role.rawValue) folderEngine=\(session.engine.rawValue)" + " offeredBy=\(offer.surfacedBy.rawValue) atOp=\(offer.offeredAtOp)" + " sendEpoch=\(session.sendEpoch()) offeredAtEpoch=\(offer.offeredAtSendEpoch)" + " digest=\(offer.digest.hexPrefix) proposing=\(offer.proposing?.hexPrefix ?? "-")" @@ -466,7 +479,7 @@ struct DifferentialRun { if ProcessInfo.processInfo.environment["DIFFERENTIAL_DEBUG_FOLDS"] != nil { FileHandle.standardError.write( Data( - "DBG fold seed=\(seed) op=\(opIndex) role=\(role.rawValue) OUTCOME=ok\n" + "DBG fold dir=\(direction.rawValue) seed=\(seed) op=\(opIndex) role=\(role.rawValue) OUTCOME=ok\n" .utf8)) } record(opIndex, role, "queueProposal", OutcomeStep(), into: &result) @@ -474,7 +487,7 @@ struct DifferentialRun { if ProcessInfo.processInfo.environment["DIFFERENTIAL_DEBUG_FOLDS"] != nil { FileHandle.standardError.write( Data( - "DBG fold seed=\(seed) op=\(opIndex) role=\(role.rawValue) OUTCOME=err class=\(errorClass(error)?.rawValue ?? "nil") text=\(String(describing: error))\n" + "DBG fold dir=\(direction.rawValue) seed=\(seed) op=\(opIndex) role=\(role.rawValue) OUTCOME=err class=\(errorClass(error)?.rawValue ?? "nil") text=\(String(describing: error))\n" .utf8)) if ProcessInfo.processInfo.environment["DIFFERENTIAL_DEBUG_GUARDS"] != nil, From abaab0aefe1e1e1d09df02d3b0a37e63ae344cb7 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Sat, 26 Sep 2026 22:52:56 -0700 Subject: [PATCH 5/5] Attribute the rotation-in-flight cascade in the differential A `.rotationInFlight` send (Swift refuses, deployed Rust permits) is the first real divergence in every affected seed; its downstream apply/skip, payload, error-class and side-band count differences are the deterministic one-commit-epoch skew, not independent signal. Record the root op per direction and count post-root mismatches in a new visible bucket instead of failing them, still never excusing an unclassified `.other`. Also stop the Rust respond adapter consuming the parked side-band leg: Swift's `pqRatchetRespond` keeps it parked, so taking on Rust made a later `handOutSideBand` see a leg on one engine and none on the other. Co-Authored-By: Claude Code --- .../DifferentialHarnessTests.swift | 43 +++++++++++++++++++ .../Differential/DifferentialRun.swift | 7 +++ .../Differential/EngineSession.swift | 6 ++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Tests/TwoMLSPQMigrateTests/Differential/DifferentialHarnessTests.swift b/Tests/TwoMLSPQMigrateTests/Differential/DifferentialHarnessTests.swift index 2678846..588b440 100644 --- a/Tests/TwoMLSPQMigrateTests/Differential/DifferentialHarnessTests.swift +++ b/Tests/TwoMLSPQMigrateTests/Differential/DifferentialHarnessTests.swift @@ -127,6 +127,12 @@ enum DifferentialChecks { var presenceByCall: [String: Int] = [:] var designedWedgeExcluded = 0 var behindOps: [Int] = [] + /// Findings excluded as the deterministic cascade of a `.rotationInFlight` root: a + /// rotation the Swift engine refuses (and the deployed Rust mirror permits) leaves one + /// commit epoch of frame-absence-vs-presence skew, whose downstream apply/skip, + /// payload, error-class and side-band count differences are not independent signal. + var rotationCascadeExcluded = 0 + var rotationOps: [Int] = [] var summary: String { let detail = presenceByCall.sorted { $0.key < $1.key }.map { @@ -136,6 +142,7 @@ enum DifferentialChecks { return "offer-presence-divergences excluded: \(presenceExcluded) [\(detail)]" + " designed-wedge findings excluded: \(designedWedgeExcluded)" + + " rotation-cascade excluded: \(rotationCascadeExcluded) rotationOps=\(rotationOps.sorted())" + " failing findings: \(findings.count) behindOps=\(behindOps.sorted())" } } @@ -165,6 +172,23 @@ enum DifferentialChecks { Array(swift.behindRestoredAt.values) + Array(rust.behindRestoredAt.values) cmp.behindOps = behindOps func behindAttributed(_ op: Int) -> Bool { behindOps.contains { $0 < op } } + // Rotation-cascade attribution: `op` postdates a `.rotationInFlight` root in either + // direction. That root is the FIRST real divergence in every affected seed; the + // downstream differences are its deterministic one-commit-epoch skew, so they are + // counted, not failed. Exact scoping only — never a both-engines field/call rule. + let rotationOps = swift.rotationInFlightAt + rust.rotationInFlightAt + cmp.rotationOps = rotationOps + func rotationCascadeAttributed(_ op: Int) -> Bool { + rotationOps.contains { $0 < op } + } + // The outcome fields a post-root skew can manufacture, and the side-band calls whose + // post-root count differences it can manufacture. + let rotationCascadeFields: Set = [ + "remoteCommitApplied", "appPayloads", "errorClass", "joined", + ] + let rotationCascadeSideBandCalls: Set = [ + "handOutSideBand", "deliverSideBand", "sideBandRespond", "sideBandApply", + ] // A misparse is never excused: an unclassified `.other`, or an unsupported tag the // error table does not equate. // `unsupportedFrameTag(_)` is a CLASSIFIED clean rejection — the engine recognised @@ -205,6 +229,15 @@ enum DifferentialChecks { classifyPresence(key) continue } + // A side-band count difference post-root is cascade — unless either side + // surfaced an unclassified `.other` (a misparse is never excused). + if rotationCascadeAttributed(key.op) + && rotationCascadeSideBandCalls.contains(key.call) + && !(a + b).contains(where: { unclassified($0.outcome) }) + { + cmp.rotationCascadeExcluded += 1 + continue + } cmp.findings.append( "op \(key.op) \(key.call) role \(key.role): call count differs swiftInitiator=\(a.count) rustInitiator=\(b.count)" ) @@ -236,6 +269,16 @@ enum DifferentialChecks { classifyPresence(key) continue } + // Post-root cascade on a skew-sensitive field: counted, not failed. An + // unclassified `.other` on either side still fails. + if rotationCascadeAttributed(key.op) + && rotationCascadeFields.contains(field) + && !unclassified(a[i].outcome) + && !unclassified(b[i].outcome) + { + cmp.rotationCascadeExcluded += 1 + continue + } if behindAttributed(key.op) && !unclassified(a[i].outcome) && !unclassified(b[i].outcome) diff --git a/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift b/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift index d9100e1..9384e5e 100644 --- a/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift +++ b/Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift @@ -88,6 +88,10 @@ struct RunResult { /// role -> op of the last `restoreBehindDelivery` that actually FIRED (behind-restore /// attribution for designed-wedge reclassification). var behindRestoredAt: [String: Int] = [:] + /// Ops whose `send` threw `.rotationInFlight` — the root of the one-commit-epoch cascade + /// (Swift refuses a rotation the deployed Rust mirror permits). Everything after a root in + /// this direction is downstream of that single frame-absence-vs-presence event. + var rotationInFlightAt: [Int] = [] /// Violations attributable to a behind-restore (designed wedge) — reported, not failed. var designedWedgeViolations: [String] = [] } @@ -331,6 +335,9 @@ struct DifferentialRun { } record(opIndex, role, "send", OutcomeStep(), into: &result) } catch { + if errorClass(error) == .rotationInFlight { + result.rotationInFlightAt.append(opIndex) + } debugSend( "ERR class=\(errorClass(error)?.rawValue ?? "nil") text=\(String(describing: error).prefix(60))" ) diff --git a/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift b/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift index 6135592..0e8ee04 100644 --- a/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift +++ b/Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift @@ -299,7 +299,11 @@ final class RustEngineSession: EngineSession { default: return nil } - let leg = session.pqTakePendingOutbound() + // Peek, never take: the respond path parks its leg exactly as Swift's + // `pqRatchetRespond` does (which returns its CT and keeps the leg parked), so a later + // `handOutSideBand` sees a leg on BOTH engines. Taking here would strand the round's + // only carrier and manufacture a Swift-leg/Rust-none count difference. + let leg = session.pqPendingOutbound(sealing: .fresh) drainSink() return leg }