Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions Tests/TwoMLSPQMigrateTests/Differential/DeliveryScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data>] = [:]
/// 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
Expand Down Expand Up @@ -83,17 +80,16 @@ 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]
}

mutating func popOffer(_ role: Role) -> OfferRecord? {
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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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())"
}
}
Expand Down Expand Up @@ -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<String> = [
"remoteCommitApplied", "appPayloads", "errorClass", "joined",
]
let rotationCascadeSideBandCalls: Set<String> = [
"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
Expand Down Expand Up @@ -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)"
)
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 25 additions & 5 deletions Tests/TwoMLSPQMigrateTests/Differential/DifferentialRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
}
Expand Down Expand Up @@ -209,7 +213,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
Expand Down Expand Up @@ -290,8 +294,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
Expand Down Expand Up @@ -321,6 +335,12 @@ 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))"
)
record(opIndex, role, "send", error, into: &result)
}
}
Expand Down Expand Up @@ -426,7 +446,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)
Expand All @@ -436,7 +456,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 ?? "-")"
Expand Down Expand Up @@ -466,15 +486,15 @@ 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)
} catch {
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,
Expand Down
25 changes: 22 additions & 3 deletions Tests/TwoMLSPQMigrateTests/Differential/EngineSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -597,7 +601,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 {
Expand All @@ -606,8 +619,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
}
Expand Down
31 changes: 25 additions & 6 deletions Tests/TwoMLSPQMigrateTests/Differential/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
Loading
Loading